When the standard library stops
You are building a CLI tool that needs to change file permissions, spawn a detached background process, or monitor a directory for changes. The standard library gives you std::fs and std::process, but they abstract away the gritty details. You hit a wall where Rust's type system ends and the operating system's C interface begins. You need the raw chmod, fork, or inotify syscall.
The POSIX contract and the FFI bridge
POSIX is the contract between user space and the kernel. It defines exactly how programs talk to Unix-like operating systems. Rust does not include these bindings by default because they violate the language's core safety guarantees. C functions do not check array bounds, they do not track ownership, and they do not return Result types. They return integers and mutate global state.
You need a bridge. Foreign Function Interface, or FFI, is the mechanism that lets Rust call code compiled in another language. Think of it like a modern smart lock on a door that still uses a standard mechanical keyhole. The lock is secure and digital, but the keyhole follows an old, universal standard. You need an adapter to turn your digital intent into a mechanical turn. The adapter does not change the lock. It just translates your request into a format the lock understands.
The compiler steps back at the FFI boundary. It cannot verify C code. It cannot prove that a pointer is valid or that a function will not corrupt memory. That is why every FFI call requires an unsafe block. The keyword does not mean the code is dangerous. It means the compiler cannot verify the code is safe. You take responsibility for the contract.
Calling a single syscall with libc
The most direct way to reach POSIX is the libc crate. It provides Rust type definitions that match the C headers on your platform. You add it to Cargo.toml and call functions directly.
use libc::{c_int, getpid};
/// Returns the current process ID using the raw libc binding.
fn get_my_pid() -> c_int {
// SAFETY:
// 1. getpid takes no arguments and requires no pointer validation.
// 2. The function is thread-safe and does not mutate external state.
// 3. The return value is always a valid process identifier.
unsafe { getpid() }
}
fn main() {
let pid = get_my_pid();
println!("Running as process {}", pid);
}
The c_int type matches the C int exactly. The unsafe block wraps the call. The compiler accepts it because you explicitly acknowledged the boundary. The function runs, the kernel returns the PID, and Rust receives an integer. No allocation happens. No trait bounds are checked. The call crosses the ABI boundary in a single instruction.
Treat the unsafe block as a contract. If you cannot prove the invariants hold, shrink the scope until you can.
How the data crosses the boundary
When Rust calls C, it follows the platform's Application Binary Interface. The ABI dictates how arguments are passed, where return values land, and how registers are cleaned up. On x86-64 Linux, the first six integer or pointer arguments go into registers rdi, rsi, rdx, rcx, r8, and r9. The return value lands in rax. Rust's compiler generates the exact same calling convention as GCC or Clang. That is why the interop works at all.
The danger appears when types do not match. If you pass a Rust String directly to a C function expecting a char*, the compiler rejects you with E0308 (mismatched types). A Rust String is a fat pointer containing a data pointer, a length, and a capacity. A C string is just a pointer to a null-terminated byte array. The layouts are incompatible. The compiler protects you from sending the wrong memory layout across the boundary.
You must convert explicitly. The standard library provides std::ffi::CString for this exact purpose. It allocates heap memory, copies the Rust string, and appends a null byte. When the CString goes out of scope, it frees the memory. The conversion is cheap, but it is not free. Every FFI call that takes a string requires this allocation and copy.
Handling strings and error codes
Real POSIX functions rarely take simple integers. They take paths, buffers, and flags. They also fail silently by convention. A C function returns 0 on success and -1 on error. It does not throw. It does not return a Result. It sets a thread-local variable called errno to indicate what went wrong.
use std::ffi::CString;
use libc::{c_int, c_uint, chmod, errno};
use std::io::Error;
/// Changes file permissions using the raw POSIX chmod call.
fn set_permissions(path: &str, mode: c_uint) -> std::io::Result<()> {
// Convert to null-terminated C string. Allocation happens here.
let c_path = CString::new(path).expect("Path contained null bytes");
// SAFETY:
// 1. c_path.as_ptr() is valid for reads during the syscall.
// 2. chmod does not store the pointer or outlive the CString.
// 3. The mode argument is a valid bitmask for the platform.
let result = unsafe { chmod(c_path.as_ptr(), mode) };
// POSIX returns -1 on error and sets errno.
if result == -1 {
// SAFETY: errno is thread-local and safe to read after a failing syscall.
let err_code = unsafe { errno };
return Err(Error::from_raw_os_error(err_code));
}
Ok(())
}
The CString lives until the end of the function. The as_ptr() call borrows it temporarily. The chmod syscall reads the bytes and returns immediately. The pointer is never stored. If chmod fails, we read errno and convert it to a Rust std::io::Error. The Error::from_raw_os_error constructor translates the numeric code into a human-readable string like "Permission denied" or "No such file or directory".
POSIX speaks in integers and side effects. Translate them to Result before they leak into your business logic.
The hidden traps of C interop
The first trap is assuming C functions are safe by default. They are not. If you pass a dangling pointer, the kernel might segfault. If you pass a buffer that is too small, the function might overwrite adjacent memory. The compiler cannot catch these mistakes. You get E0133 (dereference of raw pointer requires unsafe) if you try to dereference a *const T outside an unsafe block, but that is just the compiler asking for permission. It does not verify the pointer is valid.
The second trap is lifetime confusion. Rust tracks how long references live. C does not. If you pass a pointer to a stack-allocated buffer into a C function that stores it for later use, your program will crash the moment the stack frame unwinds. The C function thinks it owns the memory. Rust thinks it dropped it. The memory becomes garbage. You must either allocate on the heap and manage the lifetime manually, or use a crate that handles the bookkeeping for you.
The third trap is platform divergence. POSIX is a standard, but every Unix variant implements it slightly differently. Linux, macOS, FreeBSD, and musl expose different flags, different struct layouts, and different error codes. The libc crate uses conditional compilation to match your target. Code that compiles on Linux might fail on macOS because a constant does not exist. You will see E0425 (cannot find value) or E0599 (no associated item) when you cross platforms.
Convention matters here. The Rust community treats unsafe blocks like surgical incisions. Keep them as small as possible. Wrap the exact line that crosses the boundary. Do not wrap the entire function. The community calls this the minimum unsafe surface rule. It makes audits faster and bugs easier to isolate.
Pick the wrapper that matches your threat model. Raw bindings for precision, safe crates for peace of mind.
Choosing your binding strategy
You have three main paths for POSIX interop. Each one trades control for convenience.
Use libc when you need a single, low-level syscall and want zero dependencies. It gives you direct access to every C function on your platform. You handle string conversion, error codes, and platform differences yourself. It is the right choice for tiny utilities or when you are building a higher-level abstraction.
Use nix when you are building a larger Unix tool and want idiomatic Rust types with Result error handling. The crate wraps POSIX functions in safe Rust APIs. It converts C strings automatically, translates errno into nix::Error, and provides platform-specific modules. It has been the standard for years, but its design dates back to an older era of Rust FFI.
Use std::os::unix when the standard library already exposes the feature you need under a platform-specific module. Many POSIX features are already available behind #[cfg(unix)] gates. File permissions, process signals, and socket options often live here. Check the standard library first. You avoid external crates entirely.
Use rustix when you want a modern, audited, dependency-free replacement for libc that focuses on correctness over legacy compatibility. It is built from scratch with Rust idioms in mind. It uses std::io::Result everywhere, handles platform differences internally, and avoids the historical baggage of nix. It is becoming the default choice for new systems programming projects.
Counter-intuitive but true: the more you use raw unsafe bindings, the harder the rest of your code becomes to reason about. Wrap the boundary early.