← Back to DevBytes

Rust for System Programming: Hands-On Tutorial:

Rust for System Programming: A Hands-On Tutorial

System programming has traditionally been the domain of C and C++. These languages offer fine-grained control over hardware and memory, but that power comes at a cost: memory safety bugs, data races, and security vulnerabilities. Rust changes the equation by providing the performance and low-level control of C++ while guaranteeing memory safety and thread safety at compile time. In this hands-on tutorial, you'll learn what makes Rust ideal for system programming and how to start writing robust systems code with it.

What Is Rust?

Rust is a systems programming language originally sponsored by Mozilla and first released in 2010. It combines a modern type system, zero-cost abstractions, and a unique ownership model that eliminates entire classes of bugs — including null pointer dereferences, buffer overflows, and data races — without requiring a garbage collector. This makes Rust particularly well-suited for operating systems, device drivers, embedded firmware, game engines, networking stacks, and high-performance command-line tools.

Why Rust Matters for System Programming

Setting Up Your Environment

The recommended way to install Rust is through rustup, the official toolchain installer. On Linux or macOS, run:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Verify the installation:

rustc --version
cargo --version

Create a new project:

cargo new sys_demo
cd sys_demo

This generates a project with a Cargo.toml manifest and a src/main.rs file. You can build and run it with cargo run.

Ownership, Borrowing, and Lifetimes

The cornerstone of Rust's memory safety is the ownership model. Every value has a single owner, and when that owner goes out of scope, the value is dropped. References can borrow values, but the borrow checker enforces strict rules:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // ownership moves to s2; s1 is no longer valid

    // println!("{}", s1); // ERROR: value borrowed here after move
    println!("{}", s2);

    let mut data = vec![1, 2, 3];
    let r1 = &data;        // immutable borrow
    println!("{:?}", r1);
    // let r2 = &mut data; // ERROR: cannot borrow mutably while immutably borrowed
    let r2 = &mut data;    // OK now: r1's usage ended above
    r2.push(4);
    println!("{:?}", r2);
}

Lifetimes tell the compiler how long references are valid. Most of the time they are inferred, but in functions that return references you must annotate them explicitly:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let result = longest("apple", "kiwi");
    println!("Longest: {}", result);
}

Working With Raw Memory

For true system programming, you often need to manipulate memory directly. Rust provides std::ptr, std::mem, and the unsafe keyword for these situations. The key principle is that unsafe does not disable the borrow checker — it simply tells the compiler "I am upholding the invariants manually here."

use std::alloc::{alloc, dealloc, Layout};

fn main() {
    unsafe {
        let layout = Layout::new::<[u32; 4]>();
        let ptr = alloc(layout) as *mut u32;

        if ptr.is_null() {
            panic!("allocation failed");
        }

        // Write values
        for i in 0..4 {
            ptr.add(i).write((i as u32) * 10);
        }

        // Read values
        for i in 0..4 {
            println!("value[{}] = {}", i, ptr.add(i).read());
        }

        dealloc(ptr as *mut u8, layout);
    }
}

Notice how unsafe blocks are small and explicit. This is a best practice: keep unsafe code as localized as possible and expose a safe API around it.

Defining Safe Wrappers Around Unsafe Code

A common pattern in Rust system programming is to wrap unsafe operations in a safe abstraction. The unsafe code lives in a private implementation, while the public API maintains invariants that the borrow checker can verify.

use std::ptr::NonNull;

pub struct ByteBuffer {
    ptr: NonNull<u8>,
    cap: usize,
    len: usize,
}

impl ByteBuffer {
    pub fn new(capacity: usize) -> Self {
        let layout = std::alloc::Layout::array::<u8>(capacity)
            .expect("invalid layout");
        let ptr = unsafe { std::alloc::alloc(layout) };
        let ptr = NonNull::new(ptr).expect("allocation failed");
        ByteBuffer { ptr, cap: capacity, len: 0 }
    }

    pub fn push(&mut self, byte: u8) {
        assert!(self.len < self.cap, "buffer full");
        unsafe {
            self.ptr.as_ptr().add(self.len).write(byte);
        }
        self.len += 1;
    }

    pub fn as_slice(&self) -> &[u8] {
        unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
    }
}

impl Drop for ByteBuffer {
    fn drop(&mut self) {
        let layout = std::alloc::Layout::array::<u8>(self.cap)
            .expect("invalid layout");
        unsafe { std::alloc::dealloc(self.ptr.as_ptr(), layout); }
    }
}

fn main() {
    let mut buf = ByteBuffer::new(8);
    buf.push(b'H');
    buf.push(b'i');
    buf.push(b'!');
    println!("{:?}", buf.as_slice());
    // Memory is freed automatically when buf goes out of scope.
}

Error Handling Without Exceptions

Rust has no exceptions. Instead, recoverable errors are represented with the Result<T, E> enum, and unrecoverable errors trigger a panic. For system code, you should prefer Result and the ? operator for propagating errors cleanly.

use std::fs::File;
use std::io::{self, Read};

fn read_config(path: &str) -> io::Result<String> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

fn main() {
    match read_config("/etc/hostname") {
        Ok(contents) => println!("Hostname: {}", contents.trim()),
        Err(e) => eprintln!("Failed to read config: {}", e),
    }
}

Concurrency and Thread Safety

Rust's type system enforces thread safety through two marker traits: Send (a type can be transferred across threads) and Sync (a type can be shared between threads via references). The standard library provides channels and synchronization primitives like Mutex and Arc.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0u64));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            for _ in 0..1000 {
                let mut num = counter.lock().unwrap();
                *num += 1;
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final count: {}", *counter.lock().unwrap());
}

Because Mutex<T> provides interior mutability and Arc<T> provides thread-safe reference counting, the compiler verifies that no data race is possible. If you tried to use a non-thread-safe Rc<T> here, the code would not compile.

Interfacing With C Through FFI

System programming often requires calling existing C libraries. Rust's FFI lets you declare external functions and exchange data across the boundary. Here is an example calling the C standard library's abs function:

extern "C" {
    fn abs(x: i32) -> i32;
}

fn main() {
    let value = -42;
    let absolute = unsafe { abs(value) };
    println!("abs({}) = {}", value, absolute);
}

You can also expose Rust functions to C with the #[no_mangle] and extern "C" attributes:

#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
    a + b
}

Compile this as a dynamic library by adding the following to Cargo.toml:

[lib]
crate-type = ["cdylib"]

Then any C program can link against the resulting .so or .dll and call add_numbers as if it were a native C function.

Building a Simple System Tool

Let's put everything together by writing a small utility that reads a file in chunks and computes a checksum — a common task in system tools. This example demonstrates file I/O, buffered reading, error handling, and efficient byte processing.

use std::env;
use std::fs::File;
use std::io::{self, BufReader, Read};
use std::process;

const CHUNK_SIZE: usize = 4096;

fn checksum(path: &str) -> io::Result<u32> {
    let file = File::open(path)?;
    let mut reader = BufReader::new(file);
    let mut buffer = [0u8; CHUNK_SIZE];
    let mut sum: u32 = 0;

    loop {
        let bytes_read = reader.read(&mut buffer)?;
        if bytes_read == 0 {
            break;
        }
        for &byte in &buffer[..bytes_read] {
            sum = sum.wrapping_add(byte as u32);
        }
    }

    Ok(sum)
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() != 2 {
        eprintln!("Usage: {} <file>", args[0]);
        process::exit(1);
    }

    match checksum(&args[1]) {
        Ok(sum) => println!("Checksum: {}", sum),
        Err(e) => {
            eprintln!("Error: {}", e);
            process::exit(1);
        }
    }
}

Notice how the code reads the file in fixed-size chunks rather than loading it all into memory — essential behavior for a system tool that may process files larger than available RAM.

Best Practices

Conclusion

Rust brings a rare combination to system programming: the performance and control of C and C++ with compile-time guarantees that eliminate memory and concurrency bugs. By mastering ownership, borrowing, lifetimes, and the careful use of unsafe, you can write low-level code that is both fast and trustworthy. Whether you are building an operating system component, a device driver, a network daemon, or a high-performance CLI tool, Rust gives you the tools to ship reliable system software with confidence. Start small, lean on cargo and clippy, and gradually incorporate more advanced features like FFI and concurrency as your projects demand them.

— Ad —

Google AdSense will appear here after approval

← Back to all articles