This blog post is the fourth of a multi-part series of posts where I will explore various peripherals of the ESP32 using the embedded Rust embassy framework.
Prior posts include (in order of publishing):
Introduction
It is probably more common that UART is used in simplex one-direction communication Ex. to log device messages. As such, there might be less mention of UART receiver functionality. With receive functionality UART can be set up to receive and process incoming messages. For embedded applications, it can be useful if one wants to enable user input from a serial terminal. In this post, using embassy and the ESP32C3, I will be expanding on the last UART Transmitter post to include receive functionality as well. In the example in this post, I will create a UART echo application. The application will receive user input from the terminal and "echo" it back to the terminal.
π Knowledge Pre-requisites
To understand the content of this post, you need the following:
Basic knowledge of coding in Rust.
Knowledge of how the embassy executor works.
Basic knowledge of UART Communication.
πΎ Software Setup
All the code presented in this post is available on the apollolabs ESP32C3 git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.
Additionally, the full project (code and simulation) is available on Wokwi here.
π Hardware Setup
Materials
π Connections
No special connections are required for this implementation. The UART lines on the ESP32-C3 in DevKitM are connected on-board to the UART bridge.
ποΈ Note: The ESP uses the UART0 peripheral for
esp_println
. Be mindful of that if you want to modify the code in this example.
π¨βπ¨ Software Design
In the application developed in this post, the ESP will wait for user input from the serial monitor and echo it back to the monitor. As such, the ESP will wait for incoming messages on the UART receiver end. Once a new message is entered, the ESP will keep buffering input characters until a valid end of transmission (EOT) character is received. After that the ESP will send the same buffered characters back to the monitor using its transmitter.
For this functionality there will be two tasks, a uart_reader
(receive) task and a uart_writer
(transmit) task. In between the tasks there will be a shared character (u8
) buffer that I'll call DATAPIPE
.
On a read event (incoming characters from monitor/terminal), the uart_reader
task executes in taking the following steps:
read
characters from UART receive buffer until a valid EOT character.On the successful completion of a
read
, share the received characters with theuart_writer
task by writing them toDATAPIPE
.
The uart_writer
task will kick in once new data appears in DATAPIPE
taking the following steps:
write
theDATAPIPE
contents to the UARTwrite
buffer.Transmit a new line (carriage return
0x0D
followed by line feed0x0A
characters)Flush the transmit buffer.
Let's now jump into the implementation.
π¨βπ» Code Implementation
π₯ Crate Imports
In this implementation the crates required are as follows:
The
embassy_sync
crate to importPipe
, a special abstraction needed for synchronization.The
embassy_executor
crate to import the embassy executor.The
esp32c3-hal
crate to import the necessary ESP32C3 abstractions.The
esp_backtrace
crate needed to define panic behavior.
use embassy_executor::Spawner;
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, pipe::Pipe};
use esp32c3_hal::{
clock::ClockControl,
embassy,
peripherals::{Peripherals, UART0},
prelude::*,
uart::{config::AtCmdConfig, UartRx, UartTx},
Uart,
};
use esp_backtrace as _;
π Global Variables & Constants
In the application at hand, there will be two tasks that share a character buffer. The uart_reader
task will write to the shared buffer and the uart_writer
task will read from this buffer. As such, we can create a global buffer DATAPIPE
to "pipe" the characters are going to be passed around. We declare DATAPIPE
as static
and define it as follows:
static DATAPIPE: Pipe<CriticalSectionRawMutex, READ_BUF_SIZE> = Pipe::new();
π Note: Global variables shared among tasks in Rust could be a sticky topic. This is because sharing global data is
unsafe
since it can cause race conditions. You can read more about it here. Embassy offers several synchronization primitives that provide safe abstractions depending on what needs to be accomplished. There is a prior post where I go into detail about these primitives here.
In addition to the above, it would be convenient to define the following constants:
// Read Buffer Size
const READ_BUF_SIZE: usize = 64;
// End of Transmission Character (Carrige Return -> 13 or 0x0D in ASCII)
const AT_CMD: u8 = 0x0D;
READ_BUF_SIZE
will be used to define the sizes of the buffers we declare. Additionally AT_CMD
defines the character that we will use for detecting an EOT sequence.
π€ The UART Reader Task
The UART reader task is expected to accept a UART instance as input and loop
constantly check/await
if MYSIGNAL
gets updated. Though ahead of the loop
it might be beneficial to indicate that the task has started. These are the required steps:
1οΈβ£ Create a UART Reader Task: Tasks are marked by the #[embassy_executor::task]
macro followed by a async
function implementation. The task created is referred to as uart_reader
task defined as follows:
#[embassy_executor::task]
async fn uart_reader(mut tx: UartRx<'static, UART0>)
UartRx
marks a UART receiver type that is configured with an instance of UART0
. This means when spawning the task, we need pass a handle for a UART reciever configured with an instance of UART0
. This will be done in the main
task before spawning the uart_reader
task.
2οΈβ£ Declare a Read Buffer: Before entering the task loop, we need to declare read buffer rbuf
to store characters that will be received. rbuf
will later store characters written to DATAPIPE
.
let mut rbuf: [u8; READ_BUF_SIZE] = [0u8; READ_BUF_SIZE];
π Task Loop
1οΈβ£ Read Characters from Monitor/Terminal into Buffer:
As part of the embedded_io_async
crate we can use the read
implementation of the Read
trait to asynchronously read a message. read
has the following signature:
async fn read(&mut self, buf: &mut [u8]) -> Result<usize, Self::Error>
As such, we need to pass a mutable reference of a type (UartRx
in our case) that implements Read
and our message as a slice of u8
. Since read
is an async
function, the program needs to await
for the read operation to complete. Using read
we can receive characters into our buffer rbuf
as follows:
let r = embedded_io_async::Read::read(&mut rx, &mut rbuf[0..]).await;
read
will keep on reading characters into rbuf
until reaching a valid EOT character. The EOT character will be defined later in out main
when we configure the UART0
peripheral. read
also returns a Result
that contains the amount of characters that were read.
2οΈβ£ Write Buffered Characters to Pipe: The Pipe
we need to write to is DATAPIPE
. For that, we need to write all of the characters in rbuf
to DATAPIPE
There exists a write_all
method for the Pipe
type that has the following signature:
pub async fn write_all(&self, buf: &[u8])
write_all
will write all bytes from buf
into the Pipe
. This results in the following code for us:
match r {
Ok(len) => {
// If read succeeds then write recieved characters to pipe
DATAPIPE.write_all(&rbuf[..len]).await;
}
Err(e) => esp_println::println!("RX Error: {:?}", e),
}
The match
is used to process the Result
returned from the read
method. If a receive error occurs, then esp_println
is used to log the error.
πΉοΈ The UART Writer Task
The UART writer task is expected to accept a UART instance as input and loop
constantly check/await
if DATAPIPE
has new data. These are the required steps:
1οΈβ£ Create a UART Writer Task: Tasks are marked by the #[embassy_executor::task]
macro followed by a async
function implementation. The task created is referred to as uart_writer
task defined as follows:
#[embassy_executor::task]
async fn uart_writer(mut tx: UartTx<'static, UART0>)
UartTx
marks a UART transmitter type that is configured with an instance of UART0
. This means when spawning the task, we need pass a handle for a UART transmitter configured with an instance of UART0
. This will be done in the main
task before spawning the uart_writer
task.
2οΈβ£ Declare a Write Buffer: Before entering the task loop, we need to declare write buffer wbuf
to store characters that will be transmitted. wbuf
will later store characters read from DATAPIPE
.
let mut wbuf: [u8; READ_BUF_SIZE] = [0u8; READ_BUF_SIZE];
π Task Loop
1οΈβ£ Read Characters from Pipe into Write Buffer: In the task loop
, the first thing we need to do is await
a change on DATAPIPE
. For that, there exists a read
method for the Pipe
type. read
has the following signature:
pub fn read<'a>(&'a self, buf: &'a mut [u8]) -> ReadFuture<'a, M, N>
read
will read a nonzero amount of bytes from the pipe into buf
. If it is not possible to read a nonzero amount of bytes because the pipeβs buffer is empty, this method will wait until it isnβt. Consequently, we'll read DATAPIPE
contents into wbuf
as follows:
DATAPIPE.read(&mut wbuf).await;
2οΈβ£ Complete Write Operations:
As part of the embedded_io_async
crate we can use the write
implementation of the Write
trait to asynchronously write a message. write
has the following signature:
async fn write(&mut self, buf: &[u8]) -> Result<usize, Self::Error>
As such, we need to pass a mutable reference of a type (UartTx
in our case) that implements Write
and our message as a slice of u8
. Since write
is an async
function, the program needs to await
for the write operation to complete. Using write
we'll need to transmit the buffer contents, transmit a new line, and flush the transmit buffer. This results in the following code:
// Transmit Buffer Contents
embedded_io_async::Write::write(&mut tx, &wbuf)
.await
.unwrap();
// Transmit a new line
embedded_io_async::Write::write(&mut tx, &[0x0D, 0x0A])
.await
.unwrap();
// Flush transmit buffer
embedded_io_async::Write::flush(&mut tx).await.unwrap();
flush
will flush the output stream and ensure that all the buffer contents reach their destination.
π Note: I could have easily also used
esp_println
which provides more convenient abstractions to print to the console. While in this context it does not make much difference, I did this for two reasons. First, since this is a UART example, we need to demonstrate how to use UART abstractions. Second, the previous code is a good demonstration of the use of theembedded-io-async
traits. Using theembedded-io-async
traits allows for more portable code in some contexts.
π± The Main Task
The start of the main task is marked by the following code:
#[main]
async fn main(spawner: Spawner)
As the documentation states: "The main entry point of an Embassy application is defined using the #[main]
macro. The entry point is also required to take a Spawner
argument." As we'll see, Spawner
is what will allow us to spawn or kick-off the uart_writer
task.
The following steps will mark the tasks performed in the main task.
1οΈβ£ Obtain a handle for the device peripherals & system clocks: In embedded Rust, as part of the singleton design pattern, we first have to take the PAC-level device peripherals. This is done using the take()
method. Here I create a device peripheral handler named peripherals
, a system peripheral handler system
, and a system clock handler clocks
as follows:
let peripherals = Peripherals::take();
let system = peripherals.SYSTEM.split();
let clocks = ClockControl::boot_defaults(system.clock_control).freeze();
2οΈβ£ Initialize Embassy Timers for the ESP32C3:
In embassy, there exists an init
function that takes two parameters. The first is system clocks and the second is an instance of a timer. Under the hood, what this function does is initialize the embassy timers. As such, we can initialize the embassy timers as follows:
embassy::init(
let timer_group0 = esp32c3_hal::timer::TimerGroup::new(peripherals.TIMG0, &clocks);
embassy::init(&clocks, timer_group0.timer0);
π Note: At the time of writing this post, I couldn't really locate the
init
function docs.rs documentation. It didn't seem easily accessible through any of the current HAL implementation documentation. Nevertheless, I reached the signature of the function through the source here.
3οΈβ£ Obtain Handle and Configure UART: to create an instance of UART0
, there exists a new
instance method under esp32c3_hal::Uart
that requires two parameters; a UART
peripheral type and a Clocks
type. There also exists a split
method that allows us to "split" the uart0
instance into separate transmitter and receiver instances with handles tx
and rx
. split
returns a tuple with two instances. We also will be using UART
methods set_at_cmd
and set_rx_fifo_full_threshold
to configure the AT_CMD
character and the read buffer size.
let uart0 = Uart::new(peripherals.UART0, &clocks);
uart0.set_at_cmd(AtCmdConfig::new(None, None, None, AT_CMD, None));
uart0
.set_rx_fifo_full_threshold(READ_BUF_SIZE as u16)
.unwrap();
let (tx, rx) = uart0.split();
Note the following:
UART0
is chosen since it is the one that ties to the UART logging pins on board. Additionally,new
configures UART with the default 8N1 setup.set_at_cmd
takes aAtCmdConfig
parameter that is a struct with configuration options for the AT-CMD detection functionality. We only need to configure the EOT character. The rest of the members can be viewed in the documentation.
4οΈβ£ Spawn UART Reader and Writer Tasks: before entering the button press loop, we're going to need to kick off our uart_writer
and uart_reader
tasks. This is done using the spawn
method as follows:
spawner.spawn(uart_writer(tx)).unwrap();\
spawner.spawn(uart_reader(tx)).unwrap();
Next, we can move on to the task loop
.
This concludes the code for the full application.
π± Full Application Code
Here is the full code for the implementation described in this post. You can additionally find the full project and others available on the apollolabs ESP32C3 git repo. Also, the Wokwi project can be accessed here.
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]
use embassy_executor::Spawner;
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, pipe::Pipe};
use esp32c3_hal::{
clock::ClockControl,
embassy,
peripherals::{Peripherals, UART0},
prelude::*,
uart::{config::AtCmdConfig, UartRx, UartTx},
Uart,
};
use esp_backtrace as _;
// Read Buffer Size
const READ_BUF_SIZE: usize = 64;
// End of Transmission Character (Carrige Return -> 13 or 0x0D in ASCII)
const AT_CMD: u8 = 0x0D;
// Declare Pipe sync primitive to share data among Tx and Rx tasks
static DATAPIPE: Pipe<CriticalSectionRawMutex, READ_BUF_SIZE> = Pipe::new();
#[embassy_executor::task]
async fn uart_writer(mut tx: UartTx<'static, UART0>) {
// Declare write buffer to store Tx characters
let mut wbuf: [u8; READ_BUF_SIZE] = [0u8; READ_BUF_SIZE];
loop {
// Read characters from pipe into write buffer
DATAPIPE.read(&mut wbuf).await;
// Transmit/echo buffer contents over UART
embedded_io_async::Write::write(&mut tx, &wbuf)
.await
.unwrap();
// Transmit a new line
embedded_io_async::Write::write(&mut tx, &[0x0D, 0x0A])
.await
.unwrap();
// Flush transmit buffer
embedded_io_async::Write::flush(&mut tx).await.unwrap();
}
}
#[embassy_executor::task]
async fn uart_reader(mut rx: UartRx<'static, UART0>) {
// Declare read buffer to store Rx characters
let mut rbuf: [u8; READ_BUF_SIZE] = [0u8; READ_BUF_SIZE];
loop {
// Read characters from UART into read buffer until EOT
let r = embedded_io_async::Read::read(&mut rx, &mut rbuf[0..]).await;
match r {
Ok(len) => {
// If read succeeds then write recieved characters to pipe
DATAPIPE.write_all(&rbuf[..len]).await;
}
Err(e) => esp_println::println!("RX Error: {:?}", e),
}
}
}
#[main]
async fn main(spawner: Spawner) {
let peripherals = Peripherals::take();
let system = peripherals.SYSTEM.split();
let clocks = ClockControl::boot_defaults(system.clock_control).freeze();
// Initialize Embassy with needed timers
let timer_group0 = esp32c3_hal::timer::TimerGroup::new(peripherals.TIMG0, &clocks);
embassy::init(&clocks, timer_group0.timer0);
// Initialize and configure UART0
let mut uart0 = Uart::new(peripherals.UART0, &clocks);
uart0.set_at_cmd(AtCmdConfig::new(None, None, None, AT_CMD, None));
uart0
.set_rx_fifo_full_threshold(READ_BUF_SIZE as u16)
.unwrap();
// Split UART0 to create seperate Tx and Rx handles
let (tx, rx) = uart0.split();
// Spawn Tx and Rx tasks
spawner.spawn(uart_reader(rx)).ok();
spawner.spawn(uart_writer(tx)).ok();
}
Conclusion
In this post, a UART application receiving and transmitting from/to a host was created for the ESP32C3 microcontroller. The code leveraged the Pipe
synchronization primitive with the embassy async framework to enable a UART echo application. Have any questions? Share your thoughts in the comments below π.