Idea: Naked Functions 2.0

I've been thinking about this a lot recently. Here are my thoughts. Any feedback would be welcome.

EDIT: switch from "empty" to "user-defined" and updated with insights from discussion so far.

Summary

We redefine a naked function as a function using the "user-defined" calling convention, which we specify below. A naked function can define an arbitrary contract with its callers, and thus it is always an unsafe fn. Such a contract may include requirements about the state of the cpu registers, stack, or memory when the naked function is called. The compiler offers no help in enforcing this contract. This design is chosen to be maximally flexible, allowing naked functions to be used a bunch of different contexts. Functions using the "user-defined" ABI cannot be called directly from normal rust. Instead, they must be invoked through some other means, such as (inline) assembly, C, or hardware. Alternately, a function pointer can be unsafely cast to the "C" ABI or some other ABI if it truely can be called with that convention.

Finally, we define how code is generated for a naked function.

Why this proposal?

Currently, it's not really clear what's allowed in a naked function, what code is generated, and what is UB. Basically, the RFC just says that no prologue and epilogue is emitted. Moreover, the current implementation doesn't really emit errors, even for things that are clearly wrong, like using the Rust ABI for a naked function, even though the ABI is unspecified.

Naked functions are a promising mechanism for writing low-level code without requiring the user to write a separate assembly file and link it. In particular, I would like naked functions to be a very general mechanism that can be used for implementing things like context switch routines, interrupt handlers, or other ABIs.

Specification

We can define a naked function by using the "user-defined" calling convention:

unsafe extern "user-defined" fn foo() -> ! { ... }

foo's body is allowed to contain arbitrary Rust code.

Restrictions

Violating any of the following results in a compile-time error:

  • foo must be unsafe
  • foo must not declare any formal parameters
  • foo must not be const or inlined
  • foo must not be marked with #[track_caller]
  • foo must return either () or !

Some of these restrictions may be relaxed by future RFCs.

The "user-defined" calling convention

foo is an unsafe fn. That is, the caller of foo has an obligation to show that the preconditions of foo are satisfied and that calling foo will not violate any system invariants. Determining these preconditions and invariants is left entirely to the developer writing foo, and making sure they hold is left entirely to the caller of foo. This is done to make naked functions maximally flexible. For example, they can safely be used in interrupt handler contexts, where the stack may be in an unusual state, or when context switching, where the stack may be inaccessible altogether.

foo is not allowed to assume anything about the state of the machine at its entry point unless it is explicitly required by the contract with the caller. This includes assumptions about how foo was called, the contents of the registers, the contents of the stack, etc. Note that this means that foo may need to first set up a stack before it can use local variables or call other functions.

In addition to the contract with the caller, foo is required to comply with the following:

  • If foo returns (), it must ensure that returning is not UB. This is architecture-specific. For example, on x86_64, foo must ensure that executing the retq instruction is not UB by making sure that *(stack pointer + 8) is a valid return address.
  • If foo returns !, control flow must never return from foo. Returning constitutes UB.

Symbols and Scopes

The foo function defines a name-mangled symbol, just like any other normal function. foo can be used as a function pointer, just like any other function name.

The body of foo is a lexical scope, just like a normal rust function, and can contain other symbols and refer to symbols in more general scopes.

Generated code

Calling a naked function

A naked function cannot be called from normal rust (i.e. foo()) because the compiler does not know the calling convention and ABI. Thus, any attempt to invoke a function or function pointer with an extern "user-defined" ABI will result in a compile-time error.

A naked function can be invoked instead through (inline) assembly, C, or some other mechanism, such as a hardware interrupt vector. Alternately, if the naked function's contract states that it can be invoked through some other ABI, an unsafe cast to a function pointer with that ABI can be done, and the cast pointer can be invoked directly.

EDIT: this is the old text, for posterity...

foo can be entered from rust code using a normal function call (foo()), but the compiler will not assume that foo is always entered from rust code. In fact, calling foo from normal rust code may violate the contract and trigger UB. For example, an interrupt handler may assume that it will only ever be invoked by the hardware. Callers of foo are responsible for making sure that they uphold their end of the contract with foo.

When rust code does call a naked function, the compiler will emit code in the caller that follows the C ABI and architecture-specific calling conventions for calling a function. For example, on x86_64, it will save caller-saved registers and use the call foo instruction (except foo would be name-mangled), which will save the return address on the stack (there are no arguments, so none need to be passed).

If the naked function returns (), then code emitted in the caller after the function call will assume that foo obeys the C ABI and calling convention. foo is responsible for making sure it upholds any necessary invariants so that this is not UB.

Body of foo

The body of foo will generate code as follows:

  • No function prologue or epilogue is generated whatsoever. Callee-saved registers are not saved; the implementor of foo must do it if it is needed.
  • Inline assembly at the beginning or end of a naked function will be placed at the entry and exit of the code generated for the naked function, with no intervening instructions. This means that inline assembly can be used to do necessary setup and tear down for the user-defined calling convention.
  • Local variables will be lazily allocated on the stack. That is, whenever a local is first declared, instructions are generated that make space on the stack for the local. Such instructions are not to be relocated around inline asm.
  • Rust statements in the body of foo will generate the same code as they would in any other rust function. It is the responsibility of foo to guarantee that these generated statements are not UB. For example, foo may call a normal rust function in its body, but foo must ensure that there is a valid stack to do so.
  • When control flow reaches the end of foo, a ret instruction is inserted if the return type of foo is (). If control flow reaches the end of foo and it's type is !, then UB occurs.
6 Likes

possible nit: #[track_caller] also affects the ABI of the function.

This seems like a great idea to me. I can imagine using this inside of a macro that defines a function with a custom calling convention.

As a naming nit, I think "empty" doesn't quite make this concept clear. This is more a "user-defined" calling convention, as it may have arbitrary requirements.

We also need to define a bit more of the interaction between this and inline assembly. For instance, if you write asm! at the start or end of a function like this, do you know that they will be the first or last code in the function, respectively?

1 Like

When rust code does call a naked function, the compiler will emit code in the caller that follows the C ABI and architecture-specific calling conventions for calling a function.

But what if you want to have a non-default calling convention on the caller end? For instance, stdcall on Windows. There would be nowhere to specify that.

Even assuming you do want the default calling convention, a function pointer to a naked function should be typed according to that convention, not empty – i.e. it would be extern "C" fn(), not extern "empty" fn(). We could of course specify that extern "empty" functions turn into extern "C" function pointers, but that would be an odd special case that distinguishes empty from every other ABI.

For both reasons, I don’t think the ABI string is the right place to put this.


No, this reordering can normally happen; per the current inline asm RFC, if Rust code does not interact with the inline asm, they may be freely reordered.

I wasn't really aware of this. For a MVP, I suspect it would be reasonable to say that you can't use track_caller on a naked function. Often there will not be an obvious caller anyway (eg interrupt handlers).

Yes, that's my intent. I thought this would naturally happen if you have a volatile asm fragment, but it seems this is not true according to @CAD97?

Also, I agree that user-defined is a better name and that it can be used to implement other calling conventions.

I would suggest that the caller should use inline asm to call the naked function in this case.

In truth, I don't really know what the use cases for calling a naked function from rust are. I have always used naked functions as interrupt and system call handlers. The only reason I specified it above is that I thought it should be well defined.

As for the types, I suspect that "empty" should not coerce to "C". You should have to do an unsafe cast of some sort where you have a proof obligation to show that the function you're passing does indeed satisfy you're calling convention and that the user does indeed call it correctly.

An alternative would be to say that naked functions can't be called from normal rust (ie the compiler refuses to invoke functions/pointers with type extern "empty"), which would force you to use inline asm. I actually kind of like this idea because it makes sense that compiler doesn't know how to invoke a custom ABI, even though it feels very weird to say that we have a rust lvalue with a function type but we can't call it.

6 Likes

I like this idea as well. If you truly can call the function with some other ABI, then you can perform an unsafe cast of the function to a different function pointer type and call it with that calling convention. And otherwise, Rust will prevent you from calling it directly.

4 Likes

Yes, I have come to like this idea too. I think it elegantly avoids the issues @comex raised.

I have updated the OP to reflect the discussion so far.

What if the inline asm declares that it messes with the stack?

Except that LLVM does not do this: playground

This is why you are only allowed to have a single asm! in a naked function, and nothing else. This should be enforced by the compiler.

I don't think naked functions should be restricted to the "user-defined" ABI. It is perfectly reasonable for a naked function to have parameters and a return value, and even for one to be callable from safe code (though it should be required to have an explicit extern ABI).

This makes sense if you separate the implementation of such a function from the way it is called by other code:

  • The implementation is simply the contents of a single asm! block, nothing else. The #[naked] attribute only applies to the implementation.
  • The signature of the function is a description which tells other Rust code how to call that function. If a naked function accepts parameters following the "C" ABI then it is the responsibility of the asm code to fetch the arguments from the appropriate registers/stack slots. The same applies to the return value.

Of course, sometimes you may want to indicate that a function is not callable from normal Rust code (e.g. an interrupt handler). The only thing that you would be allowed to do with such a function pointer is cast it to usize so that you can store in into some platform-specific interrupt handler table.

This can be done with a "user-defined" ABI, but this is a wholly separate concern from whether the implementation of a function uses #[naked] or not. In fact, you may even want to write this function in an external asm file and import its signature with an extern "user-defined" { fn foo() -> ! }.

3 Likes

Hmm... that's really good to know. I would assume that this is not difficult to achieve technically, correct? For example, does LLVM have a "barrier" directive that prevents LLVM from reordering without actually generating a mfence or similar instruction?

@Amanieu I have actually argued for something like this in the past, but have changed my mind after more time with naked functions.

In particular, I think there are two distinct but similar use cases for naked functions:

  1. Custom ABIs and calling conventions (e.g. for interrupt handlers). "user-defined" solves this problem.
  2. Using an existing ABI, but doing something unusual in the function prologue or epilogue. #[naked] solves this problem.

The problems are similar enough that I think any solution to (0) can be used to solve (1) and vice versa. However...

I haven't actually seen any examples of (1). All of the use cases for naked function that I have seen (especially on these tracking issues) have been for (0); in particular, for interrupt/trap handlers. (1) is not a problem that people seem to need solved (at least on those threads).

Meanwhile, "user-defined" addresses my use cases and is more ergonomic than #[naked] for those use cases. Moreover, anything that can be done with #[naked] can be done with "user-defined" (AFAIK), so replacing #[naked] with "user-defined" doesn't exclude any current use cases, but makes common use cases strictly more ergonomic.

If you have examples of people specifically needing to solve problem (1), that would be really helpful.

Based on this a usecase for (1) is implementing syscall wrappers:

#[naked]
pub unsafe fn stat(buf: *const u8, buf: *const u8) {
    asm!("mov $0x20000bc, %%eax; syscall");
}

I encourage you to read the rest of the discussion on https://github.com/rust-lang/rfcs/pull/2774 about why naked functions are desirable.

1 Like

I don't see how it is more ergonomic. Anything you can do with "user-defined" you can already do with #[naked].

The way I see it you are trying to combine two separate features which I feel should stay separate.

Feature 1: extern "non-callable"

Indicates that this function cannot be called by Rust code. It must have no arguments, no return value and must be #[naked]. The only thing you can do with it is cast its address to usize.

(Note that it is irrelevant whether the function returns () or ! since it isn't callable by Rust)

Feature 2: #[naked]

The function must contain exactly one asm! and nothing else. The body of the function contains only the code in the asm!.

(This is basically what #[naked] already does, minus the compiler error if you have anything other than asm! in it)

3 Likes

How should alternative backends implement this? The way suggested for normal inline asm (wrap it with function prologue and epilogue, compile using system assembler, call it) won't work, as the inline asm must be naked.

Since naked functions are only allowed to contain a single asm! block, it can be lowered to global_asm!, which in turn can be compiled as an external asm file and linked in.

  • foo must not declare any formal parameters

Naked functions as they exist today currently can have arguments. I'd like to keep that for documentation reasons. Even if they aren't directly usable, it serves to show in the rustdocs what the inputs to the function are. Maybe allow _: Type for arguments, without generating any prelude for them?

Rust statements in the body of foo will generate the same code as they would in any other rust function. It is the responsibility of foo to guarantee that these generated statements are not UB. For example, foo may call a normal rust function in its body, but foo must ensure that there is a valid stack to do so.

One issue I have with this is that it's very unclear (and undocumented) what invariants are necessary to uphold to call arbitrary rust code inside a naked function. Are there memory requirement? Register requirements? Must the FLAGS be in a certain state? Registers initialized? I question the sanity of this. What is the benefit compared to putting the rust code in a separate extern "C" function and calling that from within the asm instead? This would make it extremely clear what the invariants are (they'd be the same as calling any other C function - see the SysV ABI, basically).

Is there any reason that instead of UB it shouldn't simply be a HALT instruction automatically inserted?

2 Likes