Fully-static, libc-free, pure-Rust Linux binaries.
fullrust is the no_std runtime that pairs with
purestd — a libc-free standard
library on raw syscalls — to let you write ordinary-looking Rust programs (with
println!, Vec, String, format!, command-line arguments, files, threads)
and compile them into Linux ELF executables that link no libc and no C runtime
at all. fullrust itself is small: it provides only what a hosted build gets
from crt0 and compiler_builtins — the process entry point and the mem*/unwind
symbols — while purestd is the standard library. Like the Go runtime, it talks to the
kernel directly through raw syscall instructions (see
Why a standalone std), and is
linked with the Rust-bundled LLVM linker, so no C toolchain is involved in
the build.
An ordinary crate — no fullrust dependency, no attributes, plain fn main —
builds into a libc-free static binary:
// src/main.rs
use std::collections::BTreeMap;
fn main() {
let mut counts = BTreeMap::new();
for w in "the quick brown fox the fox".split_whitespace() {
*counts.entry(w).or_insert(0) += 1;
}
println!("{counts:?}");
}$ cargo install cargo-fullrust
$ cargo fullrust build --release
$ file target/x86_64-fullrust-linux/release/wordcount
ELF 64-bit LSB executable, x86-64, statically linked
$ ldd target/x86_64-fullrust-linux/release/wordcount
not a dynamic executable
$ readelf -d target/x86_64-fullrust-linux/release/wordcount
There is no dynamic section in this file.There is no interpreter (PT_INTERP), no .dynamic section, and zero NEEDED
libraries. The only thing the binary needs to run is the Linux kernel.
A direct consequence — and an intended guard-rail — is that any program that tries to FFI into a dynamic library, or call an unprovided C symbol, fails at link time. Pure-Rust code links; anything reaching for libc does not.
The default (zero-touch) path needs a nightly toolchain with rust-src; the
--stable explicit-runtime path needs only stable:
rustup toolchain install nightly
rustup component add rust-src --toolchain nightlyInstall the subcommand once, then build a completely ordinary crate — plain
fn main, use std::…, no dependency on fullrust, no attributes — into a
libc-free static binary:
cargo install cargo-fullrust// src/main.rs — nothing fullrust-specific
use std::collections::BTreeMap;
fn main() {
let mut m = BTreeMap::new();
*m.entry("hi").or_insert(0) += 1;
println!("{m:?}");
}cargo fullrust build --release # -> target/x86_64-fullrust-linux/release/<bin>
cargo fullrust run -- arg1How: cargo fullrust builds (and caches) a sysroot whose std is fullrust's
own standard library, then compiles your crate against it. Your use std::…
resolves to the syscall-backed std, and the binary links no libc. Needs a
nightly toolchain with rust-src
(rustup component add rust-src --toolchain nightly). Coverage is whatever
purestd implements — gaps surface as
ordinary "not found in std" errors as the stdlib grows.
Build static artifacts in CI with the reusable action (see
action.yml). To keep them as workflow artifacts:
- uses: KarpelesLab/fullrust@master
with:
working-directory: . # your crate
args: --release
- uses: actions/upload-artifact@v4
with:
path: target/x86_64-fullrust-linux/release/<your-bin>On a tag (or a published release), build with the action and upload the binary
as a release asset. Note the contents: write permission:
name: Release
on:
push:
tags: ["v*"] # or: release: { types: [published] }
permissions:
contents: write # required to upload release assets
jobs:
static-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build the libc-free static binary
uses: KarpelesLab/fullrust@master
with:
args: --release --bin myapp # your binary
- name: Package as a .tar.gz
run: |
name="myapp-${{ github.ref_name }}-x86_64-linux-static"
mkdir -p "dist/$name"
cp target/x86_64-fullrust-linux/release/myapp "dist/$name/"
cp README.md LICENSE* "dist/$name/" 2>/dev/null || true
tar -C dist -czf "dist/$name.tar.gz" "$name"
- name: Attach to the release
uses: softprops/action-gh-release@v2
with:
files: dist/*.tar.gzsoftprops/action-gh-release creates/updates the release for the tag and
uploads the archive; with the release: published trigger it attaches to the
existing release instead. (Equivalent with the CLI:
gh release upload "$TAG" dist/*.tar.gz.)
The output path is target/x86_64-fullrust-linux/release/<bin> relative to the
crate's workspace root (so if you set working-directory:, the binary still
lands under that workspace's target/).
If you'd rather opt in directly (smaller surface, works without the sysroot),
write a no_std crate against the runtime and pass --runtime:
[dependencies]
fullrust = "0.1"#![no_std]
#![no_main]
use fullrust::prelude::*;
fn main() { println!("hello from libc-free rust"); }
fullrust::entry!(main);cargo fullrust --runtime build --release # build-std (nightly)
cargo fullrust --stable build # precompiled core/alloc, no nightlyThe ./x wrapper is the in-repo equivalent of cargo fullrust (it predates the
subcommand and is what CI uses):
./x build # all examples, stable path
./x run -p hello # build + run one example
./x run -p args -- a b c # pass arguments
./x build --nightly --release # smaller binaries via build-stdExamples live in examples/: hello, args, alloc-demo,
panic-demo, std-smoke.
fullrust pairs with a standalone standard library —
purestd — implemented directly on
Linux syscalls, instead of reusing the platform libc or the upstream std that
sits on top of it. This is the same architecture as the
Go runtime: Go issues syscalls itself and only touches libc when you opt into
cgo. fullrust is, in effect, Rust with cgo off — programs are written against
fullrust's std, and the binary's only boundary to the outside world is the
syscall instruction.
There are three ways one could get a libc-free Rust, and we deliberately chose the first:
- Own std (what fullrust does). A bespoke standard library on raw syscalls.
Crates opt in (
#![no_std]+ thestdalias;cargo fullrustdrives the build). Purest binaries, full control. - Port upstream
std's platform backend. Add a freestandingsysbackend inside Rust's ownlibrary/std(as the SGX/UEFI/Hermit targets do) andbuild-stdthe real std. Truly "it isstd," but means maintaining a fork of the standard library pinned to a nightly — large and perpetual. - A pure-Rust libc (the Eyra /
c-scapemodel). Keep the realstdand satisfy the libc symbols it calls with Rust implementations.
The deciding principle is keeping Rust's guarantees end-to-end, all the way to the kernel. Routing the standard library through a libc-shaped ABI (option 3) gives that up at a C-shaped wall in the middle of every OS interaction:
- Zero-cost abstraction survives only without the wall. In own-std,
io::Write::write→syscallinlines and monomorphizes end to end — Rust's signature zero-cost-abstraction property, intact on the hot path. Anextern "C"libc seam is opaque to the optimizer: it cannot be inlined or specialized across, so you forfeit exactly that benefit at exactly the wrong place. - Rust types all the way down. Our I/O returns
Result/Errno; buffers are slices with lengths; errors are values. A libc seam forces C shapes — raw*mut u8,c_int, NUL-terminated strings,struct stat, anderrnoas a thread-local int — with a double-translation tax on every call. - A tiny
unsafesurface instead of a vast one. A pure-Rust libc is almost entirelyunsafe: it implements a raw-pointer ABI and must reproduce glibc's quirks exactly — symbol versioning (memcpy@GLIBC_2.14), struct layouts,__errno_location, the TLS model,environ, the__libc_start_mainhandshake. Any subtle mismatch is undefined behavior. own-std's onlyunsafeis the handful ofsyscallsites plus a few compiler-mandated leaf symbols (memcpy,_start) — not an entire operating-system interface.
A clarification, to be fair to option 3: the std → libc boundary already
exists in normal Rust (std reaches glibc through unsafe extern "C"). So a libc
shim would not add unsafe FFI to your code, and a plain extern "C" call into
a Rust function is cheap (nothing like cgo's stack-switch cost). What you lose is
narrower but real: the optimizer goes blind at the seam, the API degrades to C
shapes there, and you take on a large unsafe ABI layer to build and maintain.
For "the purest possible binaries" on code you control, that trade isn't worth
it — so fullrust keeps Rust idioms and optimization unbroken from main down to
the syscall, and accepts the one cost of the own-std model: like Go, programs
target fullrust's std rather than running arbitrary upstream-std crates
unmodified. (Pure no_std + alloc libraries from crates.io still work as-is;
only crates that need OS services through std need to target fullrust's.)
A normal Rust binary starts life in libc's crt0: the kernel jumps to libc's
_start, which sets up the C runtime and eventually calls your main. Removing
libc means we have to supply everything that machinery provided. There turn out
to be only a handful of pieces.
On execve, the Linux kernel transfers control to the ELF entry symbol
_start with the stack laid out as:
rsp -> argc
argv[0] … argv[argc-1], NULL
envp[0] … envp[m-1], NULL
auxv …
There is no return address. fullrust defines _start as a naked function
(no prologue/epilogue) that captures rsp, aligns the stack as the SysV ABI
requires, parses argc/argv/envp, and calls main — see
arch/x86_64.rs and
start.rs.
There are two ways main gets wired up:
- Zero-touch (default). The sysroot
stdprovides thestartlang item, so an ordinaryfn mainlinks exactly as it would under real std — the compiler-generated entry calls ourlang_start. --runtime. Ano_stdcrate exports itsmainas__fullrust_mainvia theentry!macro, which_startcalls directly.
Either way the process ends with exit_group.
Every interaction with the outside world is a raw syscall. The instruction
wrappers (syscall0…syscall6) and the syscall-number table are the only
architecture-specific code; everything else builds on the arch-neutral,
Result-returning wrappers in syscall.rs
(read, write, open, close, mmap, munmap, getrandom,
exit_group, …).
Even pure Rust expects a few free-standing symbols that libc normally provides.
fullrust supplies them in intrinsics.rs:
memcpy/memmove/memset/memcmp/bcmp— the compiler lowers struct copies, slice fills, comparisons, etc. to these. (They are simple byte loops; LLVM's loop-idiom pass deliberately won't rewrite a loop insidememcpyinto a call tomemcpy, so there is no self-recursion.)strlen— used bycore::ffi::CStr::from_ptr, which we use to read the NUL-terminatedargv/envpstrings.rust_eh_personality/_Unwind_Resume— unwinding hooks. We build withpanic = "abort", so unwinding never happens and these are abort-stubs that are never actually executed (see the panic discussion below).
To get Box, Vec, String and format!, fullrust provides a
#[global_allocator]: a small mmap-backed segregated free-list allocator
(see allocator.rs). Requests up to 64 KiB
are rounded to a size class and served from per-class free lists carved out of
1 MiB mmap arenas; larger requests get their own mmap/munmap. It is
Sync via a spinlock guarding the arena state.
The binary needs exactly one #[panic_handler]. fullrust's
(in panic.rs) prints the message and source
location to stderr and calls exit_group(134) (mimicking 128 + SIGABRT).
Because we compile with panic = "abort", panicking never unwinds, so the
unwind stubs from (3) are never reached.
The binary-policy symbols —
_start, the#[panic_handler], and the#[global_allocator]static — are gated behind fullrust's defaultrtfeature. The--runtime/entry!model leavesrton so they come fromfullrust; the zero-touch sysrootstdturnsrtoff and supplies them itself (you can only have one of each per binary). The mechanisms — the syscalls, theAllocatortype, the mem intrinsics — are always present.
The build invokes the LLVM linker (rust-lld) directly as ld.lld, rather
than through a C compiler driver. That means no crt0, no implicit -lc, no
cc — just our object files plus core/alloc. Combined with
-relocation-model=static and -static, the result is a position-dependent,
fully static ELF with no dynamic section. (rust-lld's generic driver picks
GNU/ELF mode from the name ld.lld; the bare name rust-lld does not, which is
why ./x points at the gcc-ld/ld.lld shim.)
cargo fullrust has three ways to produce a binary; all are genuinely libc-free
and fully static.
| Mode | Crate looks like | Toolchain | How |
|---|---|---|---|
| zero-touch (default) | unmodified fn main, use std, no deps |
nightly + rust-src |
sysroot whose std is fullrust's, built once and cached; your crate compiles with --sysroot |
--runtime |
#![no_std] + fullrust::entry! |
nightly + rust-src |
-Z build-std recompiles core/alloc; fullrust supplies the runtime |
--stable |
#![no_std] + fullrust::entry! |
stable | precompiled core/alloc from the sysroot; our abort-stubs satisfy alloc's unwind references |
The --runtime/--stable paths use a different target triple per path so
the freestanding linker flags never touch host build scripts (./x mirrors this
in-repo). On a release build a trivial program is well under 10 KiB.
-Z build-std recompiles compiler_builtins, whose build script must
compile for the host (with the system cc and libc). If our freestanding
crates and that host build script shared a target triple, cargo would apply the
-static/ld.lld flags to the build script too and it would fail to link.
Giving the nightly path its own triple (x86_64-fullrust-linux) keeps host
artifacts on the normal -gnu host triple with the system linker, while our
code builds freestanding. The stable path has no build scripts in its graph, so
it can stay on the plain -gnu triple.
- Add its number to
pub mod nrinarch/x86_64.rs. - Add a safe wrapper in
syscall.rsusing the rightsyscallNandfrom_retfor error handling.
All CPU/ABI-specific code is confined to arch/. To port to, say, aarch64:
- Add
crates/fullrust/src/arch/aarch64.rsprovidingsyscall0…6(thesvc #0sequence, args inx0…x5, number inx8), the naked_start(stack pointer arrives inx0/sp), and thenrtable for that ABI. - Wire it up with a
#[cfg(target_arch = "aarch64")]arm inarch/mod.rs. - Add an
aarch64-fullrust-linux.jsontarget spec (next to the x86_64 one incrates/cargo-fullrust/) for the nightly path.
The rest of the crate is written against arch::syscallN and arch::nr only.
crates/fullrust/ the runtime (crt0 + compiler_builtins equivalent)
src/entry.rs naked _start: argc/argv/envp -> purestd's `main`
src/intrinsics.rs mem*/strlen + getauxval + _Unwind_Resume stub
src/lib.rs crate root
(the standard library is the separate `purestd` crate;
it provides the API + panic handler + allocator +
rust_eh_personality)
crates/cargo-fullrust/ the `cargo fullrust` subcommand
src/main.rs builds/caches the sysroot, drives cargo
sysroot/std_lib.rs the sysroot `std` (re-exports purestd + lang_start)
x86_64-fullrust-linux.json freestanding target spec (cargo-fullrust + ./x)
examples/ hello, args, alloc-demo, panic-demo (purestd + entry!);
std-smoke (net/time/threads; live network, manual);
plain (zero-touch demo: ordinary crate, no deps)
action.yml reusable GitHub Action (build static artifacts in CI)
x in-repo build wrapper (linker resolution + path selection)
.cargo/config.toml intentionally minimal — see ./x
- Linux + x86-64 only today (the design isolates arch-specific code; see Extending).
- Threads are real:
clone-backed with per-thread stacks, futex-basedMutex/Condvar/RwLock, and genuine per-thread TLS (thread_local!via#[thread_local], with the thread pointer set from the program'sPT_TLSimage).thread_local!needsfeature(thread_local), whichcargo fullrustand./xinject automatically on the nightly paths (so the--stablepath doesn't supportthread_local!). Still missing:process::Command(spawning child processes), and TLS destructors don't run at thread exit. - The DNS resolver does plain DNS +
/etc/hosts(no NSS). - No dynamic linking, by design. FFI into a
.socannot link. panic = "abort"is mandatory — it's what lets us drop the unwinder.- The allocator is deliberately simple (no cross-class coalescing). It is
correct and fine for typical workloads, not a general-purpose
malloc. - Build through
cargo fullrust(or./xin-repo); a barecargo buildwon't have the linker/target wiring.
MIT OR Apache-2.0.