Frontend Labs
⋯

Part IV — Systems Rust: the OS guide in Rust

The OS guide's Part 20 looks at the kernel from inside a Go program. This Part does the same in Rust. Rust has no runtime between you and the kernel: no scheduler, no GC, no netpoller. strace of a Rust binary shows almost exactly the syscalls your code makes. Chapters 42 and 44 need Linux. Use the container from §0.1.


39. Syscalls from Rust: std, libc, and nix

There are three levels, from safest to rawest:

Level Example Use when
std std::fs::File::open, TcpStream::connect portable code; this covers most programs
nix / rustix nix::unistd::fork, nix::sched::unshare Unix-specific calls with Rust types and Result errors
libc libc::prctl(...) anything else; the raw C declarations, all unsafe
use std::io::Write;

fn main() -> std::io::Result<()> {
    // Level 1: std. One write(2) per call here (stdout is a pipe or tty).
    std::io::stdout().write_all(b"std: hello\n")?;

    // Level 2: nix. A safe wrapper returning Result<_, Errno>.
    let pid = nix::unistd::getpid();
    let uname = nix::sys::utsname::uname().map_err(std::io::Error::from)?;
    println!("nix: pid={pid} kernel={:?}", uname.release());

    // Level 3: libc. The exact C call, with C error conventions.
    let msg = b"libc: hello\n";
    // SAFETY: fd 1 is open for the life of the process; the buffer is valid for msg.len() bytes.
    let n = unsafe { libc::write(1, msg.as_ptr().cast(), msg.len()) };
    if n < 0 { return Err(std::io::Error::last_os_error()); } // reads errno, like perror()

    // Resource limits, the same numbers as `ulimit -n` (OS Ch 43):
    let mut lim = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
    // SAFETY: `lim` is a valid, writable rlimit struct.
    if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } == 0 {
        println!("open files: soft={} hard={}", lim.rlim_cur, lim.rlim_max);
    }
    Ok(())
}
cargo build --release --bin rawsys
strace -f ./target/release/rawsys 2>&1 | tail -20      # Linux
# Look for: write(1, "std: hello\n", 11), uname(...), write(1, "libc: hello\n", 12), prlimit64(...)

What a Rust main does before your code. On Unix, the standard library's startup code checks that file descriptors 0, 1, and 2 are open (it opens /dev/null for any that are closed) and sets SIGPIPE to SIG_IGN. The second one surprises people. A C program writing to a closed pipe is killed by SIGPIPE. A Rust program gets an EPIPE error instead, and println! panics on that error. This is the source of the familiar cargo run | head panic: "failed printing to stdout: Broken pipe". CLI tools that are meant to be piped either write with writeln! and handle ErrorKind::BrokenPipe by exiting quietly, or restore the default handler with libc::signal(libc::SIGPIPE, libc::SIG_DFL) at startup.

Across the series: what crossing into the kernel costs is in OS Ch 2. How the Go runtime adds its own threads and syscalls on top is in OS Ch 73. A Rust binary has none of that runtime layer, which is one reason its strace output is so short.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36