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 theunsafe()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:
rustuse 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
rustuse 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
rustuse 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
- The body must contain exactly one
naked_asm!()call. - You must handle the return instruction yourself (
ret,iretq, etc.). - You cannot use Rust expressions, variables, or function calls in the body — only
naked_asm!(). - Arguments follow the declared calling convention (e.g., System V ABI for
extern "C"on x86-64).
Common Pitfalls
- Forgetting
ret— The compiler does not add a return instruction. Without it, execution falls through to whatever comes next in memory. - Assuming Rust ABI argument positions — Naked functions use the declared ABI. For
extern "C"on x86-64, arguments are inrdi,rsi,rdx, etc. - Using
#[naked]instead of#[unsafe(naked)]— Edition 2024 requires theunsafe()wrapper.
Best Practices
- Keep naked functions minimal — Do register saves/restores in the naked function, then
calla normal Rust function for the actual logic. - Always use
#[cfg(target_arch)]— Naked functions contain architecture-specific assembly. - 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
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");
}