Rust Baremetal

Most Linux software and libraries rely on the GNU C Library (glibc for short). It’s a marvel of engineering created by Roland McGrath in 1987 and incorporated into the GNU Project. It’s well known for its integration in the GNU/Linux operating system. As one of the most renowned C libraries (and software libraries), many projects rely on it: operating systems, languages, applications, libraries, etc.

While this is the go-to library in most POSIX-compliant operating systems, it’s not always possible to build software that relies on it (directly or under the hood). Such cases include kernels, embedded devices, and more. In these scenarios, you must create a platform-independent binary or library. This approach is known as creating a standalone binary or library — one that can run without relying on a host OS or standard libraries.

In today’s post, I’ll guide you through creating your own standalone binary in Rust!

Requirements

First, you’ll need to install the Rust toolchain manager. It’s the default method for installing Rust.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

This command installs three essential tools: rustc (the Rust compiler), cargo (Rust’s package manager), and rustup (for managing Rust versions and targets).

Create a New Project

Let’s start by creating a new project:

cargo new standalone_binary

You’ll see a project layout like this:

standalone_binary
├── Cargo.toml
└── src
    └── main.rs

If you’re building a library, main.rs should be lib.rs, and specific keys will be found in the Cargo.toml manifest.

This setup is typical for Rust, but it still depends on glibc for the standard library.

Toolchain Setup

Rust uses the LLVM Target Triple system in its compilation pipeline. A good example is x86_64-unknown-linux-gnu. This means the compiler builds your software for the x86_64 architecture, with no vendor (unknown), the GNU/Linux operating system, and a gnu environment.

Next, add a bare-metal target to your toolchain:

rustup target add x86_64-unknown-none

You can list installed targets with:

rustup target list --installed

Try compiling the project using the new target:

cargo build --target x86_64-unknown-none

This should produce an error like:

error[E0463]: can't find crate for `std`
  |
  = note: the `x86_64-unknown-none` target may not support the standard library
  = note: `std` is required by `standalone_binary` because it does not declare `#![no_std]`

error: cannot find macro `println` in this scope
 --> src/main.rs:2:5
  |
2 |     println!("Hello, world!");
  |     ^^^^^^^

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

error: requires `sized` lang_item

That’s expected — we’re intentionally bypassing the standard Rust setup.

To make things easier, let’s set this target as the default so you don’t have to specify it every time. Create a .cargo/config.toml file:

mkdir .cargo
touch .cargo/config.toml

Add the following content:

[build]
target = "x86_64-unknown-none"

Now you can omit --target when building!

Building

Error Explanation

The error messages are straightforward. The compiler can’t find println!, no panic_handler function exists, the Sized language item is missing, and the std crate is required.

That’s expected. The x86_64-unknown-none target doesn’t support the standard library.

You can inspect the metadata of your target:

rustc --print target-spec-json -Z unstable-options --target x86_64-unknown-none

Look for "std": false in the metadata section.

Removing the Standard Library Requirement

At the top of main.rs, disable the standard library and remove the println! macro:

#![no_std]

fn main() {
    // nothing for now
}

tip

If you’re using an LSP, you’ll probably need to configure your Cargo.toml like so:

[[bin]]
name = "standalone_binary"
path = "src/main.rs"
test = false
doctest = false
bench = false

This helps avoid issues where the LSP tries to run tests or documentation checks — features that aren’t supported in a no_std setup.

Setting Up panic_handler

Now define a basic panic handler in main.rs:

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

You can enhance it later to log messages or assist with debugging. See the Rust Nomicon for details.

tip

To avoid full panic handling for now, add this to your Cargo.toml:

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

Even with abort, you still need the basic panic_handler to satisfy the compiler.

(Binary-Specific) Setting Up an Entry Point

Rust assumes main() is the entry point, but for a bare-metal target, you must define it yourself. Replace main() with _start():

#![no_std]
#![no_main]

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

#[no_mangle]
pub extern "C" fn _start() -> ! {
    loop {}
}
  • #![no_main] disables the automatic main entry point.
  • #[no_mangle] keeps the _start name as-is.
  • extern "C" ensures compatibility with the C ABI.

note

_start is the conventional C-style entry point. Some architectures may require a different name — check their documentation.

Conclusion

With this setup, you now have a basic freestanding binary or library that doesn’t rely on std or glibc. It’s a solid foundation for bare-metal or embedded development!