Creating 1-ZSTs guaranteed to have same extern "C" ABI as ()

I originally posted this question on Stack Overflow. Someone elected to open an Github issue for rust-lang on the topic, and a commenter suggested that posing the question here would could get more traction.

Context

I'm looking at rewriting part of a Rust library which wraps a C library because the current wrapper is unsafe. The C library expects a function pointer of the form void(*)(...), so the obvious choice on the Rust side would be a function pointer of type extern "C" fn(...) -> (). For safety reasons, I would like to create a type family Invariant<'a>, which is invariant with respect to 'a, such that we can safely transmute a function pointer of type extern "C" fn(...) -> Invariant<'a> to a function pointer of type extern "C" fn(...) -> (). Users of my wrapped library would then pass a function pointer that returns an Invariant.

In order to make such a transmutation safe, we need Invariant<'a> to have the same size, alignment, and ABI as (). Surprisingly, there seems to be no way to do this that's guaranteed to work. I've tried quite a few tricks with #[repr(transparent)], but nothing seems to work (at least according to Miri) (see the Stack Overflow question for some of my attempts, one of which passes #[deny(improper_ctypes_definitions)], but all of which fail Miri). The issue is that #[repr(transparent)] is only guaranteed to work for structs which have a single non-zero-sized field (and for equivalent enums). In fact, according to the Rust Reference and the Rustonomicon, you're not actually allowed to use #[repr(transparent)] on a struct where all fields are zero-sized (although the compiler will usually allow this anyway). There was a pull request to try to address this, but this pull request seems to be in limbo.

Question

Is there some technique I haven't thought of for creating a newtype wrapper around () that lets us do what I want? In general, is there a way to create a newtype wrapper around a ZST?

If not, how can we modify #[repr(transparent)] to allow this? My proposal would be that if you have a #[repr(transparent)] struct where all fields are ZSTs, you should be able to designate a single field which will be the newtype-wrapped field. However, I'm not exactly sure how this will work with generics - there may be some technical issues I haven't thought of.

As far as I know, the lang opinion is that the transparent-around-() phrasing should just work: repr(transparent) could accept singleton ZST with alignment > 1. · Issue #100954 · rust-lang/rust · GitHub

If there's a compiler PR to make it work, cc me and I'll FCP merge it :slight_smile:

Note that Miri's rules for ABI compatibility are currently very strict. We simply don't have much if any proper documentation on call ABI compatibility, and the degrees of freedom of real platform ABIs are flabbergasting, so it's hard to say anything with certainty. I wanted to be sure not to accept anything that could lead to UB on any ABI, and I don't know much about ABIs, so I made the check very conservative.

This can be seen e.g. in this testcase.

The rules for this are implemented here. Cc @bjorn3 who might be able to tell us how we can relax them and how far it is advisable to relax them.

For return values it should be fine to allow

#[repr(transparent)]
pub struct Try1<'a>(PhantomData<Cell<&'a i32>>);

but for arguments I don't think we should allow it. Most architectures ignore ZST arguments, but some actually use a register for them.

Note that the check in Miri is just about pairing up 2 arguments, it doesn't allow any kind of skipping. (We use PassMode::Ignore to skip arguments.)

So, if one function has argument type () and the other has a PhantomData, are you saying that can still be a problem? I would assume the ABI would either skip both of them or use a register for both, but it would never treat them differently?

The bigger question is what is the underling principle that allows this. I mean we could add a special case that if both arguments are size 0 and align 1 we consider them compatible but that seems awkward? Is there something more general we can say? Like, if the PassMode are compatible and the size and alignment are the same the arguments are compatible? But that would equate i32 and f32 which AFAIK can be passed in different registers, so it seems too weak of a check...

1 Like

That is fine. I was more worried about skipping ZST args on either side.

A natural specification for #[repr(transparent)] structs would be something like this:

  1. A struct can be marked with #[repr(transparent)] if all fields are guaranteed to be a 1-ZST, except at most one.
  2. If the struct has a field which is not a 1-ZST, then the struct has the same size, alignment, and ABI as this field.
  3. Otherwise, (a) the struct is guaranteed to be a 1-ZST, and (b) all 1-ZSTs have the same ABI.

Everything here except 3(b) seems uncontroversial to me. The issue is that I'm not actually sure all 1-ZSTs do have the same ABI for all ABIs supported in Rust (or which could, conceivably, be supported in Rust in the future).

Why do we actually need to assume 3(b)? Let's suppose we have two 1-ZSTs, A and B. Then we could declare

#[repr(transparent)]
struct Pair(A, B);

Intuitively, it seems this struct should be a newtype wrapper around A, and also around B. If Pair has the same ABI as A, and Pair has the same ABI as B, then A and B have the same ABI. This intuition thus forces us to conclude 3(b).

Another way to solve this could theoretically be a breaking change. Define a ()-type to be a 1-ZST with the same ABI as (). We could try the following definition:

  1. A struct can be marked with #[repr(transparent)] if all fields are guaranteed to be a ()-type, except at most one.
  2. If the struct has a field which is not a ()-type, then the struct has the same size, alignment, and ABI as this field.
  3. Otherwise, the struct is a ()-type.

This would break existing #[repr(transparent)] code which uses a 1-ZST that is not a ()-type, so that's obviously undesirable.

The third choice, which has its own issues, would be something like this:

  1. A struct can be marked with #[repr(transparent)] if all fields are guaranteed to be a 1-ZST, except at most one. At most one field can be annotated as the wrapped field; if one field is wrapped, all others must be guaranteed to be 1-ZSTs.
  2. If the struct has a field which could, after generic substitution, not be a 1-ZST; or if it has a wrapped field; or if it has a unique field, then the struct is guaranteed to have the same size, alignment, and ABI as this field. This field is known as the "inner field".
  3. Otherwise, the struct is guaranteed to be a 1-ZST, but no guarantees are made about ABI.

This solves the problem of our Pair type; it cannot wrap both A and B. It also means that in cases like

#[repr(transparent)]
struct NotSync<A> {
    inner: A,
    _phantom: PhantomData<Cell<A>>,
}

we wouldn't have to explicitly annotate inner as the wrapped field, and NotSync<A> would be ABI-equivalent to A even when A is a 1-ZST. We could add a lint to detect when we have no inner field. Alternately, we could strengthen the requirement in (1) to require that an inner field exist. This would break some code that currently compiles (for instance, it would break our declaration of Pair unless we declared which field Pair wraps), but it would technically not be a breaking change because the documentation never guaranteed that this code would work in the first place.

1 Like

@bjorn3 all right. But what is the required check for ABI compatibility? As far as I know, i32 and f32 get the exact same PassMode, so clearly even a fully equal PassMode is insufficient. (I just verified this by commenting out the layout check in Miri, and only comparing the pass mode.) We need to check something about the Layout as well. But what? Currently (well, with a PR I am working on right now) I require that

  • Either the types are fully equal.
  • Or they both have Scalar/ScalarPair layout, and the underlying primitive is the same up to signedness.

That is not sufficient to provide () == PhantomData<T>, and it is also not sufficient to support general repr(transparent) (e.g. String and Wrapper<String> would not be considered equal).


@markcsaving I thought about implementing repr(transparent) explicitly by checking for the attribute, and if it is present find the wrapped field and compare that instead. However, as you note, if all fields are 1-ZST there is no clear way to identify the wrapped field...

1 Like

This is what the LLVM backend does: https://github.com/rust-lang/rust/blob/b60e31b673b0d36c50f8e0a3b6f8f077221d983d/compiler/rustc_codegen_llvm/src/abi.rs#L345 The rules seems to be quite entangled with the implementation. Probably a good thing to separate out.

Ah lol it completely ignores most of PassMode and instead reconstructs things from the Primitive? :person_facepalming:

Yeah that's a mess. Looks like improving Miri here is blocked on some serious refactoring.

(Though I would prefer if I wouldn't have to look at PassMode at all, since it is so subject to change across targets. I'd rather have rules that are more stable, even if they accept fewer cases, as long as they accept enough. Then I wouldn't have to worry that tests pass on x86-64 but might fail on other 64bit little-endian targets -- I know they can already also differ in other things, in particular alignment, but most of the time "same bitwidth and same endianess" implies that targets behave almost the same, and that is quite valuable.)

I've posted a pre-RFC which I hope will solve this issue. Please check it out and let me know what you think.

This seems to actually look at the type, not just the layout? For instance here. Isn't that a huge problem? This won't respect repr(transparent)! Or am I missing something?

EDIT: File a PR

This PR should fix that, at least on most targets -- some targets have problems.

However, "it works in Miri" doesn't mean "this is guaranteed to work in the future", it just means it works with the current toolchain. Stable future-proof guarantees for layout and ABI questions only exist when explicitly documented.

Rust sets its own ABI, doesn’t it? So shouldn’t it be possible to say “any repr(Rust) 1-ZST has the same ABI as ()” and then use the rule about “()-types”? Then if some weird 1-ZST is needed in the future, it can be repr(weird), which would probably be a good thing to do anyway.

This would break existing #[repr(transparent)] code which uses a 1-ZST that is not a ()-type, so that's obviously undesirable.

Do these exist?

1 Like

@jrose Rust does define the Rust ABI - that is, the extern "Rust" ABI, which is the default ABI for functions defined in Rust. However, Rust also uses ABIs like extern "C" to interoperate with other programs.

Ralf Jung found an example here of two 1-ZSTs, one of which is constructed as a repr(transparent) wrapper around the other, which don't have the same behavior in the extern "C" ABI.

Unless the target/OS defines what it means to pass a zero-sized function argument — and not doing so would not be a surprising oversight due to not existing in C/C++ and functionally useless at a machine level — Rust also defines what the behavior of ZST arguments in extern "C" is. Almost all (but not quite all) ways of defining a ZST result in the compiler warning about the use of improper C types and them having a nonguaranteed ABI.

Obviously the failure of repr(transparent) to be fully transparent in that case on that target is a bug. A bad one, certainly, since it impacts a nominally stable ABI, but a bug. We'll need to decide if we prioritize bug-for-bug compatibility with (our extensions to) the ABI or being spec correct in this case.

extern "C" is essentially a newtype alias around the platform specific ABI, e.g. extern "sysv64" on non-Windows x86_64 and extern "win64" on x86_64 Windows.

The SysV ABI doc does not define how zero byte objects (with trivial copy and drop) should be passed. ≥32 byte objects are passed as MEMORY, "C++ objects with non-trivial copy constructor or non-trivial destructor" are passed by reference, and then each eightbyte of small aggregates are classified separately. A zero-byte object (with trivial copy and drop) contains zero eightbytes and thus cannot be classified by this process. (Classifying an empty aggregate as MEMORY would have the desired no-op effect, pushing zero eightbytes to the stack.)

The Win64 ABI documentation says that any argument that isn't 1, 2, 4, or 8 bytes must be passed by reference. This results in it being fairly clear that zero-sized objects do always take up a parameter slot, but the docs are less formal than the SysV doc and as far as I'm aware it's not possible to have a zero-sized object with the official MSVC compiler.

When also considering return types, both ABIs make a distinction between POD and data with a non-trivial destructor. So that's probably the axis to look at for seeing if/when ZST (return) ABI differs in practice. (The Win64 one would be simple to adjust to make returning ZST the same as returning void — just add 0 bit length to the list of bit lengths returned in a register. Didn't particularly look at SysV.)

Right, C does not have zero-sized types (including [u8; 0]), so the only way this can be a problem is if a platform has zero-sized types as an extension of C. At which point I’m okay with those not counting as repr(Rust) 1-ZSTs, i.e. ()-types. Arrays don’t have reprs, though, so I see how that’s a bit weird.

To make repr(transparent) work, we need all 1-ZST to have the same ABI, no matter their repr.

Maybe it was a mistake to allow zero-sized arguments / return values in extern "C" functions to begin with...

We have a lot of duplicate discussion now between this thread and the other one, which is getting rather confusing. We should probably close (or stop using) one of them...