Rust Systems Programming Patterns: Zero-Cost Abstractions and FFI
Rust's promise of "zero-cost abstractions" means you pay no runtime penalty for high-level constructs. This guide explores how to leverage trait objects vs. generics, write safe unsafe code, integrate with C via FFI, develop embedded Rust, and measure where abstractions truly have zero cost.
Zero-Cost Abstractions in Practice
The core principle: what you don't use, you don't pay for. Abstractions compile away:
// High-level iterator chain
fn sum_of_squares(v: &[i32]) -> i32 {
v.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.sum()
}
// Compiles to essentially the same assembly as:
fn sum_of_squares_manual(v: &[i32]) -> i32 {
let mut total = 0;
for &x in v {
if x % 2 == 0 {
total += x * x;
}
}
total
}
Verify with Compiler Explorer (godbolt.org)—both produce identical optimized code.
Trait Objects vs. Generics
Choosing between dynamic dispatch (trait objects) and static dispatch (generics) has real performance implications:
use std::fmt;
trait Drawable {
fn draw(&self);
fn bounding_box(&self) -> (f64, f64, f64, f64);
}
struct Circle { x: f64, y: f64, radius: f64 }
struct Rectangle { x: f64, y: f64, width: f64, height: f64 }
impl Drawable for Circle {
fn draw(&self) { println!("Drawing circle at ({}, {})", self.x, self.y); }
fn bounding_box(&self) -> (f64, f64, f64, f64) {
(self.x - self.radius, self.y - self.radius,
self.x + self.radius, self.y + self.radius)
}
}
impl Drawable for Rectangle {
fn draw(&self) { println!("Drawing rect at ({}, {})", self.x, self.y); }
fn bounding_box(&self) -> (f64, f64, f64, f64) {
(self.x, self.y, self.x + self.width, self.y + self.height)
}
}
// Static dispatch: compiler generates separate code for each type
// Fast, no vtable lookup, enables inlining
fn draw_static<T: Drawable>(shape: &T) {
shape.draw();
}
// Dynamic dispatch: single code path, vtable lookup at runtime
// Slower, prevents inlining, but allows heterogeneous collections
fn draw_dynamic(shape: &dyn Drawable) {
shape.draw();
}
fn main() {
let circle = Circle { x: 0.0, y: 0.0, radius: 5.0 };
let rect = Rectangle { x: 1.0, y: 1.0, width: 10.0, height: 5.0 };
// Static: monomorphized at compile time
draw_static(&circle);
draw_static(&rect);
// Dynamic: runtime dispatch via vtable
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { x: 0.0, y: 0.0, radius: 3.0 }),
Box::new(Rectangle { x: 0.0, y: 0.0, width: 5.0, height: 3.0 }),
];
for shape in &shapes {
draw_dynamic(shape.as_ref());
}
}
Rule of thumb: Use generics for performance-critical code. Use trait objects when you need heterogeneous collections or want to reduce compile times.
Unsafe Code: When and How
Unsafe Rust is necessary for FFI, low-level memory operations, and certain performance-critical patterns:
// Raw pointer manipulation
fn split_at_mut_safe<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let len = slice.len();
assert!(mid <= len);
let ptr = slice.as_mut_ptr();
// Safe to call: ptr is valid, ranges don't overlap
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
// Atomic operations for lock-free programming
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn increment() -> usize {
COUNTER.fetch_add(1, Ordering::SeqCst)
}
// Wrapping unsafe in a safe API (the correct pattern)
pub struct SafeBuffer {
ptr: *mut u8,
len: usize,
cap: usize,
}
impl SafeBuffer {
pub fn new(capacity: usize) -> Self {
let layout = std::alloc::Layout::array::<u8>(capacity).unwrap();
let ptr = unsafe { std::alloc::alloc(layout) };
SafeBuffer { ptr, len: 0, cap: capacity }
}
pub fn push(&mut self, byte: u8) {
assert!(self.len < self.cap, "Buffer overflow");
unsafe {
*self.ptr.add(self.len) = byte;
}
self.len += 1;
}
pub fn as_slice(&self) -> &[u8] {
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl Drop for SafeBuffer {
fn drop(&mut self) {
let layout = std::alloc::Layout::array::<u8>(self.cap).unwrap();
unsafe { std::alloc::dealloc(self.ptr, layout); }
}
}
FFI: Calling C from Rust
Rust's extern "C" blocks enable seamless C interoperability:
// Declare C functions
extern "C" {
fn strlen(s: *const std::os::raw::c_char) -> usize;
fn malloc(size: usize) -> *mut std::os::raw::c_void;
fn free(ptr: *mut std::os::raw::c_void);
fn printf(format: *const std::os::raw::c_char, ...) -> std::os::raw::c_int;
}
// Safe wrapper
fn c_strlen(s: &str) -> usize {
let c_str = std::ffi::CString::new(s).unwrap();
unsafe { strlen(c_str.as_ptr()) }
}
// Exposing Rust functions to C
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn rust_process_bytes(ptr: *const u8, len: usize) -> i32 {
if ptr.is_null() {
return -1;
}
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
slice.iter().sum::<u8>() as i32
}
Using the bindgen tool to auto-generate FFI bindings:
// build.rs
fn main() {
println!("cargo:rerun-if-changed=wrapper.h");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks))
.generate()
.expect("Unable to generate bindings");
bindings.write_to_file("src/bindings.rs").unwrap();
}
Embedded Rust
Rust's no_std mode enables bare-metal programming:
#![no_std]
#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
use stm32f4xx_hal::{pac, prelude::*};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(84.MHz()).freeze();
let gpioa = dp.GPIOA.split();
let mut led = gpioa.pa5.into_push_pull_output();
loop {
led.set_high();
cortex_m::asm::delay(8_000_000);
led.set_low();
cortex_m::asm::delay(8_000_000);
}
}
Performance Measurement
Use criterion for statistically rigorous benchmarks:
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_bench"
harness = false
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn fibonacci(n: u64) -> u64 {
match n {
0 => 0,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn fibonacci_iterative(n: u64) -> u64 {
let (mut a, mut b) = (0u64, 1u64);
for _ in 0..n {
let tmp = a + b;
a = b;
b = tmp;
}
a
}
fn bench_fibonacci(c: &mut Criterion) {
let mut group = c.benchmark_group("fibonacci");
for i in [10u64, 20, 30].iter() {
group.bench_with_input(
BenchmarkId::new("recursive", i),
i,
|b, i| b.iter(|| fibonacci(black_box(*i))),
);
group.bench_with_input(
BenchmarkId::new("iterative", i),
i,
|b, i| b.iter(|| fibonacci_iterative(black_box(*i))),
);
}
group.finish();
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
Compile-Time Computation with const
Rust's const evaluation moves work to compile time:
const fn factorial(n: u64) -> u64 {
if n == 0 { 1 } else { n * factorial(n - 1) }
}
const FACTORIAL_10: u64 = factorial(10); // computed at compile time
// Const generics for type-level integers
struct Matrix<const ROWS: usize, const COLS: usize> {
data: [[f64; COLS]; ROWS],
}
impl<const N: usize> Matrix<N, N> {
fn identity() -> Self {
let mut data = [[0.0; N]; N];
for i in 0..N {
data[i][i] = 1.0;
}
Matrix { data }
}
}
fn main() {
println!("10! = {}", FACTORIAL_10); // 3628800
let identity: Matrix<3, 3> = Matrix::identity();
}
Conclusion
Rust's zero-cost abstraction model lets you write expressive, high-level code that compiles to tight machine code. Choose generics over trait objects in performance-critical paths, encapsulate unsafe code in safe abstractions, and use FFI to leverage the vast C ecosystem. The combination of safety guarantees and raw performance makes Rust uniquely suited for systems programming in 2026.