Rust as a Systems Language
Rust earns the label "systems language" through three specific capabilities that most application languages either hide or don't offer at all: direct access to the operating system boundary below the standard library's portable abstractions, the ability to work with raw memory and its exact layout, and a defined way to speak the C ABI so Rust code can call, and be called by, code written in an entirely different language. None of these are exotic add-ons - they are what "systems programming" concretely means, and Rust's answer to each is unusually explicit compared to C or C++.
This page connects those three capabilities into one picture. The section's other pages - libc and syscalls, raw memory, bindgen, cbindgen, sound bindings - each go deep on one piece of what this page maps out.
Summary
- Systems programming in Rust means working at three explicit levels below
std's portable abstractions - the OS syscall boundary, raw memory layout, and the C ABI - each requiringunsafeand each with its own failure mode. - Insight:
stddeliberately hides platform differences and memory layout for safety and portability; systems code exists precisely where those hidden details become the thing you need to control. - Key Concepts: syscall,
libc, memory layout,repr(C), ABI, FFI, undefined behavior. - When to Use: Calling C libraries, exposing Rust to other languages, parsing binary formats, or needing OS features
stddoes not expose. - Limitations/Trade-offs: Every capability described here trades Rust's default safety guarantees for control, and the compiler stops checking the specific invariant you took over.
- Related Topics: unsafe Rust, undefined behavior, cross-platform code, sound API design.
Foundations
Everything std::fs, std::net, and std::process do ultimately becomes a syscall - a request from your program to the operating system kernel to do something only the kernel is allowed to do, like open a file descriptor or send bytes over a socket. std wraps these syscalls in portable, safe Rust functions that hide the platform-specific details of how Linux, macOS, and Windows each expose that functionality. Systems programming is what happens when that portable wrapper does not go far enough - when you need a syscall, or a platform-specific behavior, that std intentionally does not expose.
The libc crate is the layer directly below that portable wrapper: raw bindings to the C standard library and the POSIX functions it exposes, with C's actual type sizes and signatures rather than Rust's idiomatic ones. Calling into it is always unsafe, because the compiler has no way to verify a C function's contract - only the ordinary Rust guarantees about types are gone.
Raw memory work is the second capability: reasoning about exactly how bytes are laid out, not just what a struct's fields are. Rust's default struct layout is deliberately unspecified - the compiler is free to reorder fields for better packing, and two structurally identical structs are not guaranteed to have the same layout across compiler versions. This is invisible and irrelevant for ordinary Rust code, and becomes critical the moment another language, or a binary file format, needs to read those bytes with its own fixed expectations.
The C ABI is the third capability: an Application Binary Interface is the low-level contract two pieces of compiled code agree on - how arguments are passed, how a return value comes back, how a struct's fields are ordered in memory. Rust's own internal ABI is unstable and undocumented, which is fine as long as everything is compiled by the same Rust compiler; C's ABI, by contrast, is a de facto stable standard nearly every language and platform can agree to speak, which is why it is the common language two different languages actually communicate through.
Mechanics & Interactions
unsafe is the single keyword tying all three capabilities together, and it means something precise: it marks a block where you are personally upholding an invariant the compiler can no longer verify for you, not a block where Rust's rules stop applying. Dereferencing a raw pointer, calling a libc function, or transmuting bytes into a struct are all unsafe for the same underlying reason - each one asks you to guarantee something (validity, alignment, a C function's documented contract) that the type system has no way to check on its own.
repr(C) is the mechanism that makes Rust's otherwise-unstable memory layout predictable enough to cross the ABI boundary. Without it, a struct's field order and padding are compiler implementation details; with it, the compiler lays the struct out exactly the way a C compiler would, field order preserved, padding inserted the same way, which is the only way another language can reliably read a Rust struct's bytes or vice versa.
// Without repr(C), field order and padding are unspecified - fine within Rust alone.
// With repr(C), the layout matches what a C compiler would produce for the same fields,
// which is what makes this struct safe to share across the FFI boundary.
#[repr(C)]
struct Header {
magic: u32,
version: u16,
flags: u16,
}Errno handling shows the same boundary discipline from the other direction: C functions typically signal failure by returning a sentinel value (-1, a null pointer) and setting a thread-local errno, which Rust reads via std::io::Error::last_os_error() immediately after the call, because that thread-local can be overwritten by the very next libc call your code makes. This is the FFI boundary's version of the same lesson raw memory work teaches: the contract lives in documentation and convention that the compiler cannot enforce, so the calling code has to honor it manually and immediately.
bindgen and cbindgen are two directions of the same ABI-crossing problem, run in reverse. bindgen reads a C header and generates matching repr(C) Rust declarations, so Rust code can call into an existing C library correctly. cbindgen reads Rust source and generates a C header describing the Rust library's exported functions and repr(C) types, so C code can call into Rust. Both exist because neither language can see the other's type definitions directly - the generated header or bindings are the artifact that lets each side agree on the shared ABI without hand-transcribing it and risking drift.
Advanced Considerations & Applications
Ownership does not stop being a Rust concept at the FFI boundary - it becomes an explicit, manual responsibility instead of a compiler-enforced one. Box::into_raw hands a heap allocation's ownership across to C as a raw pointer, and from that point forward the Rust compiler has no idea the allocation exists; a corresponding Box::from_raw on the Rust side (called exactly once, exactly when C is done with it) is what reclaims that ownership and lets Drop run. Getting this wrong in either direction - a double-free by reclaiming ownership twice, or a leak by never reclaiming it - is now a manual bookkeeping problem the type system opted out of the moment unsafe handed ownership across.
Undefined behavior (UB) is the sharpest edge in this whole layer, distinct from an ordinary runtime error. A failed syscall returns an error the type system models fine; UB is the compiler assuming an invariant holds (an unaligned pointer read never happens, a repr(packed) field is never referenced unaligned) and generating code on that assumption, so violating it does not produce a predictable failure at all - it produces a program the compiler was never obligated to make behave sensibly. Tools like Miri and sanitizers exist specifically because ordinary testing cannot reliably catch UB; the program can appear to work correctly for years and still be relying on behavior the compiler was never promising.
Sound abstraction is the discipline that makes any of this usable by code that never sees unsafe at all: wrapping raw pointers, libc calls, or repr(C) structs behind a safe public API whose function signatures alone are enough to guarantee the invariants hold, so that callers outside the module cannot misuse it without writing unsafe themselves. This is the difference between "this code happens to work" and "this code cannot be made to violate memory safety from outside this module" - and it is the standard the rest of the Rust ecosystem holds any crate exposing FFI to.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
std portable abstractions | Safe, cross-platform, no unsafe required | Deliberately hides syscalls and layout you sometimes need | The overwhelming majority of application-level Rust code |
libc / raw syscalls | Direct OS access, matches C library semantics exactly | Always unsafe; platform differences become your problem | Features std does not expose - mmap, ioctl, signal handling |
repr(C) + bindgen/cbindgen | Predictable, portable ABI shared with another language | Manual layout and ownership discipline; UB risk if invariants slip | Calling C libraries from Rust, or exposing Rust libraries to C and beyond |
Common Misconceptions
- "unsafe turns off Rust's safety checks." - It only unlocks a small set of operations (raw pointer deref, calling
unsafe fn, accessing mutable statics, and a few others) that the compiler cannot verify. Every other rule of the language still applies inside anunsafeblock. - "Rust structs always have C-compatible layout." - The default layout is deliberately unspecified and can differ between compiler versions. Only
#[repr(C)](orrepr(transparent)) guarantees a layout another language can rely on. - "If it compiles and runs correctly in testing, the FFI code is sound." - Undefined behavior can produce code that appears to work for a long time while relying on an invariant the compiler never actually promised, which is exactly what tools like Miri exist to catch that ordinary testing cannot.
- "Calling libc directly is inherently more 'raw' or dangerous than std." -
stdcalls the same syscalls underneath;libcjust removes the portable, safe wrapper. The operations are not fundamentally more dangerous, only unguarded. - "bindgen and cbindgen do the same thing." -
bindgengenerates Rust bindings from a C header for calling into C;cbindgengenerates a C header from Rust source for being called from C. They cross the boundary in opposite directions.
FAQs
What actually makes a function call "unsafe" in Rust?
It means the compiler cannot verify some invariant the operation depends on - like a pointer being valid and aligned, or a C function's documented contract being upheld - so the programmer is taking personal responsibility for it instead.
Why does std already count as touching the OS boundary?
Functions like std::fs::read or std::net::TcpStream::connect are safe wrappers around the exact same syscalls libc exposes directly - std just hides the platform-specific details and unsafety behind a portable, checked API.
Why isn't Rust's own struct layout stable by default?
Leaving field order and padding unspecified lets the compiler choose the most efficient packing for a given target, which is only possible because ordinary Rust code never needs to agree on layout with anything outside the same compilation.
What does repr(C) actually change about a struct?
It forces the compiler to lay out fields in the order they are declared, with padding inserted the way a C compiler would, so the byte layout is predictable to any language reading it through the C ABI.
How is undefined behavior different from a normal runtime error?
A runtime error (like a failed syscall) is a value the type system models and you handle with Result. Undefined behavior violates an assumption the compiler baked into its generated code, so there is no defined error path at all - the resulting behavior is not guaranteed to be predictable or safe.
Who is responsible for freeing memory that crosses an FFI boundary?
Whichever side's convention the API documents, and it must be exactly one side, exactly once. Box::into_raw hands ownership to C explicitly, and a matching Box::from_raw must reclaim it on the Rust side when it is done - the compiler no longer tracks this automatically.
Why does errno need to be read immediately after a libc call?
It is a thread-local value the next libc call can silently overwrite, so any delay between the failing call and reading std::io::Error::last_os_error() risks reading a stale or unrelated error value.
What's the actual difference between bindgen and cbindgen?
bindgen reads a C header and generates Rust bindings so Rust can call into existing C code. cbindgen reads Rust source and generates a C header so C code can call into Rust - they run in opposite directions across the same boundary.
Can a "sound" safe wrapper around unsafe code actually guarantee safety?
Yes, that is the explicit goal - the wrapper's function signatures and internal invariants are designed so that no sequence of calls from safe code outside the module can trigger undefined behavior, even though unsafe exists inside it.
Why do MUSL and glibc targets sometimes behave differently for the same libc call?
They are different implementations of the C standard library with their own internal behavior and edge cases, so code relying on implementation-specific details (not just the documented POSIX contract) can diverge between the two.
Is calling a syscall directly ever better than going through libc?
Rarely - libc wrappers handle platform differences, vDSO-based fast paths, and errno conventions that a hand-rolled direct syscall has to reimplement, so most systems code should prefer the libc wrapper unless there is a specific reason not to.
How do tools like Miri catch bugs that testing misses?
Miri interprets the program and checks memory operations against Rust's aliasing and validity rules on every run, so it can flag undefined behavior (like an out-of-bounds read that happened to return a harmless-looking value) that produced no visible symptom under ordinary execution.
Related
- Systems Basics - the hands-on walkthrough this page underlies
- libc & Syscalls - the OS boundary in depth
- Working with Raw Memory - layout, alignment, and repr(C) in depth
- C Interop with bindgen - calling C from Rust
- Exposing Rust to C (cbindgen) - calling Rust from C
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.