A revolution: Discover asynchronous embedded programming in Rust in Embassy with STM32, sensors and practical tips.
Dear readers, I hope you had a relaxing festive season and have had a good start to the new year 2025. I don't want to write a typical New Year's motivational article, but rather share some knowledge with you again. Ok, I couldn't resist a comment or two. Have fun with my "slightly different" New Year's article about asynchronous embedded programming.
I was able to free up some time over the festive period to work on a pet project in the Rust Embedded Framework - Embassy You can find the current status, with reasonably good documentation, on GitHub.
What you can expect in the article:
- Development of embedded systems!
- Asynchronous programming!
- What is concurrency and parallelism?
- The one or other "black" comment on intentions and willingness to change!
- and of course a lot about Rust!


Is Rust learning a good resolution for 2025? Yes, but it's just a resolution!
Actions speak louder than words -> Learn Rust here!
Are you still reading? Super: The article is structured as follows:
- The project idea
- Embedded programming with Rust
- What is asynchronous programming?
- Asynchronous embedded programming with Embassy
- Conclusion
The project idea
My last project in the embedded sector was several years ago. In the meantime, you have the option of using KiCAD to design a circuit board and then produce it for little money, e.g. at multi-cb. Something like this makes my tinkerer's heart beat faster. But that wasn't the only reason for starting this project.
During a customer project, I got to know a freelancer who also worked on embedded systems and through him I gained insights into the current state of embedded C programming.
What's more, I've been watching the development of Embedded Rust with interest for some time now. In particular the framework Embassywhich is based on the concept of asynchronous programming. If you have heard of asynchronous programming, it is most likely in the context of web services rather than bare-metal development.
But even without Embassy, Rust adds a lot of value to the world of embedded programming: At compilation time, Rust checks the hardware configuration. However, asynchronous embedded programming is of course cooler.
Yes, this is demanding brain power, but you don't actually want to learn Rust?
Then read about my experiences at the ACHEMA and the Rust Nation UK 2024!
Now it's time to go shopping
So a week before the holidays, I went on a little shopping spree at Reichelt (a cheap and popular electronics retailer in Germany). I wanted to build a small sensor platform with which I can measure temperature and brightness and send it to the PC via UART. My purchase:
- STM32 NUCLEO-144 with an STM32F767ZI chip
- DS18B20 Programmable Resolution 1-Wire Digital Thermometer
- BH1750FVI – Digital 16bit Serial Output Type Ambient Light Sensor IC
- A Breadboard Set with power and some cables
The objectives of the "sensor platform" and the status quo can be found at GitHub.
Of course I had forgotten a few things. For example, a 4.7k Ohm resistor for the I2C bus. Many thanks to my father, who diligently and dutifully went looking for my old electronic parts so that I could take them with me after Christmas. Now, back to the topic: What is embedded programming and why with Rust? You'll find out in the next section.
Embedded Programming with Rust
Basically, embedded programming is based on controlling the voltage of PINs. The whole thing is of course much more complicated because in addition to pins there are other hardware components such as memory, buses and timers. For example, communication protocols such as I2C which are equipped with functions such as direct memory access (DMA) can be operated.
DMA enables continuous data streams and higher performance. Example: If a sensor is controlled via I2C, DMA can ensure that measured values are transferred to the memory without CPU intervention while the CPU is still performing other tasks. This is particularly advantageous for time-critical applications.
Fortunately, most hardware manufacturers provide so-called SVD files, which describe the registers of an MCU in detail, i.e. the memory address for mapping, register addresses and bit fields. This information was already used in the C ecosystem for the automatic generation of low-level code. Keyword CMSIS-SVD. For Rust, the command line tool svd2rust was created: But Rust, or svd2rust, offers even more.

In addition to the hardware abstraction layers (HALs), there are also peripheral access crates (PACs) in the Rust ecosystem. The latter are automatically created using svd2rust PACs use Rust Traits to ensure that peripherals are linked to drivers and DMA in the correct way during compilation.
In the picture above, for example, you can see a Rust compiler message that I received when I tried to map a wrong peripheral to the serial UART protocol. The solution is highlighted in yellow.
Do you use C for embedded systems? Great, others once bet on the horse!
„Ich setze aufs Pferd!“ – Kaiser Wilhelm II.
The mistake was that I selected PA1 instead of PA9 as the periphery. In C I would have observed something at runtime, or more likely: I wouldn't have observed anything, and then I would have started looking for the error in amazement. Thanks Rust!
So asynchronous embedded programming, but what is asynchronous programming anyway?
What is asynchronous programming
Instead of defining asynchronous programming, we first define what synchronous programming means. This means that a function call is blocked - i.e. the function call is completed and then the next function call is executed. The code below runs from top to bottom, i.e. as it is read. This is so intuitive that this explanation is probably rather confusing.
Let's assume functions query databases, download a file or wait for a sensor value that is sent via the I2C bus. The CPU has nothing to do, but you wait for memory or communication. During this time, it would be nice to use the CPU time for another task.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let reval_a = my_func_a();
let reval_b = my_func_b();
printnln!("A={}, B={}", reval_a, reval_b);
}
In asynchronous programming, an async function call triggers a task that is executed by an executor at a given time. For example, in the code below, the function my_async_b may be calculated first. Note: In asynchronous Rust, the function call only happens if the result is explicitly requested.
In the code below, the asynchronous runtime of Tokio is used. You usually see something like this in server/client applications. Nothing happens when the first two functions are called. For something to happen, the future must be explicitly requested using await or the join! macro (this is different from C++). For embedded programming, which usually only uses one computing core, it is useful to explain the difference between parallel and concurrent control-flow.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let future_a = my_async_func_a();
let future_b = my_async_func_b();
let (reval_a, reval_b) = join!(future_a, future_b);
println!("A={}, B={}", reval_a, reval_b);
let reval_c = my_async_func_c().await;
}
Concurrency and parallelism
An asynchronous runtime generates a concurrent execution. Important: This is also possible with a single thread or a single computing core. A chip with only one computing core is often installed in embedded hardware. This is also the case for the STM32 chip on the NUCLEO board used here.
Parallel execution is only possible as soon as more than one computing core is available. In this case, an executor could execute function a and b simultaneously on different cores. In the case of a single core, the behaviour is the same as in the operating systems of the 90s. This means that a worker switches tasks and because CPUs and MCUs are so fast, it feels simultaneously to us. For this, the process scheduler of an operating system corresponds to an asynchronous runtime, e.g. Tokyo or Embassy. The processing of the tasks/functions can overlap, but simultaneity is neither necessary nor guaranteed.
The typical application scenario for asynchronous programming is a web server with many requests and long waiting times due to input and output, such as reading a database or accessing a file on another server.
However, this very principle is used by Embassy for embedded systems, e.g. to organise several sensors and communication tasks simultaneously without the need for a real operating system scheduler. So we get some features of a real-time operating system (RTOS).
This is asynchronous embedded programming. Thank you Embassy!
Getting started with Rust? We are happy to support you! Arrange a initial consultation.
Or buy us lunch without obligations !
Synchronous programming
- Function only returns after the calculation has been completed
- Return value is actual return
- Well-defined sequence of calls
- Control-flow is much easier to understand
Asynchronous programming
- Function returns immediately
- The return value is "Pending" or "Completed", the actual return value can only be retrived in the "Completed" state
- Function b could complete its calculation before function a
Asynchronous embedded programming with Embassy
Unlike Tokyo, Embassy is designed for bare-metal hardware. Instead of classic threads that are managed by the operating system, Embassy uses its own scheduler implementation that reacts to hardware interrupts or user-specific synchronizations, such as timers or signals, to wake up the scheduler. This provides an excellent basis for saving energy, because thanks to these concepts it is clear when the CPU can and should be sent to sleep.
Other important concepts are the tasks that are passed to the scheduler. Tasks are ultimately asynchronous Rust functions. Synchronisation primitives such as signals, channels and mutexes enable tasks to exchange data. This gives you features like in an RTOS (real-time operating system) but with a significantly smaller footprint.
In contrast to a classic RTOS such as FreeRTOS or Zephyr, Embassy uses Rust's own concepts such as async/await, channels, signals and timers. Of course, there are use cases where an RTOS cannot be avoided, as the co-operative scheduling always has to wait for a task completion, even with a high-priority variant, so that absolutely hard real-time requirements in the milli or micro second range may not be met.
All right, but how do you use Embassy?
Setting up the peripherals, interrupts and tasks
Embassy users can "spawn" tasks in the main function and these are executed cooperatively in a scheduler by default. An additional high-priority scheduler can be used for real-time critical modules.
But let's examine the code snippet below. The main function is annotated as in Tokyo, only this time with #[embassy_executor::main]. First, a representation of the STM32 periphery is created with an init call. Then the macro bind_interrupts!
connects our peripherals (USART, I2C) with the corresponding interrupt routines from Embassy.
usart is a driver for serial communication with a computer. In the case of the NUCLEO board, the USB connection is used for this. The split function can be used to transfer receivers and transmitters to different tasks. If more than one task is to send or transmit, it is necessary to ensure synchronization, e.g. using a Mutex. Otherwise the Rust compiler will issue an error message.
In this article we focus on code snippets, for more details check out the complete code in GitHub The uart_status_report_transmitter task uses the uarts transmitter tx to send status reports. The second task uart_receiver_and_cmd_forwarder is used to receive messages from a computer. So far we have ignored the channels and signals in the code. These are, along with timers, our tools for using concurrency,
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
//...
// bind interrupts
bind_interrupts!(struct Irqs {
USART3 => embassy_stm32::usart::InterruptHandler<embassy_stm32::peripherals::USART3>;
I2C1_EV => embassy_stm32::i2c::EventInterruptHandler<embassy_stm32::peripherals::I2C1>;
I2C1_ER => embassy_stm32::i2c::ErrorInterruptHandler<embassy_stm32::peripherals::I2C1>;
});
let mut usart = setup_usart_developer_console!(p, Irqs, UsartConfig::default());
// ...
// spawn a task for uart sending and receiving each
let (tx, rx) = usart.split();
spawner.spawn(uart_receiver_and_cmd_forwarder(rx, CHANNEL_COMMANDS.sender())).unwrap();
spawner.spawn(uart_status_report_transmitter(tx)).unwrap();
// ...
spawner.spawn(process_light_sensor(
&LIGHT_SENSOR_SIGNAL,
i2c
)).unwrap();
// ...
loop {
button.wait_for_rising_edge().await;
CHANNEL_COMMANDS.sender().send(Commands::LightSensor(LightSensorCommands::SingleMeasurment)).await;
Timer::after(Duration::from_millis(50)).await;
}
}
So far we only know how tasks are set up, but the advantage of asynchronous embedded programming is realised through the interaction of the tasks.
Interaction between tasks
With the help of channels and other synchronization mechanisms, such as Signal, event-type communication between the tasks is possible. It is also possible to share data that is synchronized using a mutex.
In the code block above, the variables CHANNEL_COMMANDS and LIGHT_SENSOR_SIGNAL are responsible for the control flow and thus hint the scheduler and on-top enable the communication between tasks.
Commands from the uart receiver task uart_receiver_and_command_forwarder are forwarded to a task for processing commands (not shown in the snippet). It is possible to use several transmitters, as you can see in the loop below, which checks whether the user button has been pressed. If this is the case, the command processor is informed first, which then triggers a LIGHT_SENSOR_SIGNAL to determine a single sensor value.
Channels are an important concept for fearless concurrency and parallelism in Rust.
The anxious Germans in particular would benefit from being more fearless! What do you think?
In the following, we examine how LIGHT_SENSOR_SIGNAL implements the delivery of the control-flow back to the scheduler in the process_light_sensor task. The code block below illustrates two cases: In the simpler case only a measured value is read out and for this we wait on a signal.
In the second case, we either waits for new values on the I2C bus or react when the LIGHT_SENSOR_SIGNAL is triggered. At the bottom, It is also illustrated how timers can be used to return the control-flow to the scheduler.
// in non-continuous modes:
LightSensorState::PowerOff | LightSensorState::SingleMeasurement => signal.wait().await,
// ...
// in continuous sending mode
let f1 = i2c.read(BH1750_ADDR_L, &mut rx_buf);
let f2 = signal.wait();
select(f1, f2).await
// ...
Timer::after(Duration::from_millis(150)).await;
An Embassy user can therefore use await to inform the scheduler that the current function has to wait and thus write modular code divided into different tasks that communicate with each other using events such as channels or signals.
Conclusion
Rust and the Embassy Framework rethink embedded programming, making asynchronous embedded programming salon-ready. Embassy is part of the no_std world and is based on the Rust Embedded HAL to connect drivers (I2C, SPI, UART) asynchronously.
The framework is successfully used by the company Akiles and has the potential to implement many use cases in embedded systems better than a pure C solution.
Embassy is not suitable for absolutely hard security-relevant real-time requirements in the small millisecond range and a real RTOS is required. Most use cases will not have such strict requirements and can get features of an RTOS with hardly any overhead thanks to Embassy.