Reaching for the C standard library
You're a few weeks into Rust and you hit a wall. You need to call getpid() to log the process ID. Or you want mmap for a memory-mapped file. Or some old C library at work has the parser you need and rewriting it in Rust is not on the table this quarter. Rust's standard library is excellent, but it doesn't expose every corner of POSIX or the Windows API. That's where the libc crate comes in.
libc is the official Rust binding to the platform's C standard library. It's the bridge between your safe, type-checked Rust code and the giant pile of C functions and structs that every operating system ships with. Think of it like a phone book that lists every C function with the right Rust signature attached, so the compiler knows how to call them. The functions themselves still live in libc.so (or msvcrt.dll, or whatever your OS uses). The crate just teaches Rust how to dial the number.
Adding it to your project
Open your Cargo.toml and add a single line:
[dependencies]
# The platform C library bindings. 0.2 has been stable for years; new platforms
# get added in patch releases without breaking your code.
libc = "0.2"
That's the whole installation. No build script, no system packages to install, no extra flags. The crate vendors its bindings for every platform Rust supports, and Cargo picks the right ones at compile time.
Your first call into C
Here's a minimal program that calls C's printf from Rust:
use std::ffi::CString;
fn main() {
// C strings are null-terminated bytes. Rust's String isn't, so we have to
// convert. CString::new appends the trailing zero and checks that we don't
// accidentally include any interior nulls (which C would treat as the end).
let msg = CString::new("Hello from libc!\n").unwrap();
// Every libc function is `unsafe` because the compiler can't verify that
// the C side honours Rust's invariants. The `unsafe` block is your promise:
// "I read the man page, I'm passing valid data, I take responsibility."
unsafe {
// as_ptr() hands C a `*const c_char` pointing at the null-terminated bytes.
// The CString must outlive this call, which it does because it's still
// bound to `msg` on the line above.
libc::printf(msg.as_ptr());
}
}
Run cargo run and you'll see "Hello from libc!" pop out. Notice that we didn't go through println!. We went straight to the C runtime's printf, the same one a gcc hello.c program would use.
Why every libc call is unsafe
Rust's safety guarantees rest on a small set of rules: no dangling pointers, no data races, no use-after-free, no out-of-bounds reads. The compiler proves those rules hold by analysing your code. But it can't analyse C. When you call libc::strlen(ptr), the compiler has no idea whether ptr actually points at a null-terminated byte string or at random garbage. Maybe it points at freed memory. Maybe it's null. C doesn't care, and the compiler can't tell.
So Rust does the next best thing. It marks every extern "C" function as unsafe, which forces you to wrap the call in an unsafe { } block. That block is a contract: you're telling the compiler "I have audited this call, the preconditions are met, trust me." If you got it wrong, you get the same fun bugs C programmers have lived with for fifty years. If you got it right, the unsafety is contained to that little block, and everything around it stays as safe as ordinary Rust.
The job, then, is to read the man page carefully. man 3 strlen will tell you that the pointer must be non-null and must point to a null-terminated byte sequence. Your unsafe block is where you uphold those rules.
A more useful example: getting the process ID
println! is fine, but you can already do that without libc. Here's something std doesn't give you directly: the parent process ID.
use libc::{getpid, getppid};
fn main() {
// getpid and getppid are both safe to call with no arguments and they
// can't fail. They just return integers. Still wrapped in unsafe because
// they're FFI: the wrapper isn't trying to assess danger, just to mark
// the boundary.
let (pid, ppid) = unsafe { (getpid(), getppid()) };
println!("This process: {pid}");
println!("Parent process: {ppid}");
}
Run it from a shell and you'll see your shell's PID as the parent. Run it from another program and you'll see that program's PID. Useful for debugging daemon hierarchies, init systems, anything where you care about who launched you.
Note that std::process::id() exists and gives you the PID. But there's no equivalent for the parent, so libc fills the gap.
Working with structs from C
Functions are easy. Structs are where it gets interesting. C structs have specific memory layouts, and Rust has to match them exactly or you'll read the wrong fields. The libc crate defines all the standard ones: timespec, stat, sockaddr_in, and so on.
Here's calling clock_gettime to get high-resolution time:
use libc::{clock_gettime, timespec, CLOCK_MONOTONIC};
use std::mem::MaybeUninit;
fn main() {
// MaybeUninit is how you tell Rust "I'm going to give this to C and let
// C fill it in." Allocating a zeroed timespec would also work, but
// MaybeUninit is the idiomatic way: no wasted writes.
let mut ts = MaybeUninit::<timespec>::uninit();
// clock_gettime returns 0 on success and -1 on error. We ignore the result
// here for brevity; in real code you'd check it and read errno on failure.
unsafe {
clock_gettime(CLOCK_MONOTONIC, ts.as_mut_ptr());
}
// assume_init() promises the syscall actually wrote to the struct. If we
// got -1 back and skipped writing, this would be UB. So in production,
// check the return value first.
let ts = unsafe { ts.assume_init() };
println!("Monotonic time: {}.{:09} seconds", ts.tv_sec, ts.tv_nsec);
}
Two things to flag here. The struct is owned and laid out by Rust, but C does the writing. That round-trip works because libc::timespec is defined with #[repr(C)], which forbids Rust from reordering fields or adding padding the C side doesn't expect. And MaybeUninit is the modern replacement for the older mem::uninitialized(), which is now soft-deprecated because it caused too many subtle UB cases.
Common pitfalls
Forgetting the null terminator. CString::new("hello") works because it adds the zero byte for you. But if you build a buffer manually (say, a Vec<u8>) and pass it to a function expecting a C string, you must include the trailing zero yourself or the C function will read past the end. The compiler error you'll see if you try to bypass it without unsafe:
error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
--> src/main.rs:5:5
|
5 | libc::printf(msg.as_ptr());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
Wrap it in unsafe { } and the error goes away. The compiler is reminding you, not blocking you.
Letting a CString drop too early. This is sneaky:
// BUG: do not do this
let ptr = CString::new("oops").unwrap().as_ptr();
unsafe { libc::printf(ptr); } // ptr now points into freed memory
The CString is a temporary. It gets dropped at the end of the statement, which means its bytes are freed. Then you hand the now-dangling pointer to C. Bind the CString to a name first so it lives long enough:
let s = CString::new("ok").unwrap();
unsafe { libc::printf(s.as_ptr()); } // s is alive for the whole block
Mismatched signed and unsigned types. C's int is c_int in libc, which is i32 on most platforms. C's size_t is usize. If a function takes a size_t and you pass an i32, the compile fails. Rust will not silently coerce. That's a feature.
Reading errno. After a libc call fails, the error code is stashed in a thread-local errno. Use *libc::__errno_location() on Linux or the errno crate for a portable wrapper. Don't read it through arbitrary intervening code, because almost any libc call can stomp it.
When to reach for libc, and when not to
Use libc when you need a specific syscall or C function that Rust's standard library doesn't expose. Examples: mmap, ioctl, setrlimit, prctl, fork, signal. Use it when you're writing FFI glue between Rust and an existing C library, often as the lowest layer beneath a safe wrapper crate.
Don't use it for things std already does well. std::fs::File, std::process::Command, std::time::Instant: all of these wrap libc internally and give you a safe, ergonomic API. You'd be reinventing them, badly, if you went straight to libc. Reach for libc only when std runs out of road.
For higher-level FFI to C++ libraries, look at cxx. For binding to whole C libraries automatically, bindgen will read a header file and generate the libc-style declarations for you. And if you're calling Rust code from another language instead of the other way round, the patterns are different again.