Rust for systems programming: what developers should know
When we talk about systems programming, we're dealing with software that runs close to the metal—operating systems, device drivers, embedded systems, and high-performance networking infrastructure. Rust for systems programming has become a compelling choice precisely because it eliminates entire categories of bugs that plague traditional systems languages like C and C++, while maintaining zero-cost abstractions and the performance guarantees those domains demand. If you've spent years in backend services or web development, stepping into systems programming with Rust requires a different mindset, but the payoff is substantial.
Over the past decade, we've watched Rust mature from an interesting experiment into production reality across major infrastructure projects. From Linux kernel modules to embedded firmware running on microcontrollers, teams are replacing C with Rust not out of fashion but out of necessity—memory safety without garbage collection changes what's possible.
No runtime — Run on bare metal and microcontrollers
One of Rust's defining characteristics for systems programming is the absence of a runtime. Unlike languages bundled with garbage collectors or heavyweight virtual machines, Rust compiles directly to machine code. This matters enormously when your target is a microcontroller with kilobytes of RAM or when you're writing code that executes before an operating system even boots.
Consider a scenario where you're writing firmware for an embedded device with 32KB of RAM total. Every kilobyte counts. Rust's zero-cost abstractions mean you can write expressive, safe code that compiles down to the same tight machine instructions you'd hand-craft in assembly. The compiler doesn't insert hidden allocations or maintain metadata for runtime features you didn't ask for.
To run Rust on bare metal, you'll often need to tell the compiler to skip the standard library and build against the core library instead. Here's what that looks like:
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle]
pub extern "C" fn _start() -> ! {
// Your bare metal code here
// Direct hardware access, no OS abstractions
let hardware_register = 0x4000_0000 as *mut u32;
unsafe { *hardware_register = 42; }
loop {}
}The #![no_std] attribute strips away everything that depends on an operating system. The #![no_main] tells Rust not to generate a standard main function. You're now responsible for the entry point, which means you control exactly what runs and when. This is genuinely bare metal—no hidden initialization, no runtime overhead.
Teams working on bootloaders, BIOS implementations, and kernel-level code rely on this capability. You get memory safety checks at compile time without paying a runtime cost, which simply wasn't possible in C without discipline and extensive tooling.
Embedded — embedded-hal and no_std crates
Most embedded development doesn't target bare metal directly; it targets a microcontroller running minimal firmware. The embedded-hal crate provides a hardware abstraction layer—a set of traits that abstract over common peripherals like GPIO, SPI, UART, and I2C. Different microcontroller manufacturers implement these traits for their chips, letting you write device drivers once and port them across platforms.
Imagine you're building a sensor-reading application for an STM32 microcontroller. You want to read temperature from an I2C sensor and log it over UART. Without abstraction, you'd write vendor-specific code tied to that chip. With embedded-hal, you write against traits:
use embedded_hal::i2c::I2c;
use embedded_hal::serial::Write;
pub struct TemperatureSensor {
i2c: I2C,
address: u8,
}
impl TemperatureSensor {
pub fn new(i2c: I2C, address: u8) -> Self {
TemperatureSensor { i2c, address }
}
pub fn read_temperature(&mut self) -> Result {
let mut buffer = [0u8; 2];
self.i2c.read(self.address, &mut buffer)?;
let raw = u16::from_be_bytes(buffer);
let celsius = (raw as f32) / 256.0;
Ok(celsius)
}
}
pub fn log_sensor(
sensor: &mut TemperatureSensor,
uart: &mut S,
) -> Result<(), S::Error> {
match sensor.read_temperature() {
Ok(temp) => {
writeln!(uart, "Temperature: {} C", temp)?;
Ok(())
}
Err(_) => Err(/* handle error */),
}
}The concrete I2C and UART implementations come from your microcontroller's HAL crate (like stm32f4xx-hal). You've written platform-agnostic sensor code—test it in simulation, port it to different hardware by swapping the concrete implementation, and maintain type safety throughout.
The no_std ecosystem has matured significantly. Crates like heapless provide fixed-capacity collections (Vec, HashMap, String variants) that don't require dynamic allocation. Libraries for common tasks—CRC calculations, fixed-point math, protocol parsing—all support no_std environments. This unlocks Rust's expressiveness in memory-constrained systems without sacrificing safety.
Networking — High-performance proxies and servers
Move up the stack to systems software for networking, and Rust's safety and performance shine. Network services handle untrusted input at scale. Memory corruption vulnerabilities in C-based proxies, load balancers, or packet processors translate directly to security incidents.
Consider building a simple TCP reverse proxy. You need to handle concurrent connections, buffer data, parse HTTP headers, and forward requests. In C, this is error-prone—off-by-one bugs in buffer management, use-after-free in concurrent code, and subtle race conditions are common. Rust catches most of these at compile time.
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub async fn proxy_connection(
mut client: TcpStream,
backend_addr: &str,
) -> Result<(), Box> {
let mut backend = TcpStream::connect(backend_addr).await?;
let (mut client_read, mut client_write) = client.split();
let (mut backend_read, mut backend_write) = backend.split();
// Forward client -> backend
let forward_client = async {
let mut buf = vec![0; 4096];
loop {
match client_read.read(&mut buf).await? {
0 => break,
n => backend_write.write_all(&buf[..n]).await?,
}
}
Ok::<_, std::io::Error>(())
};
// Forward backend -> client
let forward_backend = async {
let mut buf = vec![0; 4096];
loop {
match backend_read.read(&mut buf).await? {
0 => break,
n => client_write.write_all(&buf[..n]).await?,
}
}
Ok::<_, std::io::Error>(())
};
tokio::select! {
res1 = forward_client => res1?,
res2 = forward_backend => res2?,
}
Ok(())
}This code is concurrent, memory-safe, and efficient. Tokio handles thousands of concurrent connections on a single thread without the callback hell of Node.js or the complexity of thread-per-connection models. The borrow checker ensures you can't accidentally double-free a buffer or corrupt state during concurrent operations.
Production systems like Cloudflare's Pingora, AWS's fireworks, and countless internal infrastructure projects at scale now run on Rust's async ecosystem. The performance characteristics match or exceed C equivalents while eliminating entire categories of bugs.
Kernel — Rust is used in Linux kernel drivers
The most ambitious use case for Rust in systems programming is kernel code itself. The Linux kernel has begun accepting Rust code for drivers and subsystems. This represents a fundamental shift—kernel code has historically been off-limits for anything but C due to performance and control requirements. Rust changes that calculus.
Kernel Rust doesn't use the standard library. Instead, it uses kernel-provided abstractions. A simple character device driver might look like this:
use kernel::prelude::*;
use kernel::file::File;
use kernel::ioctl;
module! {
type: SampleDevice,
name: b"sample_driver",
author: b"Your Team",
description: b"A sample Rust kernel driver",
license: b"GPL",
}
pub struct SampleDevice;
impl kernel::Module for SampleDevice {
fn init(_name: &'static CStr, _module: &'static ThisModule) -> Result {
pr_info!("Sample driver loaded\n");
Ok(SampleDevice)
}
}
impl Drop for SampleDevice {
fn drop(&mut self) {
pr_info!("Sample driver unloaded\n");
}
}The kernel provides bindings to low-level operations—interrupt handling, memory management, synchronization primitives—wrapped in Rust abstractions. The borrow checker still enforces that you can't hold references across certain kernel boundaries where safety assumptions change, but you gain memory safety for the code you do write.
Why this matters: kernel drivers have historically been a major source of system instability and security vulnerabilities. A buggy driver can crash the entire kernel or create exploitable conditions. Rust can't eliminate all kernel-level bugs, but it eliminates memory corruption bugs in driver code, which are disproportionately common and severe.
Linux maintainers are pragmatic—they're not rewriting everything in Rust. Instead, they're using Rust for new subsystems and drivers where the safety benefits justify the ecosystem immaturity. This incremental approach is realistic and sustainable.
Common mistakes
- Fighting the borrow checker instead of redesigning. If you're constantly writing
unsafeblocks in embedded code, you're probably fighting Rust's model. Step back—can you restructure ownership? Rust's constraints often reveal better designs than your original C thinking would produce. - Ignoring memory layout in systems code. Rust's default struct layout matches C, but it's not guaranteed. When interfacing with hardware registers or C FFI, use
#[repr(C)]explicitly and verify alignment. A casual mistake here causes subtle hardware bugs. - Assuming unsafe is free.
unsafeblocks aren't performance wins—they're necessary for hardware access and FFI. But they're still code you need to reason about carefully. Write the smallestunsafeblock possible and wrap it in safe abstractions. - Neglecting embedded-hal abstractions. Writing vendor-specific code directly is tempting when you "just need to get it working." But leveraging HAL traits pays off immediately—your code becomes testable, portable, and maintainable. The small upfront effort compounds.
Key takeaways
- Rust's lack of runtime and no_std support make it practical for bare metal and microcontroller programming where every byte of RAM matters.
- The embedded-hal ecosystem provides portable abstractions for common hardware interfaces, letting you write once and port across microcontroller families.
- Async/await and Tokio enable safe, concurrent networking code that rivals C's performance while eliminating memory corruption bugs common in traditional C-based proxies and servers.
- Linux kernel developers are adopting Rust incrementally for drivers and new subsystems, prioritizing safety in code that has historically been a major source of system instability.
- The borrow checker, once felt as friction, becomes a tool for better design in systems programming—it pushes you toward patterns that are both safer and often more elegant than equivalent C code.






Comments
Loading comments…