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

  1. Change the exit status of the first program to the product of the three values and read it back with echo $?.
  2. Write a no_std function that reverses a fixed array in place and check it by returning a checksum as the exit status.
  3. 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.

  1. 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.

  2. Why does a no_std binary define _start instead of main?

    Answer

    The operating system starts at _start, and the C library is what normally calls main

    With no C start-up code, the program supplies the entry symbol itself.

  3. Why is -C panic=abort required for a hosted no_std build?

    Answer

    Unwinding needs the standard library

    The unwinding machinery lives in std, so a no_std binary aborts instead.

  4. Who provides #[panic_handler]?

    Answer

    The program, exactly once

    A no_std binary must define the function, and the build fails without it.

  5. Why is the exit status written into rdi and the number 60 into rax?

    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.

  6. 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 with echo "status $?".

    Answer

    status 9

    The sum of two, three and four is nine, and the program prints nothing else.

  7. 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 found

    A no_std program has to say what a panic means, and the linker build stops there.

  8. What is the first line of the compiler output for this program? #![no_std] pub fn banner() { println!("hello"); }

    Answer

    error: cannot find macro println in this scope

    The macro lives in std, so a no_std crate cannot resolve its name.

  9. 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 with echo "status $?".

    Answer

    nothing, and the status is 30

    The squares one to four sum to thirty, and the program has no way to print.

  10. Which of these can a #![no_std] crate with no allocator use?

    Answer

    core::fmt with a buffer it owns

    The 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