How Rust & Embassy Shine on Embedded Devices (Part 2)
Insights for Everyone and Nine Rules for Embedded Programmers
By Carl M. Kadie and Brad Gibson
This is Part 2 of an article about effective embedded programming with Embassy and Rust. The article also tries to foster an appreciation for Rust in general by understanding how it works in the embedded domain. (See Part 1). Here in Part 2, we will look at rules 6 to 9:
- 6. Simplify hardware use with device abstractions.
- 7. Use Embassy tasks to manage state, interactions, and automation in device abstractions.
- 8. Layer device abstractions to extend functionality and modularity.
- 9. Embrace
no_std
and avoidalloc
if possible.
Recall from Part 1, rules 1 to 5:
- Use Embassy to model hardware with ownership.
- Minimize the sins of static lifetimes, static variables, and lazy initialization.
- Adopt async programming to escape the peril of busy waiting.
- Replace panics with
Result
enums for robust error handling. - Make system behavior explicit with state machines and enum-based dispatch.
In Part 1, we explored the rules with a simple blinking LEDs project. Now, in Part 2, we build a clock with a 4-cell (digit), 7-segment LED display, controlled with a single button. As with Part 1, for added fun, we’ll also set up an emulator, so you can try the project yourself — even without hardware.
We begin by simplifying the LED display for easier use.
Rule 6: Simplify hardware use with device abstractions.
Programming real hardware can be frustrating. Physical buttons, for instance, often produce noisy, glitchy signals when pressed or released, making reliable input detection difficult. They also lack features like timers to measure press durations.
We can address these shortcomings by wrapping low-level hardware in layers of software. These abstractions transform raw hardware into components that behave predictably and intuitively. We’ll refer to them as device abstractions.
Here is the start of our button abstraction:
/// A button abstraction backed by an Embassy input pin.
pub struct Button<'a>(Input<'a>);
impl<'a> Button<'a> {
/// Creates a new `Button` instance.
#[must_use]
pub const fn new(button: Input<'a>) -> Self {
Self(button)
}
#[inline]
async fn wait_for_button_up(&mut self) -> &mut Self {
self.0.wait_for_low().await;
self
}
#[inline]
async fn wait_for_button_down(&mut self) -> &mut Self {
self.0.wait_for_high().await;
self
}
//...
}
Our button abstraction wraps an Embassy Input
pin, simplifying interaction with the raw hardware. To make the button easier to use, we provide two helper methods: wait_for_button_up
and wait_for_button_down
. These methods translate the physical button's voltage levels into intuitive and descriptive terms.
The most notable public method in Button
determines the duration of a button press. As we’ll discuss shortly, the method can return even before the user releases the button— a feature useful to our application.
pub async fn press_duration(&mut self) -> PressDuration {
self.wait_for_button_up().await;
Timer::after(BUTTON_DEBOUNCE_DELAY).await;
self.wait_for_button_down().await;
Timer::after(BUTTON_DEBOUNCE_DELAY).await;
let press_duration =
match select(self.wait_for_button_up(), Timer::after(LONG_PRESS_DURATION)).await {
Either::First(_) => PressDuration::Short,
Either::Second(()) => PressDuration::Long,
};
info!("Press duration: {:?}", press_duration);
press_duration
}
//... Defined in `shared_constants.rs`
pub const BUTTON_DEBOUNCE_DELAY: Duration = Duration::from_millis(10);
pub const LONG_PRESS_DURATION: Duration = Duration::from_millis(500);
Key Details:
- Timing starts when the user presses the button: In this application, we want to measure the press duration from the moment the user starts pressing the button. To ensure an accurate start, we first wait (if necessary) for the user to release the button. Then, as soon as the user presses the button down, we begin timing.
- Debouncing for reliable input: Mechanical buttons don’t produce a clean, single transition when pressed or released. Instead, they often generate a series of rapid, unintended voltage fluctuations (“bouncing”) before settling into a stable state. The debounce delay filters out these false signals, ensuring each press registers as a single, clean event.
- Immediate response for long presses: In this application, we want the device to recognize long presses before the user releases the button. To achieve this, we use
select
to wait for two events simultaneously: either the button is released, or a half-second timer expires. Whichever happens first determines the function's return value.
Our button abstraction transforms a physical pin, with raw voltage levels and no timing capabilities, into a user-friendly and responsive input mechanism. However, some devices require more — they must remember state, respond to method calls that update that state, and animate their behavior over time. For these more complex needs, we rely on Embassy tasks and layered abstractions.
Rule 7. Use Embassy tasks to manage state, interactions, and automation in device abstractions.
Multiplexing
Here’s a puzzle: A four-cell (or “digit”) seven-segment LED display has just 12 pins. That’s enough for 2¹² (4096) combinations, yet the display can show numbers up to 9999. How is this possible?
The answer lies in multiplexing, a clever technique that enables efficient use of limited pins. A typical four-cell seven-segment LED display organizes its pins as follows:
- 8 pins for segments: These control which of the 7 segments (plus the decimal point) light up to form patterns such as “3”, “7”, “r”, “U”, “S”, “t”.
- 4 pins for cells: These determine which cells (digits) are active.
Here’s how it works:
- Wiring: Connect the display’s 12 pins to 12 of the microcontroller’s pins. Include resistors in series with the segment pins to limit current and protect the LEDs.
- Shared Segments: All cells share the same 8 segment pins.
- Cell Selection: The 4 cell pins activate one or more cells, determining on which cells the selected pattern is displayed. For example, if we select cells C2 and C3 and segments FEDCBA. We’ll see:
␠00␠".
- Multiplexing: To display different patterns on different cells (for example, “1234”), the display rapidly updates the segment pattern while alternating which cells are active. This creates the illusion that all cells are simultaneously lit.
The Display
Device Abstraction
We can create a Display
device abstraction to simplify multiplexing. Instead of manually managing cell and segment activations, we create a Display
abstraction to automatically manage this for us. Once set up, we can write text to Display
, and it determines the necessary cell and segment activations to multiplex the correct output.
Here is a usage example:
let hardware = lib::Hardware::default();
let mut button = Button::new(hardware.button);
let display = Display::new(hardware.cells, hardware.segments, /*???*/)?;
loop {
display.write_text(['1', '2', '3', '4']);
button.press_duration().await;
display.write_text(['r', 'u', 's', 't']);
button.press_duration().await;
}
This code displays “1234” until the user presses the button. It then switches to “rUSt” until the user presses the button again, looping back to “1234” and so on.
Under the Covers
Our Display
struct could control the physical LED directly — managing segment patterns and cycling through cells 333 times per second — the LED’s multiplexing rate. But then how would the Button
abstraction simultaneously measure our half-second presses?
The solution is to add an Embassy task to our Display abstraction. The task will encapsulate the complexities of multiplexing and display updates, enabling seamless integration with other device abstractions, like the button.
Here’s the updated initialization code for Display
. We’ll break down the details shortly, including why we need "sinful" static
.
static DISPLAY_NOTIFIER: DisplayNotifier = Display::notifier();
let display = Display::new(
hardware.cells,
hardware.segments,
&DISPLAY_NOTIFIER,
spawner,
)?;
Let’s look at the struct
and type
that makes this work:
pub struct Display<'a>(&'a DisplayNotifier);
pub type DisplayNotifier = Signal<CriticalSectionRawMutex, BitMatrix>;
Here’s an overview of these components:
struct Display
: Now just a lightweight wrapper around a reference to aDisplayNotifier
. It still, however, lets us interact with the LED display with statements such as:
display.write_text(['1', '2', '3', '4']);
type DisplayNotifier
: An EmbassySignal
orChannel
used to pass notifications to an Embassytask.
That task, described two subsections below, runs autonomously and controls the physical LED display. Because the task runs independently of the function that spawned it, any notifier that communicates with it must be static.
static DISPLAY_NOTIFIER: DisplayNotifier = Display::notifier();
Aside: A notifier can be a
Signal
or aChannel
. We’ll discuss this more in Rule 8.
struct BitMatrix
(defined elsewhere): A custom struct (or potentially an enum) that represents the payload of a notification. Here it specifies which segments (out of the display's 4×8 segments, including decimal points) we should illuminate.
Methods on Display
The Display
struct includes two constructors and one regular method:
Display::notifier
: Creates aDisplayNotifier
, which acts as the communication bridge between theDisplay
struct and thedevice_loop
Embassy task that we’ll see in a moment.
#[must_use]
pub const fn notifier() -> DisplayNotifier {
Signal::new()
}
Display::new
: Initializes aDisplay
in two steps. First, it spawns thedevice_loop
task, which manages the LED's multiplexing and updates. This task takes the LED's 12 pins and a static reference to the notifier as inputs. Second, it constructs and returns aDisplay
that wraps the static reference to the notifier.
#[must_use = "Must be used to manage the spawned task"]
pub fn new(
cell_pins: OutputArray<'static, CELL_COUNT>,
segment_pins: OutputArray<'static, SEGMENT_COUNT>,
notifier: &'static DisplayNotifier,
spawner: Spawner,
) -> Result<Self, SpawnError> {
spawner.spawn(device_loop(cell_pins, segment_pins, notifier))?;
Ok(Self(notifier))
}
write_text
: As before, updates the display's content. This method converts an array of characters (for example,['1', '2', '3', '4']
) into aBitMatrix
and sends it to thedevice_loop
task via theDisplayNotifier
.
pub fn write_text(&self, text: Text) {
info!("write_chars: {:?}", text);
self.0.signal(BitMatrix::from_text(&text));
}
Embassy Task
For the final part of this display abstraction, we introduce the autonomous device_loop
Embassy task. (As discussed in Rule 4, using an inner_device_loop
function allows us to use the ?
operator for simpler error handling.) After the code, we briefly describe how it controls the physical display.
#[embassy_executor::task]
async fn device_loop(
cell_pins: OutputArray<'static, CELL_COUNT>,
segment_pins: OutputArray<'static, SEGMENT_COUNT>,
notifier: &'static DisplayNotifier,
) -> ! {
// should never return
let err = inner_device_loop(cell_pins, segment_pins, notifier).await;
panic!("{:?}", err);
}
#[expect(dead_code, reason = "for article")]
async fn simple_inner_device_loop(
mut cell_pins: OutputArray<'static, CELL_COUNT>,
mut segment_pins: OutputArray<'static, SEGMENT_COUNT>,
notifier: &'static DisplayNotifier,
) -> Result<Never> {
let mut bit_matrix: BitMatrix = BitMatrix::default();
'outer: loop {
info!("bit_matrix: {:?}", bit_matrix);
for index in (0..CELL_COUNT_U8).cycle() {
segment_pins.set_from_bits(bit_matrix[index]);
cell_pins.set_level_at_index(index, Level::Low)?;
let timeout_or_signal = select(Timer::after(MULTIPLEX_SLEEP), notifier.wait()).await;
cell_pins.set_level_at_index(index, Level::High)?;
if let Either::Second(notification) = timeout_or_signal {
bit_matrix = notification;
continue 'outer;
}
}
}
}
The heart of the task code is an endless cycle through the four cells. For each cell, the task updates the LED segment pins, activates the current cell, sleeps for the multiplexing interval, and then deactivates the cell. If awakened by a notification, it updates the BitMatrix
with the new data and restarts the outer loop. Otherwise, it continues cycling through the cells after each multiplexing sleep.
The diagram shows the Display
struct using an Embassy signal
to communicate with its Embassy task.
Rule 7 Summary
Here’s how adding an Embassy task to our device abstraction helps us meet our goals:
- State: Surprisingly, the
Display
struct doesn’t store the current text in its fields. Instead, it passes the text to the Embassy task. It’s that task that maintains state internally, here using the local variablebit_matrix
. - Method-Based Interaction: The
Display
struct provides an intuitive method,write_text()
, for updating the device abstraction. Such methods send structured data (like a struct or enum) to the Embassy task via a signal or channel. - Automation: Encapsulating behavior within the Embassy task allows the display to operate independently — updating LEDs, timing cycles, and responding to changes seamlessly.
Next, we’ll explore how layering device abstractions can extend functionality while keeping each component simple and modular.
Rule 8: Layer device abstractions to extend functionality and modularity.
We face a challenge: we don’t just need a display; we need a display that can blink. But that’s not all — we also want a clock display with eight visual modes and the ability to adjust the time.
The solution is to layer “device abstractions”, each adding new features while preserving modularity. Conceptually, we’ll wrap a Display
within a Blinker
, and then wrap that Blinker
within a Clock
:
Display
: Controls multiplexing text on our four-digit eight-segment display. (See Rule 7.)Blinker
: Adds the ability to blink the display. When blinking is on, it toggles the display between text and blank.Clock
: Manages time display, supports multiple visual modes, and allows time adjustments via a button.
Following Rule 7, each device abstraction consists of a struct, a notifier, and a task. In this design, the Blinker
struct lives in the Clock
’s task, and the Display
struct lives in the Blinker
’s task, as illustrated by the diagram:
So, for example, suppose we are already displaying the time “four minutes after Noon” in hours-and-minutes format — 1204. Clock
’s task sleeps until the next minute. When it wakes, it instructs the Blinker
to display 1205 without blinking. Blinker
will send the notification (BlinkState::Solid, "1205")
to its task. Blinker
’s task instructs the Display
to show 1205, sending a notification to its task. Display
’s task will use multiplexing to display 1205 on the physical display.
At this point:
- The Clock’s task sleeps until the next minute or a new notification.
- The Blinker’s task sleeps until a new notification. (In this example, it doesn’t need to blink.)
- The Display’s task sleeps until a new notification or the next multiplex update.
This system works well and follows the principles from Rule 7. However, two common issues merit special attention when layering.
Minimizing Notifiers in Layered Designs
When stacking multiple layers, you might be tempted to create a separate static notifier for each one. Instead, you can combine them using tuples, reducing redundancy. For example, BlinkerNotifier
is a tuple composed of the Signal
used by Blinker
plus the DisplayNotifier
type:
pub type BlinkerNotifier = (BlinkerOuterNotifier, DisplayNotifier);
pub type BlinkerOuterNotifier = Signal<CriticalSectionRawMutex, (BlinkState, Text)>;
# ...
#[must_use]
pub const fn notifier() -> BlinkerNotifier {
(Signal::new(), Display::notifier())
}
Likewise, ClockNotifier
is a tuple containing the Channel
used by Clock
plus BlinkerNotifier
:
pub type ClockNotifier = (ClockOuterNotifier, BlinkerNotifier);
pub type ClockOuterNotifier = Channel<CriticalSectionRawMutex, ClockNotice, 4>;
#...
#[must_use]
pub const fn notifier() -> ClockNotifier {
(Channel::new(), Blinker::notifier())
}
With these definitions, you can create a single, compound static variable, which your top-level constructor uses to set everything up:
static CLOCK_NOTIFIER: ClockNotifier = Clock::notifier();
let mut clock = Clock::new(hardware.cells, hardware.segments, &CLOCK_NOTIFIER, spawner)?;
Choosing Between Signals
and Channels for Notifications
For each device abstraction, you must decide if your notifier will be a Signal or a Channel. Here is how to choose:
- Signal: Best when only the latest request matters. In the
Display
andBlinker
layers, if requests come in too fast, we can safely ignore intermediate requests. It’s fine to process just the most recent notification. - Channel: Necessary when each command must be handled. For
Clock
, we must process every instruction (like setting the time or changing a display mode) in order.
This layered approach keeps your design modular, while making sure each component handles its own complexity with the appropriate notifier type.
Bringing It All Together
You can see all the details of these three layered, device abstractions in the full code for this project, on GitHub. As an overview, here is the state machine implemented by the top-level Clock
:
And here is a video of changing display modes and setting the time:
Rule 8 Conclusion: Layered Device Abstractions in Action
We have Clock
wrapping a Blinker
wrapping a Display
wrapping hardware. Each layer offers a reusable modular component. This design enables responsive, state-dependent behavior — like instantly reacting to a button press — while efficiently sleeping between tasks such as updating the time, toggling the blink state, and cycling multiplexing steps.
This modular approach avoids the pitfalls of monolithic design — complexity, sluggishness, and excessive resource usage — while maintaining responsiveness for time-sensitive tasks. Even with this modular design, however, resource constraints remain a challenge in embedded systems. This brings us to Rule 9.
9. Embrace no_std
and avoid alloc
if possible.
When writing for embedded systems, you cannot use the Rust standard library (std
) because it depends on operating system features that we assume are not available on our target devices. That's the bad news. The good news is that much of what you use in std
is also available in core
and, if necessary, alloc
. For example:
core::result::Result
andcore::option::Option
for error handling.core::iter::Iterator
for iterables.alloc::collections::Vec
for dynamically sized vectors, but only if you’ve set up your project to support heap allocation.
Aside: For tips on writing and porting code to
no_std
, see Carl's article: Nine Rules for Running Rust on Embedded Systems | Towards Data Science.
We have more bad news: Whenever possible, you should avoid using alloc
. The problem with heap allocation in embedded systems is its unpredictability. It can lead to fragmentation and out-of-memory errors, both of which are difficult to handle in constrained environments.
We have more good news: You can often avoid alloc by using crates designed specifically for embedded programming. For example, collections with pre-allocated or bounded storage, like those provided by the heapless
crate, can provide many of the benefits of dynamic memory allocation while maintaining predictability and avoiding runtime memory management.
Aside: While avoiding dynamic allocation is best in embedded systems, certain use cases may still require it. When allocation is necessary, arena allocators (also known as region-based memory allocators) can help manage fragmentation and improve performance predictability.
Let’s look at an example that adds fancier multiplexing to Display
while avoiding alloc. Recall from Rule 7 that multiplexing involves turning on one of the four cells at a time, cycling through them fast enough that they appear continuously lit.
Now, consider three cases:
- Displaying four spaces, for example,
"␠␠␠␠"
: No need to multiplex—just turn off the display. - Displaying one distinct LED segment pattern, for example,
"␠00␠"
: Again, no need to multiplex—just activate the correct segment pins and cell pins.
Aside: The character
0
corresponds to the LED segment pattern0b_0011_1111
(63u8). These bits represent which of the seven segments (plus decimal point) we should light, not ASCII or Unicode.
- Displaying two or more distinct characters, for example,
"1212"
: Multiplexing is necessary. However, instead of multiplexing each cell individually, here we could alternate between showing both"1"
's and then both"2"
's.
Does this fancy approach make sense? Not really. It introduces inconsistent brightness levels and adds complexity. But it seemed fun, so we decided to do it.
The crux of this approach is counting the distinct, non-space segment patterns and remembering which cells use each pattern. Here’s the algorithm:
- Clear a pre-allocated map that associates non-zero
u8
segment patterns (as bits) with lists of cell indexes. - For each cell index and segment pattern:
- If the segment pattern is not empty:
- →If the map already contains this pattern, add the index to the list.
- →Otherwise, add the pattern and its index to the map.
And here it is in Rust:
pub fn bits_to_indexes(&self) -> Result<BitsToIndexes> {
let mut bits_to_index: BitsToIndexes = LinearMap::new();
for (&bits, index) in self.iter().zip(0..CELL_COUNT_U8) {
if let Some(nonzero_bits) = NonZeroU8::new(bits) {
if let Some(vec) = bits_to_index.get_mut(&nonzero_bits) {
vec.push(index).map_err(|_| BitsToIndexesNotEnoughSpace)?;
} else {
let vec =
Vec::from_slice(&[index]).map_err(|()| BitsToIndexesNotEnoughSpace)?;
bits_to_index
.insert(nonzero_bits, vec)
.map_err(|_| BitsToIndexesNotEnoughSpace)?;
}
}
}
Ok(bits_to_index)
}
Our challenge is to create this project without heap allocation, so how can we use a map containing vectors? The solution is to use crates that replace standard alloc
types like Vec
, String
, and HashMap
with non-alloc alternatives.
We recommend the heapless
crate, which provides a suite of data structures designed to work well together. In this case, we use heapless::LinearMap
and heapless::Vec
, both of which require you to specify a maximum size. For this project, we know the maximum size: there can be at most four distinct segment patterns, and any given pattern can appear in at most four index locations. We define:
pub type BitsToIndexes = LinearMap<NonZeroU8, Vec<u8, CELL_COUNT>, CELL_COUNT>;
This data structure uses a fixed 52 bytes of stack memory.
You may notice that the bits_to_indexes
function can return BitsToIndexesNotEnoughSpace
errors. While this seems unnecessary—since we've ensured enough memory—the heapless
data structures don’t trust us. By handling the error, we gain a safeguard: if we miscalculate the maximum sizes, we’ll be alerted.
We’ve now covered nine rules for designing modular and efficient embedded systems in Rust. These principles help us break down complex functionality into reusable device abstractions, making code clearer and better suited to handle resource constraints and the lack of dynamic memory allocation. Next, we’ll see these ideas in action by running the hardware clock through software emulation. Finally, we’ll conclude with a discussion reflecting on the lessons from our two projects.
Hands-On: Run clock
on an emulator.
The clock
project displays the time on a four-cell (digit), seven segment LED display. It aims to simplify setting the time, even with the limitation of a single button.
For details on wiring the physical project, refer to the project’s README.md
. However, you don’t need physical hardware to explore the project. You can use a software emulator that looks like:
Refer to the end of Part 1 for set up steps. Then:
- If needed, in your working folder, clone the Renode repository for the Raspberry Pi Pico. Start with the latest version, and if you encounter issues later (such as during compilation), fall back to a known stable version:
git clone https://github.com/matgla/Renode_RP2040.git
## If needed, use this known working commit:
# cd Renode_RP2040
# git checkout 060be525
# ..
- In your working folder, clone the
clock
project.
git clone https://github.com/CarlKCarlK/clock.git
- Move into the
clock/tests
folder. Create and activate a Python environment.
cd clock
cd tests
uv venv
# ACTIVATE via:
# `source .venv/bin/activate` or `.venv\Scripts\activate` (Windows)
- Install the required Python dependencies:
uv pip install -r ../../Renode_RP2040/visualization/requirements.txt
- Add the Raspberry Pi Pico target to Rust:
rustup target add thumbv6m-none-eabi
- Build the
clock
project:
cargo build
- Run the emulator and start the emulation:
renode --console run_clock.resc
s
# use p to pause and q to quit
- Now, point a web browser to http://localhost:1234/ and play with the emulated device. A short tap will move you between
HH:MM
andMM:SS
display modes. A long hold will move you to time setting mode, first setting seconds to00
, then setting minutes, and finally hours. Keep in mind, however, that as a simulation, the emulator does not keep accurate time. - In your console window, use ‘p’ to pause the emulation and ‘q’ to quit the emulator.
By completing these steps, you can run and modify this project without hardware, enabling you to experiment, test, and explore.
Conclusion
So, there you have it: nine rules for effective embedded programming using Embassy and Rust. These rules emphasize achieving safety, modularity, and efficiency, while addressing the specific constraints and demands of embedded systems.
What Worked Well
- Safety: Rust’s ownership model and type system eliminate common bugs like null pointer dereferences and data races. In embedded systems without Rust, global data is often used extensively, making it easy for different parts of the code to inadvertently overwrite each other’s work. Rust’s strict ownership rules help prevent such issues, making embedded development safer and more predictable.
- Concurrency on a Single Hardware Processor: Embassy’s async runtime lets you manage multiple tasks efficiently, even on resource-constrained microcontrollers, by leveraging cooperative multitasking. While a manual event loop can achieve similar results, Embassy simplifies concurrency management with near-zero overhead.
- Zero-Sized Types (ZST) and Optimizations: Rust optimizes Zero-Sized Types for efficient memory usage and performance. For example, when we created our
Hardware
struct, theCORE1
processor was a ZST, so copying took no time. Meanwhile, each pin fit into a single byte and was constructed in place without copying, demonstrating how Rust minimizes overhead even in complex abstractions. - Efficiency: Rust’s combination of zero-cost abstractions and explicit control over memory and performance enables developers to write highly performant code.
- Modular Programming: The use of device abstractions and layered designs streamline programming by assigning clear responsibilities to each component. This approach makes it easier to automate tasks like blinking and multiplexing while ensuring instant responses to events such as button presses. By isolating behaviors into distinct layers, the system remains manageable and adaptable to new features.
Challenges and Limitations
- Less Support than C/C++ and Python: Every microprocessor begins with support for C, as it’s the industry standard. Microprocessors appealing to hobbyists typically add a Python variant early on. Rust support, on the other hand, tends to arrive later. For example, while the Pico 2, released in August 2024, shipped with MicroPython, C, and C++ support, its Rust ecosystem was still in its infancy. Using Embassy, as demonstrated here, wasn’t fully practical until several months later and continues to require additional setup and special steps.
- Cooperative Multitasking: Embassy’s lightweight and efficient multitasking works well for embedded systems but relies on developers to ensure tasks yield control. If a task forgets to yield — whether due to a bug or oversight — it can freeze the system. Such issues can be difficult to catch, as they are neither detected by the compiler nor do they trigger runtime errors.
- Testing is Challenging: Embedded development is inherently difficult to test, regardless of the programming language. Effective testing and continuous integration (CI) become feasible only with emulation. The Renode_RP2040 project demonstrates how emulator-based testing can be applied to the Pico platform.
- Generics for Tasks: While Embassy is powerful, its lack of support for generics in task definitions limits flexibility. For example, tasks cannot be parameterized by size, meaning you can’t create a single task that generically handles different LED display sizes.
- Traits for Notifiers: Enhanced associated type support could allow for cleaner abstractions. For instance, we could define a generic trait for device abstractions with notifiers, avoiding boilerplate.
Lessons for General-Purpose Rust Programming
Can these rules inform general-purpose Rust development? The answer is nuanced:
- Rules 1–5 (Ownership, Avoiding Sins, Async, Results, State Machines): These rules apply universally. Ownership and borrowing are foundational to Rust’s philosophy, and async programming with
Result
-based error handling is as relevant to web development as it is to embedded systems. State machines, while less common in everyday programming, excel in scenarios requiring predictable, event-driven behavior — such as handling hardware interactions, user interfaces, communication protocols, simulations, game development, and workflow management. - Rules 6, 7, and 8 (Device Abstractions and Tasks): While device abstractions and tasks are core to embedded systems, the underlying concepts — modularity, state management, and structured concurrency — apply to broader domains. For example, device abstractions could manage persistent state in chat systems or other asynchronous environments.
- Rule 9 (no_std and Avoiding Alloc): This rule is less critical for general-purpose programming but offers a valuable reminder: dynamic data structures are not always necessary. Fixed-size alternatives may simplify code and improve predictability in performance-sensitive applications.
Thank you for joining us on this journey through embedded development with Rust, from principles to hands-on implementation. Rust impresses us with its ability to deliver efficiency and safety across diverse environments. As you explore the world of embedded systems, we hope you find Rust’s flexibility and power as compelling as we do.
For Brad’s projects, follow him on GitHub.
Interested in future articles, please follow Carl on Medium. He writes on scientific programming in Rust and Python, machine learning, and statistics. He tends to write about one article per month.