Introduction

Naked functions give you complete control over a function's assembly, with no compiler-generated prologue or epilogue. Stabilized in Rust 1.88, the #[unsafe(naked)] attribute combined with naked_asm!() lets you write functions that the compiler does not touch at all — every instruction is yours. This is essential for interrupt handlers, context switching, and trampoline functions.

Key Concepts

  • #[unsafe(naked)]: An attribute that tells the compiler to emit no prologue (stack frame setup) or epilogue (stack cleanup) for the function. Edition 2024 requires the unsafe() wrapper.
  • naked_asm!(): The only statement allowed in a naked function body. It contains the complete assembly for the function.
  • Prologue/Epilogue: The compiler-generated code that sets up and tears down the stack frame (push rbp, mov rbp rsp, etc.).
  • Calling convention: Naked functions still declare a calling convention (extern "C") so callers know how to pass arguments.

Real World Context

Operating system kernels use naked functions for interrupt handlers (where the CPU pushes a specific stack layout), context switches (where you must save/restore all registers yourself), and system call entry points. Embedded Rust uses them for exception vectors and bootloader entry points.

Deep Dive

Basic Naked Function

A naked function contains only a naked_asm!() call:

rust
use std::arch::naked_asm;

#[unsafe(naked)]
pub extern "C" fn add_naked(a: u64, b: u64) -> u64 {
    // No prologue or epilogue generated by the compiler
    naked_asm!(
        "mov rax, rdi",  // a is in rdi (System V ABI)
        "add rax, rsi",  // b is in rsi
        "ret",           // Return value in rax
    );
}

The compiler generates no extra code — the assembly you write is the entire function. You must include the ret instruction yourself.

Why Naked Functions Exist

Normal Rust functions get a prologue and epilogue:

asm
; Compiler-generated prologue
push rbp
mov rbp, rsp
sub rsp, 32

; Your code here

; Compiler-generated epilogue
add rsp, 32
pop rbp
ret

For interrupt handlers, this is wrong because the CPU has already pushed a specific stack frame. For context switches, you need to save registers in a specific order. Naked functions eliminate the compiler's interference.

Interrupt Handler Example

rust
use std::arch::naked_asm;

#[unsafe(naked)]
extern "C" fn timer_interrupt_handler() {
    naked_asm!(
        // Save all general-purpose registers
        "push rax",
        "push rcx",
        "push rdx",
        "push rbx",
        "push rbp",
        "push rsi",
        "push rdi",

        // Call the Rust handler
        "call {handler}",

        // Restore all registers
        "pop rdi",
        "pop rsi",
        "pop rbp",
        "pop rbx",
        "pop rdx",
        "pop rcx",
        "pop rax",

        // Return from interrupt
        "iretq",
        handler = sym handle_timer,
    );
}

extern "C" fn handle_timer() {
    // Safe Rust code for timer handling
}

The naked function handles the register save/restore and iretq, while the actual logic is in a safe Rust function.

Context Switch Skeleton

rust
use std::arch::naked_asm;

#[repr(C)]
struct TaskContext {
    rsp: u64,
    rbp: u64,
    rbx: u64,
    r12: u64,
    r13: u64,
    r14: u64,
    r15: u64,
}

/// Switch from `old` task to `new` task.
/// # Safety
/// Both pointers must be valid TaskContext pointers.
#[unsafe(naked)]
pub unsafe extern "C" fn switch_context(
    old: *mut TaskContext,
    new: *const TaskContext,
) {
    naked_asm!(
        // Save callee-saved registers to old context
        "mov [rdi + 0],  rsp",
        "mov [rdi + 8],  rbp",
        "mov [rdi + 16], rbx",
        "mov [rdi + 24], r12",
        "mov [rdi + 32], r13",
        "mov [rdi + 40], r14",
        "mov [rdi + 48], r15",
        // Load callee-saved registers from new context
        "mov rsp, [rsi + 0]",
        "mov rbp, [rsi + 8]",
        "mov rbx, [rsi + 16]",
        "mov r12, [rsi + 24]",
        "mov r13, [rsi + 32]",
        "mov r14, [rsi + 40]",
        "mov r15, [rsi + 48]",
        "ret",
    );
}

This is the core of any green-thread or coroutine runtime.

Rules for Naked Functions

  1. The body must contain exactly one naked_asm!() call.
  2. You must handle the return instruction yourself (ret, iretq, etc.).
  3. You cannot use Rust expressions, variables, or function calls in the body — only naked_asm!().
  4. Arguments follow the declared calling convention (e.g., System V ABI for extern "C" on x86-64).

Common Pitfalls

  1. Forgetting ret — The compiler does not add a return instruction. Without it, execution falls through to whatever comes next in memory.
  2. Assuming Rust ABI argument positions — Naked functions use the declared ABI. For extern "C" on x86-64, arguments are in rdi, rsi, rdx, etc.
  3. Using #[naked] instead of #[unsafe(naked)] — Edition 2024 requires the unsafe() wrapper.

Best Practices

  1. Keep naked functions minimal — Do register saves/restores in the naked function, then call a normal Rust function for the actual logic.
  2. Always use #[cfg(target_arch)] — Naked functions contain architecture-specific assembly.
  3. Test with integration tests — Naked functions are hard to unit test. Test them through integration tests that exercise the full call path.

Summary

  • #[unsafe(naked)] + naked_asm!() gives complete control over function assembly (stabilized in Rust 1.88).
  • The compiler generates no prologue, epilogue, or return instruction.
  • Essential for interrupt handlers, context switches, and trampoline functions.
  • The body must contain only a single naked_asm!() call.
  • Keep the assembly minimal and delegate to safe Rust functions via call.

Code Examples

rust
use std::arch::naked_asm;

/// A trampoline that saves all callee-saved registers,
/// calls a Rust closure via a function pointer, then restores
/// registers. Used for green thread initialization.
#[unsafe(naked)]
#[cfg(target_arch = "x86_64")]
pub extern "C" fn thread_entry_trampoline() {
    naked_asm!(
        // rdi = pointer to the entry function
        // rsi = pointer to the argument

        // Align stack to 16 bytes (required by System V ABI)
        "and rsp, -16",

        // Call the entry function with the argument
        "mov rdi, rsi",
        "call rax",       // rax was set up by the scheduler

        // If the function returns, call thread_exit
        "call {exit}",

        // Should never reach here
        "ud2",

        exit = sym thread_exit,
    );
}

extern "C" fn thread_exit() {
    // Clean up thread resources
    println!("Thread finished");
}
✓ Completed