Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

HW block diagram

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:

PuTTY

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!