Dependency Free Rust Binary
09 Dec 2024Introduction
In some situations, you may need to build yourself a bare machine binary file. Some embedded applications can require this, as well as systems programming where you might be building for scenarios where you don’t have libraries available to you.
In today’s post, we’ll go through building one of these binaries.
Getting Started
Let’s create a standard binary project to start with.
cargo new depfree
This will produce a project that will have the following structure:
.
├── Cargo.toml
└── src
└── main.rs
Your application should have no dependencies:
[package]
name = "depfree"
version = "0.1.0"
edition = "2021"
[dependencies]
and, you shouldn’t have much in the way of code:
fn main() {
println!("Hello, world!");
}
We build and run this, we should see the very familiar message:
➜ cargo build
Compiling depfree v0.1.0 (/home/michael/src/tmp/depfree)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.92s
➜ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/depfree`
Hello, world!
This is already a pretty minimal program. Now our job starts!
Standard Library
When you build an application, by default all Rust crates will link to the standard library.
We can get rid of this by using the no_std
attribute like so:
#![no_std]
fn main() {
println!("Hello, world!");
}
After a quick re-build, we quickly run into some issues.
error: cannot find macro `println` in this scope
--> src/main.rs:3:5
|
3 | println!("Hello, world!");
| ^^^^^^^
error: `#[panic_handler]` function required, but not found
error: unwinding panics are not supported without std
Clearly, println
is no longer available to us, so we’ll ditch that line.
#![no_std]
fn main() {
}
We also need to do some extra work around handling our own panics.
Handling Panics
Without the no_std
attribute, Rust will setup a panic handler for you. When you have no_std
specified, this
implementation no longer exists. We can use the panic_handler
attribute to nominate a function that will handle our
panics.
#![no_std]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop { }
}
fn main() {
}
Now we’ve defined a panic handler (called panic
) that will do nothing more than just spin-loop forever. The
return type of !
means that the function won’t ever return.
We’re also being told that unwinding panics are not supported when we’re not using the standard library. To simplify
this, we can just force panics to abort. We can control this in our Cargo.toml
:
[package]
name = "depfree"
version = "0.1.0"
edition = "2021"
[profile.release]
panic = "abort"
[profile.dev]
panic = "abort"
[dependencies]
We’ve just disabled unwinding panics in our programs.
If we give this another rebuild now, we get the following:
error: using `fn main` requires the standard library
|
= help: use `#![no_main]` to bypass the Rust generated entrypoint and declare a platform specific entrypoint yourself, usually with `#[no_mangle]`
This is progress, but it looks like we can’t hold onto our main
function anymore.
Entry Point
We need to define a new entry point. By using the no_main
attribute, we are free to no longer define a main
function in our program:
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop { }
}
We really have no entry point now. Building this will give you a big horrible error and basically boils down to a linker error:
(.text+0x1b): undefined reference to `main'
/usr/bin/ld: (.text+0x21): undefined reference to `__libc_start_main'
Fair enough. Our linker is taking exception to the fact that we don’t have a _start
function which is what the
underlying runtime is going to want to call to start up. The linker will look for this function by default.
So, we can fix that by defining a _start
function.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop { }
}
#[no_mangle]
pub extern "C" fn _start() -> ! {
loop { }
}
The no_mangle
attribute makes sure that the _start
function maintains its name, otherwise the compiler will
use its own creativity and generate a name for you. When it does this, it mangles the name so bad that the linker
can no longer find it.
The extern "C"
is as you’d expect, giving this function C calling conventions.
The C Runtime
After defining our own _start
entrypoint, we can give this another build.
You should see a horrific linker error.
The program that the compiler and linker is trying to produce (for my system here at least) is trying to do so using the C runtime. As we’re trying to get dependency-free, we need to tell the build chain that we don’t want to use this.
In order to do that, we need to build our program for a bare metal target. It’s worth understanding what a “target triple” is and what one is made up of that you can start using. The rust lang book has a great section on this.
These take the structure of cpu_family-vendor-operating_system
. A target triple encodes information about the target of a
compilation session.
You can see all of the targets available for you to install with the following:
rustc --print=target-list
You need to find one of those many targets that doesn’t have any underlying dependencies.
In this example, I’ve found x86_64-unknown-none
. A 64-bit target produced by unknown
for not particular operating
system: none
. Install this runtime:
rustup target add x86_64-unknown-none
Let’s build!
➜ cargo build --target x86_64-unknown-none
Compiling depfree v0.1.0 (/home/michael/src/tmp/depfree)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s
We’ve got a build!
Output
Now we can inspect the binary that we’ve just produced. objdump
tells us that we’ve at least made an elf64:
target/x86_64-unknown-none/debug/depfree: file format elf64-x86-64
Taking a look at our _start
entrypoint:
Disassembly of section .text:
0000000000001210 <_start>:
1210: eb 00 jmp 1212 <_start+0x2>
1212: eb fe jmp 1212 <_start+0x2>
There’s our infinite loop.
Running, and more
Did you try running that thing?
As expected, the application just stares at you doing nothing. Excellent. It’s working.
Let’s add some stuff back in. We can start writing a little inline assembly language easy enough to start to do some things.
We can import asm
from the core::arch
crate:
use core::arch::asm;
pub unsafe fn exit(code: i32) -> ! {
let syscall_number: u64 = 60;
asm!(
"syscall",
in("rax") syscall_number,
in("rdi") code,
options(noreturn)
);
}
The syscall at 60 is sys_exit
. In 64-bit style, we load it up in rax
and
put the exit code in rdi
.
We can relax in _start
point now that it’s unsafe:
#[no_mangle]
pub unsafe fn _start() {
exit(0);
}
We can now build this one:
➜ cargo build --target x86_64-unknown-none
Compiling depfree v0.1.0 (/home/michael/src/tmp/depfree)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
We can crack this one open now, and take a look at the underlying implementation.
Disassembly of section .text:
0000000000001210 <_ZN7depfree4exit17h5d41f4f9db19d099E>:
1210: 48 83 ec 18 sub $0x18,%rsp
1214: 48 c7 44 24 08 3c 00 movq $0x3c,0x8(%rsp)
121b: 00 00
121d: 89 7c 24 14 mov %edi,0x14(%rsp)
1221: b8 3c 00 00 00 mov $0x3c,%eax
1226: 0f 05 syscall
1228: 0f 0b ud2
122a: cc int3
122b: cc int3
122c: cc int3
122d: cc int3
122e: cc int3
122f: cc int3
0000000000001230 <_start>:
1230: 50 push %rax
1231: 31 ff xor %edi,%edi
1233: e8 d8 ff ff ff call 1210 <_ZN7depfree4exit17h5d41f4f9db19d099E>
Unsurprisingly, we’re calling our exit implementation which has been mangled - you’ll notice.
Let’s give it a run.
➜ ./depfree
➜ echo $?
0
Conclusion
Success - we’ve made some very bare-bones software using Rust and are ready to move onto other embedded and/or operating system style applications.