For decades, embedded systems development has been dominated by C/C++, and for a good reason. These languages offer predictable performance, low-level hardware access, and minimal runtime overhead. However, this can be a double-edged sword, as there is an entire class of bugs known to be difficult to detect yet catastrophic if they find their way into production firmware.
As safety-critical systems become part of our everyday life, the industry is asking a serious question: “Is C/C++ still sufficient for new embedded development, or is it time to consider safer alternatives?”
Rust is explicitly cited as a solution to the memory safety crisis in systems programming. Major organizations and projects, including Microsoft, Google, and the Linux kernel project, have begun integrating Rust into security-critical code.
This article examines what Rust brings to embedded systems development, its drawbacks, and whether it has a realistic future on bare-metal platforms.
The Problem with C/C++ in Embedded Development
The C language has existed since the 1970s. Its memory management model was designed for a world where it was assumed that programmers knew exactly what they were doing. That assumption has not held up particularly well over time.
In embedded systems, those mistakes can be especially severe.
Common Memory-Safety Issues
Buffer Overflows
Writing beyond the end of an array can corrupt adjacent variables, overwrite return addresses, or silently alter hardware register mappings.
0 | void process_packet(uint8_t *data, size_t len) {
1 | uint8_t buffer[64];
2 | memcpy(buffer, data, len); // What if len > 64?
3 | parse(buffer);
4 | }
If len is greater than 64, this code invokes undefined behavior.
Use-After-Free
Accessing memory after it has been freed often leads to silent data corruption. On embedded systems without a memory management unit (MMU), or with limited memory protection, this may not cause an immediate system crash. Instead, the system may continue running in an invalid state, making debugging the problem significantly more difficult.
0 | uint8_t *buf = malloc(32);
1 | free(buf);
2 | buf[0] = 0xFF; // Undefined behavior — may corrupt heap metadata
Dangling Pointers
Dangling pointers occur when a pointer outlives the object it points to. A classic and most seen example in C++ is returning a reference to a local variable, or storing a pointer to an object that gets destroyed when it goes out of scope.
Data Races
In multi-threaded embedded systems (RTOS, dual-core MCUs), unprotected access to shared memory produces non-deterministic behavior that is almost impossible to reproduce in a debugger.
Null Pointer Dereference
Null pointer dereference is still one of the most common causes of system crashes in embedded systems, especially following poorly validated external input.
What Makes the Most Common Mistakes Painful in Embedded Systems
In a desktop application, many of these bugs result in a crash that is often recoverable.
In an embedded system:
- There is often no OS to contain the fault.
- Watchdog timers may reset the system and hide the original root cause.
- Hardware peripherals may be left in inconsistent states.
- Safety-critical behavior (braking, actuation, communication) may be compromised silently.
- Field debugging is expensive, limited, or impossible
The National Security Agency (NSA), The Cybersecurity and Infrastructure Security Agency (CISA), and several other EU cybersecurity agencies now classify memory-unsafe languages as a systemic risk for connected systems. The embedded world is a primary target.
How Rust Addresses These Problems
Rust introduces an ownership and borrowing system as its core safety mechanism. The borrow checker enforces a set of compile-time rules that guarantee memory safety without a garbage collector.
This safety comes from the compiler, not from runtime overhead.
Ownership: One Owner, One Lifetime
Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped. This eliminates many common manual memory-management errors:
- No manual free().
- No double-free.
- No use-after-free
A Rust function might look like this:
0 | fn process_packet(data: &[u8]) {
1 | let buffer: Vec = data.to_vec(); // buffer owns the allocation
2 | parse(&buffer);
3 | // buffer is dropped here - automatically, deterministically
4 | }
This example demonstrates ownership: the buffer owns the allocated memory, and it drops automatically at the end of the function.
However, there is an even simpler and technically cleaner example of the same function that includes the next topic – borrowing.
Borrowing: Compile-Time Reference Checking
Rust's borrow checker enforces two fundamental rules at compile time:
- You can have many immutable references (&T).
- Or exactly one mutable reference (&mut T).
- You cannot have both at the same time.
This eliminates entire categories of data races and dangling pointer bugs.
0 | fn update_sensor(reading: &mut SensorReading, calibration: &Calibration) {
1 | reading.value = raw_adc() * calibration.factor;
2 | // The compiler guarantees calibration cannot be mutated while we hold &mut reading
3 | }
If you try to hold two mutable references to the same data, the code simply will not compile:
0 | let mut sensor = SensorReading { value: 0.0 };
1 | let r1 = &mut sensor;
2 | let r2 = &mut sensor; // COMPILE ERROR: cannot borrow sensor as mutable more than once
3 | r1.value = 1.0;
This is one of Rust’s most important safety features. It prevents invalid aliasing before the program is ever executed.
And when it comes to previous example, here it is:
0 | fn process_packet(data: &[u8]) {
1 | parse(data);
2 | }
This is borrowing in practice: the function gets controlled access to existing data without taking ownership. For embedded systems, this is especially useful because it avoids unnecessary copying and does not require heap allocation. At the same time, Rust still preserves memory safety because the compiler verifies that the referenced data remains valid for the duration of the call.
No NULL pointers
Rust has no null value. Instead, optional values are represented explicitly.
Those are represented with Option
0 | fn find_device(id: u8) -> Option<&'static Device> {
1 | DEVICE_TABLE.iter().find(|d| d.id == id)
2 | }
3 | // At the call site, you MUST handle the None case
4 | match find_device(0x42) {
5 | Some(dev) => dev.init(),
6 | None => log::warn!("Device 0x42 not found"),
7 | }
In many languages, a NULL dereference can cause a runtime crash. In Rust, there is a compile-time guarantee that a NULL pointer cannot be dereferenced at runtime.
Bounds checking
Array indexing in Rust includes bounds checks by default. Accessing out-of-bounds memory causes a controlled panic! rather than undefined behavior.
0 | let buffer: [u8; 64] = [0; 64];
1 | let idx = incoming_len; // could be anything
2 | let byte = buffer[idx]; // panics at runtime if idx >= 64, never silently corrupts
Fearless concurrency
In an RTOS or multi-core embedded system, concurrent access to shared data is one of the most common sources of subtle and difficult-to-debug errors. In C/C++, this often relies on developer discipline: using the correct locks, disabling interrupts at the right time, or remembering to mark shared variables as volatile or atomic where necessary.
Rust approaches the problem differently. It enforces safe concurrent access at compile time. Types that are not safe to share across threads cannot be sent across thread boundaries without explicit synchronization.
Rust expresses these guarantees through traits such as Send and Sync:
- Send means a value can be safely transferred to another thread or task.
- Sync means a value can be safely shared by reference between threads or tasks.
A simple example is a counter shared between an interrupt service routine and the main loop:
0 | use core::sync::atomic::{AtomicU32, Ordering};
1 | static INTERRUPT_COUNT: AtomicU32 = AtomicU32::new(0);
2 | // This is safe to access from both interrupt context and main loop
3 | #[interrupt]
4 | fn TIM2() {
5 | INTERRUPT_COUNT.fetch_add(1, Ordering::Relaxed);
6 | }
7 | fn main_loop() {
8 | let count = INTERRUPT_COUNT.load(Ordering::Relaxed);
9 | // ...
10| }
Sharing a plain u32 (non-atomic) between an interrupt handler and the main loop would be a compile error. The type system prevents the data race before it ever reaches hardware.
The embedded Rust ecosystem
Rust's embedded ecosystem has matured significantly over the last few years. Although it is not yet at the level of C's decades-deep toolchain, it is production-capable for a growing range of targets.
Rust without the standard library (no_std)
Embedded targets typically don't have an OS, heap allocator, or standard I/O. Rust supports this through the #![no_std] attribute, which strips the standard library and gives you access only to core (the hardware-independent subset) and optionally alloc (if you bring your own allocator).
0 | #![no_std]
1 | #![no_main]
2 |
3 | use cortex_m_rt::entry;
4 | use panic_halt as _;
5 |
6 | #[entry]
7 | fn main() -> ! {
8 | // Bare-metal entry point — no OS, no heap, no runtime
9 | loop {
10| // application logic
11| }
12| }
When we talked about bounds checking, we didn’t consider what if there is not standard library. For no_std embedded targets where panicking is not acceptable, the get() method returns Option<&T> what forces explicit handling.
0 | match buffer.get(idx) {
1 | Some(byte) => process(*byte),
2 | None => { /* handle gracefully */ }
3 | }
This approach avoids silent memory corruption.
Key Rust ecosystem components
| Layer | Primary Crates / Tools | Purpose |
|---|---|---|
| Hardware Abstraction | embedded-hal | Common traits for I²C, SPI, UART, GPIO, PWM, etc. |
| MCU Support |
stm32f4xx-hal,
nrf-hal,
rp2040-hal,
esp-hal | Vendor- and MCU-specific peripheral implementations |
| RTOS integration |
Embassy,
RTIC | Async task execution (Embassy) and real-time interrupt-driven applications (RTIC) |
| Memory Allocation |
embedded-alloc,
tlsf-allocator | Dynamic memory allocation for embedded targets |
| Debugging/Flashing |
probe-rs,
cargo-flash,
defmt | Programming, debugging, and efficient logging |
| Bootloaders |
embassy-boot | Secure firmware updates and boot management |
| Async Runtime |
Embassy Executor | Lightweight async executor (no heap, no OS required) |
From an architectural point of view, it would look like:
Figure 1. Layered architecture of the Rust embedded ecosystem
embedded-hal
One of Rust's most powerful contributions to embedded is the embedded-hal trait crate. It defines standard interfaces for common peripherals, similar to what HAL means in the C world, but enforced by the type system.
0 | use embedded_hal::i2c::I2c;
1 | // This function works with ANY I2C implementation — STM32, nRF, RP2040, simulated...
2 | fn read_temperature(i2c: &mut I, addr: u8) -> Result {
3 | let mut buf = [0u8; 2];
4 | i2c.write_read(addr, &[0x00], &mut buf)?;
5 | Ok(((buf[0] as f32) * 256.0 + buf[1] as f32) / 128.0)
6 | }
This means driver code written against embedded-hal traits is portable across microcontrollers with zero modification.
Embassy
Embassy brings async/await to bare-metal embedded systems. It provides cooperative multitasking without requiring a traditional operating system, dynamic memory allocation, or a conventional scheduler.
0 | #[embassy_executor::task]
1 | async fn sensor_task(mut i2c: I2cDevice<'static>) {
2 | loop {
3 | let temp = read_temperature(&mut i2c).await;
4 | log::info!("Temperature: {:.1}°C", temp);
5 | Timer::after_secs(1).await;
6 | }
7 | }
This is compelling for embedded systems because it enables structured concurrency, readable asynchronous code, and deterministic memory usage.
Advantages, disadvantages, and limitations
Let's be honest, Rust is not a silver bullet, and embedded Rust is not C. Still, there are several advantages that are worth mentioning.
| Advantage | Description |
|---|---|
| Compile-time memory safety | eliminates buffer overflows, use-after-free, data races by construction |
| No runtime overhead for safety | borrow checking happens at compile time, not at runtime |
| Zero-cost abstractions | high-level code compiles to tight machine code comparable to C |
| Expressive type system | encode hardware states, protocols, and invariants into types |
| Excellent tooling | cargo, rustfmt, clippy, rust-analyzer are best-in-class |
| Cross-compilation | first-class support for ARM Cortex-M, RISC-V, Xtensa, and more |
| Growing ecosystem | embedded-hal, Embassy, RTIC are production-ready |
| C/C++ interoperability | Foreign Function Interface (FFI) allows calling C libraries from Rust and exposing Rust code to C; migration can be gradual. |
| Incremental adoption | Rust can replace one module at a time. Existing C/C++ stays in place; Rust links in as a static or shared library. No big-bang rewrite required. |
Disadvantages and Limitations
As we said, Rust is not perfect, so it is important to mention also disadvantages and limitations.
Steep Learning Curve
Rust requires a different way of thinking about memory, ownership, and references. The borrow checker requires a fundamentally different way of thinking about memory. Expect weeks to months before feeling productive, and longer before feeling fluent. This is the most significant barrier to adoption.
Smaller Talent Pool
There are fewer embedded engineers with professional Rust experience compared to C/C++. Most embedded teams have deep C/C++ expertise, and many engineers are reluctant to leave their comfort zones or are understandably cautious about changing established workflows.
Ecosystem Gaps
Not every MCU has a mature Rust HAL. Some vendor SDKs (especially proprietary ones) have no Rust bindings at all. Calling into them via Rust's Foreign Function Interface (FFI), which allows calling existing C libraries, is common and works well but adds friction. That friction is largely a one-time cost, once a vendor library is wrapped behind a safe Rust API, it behaves like any other Rust dependency to the rest of the codebase.
0 | // Calling C vendor SDK from Rust via FFI
1 | extern "C" {
2 | fn vendor_adc_read(channel: u32) -> i32;
3 | }
4 | fn read_adc(channel: u32) -> i32 {
5 | unsafe { vendor_adc_read(channel) } // unsafe block required - you're leaving Rust's safety guarantees
6 | }
The example above shows the raw FFI call, but production code wraps it in a safe API so that unsafe stays contained in one place.
A common pattern is always the same:
- validate inputs,
- call the C function inside a single unsafe block,
- and return a safe Rust type to the caller.
Everything above the wrapper is then fully protected by the Rust’s safety guarantees.
Two tools from the Rust ecosystem reduce the friction of writing bindings by hand. bindgen generates Rust FFI declarations automatically from a C header file, eliminating the risk of silent type mismatches between the two sides. cbindgen does the reverse, it reads a Rust library and generates a C header, so existing C callers can link against a Rust implementation without any changes on their side. Together, they make the FFI boundary maintainable even for large vendor SDKs.
Unsafe Code Still Exists
Low-level embedded work inevitably requires unsafe blocks for direct memory-mapped register access, DMA, or FFI. Unsafe does not mean memory-unsafe C, it means you are responsible for upholding safety invariants in that block. The rest of the codebase remains safe, but unsafe is still there!
Binary Size and Compile Time
Rust's monomorphization (generic specialization) can increase binary size compared to equivalent C code. For MCUs with very limited flash (< 32 KB), this can be a real constraint. This is improving with tools like cargo-bloat and careful crate selection. Compile times are usually longer, especially for large dependency trees. Incremental compilation can help, but it's noticeable on large projects.
Certification and Qualification
Safety-critical systems often require qualified toolchains under standards such as IEC 61508, ISO 26262, or DO-178C. Certified C/C++ compilers from established vendors have a long history in these environments.
Qualified Rust toolchains are emerging, but the ecosystem is still maturing compared with traditional embedded C/C++ toolchains.
The Future of Rust in Embedded from Industry Perspective
The trajectory is clear, even if the pace is debated.
The Linux kernel has officially adopted Rust as a second implementation language. This is no longer just an experiment, it is merged into the mainline kernel. Drivers can now be written in Rust, with more modules following.
In automotive, the AUTOSAR foundation has published guidance on Rust for automotive software. Ferrous Systems' Ferrocene compiler has achieved ISO 26262 and IEC 61508 qualification. Several Tier 1 suppliers are piloting Rust for new ECU development where safety certification is required.
For IoT and connectivity, AWS IoT, Google, and Espressif (ESP32) have invested in Rust support for their platforms. esp-hal and esp-idf-svc make it possible to build production IoT firmware in Rust today.
The 2024 U.S. White House National Cybersecurity Strategy specifically calls out memory-safe languages as a priority for critical infrastructure. This is creating pressure, especially in defense and industrial sectors, to justify continued use of C for new projects.
The most realistic path for most teams is not a full rewrite but incremental adoption:
- Write new modules in Rust.
- Keep existing C codebases where it already works.
- Use FFI to connect Rust modules with existing codebases.
- Gradually expand Rust usage where it provides clear safety benefits.
This is exactly how Rust entered the Linux kernel, Android, and Firefox.
The key question is not whether Rust will appear in more embedded systems, it clearly will. The question is how fast the ecosystem matures, how quickly the talent pool grows, and whether certified toolchain support keeps pace with safety-critical industry requirements.
Conclusion
Rust does not solve every problem in embedded development, and it is not ready to replace C/C++ overnight. However, it addresses a very real and costly problem, memory safety, in a way that no previous systems language has: at compile time, without a garbage collector, with zero runtime overhead.
For new embedded projects, especially those involving networking, security, or safety certification, Rust deserves serious evaluation. The borrow checker feels like a wall when you first encounter it. On the other side of that wall is a compiler that catches entire classes of bugs before they ever reach hardware.
In an industry where a buffer overflow can disable a brake system or expose a factory network, that is worth something.

