Rust nostd nolibc

What is it about ?

Last time we created a binary without stdlib in rust. But we still had a dependency on libc. If we really want to go baremetal, we need to remove this dependency.

This time we need to dig into how a binary is started. Here I’m focusing on Linux, but the usual target for this kind of project is not Linux. For your specific case, you may need to look for documentation on how to run a binary. You may boot from : bios, grub, efi, FreeRTOS, uboot… Anything that doesn’t have a libc.

But in our case we’ll have the opportunity to know how libc itself starts binaries.

First try

If we try the code from previous post but remove the libc dependency we get the following error :

  = note: /usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/12/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
          (.text+0x1d): undefined reference to `__libc_start_main'
          collect2: error: ld returned 1 exit status

It means that the compiler generated a _start entry point (it’s not really a function) for us. This is where Linux loads programs. Since rust generates code with llvm, it knows that you are using C (or a derivative (or something that uses libc)). So it generated some code that runs C’s main from libc.

Note: you may not have this exact method call error, depending on the platform you are running on. Each OS/architecture has a slightly different way of starting binaries. Here I’m on Linux x86_64.

Let’s try to provide this function ourselves. It is described in LinuxStandardBase documentation.

// on linux we have to depend on linux-syscall crate for this
use linux_syscall::*;

// Make sure the function name is not changed by the compiler
#[no_mangle]
pub extern "C" fn __libc_start_main(main: extern "C" fn(isize, *const *const u8, *const *const u8) -> isize,
                                    argc: isize,
                                    argv: *const *const u8,
                                    _init: extern "C" fn() -> (),
                                    _fini: extern "C" fn() -> (),
                                    _rtld_fini: extern "C" fn() -> (),
                                    _stack_end: *const u8
                                   ) -> isize {
    // call the real main (ie the one we just defined before)
    let ret = main(argc, argv, argv);
    // This is mandatory to exit properly a linux binary
    unsafe {
        // .as_usize_unchecked is called but ignored to suppress compiler warning about must use result
        syscall!(SYS_exit, ret).as_usize_unchecked();
    };
    // this return will never be reached but the compiler doesn't know that
    0
}

You can check that it works by returning a non zero value in your main.

Hello again!

We are missing our beloved “hello world”, but as you can see, we added a dependency to linux-syscall because we are on Linux. This is a real gain, because contrarily to other no-stdlib platforms (like bare metal), we have access to a wide variety of services provided by the Linux kernel; and they are named syscalls.

For a proper hello world, let’s add this to our main.

    let hello = "Hello world\n";
    let stdout = 1;
    unsafe {
        syscall!(SYS_write, stdout, hello.as_ptr(), hello.len()).as_usize_unchecked();
    }

Here we should check the return value of as_usize_unchecked() but let’s work like real C developer and just ignore it.

You may have expected me to handle manually the final null byte because we are working with C strings, since but we are writing directly on a file descriptor (stdout) with a syscall, this not needed.


Next time we replace this ugly __libc_start_main