How to use libc crate

The `libc` crate provides Rust bindings to standard C library functions, allowing you to call low-level system APIs like `fork`, `open`, or `getpid` directly from safe Rust code.

The bridge to the operating system

You're building a system tool in Rust. You need to fork a child process, query the kernel for CPU temperature, or interface with a legacy driver. The standard library stops short. It gives you abstractions, but not the raw system calls. You need to talk to the operating system directly. That's where libc comes in. It's the bridge to the C standard library, the layer that sits right on top of the OS kernel.

Think of libc as a catalog of every button on the control panel of your operating system. Rust's standard library hides most of these buttons behind safe knobs. You turn a knob to open a file, and Rust handles the details. libc hands you the raw buttons. You can press open, read, fork, or kill. The power is there, but there are no guards. Press the wrong button, or press it with the wrong argument, and you get undefined behavior. The compiler won't save you. You have to read the manual.

What libc actually gives you

The libc crate is a collection of Rust bindings generated from C headers. It maps C types to Rust types and exposes C functions as unsafe fn in Rust. When you import libc, you get access to thousands of functions and types that vary by platform. The crate uses cfg attributes to handle the differences. On Linux, pid_t maps to i32. On some embedded systems, it might map to i16. You write use libc::pid_t; and the crate picks the right type for your target. You don't need to scatter #[cfg(target_os = "linux")] checks everywhere.

This abstraction is the main value of libc. It gives you a single dependency that works across Unix, macOS, and Windows, while hiding the messy type differences. The functions are still unsafe. The crate cannot guarantee that the C code you're calling respects Rust's memory safety rules. You are responsible for verifying the preconditions.

A minimal call

Start with a simple function to see the pattern. getpid returns the ID of the current process. It takes no arguments and returns a pid_t.

use libc::getpid;

/// Get the current process ID using the libc crate.
fn main() {
    // Wrap the FFI call in unsafe because the compiler
    // cannot verify the safety of external C functions.
    let raw_pid = unsafe { getpid() };

    // pid_t is a C type alias that maps to i32 on most platforms.
    // Cast to a native Rust type to use it safely.
    let pid = raw_pid as i32;

    println!("Process ID: {}", pid);
}

If you try to call getpid without the unsafe block, the compiler rejects you with E0133 (call to unsafe function requires unsafe function or block). The compiler knows libc functions are unsafe and demands the block.

Convention aside: cast C types to Rust types immediately. Don't carry pid_t or c_int through your codebase. Convert to i32, u64, or usize as soon as you get the value. This keeps your safe code free of C artifacts and makes the types easier to reason about.

Cast C types to Rust types immediately. Don't carry pid_t through your codebase.

Handling files and errors

Real usage involves more than a single function call. You often need to pass strings, check return values, and manage resources. C functions expect null-terminated strings. Rust strings are not null-terminated by default. You must use std::ffi::CString to create a C-compatible string.

C functions also don't panic on failure. They return error codes, usually -1. You must check the return value explicitly. Ignoring errors leads to crashes or silent corruption.

use libc::{open, read, close, O_RDONLY, O_CREAT, S_IRUSR, S_IWUSR};
use std::ffi::CString;
use std::os::unix::io::RawFd;

/// Read the first 10 bytes from a file using libc calls.
fn read_file_bytes() {
    // C strings must be null-terminated. CString ensures this
    // and handles the allocation correctly.
    let path = CString::new("/tmp/test.txt").expect("Path contained null byte");

    // O_RDONLY | O_CREAT opens the file for reading and creates it
    // if it doesn't exist. S_IRUSR | S_IWUSR sets permissions.
    let flags = O_RDONLY | O_CREAT;
    let mode = S_IRUSR | S_IWUSR;

    // SAFETY:
    // 1. path.as_ptr() is valid and null-terminated because CString guarantees it.
    // 2. flags and mode are valid constants defined by libc.
    // 3. The return value is checked for errors before use.
    let fd = unsafe {
        open(path.as_ptr(), flags, mode)
    };

    // libc functions return -1 on error. Check this explicitly.
    if fd < 0 {
        eprintln!("Failed to open file");
        return;
    }

    let mut buffer = [0u8; 10];

    // SAFETY:
    // 1. fd is valid because we checked it against -1.
    // 2. buffer.as_mut_ptr() points to valid memory of size 10.
    // 3. buffer.len() matches the buffer size.
    let bytes_read = unsafe {
        read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len())
    };

    if bytes_read > 0 {
        // Slice the buffer based on actual bytes read.
        let content = &buffer[..bytes_read as usize];
        println!("Read: {:?}", content);
    }

    // Always close the file descriptor to avoid resource leaks.
    // SAFETY: fd is valid and open.
    unsafe { close(fd) };
}

Convention aside: use std::io::Error::last_os_error() to get the platform-specific error code. C uses a global variable called errno to store the last error. errno is thread-local and can be overwritten by other calls. last_os_error() captures the error immediately and converts it to a Rust io::Error. Don't read libc::errno directly.

Wrap libc calls in helper functions that return Result. This keeps the error handling logic in one place and makes the rest of your code safe.

/// Wrap a libc open call in a Result for safer usage.
fn open_file(path: &str) -> Result<RawFd, std::io::Error> {
    let c_path = CString::new(path).map_err(|_| {
        std::io::Error::new(std::io::ErrorKind::InvalidInput, "Null byte in path")
    })?;

    // SAFETY: c_path is valid and null-terminated.
    let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY) };

    if fd < 0 {
        // last_os_error() reads the platform-specific errno.
        Err(std::io::Error::last_os_error())
    } else {
        Ok(fd)
    }
}

Check every return value. libc functions don't panic. They return error codes. If you ignore them, you're flying blind.

Pitfalls and compiler errors

The compiler catches syntax errors. It won't catch logic errors. If you pass a dangling pointer to libc, the compiler won't stop you. The program might crash, or it might corrupt memory silently.

Common pitfalls include passing &str instead of CString. C functions expect a null terminator. If you pass a Rust string slice, the C function reads past the end of the string until it finds a null byte. This is undefined behavior. The compiler rejects this with E0308 (mismatched types) if you try to pass &str where *const c_char is expected. If you cast it manually, the compiler won't complain, but the runtime will misbehave.

Another pitfall is ignoring errno race conditions. If you call a libc function, check the return value, and then read errno, another thread might have changed errno in between. Always capture the error immediately using last_os_error() or by reading errno right after the call in a single unsafe block.

Resource leaks are also common. File descriptors, sockets, and memory allocated by libc must be freed manually. If you open a file and forget to close it, the descriptor leaks. Eventually, the process runs out of descriptors and fails. Use Drop implementations or ManuallyDrop to ensure cleanup happens.

Treat SAFETY comments as proof. If you can't write the proof, don't write the code.

When to reach for libc

Rust provides safe alternatives for most common tasks. Use libc only when you need functionality that the standard library doesn't expose.

Use libc when you need standard POSIX or Windows API calls that the Rust standard library doesn't expose. Use std::fs and std::process when you need safe, portable file or process operations. The standard library wraps libc calls and handles errors for you. Use bindgen when you need to call a custom C library that isn't part of the standard C library. bindgen generates the Rust bindings from C headers automatically. Use nix or windows-sys crates when you want a safer, more Rust-idiomatic wrapper around system calls. These crates provide Result types and better error handling than raw libc. Use cty or core::ffi when you need C type aliases without pulling in the full libc crate. This is useful for minimal dependencies or no-std environments.

Start with the standard library. Reach for libc only when you hit a wall. When you do, keep the unsafe block small and the SAFETY comment precise.

Where to go next