How to create a no_std rust binary

A no_std rust binary is an executable that does not link to the rust standard library.

Create the project

cargo new --bin nostd

Avoid linking to std

To tell the compiler that we don't need to stinking std, we need to add [no_std] at the top of our main file

// src/main.rs
#![no_std]

fn main() {
    println!("Hello, world!");
}

Now, if we try to build the project with cargo build, we'll get a bunch of errors.

println

error: cannot find macro `println` in this scope

The first is about println, which we can get around by removing it from our code

// src/main.rs
#![no_std]

fn main() {
}

panic_handler

error: `#[panic_handler]` function required, but not found

The second is about panic_handler. To get around this error, we're gonna define a function that we will annotate as a panic handler

// src/main.rs
#![no_std]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

fn main() {
}

The panic function's return type is ! because it's a function that never returns., moreover, the compiler will warns us about this if we don't specify ! as its return type.

eh_personality

error: language item required, but not found: `eh_personality`

eh_personality is a language item that is used by the compiler for stack unwinding and general failure. We do not support stack unwinding for now, so we'll just tell the compiler to abort on failure. To do so, create a .cargo directory inside our project, then create a config.toml file inside it

# .cargo/config.toml

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

start language item

error: requires `start` lang_item

This means the compiler cannot find the entry point of our program. How come? We've defined main, ain't it? Well, turns out main is not the real entry point of programs, the rust standard library sets up some required magic before handing control to us. This magic should happen in the start language item. For that, we could change the arguments of main and annotate it with #[start]

// src/main.rs
#![no_std]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[start]
fn main(argc: isize, argv: *const *const u8) -> isize {
    0
}

If we do that however, the compiler will complain that it's an experimental feature, and we would need to use the nightly toolchain. We do not want to use the nightly toolchain, however.

To get around this error, we're gonna add #![no_main] to our source file to tell the compiler to shut up about main

// src/main.rs
#![no_std]
#![no_main]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

But now, it's the linker that complains it can't find a _main symbol:

note: ld: entry point (_main) undefined. for architecture arm64

It's libc that wants to call the program's entry point (main). No problem, we just have to create a function called main, disable name mangling, and give it C linkage

// src/main.rs
#![no_std]
#![no_main]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[no_mangle]
pub extern "C" fn main() -> ! {
    loop {}
}

On macOS however, every executable needs to link to libSystem.dylib, so we also have to tweak .cargo/config.toml

# .cargo/config.toml

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-lSystem"]

Printing to stdout

If we run the executable with cargo run, it just hangs indefinitely, because main only contains an infinite loop. We need to make it do something so we can be sure it really works, for that, we're gonna print a string to stdout. Printing a string to stdout is writing a string to file descriptor number 1. Writing to a file descriptor is calling the write libc function, which is defined as:

ssize_t write(int fildes, const void *buf, size_t nbyte);

To call a C function from rust, we need to define it inside an extern "C" block, and convert it's definition to rust syntax

extern "C" {
    fn write(fildes: i32, buf: *const core::ffi::c_void, nbyte: usize);
}

To call it from rust, it must be inside an unsafe block:

let msg = b"It works!\n";
unsafe {
    write(1, msg.as_ptr() as *const core::ffi::c_void, msg.len());
}

And now that it does something, we could also just return a value from main instead of looping indefinitely.

Final program

Putting it all together:

// src/main.rs
#![no_std]
#![no_main]

extern "C" {
    fn write(fildes: i32, buf: *const core::ffi::c_void, nbyte: usize);
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! { 
    loop {}
}

#[no_mangle]
pub extern "C" fn main() -> u32 {
    let msg = b"It works!\n";
    unsafe {
        write(1, msg.as_ptr() as *const core::ffi::c_void, msg.len());
    }   
    0
}

If we run this program, it will print It works to stdout, and exit with a status code of 0