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.