Introduction
This is the exercise book accompanying the Embedded Rust Workshop. The Workshop provides exercises for Embedded Rust which run on the BBC microbit v2.
For this exercises, you only require the following hardware:
- The microbit v2.
- A Micro-USB cable to power the microbit while also connecting it to your computer.
This little educational board has everything we need to teach you the practical part of embedded Rust, including:
- A 5x5 LED matrix.
- The LSM303AGR on-board motion sensor.
- A serial connection accessible by a USB-CDC device
This device has even more features and external connectivity, and you can find all of this information of the microbit website.
Goals
After working through this exercises, you should have the following skills:
- Extract relevant information from datasheets and/or schematics for firmware development.
- Understand and work in embedded code using the the
embassyasynchronous runtime, which includes using theasync/awaitsyntax. - Schedule concurrent tasks using
embassy. - Work with some components of a provided HAL (or BSP).
- Using
embassy-timeto perform delays and periodic operations or measure elapsed time. - Writing drivers for simple sensors
However, these exercises do not replace or provide a good theoretic foundation and it also does not teach general Rust programming. Low-level aspects like working with registers and details about system boot are are intentionally skipped. The next section provides some further material recommendations to address this.
Further Materials
If you are a beginner in Rust, it is strongly recommended to work through the Rust book or work through some other method of your choice to learn the general language.
The following materials might be valuable for you as well:
- The embassy book provides additional information and documentation about the embassy asynchronous run-time.
- The Rusty bits provides excellent visual resources about various embedded Rust aspects.
- The Ferrous Systems Rust Training slides provide high-quality slides about various topics, including embedded Rust and aspects like system boot and peripheral access crates.
Preparation
We are going to start with the preparation of the software tools in the next chapter.
Preparation
Cloning the project
TODO: git instructions, clone instructions
Rust and cargo
The first thing required is the Rust compiler and the cargo build and dependency management
tool. The Rust installation includes the cargo tool, so all you need to do is install
Rust by going to the Rust website and following the operating
system specific instructions.
After you have done this, you can verify your installation by running:
cargo version
Normally, you would have to also install the thumbv7em-none-eabihf toolchain and some other
useful tools, but this is performed automatically for you through the rust-toolchain.toml
file in the code directory.
flip-link linker
You need to install a special linker called flip-link for building the software. Run
the following command in the terminal:
cargo install flip-link
Flasher tool probe-rs
Next, you need some software which allows flashing the microbit v2 via the USB interface.
We are going to use the probe-rs tool, which is well integrated into the Rust ecosystem:
The probe-rs website has install instructions
for various operating systems. Follow these, and then test your installation using the following
command
probe-rs --version
USB permission setup (Linux only)
Finally, if you are on Linux, you need to perform some steps related to udev to avoid permission
issues. probe-rs has a page with steps you can follow.
IDE
You can use any IDE of your choice which has good Rust Analzyer Support.
If you are looking for a solid graphical IDE, VS Code is an excellent choice. Make sure to install the rust-analyzer plugin as well.
Testing everything
Now you should have everything you need to build and flash some application to the board. You can flash a test application now to test everything. We provide some test applications for you.
Connect the board to your computer using a Micro-USB cable. Make sure that your cable also supports the data interface and is not power-only.
Go into the code/app directory.
Now, you can run the following command to build and flash a blinky application:
cargo run --bin blinky
On the console, you should see an output like this:
❯ cargo run --bin blinky
Compiling rust-app v0.1.0 (/home/muellerr/Rust/rust-embedded-workshop/code/app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s
Running `probe-rs run --chip nRF52833_xxAA --allow-erase-all target/thumbv7em-none-eabihf/debug/blinky`
Erasing ✔ 100% [####################] 48.00 KiB @ 35.81 KiB/s (took 1s) Finished in 3.55s
-- microbit v2 Blinky application --
If probe-rs can not detect anything, make sure that (a) the board is connected through USB, and
(b) the udev rules were setup properly if you are on Linux.
If everything goes right, you should see the LED in the top-left corner blinking with a frequency of 1 second. If this is the case, congratulations, you have built and flashed an embedded Rust app and you made if through the preparation chapter successfully!
In the first exercise, you are going to build most parts of a blinky.
Terminology and Glossary
You can look up some terms in this terminology chapter if you have never heard them before or if they confused you.
- Peripheral: Dedicated hardware unit. On microcontrollers, this can be something like a UART, SPI or timer hardware block.
- Flash: Non-volatile memory which you can use to store your code and constants.
- Stack: Local memory that your program uses as it executes functions
- Heap: Free memory that can be allocated in blocks. Oftentimes not available on microcontrollers.
- Static: Usually refers to the lifetime of a variable. A static variable is valid for the whole program duration.
- RAM: Volatile memory used to store your stack, heap and static variables.
- Processor: Executes your code.
- MCU or Microcontroller: Integrates the processor, peripherals, flash, RAM and is usually placed on a printed circuit board as part of an embedded system.
- Firmware: Software which interacts a lot with hardware. Usually refers to the finished software product on your MCU which runs the embedded system.
- PCB - Printed Circuit Board: This is usually an electronic circuit integrated on a sandwhich structure. Oftentimes, chips, sensors and other components will be soldered on top of the PCB.
- UART - Universal Asynchronous Receiver-Transmitter: Asynchronous serial communication interface which only requires two physical pins, one for transmission and one for reception.
- DMA - Direct Memory Access: Hardware subsystems can access the memory of a system directly without CPU intervention.
- HAL - Hardware Abstraction Layer: High level library providing drivers for the hardware blocks on a microcontroller.
Blinky Exercise
The end goal of this task is to make the LED D2, which is the LED is the upper left corner of the LED matrix, blink with a frequency of 1 second.
It involves working with a general purpose Input/Output (GPIO) pin which is a very common task on microcontrollers. It also involves a timing components to achieve the 1 second blink frequency. In this exercise, you are going to build this application.
Go into the microbit-code/exercises directory. Inside the src/bin/blinky.rs file, you can
find the skeleton project that you should edit to work towards the blinky application. It includes
an explanation of the intermediate steps. Each intermediate step is explained in this document
in detail, including an intermediate solution which you can see by opening expanding the detail
segment.
You can find a full solution inside the blinky_solution.rs file.
Notice that you can always build and run the current state of your solution using
cargo run --bin blinky
Some notes on the skeleton
Okay, there is not much here in this empty skeleton app, but you might still be interested in what it does.
The #![no_std] directive must be used because we do not have a standard runtime on our
microcontroller. The #![..] syntax applies this attribute to the whole module, which is our
whole application in this case. A standard runtime is usually only available on a full host system, for example
your development laptop. It usually includes components which make use of the operating systems,
for example filesystem handling, input/output libraries printing to the console, time libraries
and much more. We do not have an operating system, so this does not exist for our target.
The #[no_main] directive also applies to the whole module and must be used because we do not want
to use the default main method, which is the entry point of the program. This main method
would not exist for our bare-metal target anyway. Instead, we want to use an entry point method
provided and called by an external library. In this case,
cortex-m-rt is used.
The use exercises as _; line imports everything in the library lib.rs. exercises is the name
of the library/crate. Inside lib.rs, we are including some important tools:
use defmt_rtt as _;: We want to usedefmtas our logging library, and combine it with SEGGER RTT as the transport protocol.use embassy_nrf as _;: We need to include this for the compilation of our run-time library to work. It needs access to an interrupt vector structure which is imported as a side-effect of importing the HAL.use panic_probe as _;: We need to provide apanichandler for the compilation to work. This includes a panic handler provided by a library.
#[embassy_executor::main] is used to annotate our entry point. It allows that entry point
to be an async function as well.
The async fn main(_spawner: Spawner) -> ! function prototype contains the following components:
asyncbecause this is an asynchronous function. This allows us, among many other things, to use otherasyncAPI inside the function- The
!return type means that this function should never return. A microcontroller software generally must run forever, because what would the system do if there is no more code to execute? - The
spawnerargument can be used to spawn otherasynctasks. This is important for multi-tasking, but not relevant for us now. To avoid clippy/linter errors, a leading underscore was added to mark the unused argument.
Do not worry if you do not fully understand all of this! It is not necessary for practical programming
purposes. It is included once for completeness sake, because you will see these directives in
most embassy based programs.
First step: Initializing the chip
We are using a hardware abstraction layer (HAL) library to simplify our job. If we did not use this, we would have to use low-level register access code to interact with the hardware directly. That is not really beginner friendly, so we will start with something more high-level. The HAL introduces hardware abstractions, data structures and objects to interact with the hardware.
The nRF52833 chip which is part of the microbit v2 has some initialization which makes sense for most firmware. This can include something like the clock initialization. When writing this HAL, it makes sense packaging that configuration inside some initializer function.
Rust also has a nice type system which allows modelling of our problem domain. We have a
microcontroller which has peripherals and physical pins. We can model
these entities in our Rust code to allow ownership checks and resource management. For example,
the chip has a physical pin called P0_06. We can model this physical pin as a P0_06 field
of a data structure. The we might have some other API which “consumes” this pin to take ownership
of it and use it for certain purposes. The pin can not be used for some other purpose anymore
and we prevented one source of a bug using the type system.
We use the embassy-nrf HAL which provides an initializer method providing both of these tasks.
It is already included in the dependency list inside Cargo.toml
for you so you can import and use it in your code directly. Look at the
docs of the init
method. This is what you want to use to initialize the chip. You can use the default method
of embassy_nrf::config::Config, it serves our purposes for now. Have a look at the
documentation of the Peripherals
data structure which is returned by the init function. It models all the peripherals and physical
pins like we previous metioned.
Call this method and store the Peripherals object inside a variable called periphs.
#![allow(unused)]
fn main() {
let periphs = embassy_nrf::init(embassy_nrf::config::Config::default());
}
Second step: Print something
We mentioned that we use the defmt library and the RTT protocol for logging purposes.
Our flasher takes care of grabbing log frames sent via RTT. Print something to the console so we
know what program is running. For example, you can use defmt::println!("your string") to print
something to the RTT pipe.
#![allow(unused)]
fn main() {
defmt::println!("-- microbit v2 Blinky application --");
}
Third step: Creating the GPIO drivers
Before we talk about creating the GPIO drivers for switching the LED, let’s talk about the the hardware first. This is not a classic LED which can be drive by simply toggling a GPIO pin. Instead, it is a matrix where each row and each column has one connected GPIO line.

There is no reason to be overly scared of electronic schematics. Learning to read them is something that can be learnt without having to study electronic engineering, and with schematics you usually have the source of truth which is relevant for writing your software. This is an excerpt of the full schematics that we have also included in the repository. There is also a pin map table on the website.
Have a look at D2. This is a LED, and the task is to make that one blink. You can assume that the LED will turn on if the ROW1 GPIO is configured as an output pin and then driven high while the COL1 GPIO is configured as an output pin and then driven low.
But what is ROW1 and COL1? Those are actually connected to physical pins of your MCU:

Search for the two pins and look for the P0.XY number which is on the chip side (yellow background) on the left. This number is relevant for the code. Alternatively, open the schematics directly and use the search function to find them quickly. If you are struggling with this task, you can also simply use the pin map table and look at the GPIO name for COL1 and ROW1.
COL1 is P0.28 and ROW1 is P0.21
Now we have our physical pins. Have a look at the GPIO Output driver documentation.
The first argument is a peripheral resource which is a field of the periphs structure we
created earlier. The initial level is required because Output pins must have a defined state.
The third argument is the drive strength. You can use the standard value here.
<35;48;33M
Create an output driver for ROW1 and store it as a row1 object. Also do the same for COL1 and
store it as a col1 object. Remember that you assign the actual physical pin, which is re-presented
by an ID like P0.XY, and which you extracted earlier, by passing the corresponding field of the
periphs structure to the Output constructor.
If this all sounds very confusing to you and you do not really know what to do, look at the solution and try to understand it:
#![allow(unused)]
fn main() {
let mut row1 = Output::new(
periphs.P0_21,
embassy_nrf::gpio::Level::Low,
embassy_nrf::gpio::OutputDrive::Standard,
);
let col1 = Output::new(
periphs.P0_28,
embassy_nrf::gpio::Level::Low,
embassy_nrf::gpio::OutputDrive::Standard,
);
}
Notice how we pass the physical pin object to the output driver.
Fourth step: Toggling the LED
Now, we have all the objects required to fulfill our task. For the remainder of the program lifetime, we just want to toggle the LED.
That is equivalent to a permanent loop, so you can use the Rust loop constructor for this.
There already is one in the skeleton to avoid a compilation error.
Use the toggle method on the correct
GPIO driver to toggle the LED inside the loop. We actually told you the correct object/driver to use this on before.
If you forgot, maybe you can also figure it out from the schematic?
Toggling the LED in a permanent loop would cause the LED to not be on long enough for you
to see anything. Beside, the tasks was to make it blink with a frequency of 1 second.
We need to introduce a delay. We are going to use embassy_time for this.
Again, we included the dependency for you, so you can use it directly. So far, we did not have to use
any async API. This is because all the code we used so far was strictly synchronous, with no
need to delay in any shape or form. For example, configuring a GPIO driver usually only
requires a few writes to certain memory addresses. A delay can actually be modelled as an
asynchronous operation: We tell the compiler to asynchronously wait for a delay of 1 second to elapse.
We recommend using the Timer::after_millis API for this.
You can also use the Delay API but
you need to import the embedded_hal_async::delay::DelayNs trait for this to work.
You can store the timer inside a variable called timer. Notice that this does not perform
the required delay. For that, you need to await the timer. Use this information to perform
an asynchronous delay of 1 second or 1000 milliseconds inside the loop.
This could look like this:
#![allow(unused)]
fn main() {
let timer = Timer::after_millis(1000);
timer.await;
}
You can also directly write this in one line to avoid the intermediate variable:
#![allow(unused)]
fn main() {
Timer::after_millis(1000).await;
}
Finishing up
When you run cargo run --bin blinky, you should see something like this:
#![allow(unused)]
fn main() {
❯ cargo run --bin blinky
Compiling exercises v0.1.0 (/home/muellerr/Rust/embedded-rust-workshop/microbit-code/exercises)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
Running `probe-rs run --chip nRF52833_xxAA --allow-erase-all target/thumbv7em-none-eabihf/debug/blinky_solution`
Erasing ✔ 100% [####################] 48.00 KiB @ 35.91 KiB/s (took 1s)
Programming ✔ 100% [####################] 48.00 KiB @ 22.82 KiB/s (took 2s) Finished in 3.54s
-- microbit v2 Blinky application --
}
Also, you should see the LED D2 blinking with a frequency of 1 second. If this is the case, you did it! Blinking a LED might seem like a mundane task, but it actually teaches various concepts that can be transferred to other tasks because it involes resource management, working with hardware, and time handling. Also, you have extracted information from a schematic now! This is very useful skill that embedded engineers should have. It allows you to directly use the schematic that is created as a side-product of the PCB design process.
UART echo application
The end goal of this task is to communicate with the serial port of the microbit v2. A very common interface to allow communication with a microcontroller is the UART protocol.
This is a protocol which facilitates communication through two physical pins, a transmit pin called TX and a receive pin RX. You cross-wire the TX pin of one side to the RX pin of the other side and vice-versa. Both sides have to agree on the communication speed which is commonly called baudrate.
The specific task is an echo application: Everything that is received on the RX pin of the microcontroller UART should be sent back to the sender via the TX pin.
The microbit v2 UART interface
The microbit v2 has a very convenient feature which allows use to talk with one of its UART interfaces via the USB interface you already have. Have a look at this hardware block diagram taken from the website:

The nRF52833-QIAA block on the left side is the target MCU we are always programming. The
other microcontroller on the right side is the interface microcontroller. When we talk to the UART
of the target MCU, or we use probe-rs to flash new software to the target or read/write to its
RAM, we always do this through the interface MCU. The interface MCU exposes the serial port
via its USB interface as a so called USB-CDC device, where CDC is an abbreviation for
Communication Device Class.
Install the cyme tool using the following command:
cargo install cyme
Then run the command cyme with the microbit v2 connected via USB. You should see a line like
this:
3 6 0x0d28 0x0204 BBC micro:bit CMSIS-DAP 9906360200052820ea998ce1eddd4919000000006e052820 - 12.0 Mb/s
Next, you can figure out the actual device name that you have to use to talk with the MCU by running the following command on Linux:
❯ ls -l /dev/serial/by-id/*
(...)
lrwxrwxrwx - root 12 Jun 09:39 /dev/serial/by-id/usb-Arm_BBC_micro:bit_CMSIS-DAP_9906360200052820ea998ce1eddd4919000000006e052820-if01 -> ../../ttyACM0
On Windows, you can instead use this command in the PowerShell terminal:
Get-WmiObject Win32_SerialPort | Select-Object Name,Description
Connecting to the UART interface - Linux
These instructions are Linux specific. Check the next segment for Windows specific instructions.
There are various programs available to connect to a serial port. For example, you can install
picocom and then run the following command:
picocom -b 115200 /dev/ttyACM0
Your device name might be different! It is named /dev/ttyACM0 because that was the output of
ls -l /dev/serial/by-id/*. Change this for you command if necessary.
Connecting to the UART interface - Windows
There are various programs available to connect to a serial port. On Windows, you can install PuTTY and then connect to the serial port using the COM port name you found before.
You need to use the Serial connection type and specify a speed of 115200. This could look something liket his:

You can then open the connection to open a session connected to the serial port of the MCU.
UART hardware
Before we start writing code, lets look at the hardware first. We mentioned that UART uses 2 physical pins. This means that two of the GPIO pins of the microbit v2 need to be configured so they can be used by the UART hardware block for communication.
For a peripheral like UART, it is very common that a microcontroller support a larger selection of pins to be assigned to the UART. Similarly to the blinky exercise where LED control is mapped to certain GPIO pins, there is a pin mapping which depends on the board design.
You can either look at the pin map or the schematic to find the GPIO pin assignment. Try to figure the pin mapping out on your own.
The RX pin is mapped to P0.06 while the TX pin is mapped to P1.08.
The microbit v2 also has multiple UART instances. It allows using both of them, and we are going to use instance 0.
Some background information: Interrupts and direct memory access (DMA)
In the next segment, we will mention interrupts. The HAL we are using abstracts a lot of things away from us, but it does not hurt to have a basic understanding of what is happening behind the scenes.
In computing systems, processing important events in a timely manner is oftentimes done using interrupts. A really simple analogy: When the door bell rings or someone calls you , you will generally drop whatever you are doing right now to open the door or answer the phone call.
Mapping this analogy on a computer system, the delivery man ringing your door bell is the UART peripheral informing you about the data delivery, while you are the processor. When the hardware peripheral fires an interrupt, the CPU will stop whatever it is doing to service the peripheral, and then go back to whatever it was doing before. Depending on how the hardware is designed, you can do a lot of work with interrupts.
Some MCUs are designed in a way which allows hardware peripherals to access memory like RAM directly. This technique is called direct memory access (DMA). Combining interrupts and DMA allows to perform something like large data transfers with minimal CPU intervention.
The UART driver provided by the HAL that we are going to use combines both of these concepts as well.
First step - Create a UART driver
In the previous exercise, we created a driver for a GPIO pin. Now, we create a driver for another hardware module: The UART. The HAL we are using provides a driver for us.
Read the uarte module docs in embassy
first. There are two flavors of this driver: A buffered one and a more simple one. In this example,
and for most of the applications in our domain, we generally want to ensure that we never lose
data, ideally independent of whatever the software is doing. A detailed explanation of how
this can be done would exceed the scope of this task, but you can assume that you need the
buffered flavor to not lose data between read calls, so that is what we are going to use.
Have a look at the constructor documentation of the buffered UART. It has 11 arguments! The driver is relatively complex, and the constructor is not spared from that. It allows reliable communication and exposes an elegant API though.
Remember that the Peri type is always used for resource management types are comes from the
peripheral singleton field which is named _periphs in our example. Remove the leading underscore,
because we are going to use this type now.
Let’s go through the arguments of the constructor one by one. You do not need to understand all of the details here but they are mentioned for completeness.
uarte- This is the UART instance we want to use. For our solution we are going to use instance 0 but the hardware allows to use instance 1 as well.timer- The driver needs one of the hardware timer blocks to count the number of received bytes. You can use any unused timer instance here.ppi_ch1- This is used to connect the UART hardware to the time hardware for byte counting. Have a look at the PPI docs if you are interested in more information of this hardware feature. You can pass any unused PPI channel instance here.ppi_ch2- This is used for implementing permanent reception on the RX side in the background using DMA and interrupts. You can pass any unused PPI channel instance here.ppi_group- Required so that the PPI channel 2 can disable itself on certain events. You can pass any PPI group instance here.rxd- This is the physical GPIO pin which shold be used as the RX pin. We figured out which pin this out in a previous section.txd- This is the physical GPIO pin which shold be used as the TX pin. We figured out which pin this is in a previous section.irq- This is something embassy HAL specific. The driver relies on a interrupt handler being called for the UART. For technical reasons, that handler can not be specified in the HAL itself. Instead, the HAL provides a function that you should call on an interrupt, and we need to call this function in our own interrupt handler. However, the HAL provides a nice little macro that does this for us and creates a token structure for us. We need to provide that token structure to the HAL driver as proof that we have specified an interrupt handler.config- Configuration of the UART parameters. UART has various configurable parameters, and both communication partners have to agree on the same parameters. Usually, the most important parameter here is the baudrate.rx_buffer- Buffer used by the driver to permanently receive data in the background. The driver uses a double buffering scheme in the background to allow permanent reception of data.tx_buffer- Buffer used by the driver to transmit data.
Phew, that is a lot! We are going to provide multiple hints to simplify this task, in addition to the hints you can derive from the information above.
Your task is to create the driver and store it with the name uart.
If you are struggling with figuring out how to declare the interrupt handler and create the irq
argument, you can have a look at the hint:
Write this above your main method.
#![allow(unused)]
fn main() {
use embassy_nrf::{buffered_uarte, peripherals};
embassy_nrf::bind_interrupts!(
struct Irqs {
UARTE0 => buffered_uarte::InterruptHandler<peripherals::UARTE0>;
}
);
}
If you are struggling with figuring out the UART configuration, remember that we want to use
a baudrate of 115200 and that we can use the default configuration otherwise.
#![allow(unused)]
fn main() {
use embassy_nrf::uarte;
let mut uarte_config = uarte::Config::default();
uarte_config.baudrate = uarte::Baudrate::BAUD115200;
}
If you can not figure out how the buffers are specified:
#![allow(unused)]
fn main() {
let mut driver_rx_buf: [u8; 256] = [0; 256];
let mut driver_tx_buf: [u8; 256] = [0; 256];
}
Of course, other sizes work as well, but multiples of 128 are common. The size MUST be even for technical reasons.
For all other arguments, you have to pass in fields of the periphs structure.
The full solution of this step:
#![allow(unused)]
fn main() {
let mut uarte_config = uarte::Config::default();
uarte_config.baudrate = Baudrate::BAUD115200;
let mut driver_rx_buf: [u8; 256] = [0; 256];
let mut driver_tx_buf: [u8; 256] = [0; 256];
let uart = buffered_uarte::BufferedUarte::new(
periphs.UARTE0,
periphs.TIMER0,
periphs.PPI_CH0,
periphs.PPI_CH1,
periphs.PPI_GROUP0,
periphs.P1_08,
periphs.P0_06,
Irqs,
uarte_config,
&mut driver_rx_buf,
&mut driver_tx_buf,
);
}
Not all UART driver initialization will be that complex! This is actually a very capable, but also very complex driver. There are other UART hardware implementations out there that do not support DMA but that are also less complex. Generally, most driver constructors will at the minimum consume pin resource handles and the UART resource handle while also expecting some UART configuration.
Second step - Split the driver into an RX and TX handle
Many Rust UART drivers allow splitting themselves up into separate RX and TX handles. For example,
you might be interested in handling reception and transmission in separate tasks or doing them
concurrently. Many hardware implementations can also support this. This driver has
a split method.
Create distinct uart_rx and uart_tx driver handles.
#![allow(unused)]
fn main() {
let (mut uart_rx, mut uart_tx) = uart.split();
}
These are already mutable because the methods we are going to use require mutable access.
Third step - Read into a reception buffer inside a loop
Now we want to read something from the UART. You can use the uart_rx driver to do this. It has
an async read method
you can use for this purpose.
We need a separate buffer for this again. The buffer that we already declared is used exclusively by the driver. You can create a new buffer similarly to the way you created the first one. You can use a size like 64 or 128 here. Generally, it often makes sense to determine the dimension based on the maxium expected packet size.
Your task is to asynchronously read into a buffer. Do a match call on the resulting
Result so that you can do clean error handling as well. Keep in mind that you also need to
call await on the read call because it is an async function. Create the buffer before
the loop call because there is no need to re-instantiate it for every read call.
#![allow(unused)]
fn main() {
let mut rx_buf: [u8; 64] = [0; 64];
loop {
match uart_rx.read(&mut rx_buf).await {
Ok(_bytes_received) => (),
Err(_e) => ()
}
}
}
Fourth step - Write back whatever was received
Now, we want to send back everything we received. In the Ok case, we will have access
to the number of received bytes. This can also be smaller than the full buffer size!
In the Ok arm of the match statement on the read call, use the write_all method of
uart_tx. You also need to import the embedded_io_async::Write trait for this to work.
Remember that you want to send the number of bytes you actually received back, not the full buffer.
#![allow(unused)]
fn main() {
let mut rx_buf: [u8; 64] = [0; 64];
loop {
match uart_rx.read(&mut rx_buf).await {
Ok(read_bytes) => {
match uart_tx.write_all(&rx_buf[0..read_bytes]).await {
Ok(_) => ()
Err(_e) => ()
}
}
Err(_e) => ()
}
}
}
Fifth step - Verifying everything works
Now, after you have flashed this application using cargo run --bin uart_echo, you send
anything to the MCU and it should be sent back. When you use an application like picocom or
PuTTY, this has the side effect that it looks like you are typing on a console.
Test that your echo application works properly by connecting to the serial port like we explained earlier and typing anything.
Finishing Up
You are able to send and receive data to the MCU from your computer via UART now! The UART echo application
is oftentimes the “Hello World” of UART applications. This one is actually quite capable.
There are some things that can be improved in our app. For example, you could add proper
error handling for the Err(e) match arms, which could at the minimum include a defmt::warn! or
defmt::error! log.
Some interesting information: When you asynchronously call read and nothing is arriving on
the RX pin, the CPU can actually do other stuff! All the reception is handled in the background
by the dedicated interrupt handler provided by our HAL. Similarly, while you are writing
data out asynchronously using write and/or write_all, all the driver needs to do it to
pass the address and the transfer size to the hardware. All the rest is done by the hardware
using DMA.
Practical applications in our domain will oftentimes use binary protocols. In principle, we could
send binary packets to this application and we would also receive those back. However, using a utility
like picocom allows for nice visualization that everything is working.
There is an UART Spacepackets exercise where we communicate with an MCU using standardized CCSDS spacepackets via the serial interface. This communication pattern is very commonly used at the IRS!