In the world of embedded systems, every byte counts. I’ve spent years optimizing Rust applications for tiny MCUs, and I’ve discovered that smart binary size reduction is both an art and a science. Reducing your binary footprint isn’t just about meeting hardware constraints—it also improves load times, reduces memory pressure, and often enhances runtime performance.
Link-Time Optimization
Link-time optimization (LTO) provides significant size reductions by allowing the compiler to optimize across module boundaries. When the compiler can see your entire program, it makes better decisions about inlining, dead code elimination, and constant propagation.
I typically configure my projects with these Cargo.toml settings:
[profile.release]
lto = true
codegen-units = 1
opt-level = "z" # Optimize aggressively for size
strip = true # Remove debug symbols
panic = "abort" # Smaller panic implementation
The first time I applied these settings to a sensor monitoring firmware, the binary shrunk from 42KB to just 28KB—a 33% reduction with no functionality changes.
Conditional Compilation
I’ve found that feature flags and conditional compilation are powerful tools for trimming unnecessary code. Rather than commenting out code or using runtime checks, we can exclude entire features at compile time.
#[cfg(feature = "detailed-logging")]
fn log_system_state(sensors: &SensorArray) {
// Complex logging with sensor details, timestamps, etc.
for (idx, reading) in sensors.readings().enumerate() {
log::info!("Sensor {}: {:.2}°C, status: {}", idx, reading.temperature, reading.status);
}
}
#[cfg(not(feature = "detailed-logging"))]
fn log_system_state(_sensors: &SensorArray) {
// Minimal implementation that just notes the check happened
log::trace!("System check completed");
}
This pattern lets me build different versions of the same application, including only what’s needed for each deployment scenario.
String Optimization
Strings consume valuable space in embedded systems. I’ve developed several techniques to minimize their impact:
// Instead of multiple string literals, use a lookup table
const ERROR_MESSAGES: &[&str] = &[
"File not found",
"Connection failed",
"Calibration error",
"Battery low",
];
// Define constants for indexing
const ERR_FILE: u8 = 0;
const ERR_CONNECTION: u8 = 1;
const ERR_CALIBRATION: u8 = 2;
const ERR_BATTERY: u8 = 3;
fn get_error_message(code: u8) -> &'static str {
ERROR_MESSAGES.get(code as usize).unwrap_or("Unknown error")
}
For extremely constrained systems, I sometimes replace string messages completely with numeric codes that can be looked up in documentation.
Custom Memory Allocation
The standard allocator in Rust is optimized for general-purpose computing and includes features unnecessary for many embedded applications. I often implement a minimal allocator:
use core::alloc::{GlobalAlloc, Layout};
struct EmbeddedAllocator;
unsafe impl GlobalAlloc for EmbeddedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// Static memory pool for our allocator
static mut MEMORY_POOL: [u8; 8192] = [0; 8192];
static mut NEXT_FREE: usize = 0;
// Calculate aligned offset
let align_mask = layout.align() - 1;
let start = (NEXT_FREE + align_mask) & !align_mask;
let end = start + layout.size();
if end <= MEMORY_POOL.len() {
NEXT_FREE = end;
MEMORY_POOL.as_mut_ptr().add(start)
} else {
core::ptr::null_mut()
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// For simplicity, this example doesn't free memory
// Real implementations would track allocations
}
}
#[global_allocator]
static ALLOCATOR: EmbeddedAllocator = EmbeddedAllocator;
This simplified allocator saved me 2.8KB in a recent project compared to using the standard allocator.
Strategic Code Organization
How you structure your code significantly impacts binary size. I use these function attributes to guide the compiler:
// Critical path function that should be inlined for performance
#[inline(always)]
fn fast_sensor_read(address: u8) -> u16 {
// Time-critical I2C or SPI communication
// Direct hardware register manipulation
unsafe { core::ptr::read_volatile((0x4000_0000 + address as usize) as *const u16) }
}
// Rarely used error handling that shouldn't be inlined
#[inline(never)]
fn handle_calibration_error(error_code: u8) {
// Complex error recovery procedures
// This stays out of the hot path
}
By carefully marking functions, I ensure that critical code is optimized for speed while rarely-used code stays out of the instruction cache.
Control Over Generics
Generic code is powerful but can lead to code bloat through monomorphization. I’ve developed patterns to limit this effect:
// Type-erased interface that only generates one implementation
pub trait DeviceOperation {
fn execute(&self, device: &mut Device);
}
// Concrete implementations
struct ReadOperation { register: u8 }
struct WriteOperation { register: u8, value: u16 }
impl DeviceOperation for ReadOperation {
fn execute(&self, device: &mut Device) {
device.read_register(self.register);
}
}
impl DeviceOperation for WriteOperation {
fn execute(&self, device: &mut Device) {
device.write_register(self.register, self.value);
}
}
// Queue operations using trait objects to avoid monomorphization
struct OperationQueue {
operations: [Option<Box<dyn DeviceOperation>>; 16],
count: usize,
}
This approach can sometimes trade a small performance cost for significant code size reductions—often a worthwhile exchange in embedded systems.
Dead Code Elimination
The compiler is good at removing unused code, but we can help it by structuring our project appropriately:
// Public API module that exposes only what's needed
pub mod api {
use super::implementation;
pub fn initialize_system() {
implementation::setup_hardware();
implementation::configure_peripherals();
}
pub fn process_sensor_data() -> [u16; 4] {
implementation::read_sensor_array()
}
}
// Implementation details that aren't directly exposed
mod implementation {
pub(super) fn setup_hardware() {
// Hardware initialization
}
pub(super) fn configure_peripherals() {
// Peripheral setup
}
pub(super) fn read_sensor_array() -> [u16; 4] {
// Read from sensors
[0, 0, 0, 0] // Placeholder
}
// This function never gets called from public API, so it's eliminated
pub(super) fn diagnostic_routine() {
// Extensive diagnostics not used in production
}
}
By carefully controlling the public API surface, I ensure that only the necessary implementation details are included in the final binary.
Data Compression
For embedded applications with substantial data requirements, I compress static data:
// Include compressed firmware image or configuration data
static COMPRESSED_CONFIG: &[u8] = include_bytes!("../assets/config.bin.lz4");
fn load_configuration() -> Result<Config, Error> {
// Static buffer for decompressed data
static mut CONFIG_BUFFER: [u8; 4096] = [0; 4096];
// Decompress data when needed
let size = lz4_decompress(
COMPRESSED_CONFIG,
unsafe { &mut CONFIG_BUFFER }
)?;
// Parse the decompressed data
Config::parse(unsafe { &CONFIG_BUFFER[..size] })
}
This technique saved me nearly 70% of ROM space when dealing with large lookup tables and calibration data.
Real-World Results
I recently applied these techniques to a commercial temperature monitoring system running on an STM32F0 microcontroller with just 64KB of flash. The initial build using standard Rust practices produced a 78KB binary—too large for the target.
After systematically applying these optimization strategies:
- LTO and other compiler flags reduced the size by 26%
- Removing string formatting saved another 18%
- Customizing the allocator saved 5%
- Controlling generic code generation saved 8%
- Compressing calibration data saved 12%
The final binary was 42KB—comfortably fitting in the available flash with room for future features. The system maintained all functionality with no measurable performance impact.
I’ve found that binary size optimization isn’t a one-time effort but an ongoing process. Each new feature needs evaluation for its size impact, and regular auditing helps identify new opportunities for optimization.
These techniques have helped me deploy Rust to platforms that many considered too constrained for a modern language. The result is embedded systems that benefit from Rust’s safety guarantees without sacrificing the ability to run on small microcontrollers.