no_std and embedded Rust
#![no_std] tells the compiler that this crate does not use the standard library. What remains is core: the types, the traits, the iterators, the slices, the formatting machinery and the arithmetic, all of it usable without an operating system. A std program starts in main because a C library calls it; a no_std program has no such caller and brings its own entry point.
This lesson builds a freestanding binary on a normal Linux machine, which is the smallest way to see the whole shape: core instead of std, a _start that ends with a system call, the panic handler the linker insists on, the size difference against a hello world, and the call that a no_std crate cannot resolve.
core instead of std
A crate with #![no_std] and #![no_main] has no main, no heap allocator and no file system. The program below sums three numbers with a while loop and leaves the result as its exit status, which is the only channel a machine with no terminal provides.
The exit is a system call: Linux takes its number in rax and the status in rdi. The options(noreturn) tells the compiler that the block does not come back, so the function needs no return.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
let values = [1u64, 2, 3];
let mut sum = 0u64;
let mut i = 0;
while i < values.len() {
sum += values[i];
i += 1;
}
unsafe {
core::arch::asm!("syscall", in("rax") 60u64, in("rdi") sum, options(noreturn));
}
}
rustc --edition 2024 -C panic=abort -C link-arg=-nostartfiles -C link-arg=-static sum.rs -o sum
./sum; echo "status $?"
status 6
Six is the sum of the three values, delivered as the exit status of a process that never printed anything. The attribute is written #[unsafe(no_mangle)] in edition 2024: the compiler treats a name that the linker must see as part of the unsafe code a programmer takes responsibility for. The lesson on editions covers the other changes of the same edition.
Three flags make this link on a hosted machine. -C panic=abort is required because unwinding needs the standard library, -nostartfiles removes the C entry point that would call main, and -static links the few pieces of libc that remain. On a bare-metal target there is no C library at all and the same program is the whole program.
The entry point
The operating system starts the process at the symbol _start and passes the arguments on the stack, which the C library normally unpacks before calling main. A no_std program defines the symbol itself and decides what to do with the registers, which is why the signature is extern "C" fn() -> ! and why it may not return.
The second program computes a checksum over four bytes with core only and returns the low byte of it as the status. The multiplication wraps, because a bare-metal build has no overflow panic to fall back on unless the programmer asks for one.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
fn checksum(bytes: &[u8]) -> u32 {
let mut sum = 0u32;
for byte in bytes {
sum = sum.wrapping_mul(31).wrapping_add(*byte as u32);
}
sum
}
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
let value = checksum(b"rust");
unsafe {
core::arch::asm!("syscall", in("rax") 60u64, in("rdi") (value & 0xff) as u64, options(noreturn));
}
}
rustc --edition 2024 -C panic=abort -C link-arg=-nostartfiles -C link-arg=-static check.rs -o checksum
./checksum; echo "status $?"
status 228
The for loop over a slice, the iterator behind it and the wrapping_mul all come from core, and none of them needs an allocator. The status is masked to one byte because a process status is a byte on Unix and a wider value would be truncated, which is the kind of detail a bare-metal program expresses with a return value instead. The lesson on numbers covers the wrapping arithmetic.
A panic handler
A panic has to end somewhere, and in a std program it ends in code that prints a message and exits. A no_std crate has no such code, so the compiler requires the program to provide one function with the #[panic_handler] attribute. Without it the build stops with one line that names the missing item.
#![no_std]
#![no_main]
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
loop {}
}
rustc --edition 2024 -C panic=abort -C link-arg=-nostartfiles -C link-arg=-static nopanic.rs -o nopanic
error: `#[panic_handler]` function required, but not found
error: aborting due to 1 previous error
The handler of the programs above never returns: it loops forever, which is the shape a target with no way to report an error uses, and a real board writes a message to a serial port and resets instead. This build links against the host, so a panic that core does not fold away may reach the C library rather than this function; the handler is still required, because the compiler has to know what a panic means before it can emit any code at all.
What std costs
The difference is easiest to see as a file size. A hello world built with the default settings and the checksum binary from the previous section do very little, and one of them carries the standard library's start-up path, the panic machinery, the formatting code and the system-call wrappers.
fn main() {
println!("hello");
}
rustc --edition 2024 hello.rs -o hello
ls -l checksum hello | awk '{print $5, $9}'
14064 checksum
444256 hello
Four hundred and forty thousand bytes against fourteen thousand: most of the difference is code that a hello world never calls but that the linker keeps because the program starts through it. A release build with -O and -C strip=symbols shrinks both, and the gap stays, because the standard library is the bigger half to begin with. On a microcontroller with sixty-four kilobytes of flash the first number is the one that fits, and it is the reason embedded projects are no_std by default.
The lesson on profiles covers the flags that shrink a release build, and the lesson on Box explains the allocator that a no_std crate does not have.
The standard library call
The first thing a newcomer writes in a no_std crate is a println!, and the error is short: the macro is not in scope, because it belongs to std and the crate asked for core.
#![no_std]
pub fn banner() {
println!("hello");
}
rustc --edition 2024 --crate-type lib stdcall.rs
error: cannot find macro `println` in this scope
--> stdcall.rs:4:5
|
4 | println!("hello");
| ^^^^^^^
error: aborting due to 1 previous error
The same happens to String, Vec, HashMap and File: they need an allocator or an operating system, and neither exists here. What is left is the core half of the same API: fmt::Write for a buffer of one's own, slices and arrays instead of vectors, and a &mut dyn Write that a board implements for its serial port.
The lesson on allocators shows how a crate brings a heap back by implementing GlobalAlloc, and the next lesson starts the first capstone, a JSON tokenizer that uses the standard library comfortably.
Exercises
- Change the exit status of the first program to the product of the three values and read it back with echo $?.
- Write a no_std function that reverses a fixed array in place and check it by returning a checksum as the exit status.
- Add -C strip=symbols to the hello world and compare the two sizes again.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does
#![no_std]remove from a crate?Answer
The standard library, leaving the core crate
core holds the language-level items; std adds the heap, files, threads and the OS.
-
Why does a
no_stdbinary define_startinstead ofmain?Answer
The operating system starts at
_start, and the C library is what normally callsmainWith no C start-up code, the program supplies the entry symbol itself.
-
Why is
-C panic=abortrequired for a hostedno_stdbuild?Answer
Unwinding needs the standard library
The unwinding machinery lives in std, so a no_std binary aborts instead.
-
Who provides
#[panic_handler]?Answer
The program, exactly once
A no_std binary must define the function, and the build fails without it.
-
Why is the exit status written into
rdiand the number 60 intorax?Answer
That is the Linux system call convention for exit
On x86-64 Linux the call number goes in rax and the first argument in rdi.
-
What does this program print?
#![no_std] #![no_main] use core::panic::PanicInfo; #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} } #[unsafe(no_mangle)] pub extern "C" fn _start() -> ! { let values = [2u64, 3, 4]; let mut sum = 0u64; let mut i = 0; while i < values.len() { sum += values[i]; i += 1; } unsafe { core::arch::asm!("syscall", in("rax") 60u64, in("rdi") sum, options(noreturn)); } }The program is built and run, and its status is printed withecho "status $?".Answer
status 9
The sum of two, three and four is nine, and the program prints nothing else.
-
What is the first line of the compiler output for this program?
#![no_std] #![no_main] #[unsafe(no_mangle)] pub extern "C" fn _start() -> ! { loop {} }Answer
error:
#[panic_handler]function required, but not foundA no_std program has to say what a panic means, and the linker build stops there.
-
What is the first line of the compiler output for this program?
#![no_std] pub fn banner() { println!("hello"); }Answer
error: cannot find macro
printlnin this scopeThe macro lives in std, so a no_std crate cannot resolve its name.
-
What does this program print, and what is its exit status?
#![no_std] #![no_main] use core::panic::PanicInfo; #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} } #[unsafe(no_mangle)] pub extern "C" fn _start() -> ! { let mut total = 0u32; let mut i = 1; while i <= 4 { total += i * i; i += 1; } unsafe { core::arch::asm!("syscall", in("rax") 60u64, in("rdi") (total & 0xff) as u64, options(noreturn)); } }The program is run and its status printed withecho "status $?".Answer
nothing, and the status is 30
The squares one to four sum to thirty, and the program has no way to print.
-
Which of these can a
#![no_std]crate with no allocator use?Answer
core::fmtwith a buffer it ownsThe formatting traits are in core; Vec and String need an allocator and File needs an OS.
The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev