This blog post is the second of a multi-part series of posts where I explore various peripherals in the ESP32C3 using standard library embedded Rust and the esp-idf-hal. Please be aware that certain concepts in newer posts could depend on concepts in prior posts.
Prior posts include (in order of publishing):
Introduction
UART serial communication is a valuable protocol for facilitating direct device-to-device communication. In the past, it was commonly employed in development scenarios to log output to a PC. However, contemporary microcontrollers now incorporate sophisticated debug functionalities such as the instruction trace macrocell (also known as ITM), which do not necessarily rely on the device's peripheral resources. Nevertheless, UART still finds its way into several serial communication applications.
In this post, I will be configuring and setting up UART communication for the ESP32C3 to create a loopback application with a bit of a spin. I will be incorporating a simple encoding scheme (XOR Cipher) that garbles the message before its sent. Upon receiving the garbled message, it will then be decoded and printed to the console.
๐ Knowledge Pre-requisites
To understand the content of this post, you need the following:
Basic knowledge of coding in Rust.
Familiarity with UART communication basics.
Familiarity with XOR Ciphers.
๐พ 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
๐ Note
All connection details are also shown in the Wokwi example.
Connections include the following:
- gpio5 wired to gpio6 on the devkit.
๐จโ๐จ Software Design
In the application developed in this post, a text message will be sent from the ESP32 back to itself. In order to to achieve this, the ESP32 transmit pin is wired externally to the receive pin (a.k.a. loopback). However, ahead of sending the text, I will be encoding the message using an XOR Cipher. The XOR cipher encrypts a text string by applying the bitwise XOR operator to each character using a specified key. To decrypt the encrypted output and revert it back to its original form, one simply needs to reapply the XOR operation with the same key. This process effectively removes the cipher and restores the plaintext. As such, the key is a value that is shared between the transmitter and the receiver.
The figure above demonstrates the algorithm flow. As such, here are the steps the algorithm would go through:
Encode the message to transmit using a bitwise XOR operator.
Transmit the encoded message over UART
Recover the encoded received UART message
Decode the received message to recover the plaintext
The above might seem straightforward, but there is a caveat. While UART supports different data lengths in a single frame (5 bits - 8 bits), byte-sized data will be the most convenient to deal with. As such, the code will be developed to accommodate byte-sized data.
Let's now jump into implementing this algorithm.
๐จโ๐ป Code Implementation
๐ฅ Crate Imports
In this implementation, one crate is required as follows:
- The
esp_idf_hal
crate to import the needed device hardware abstractions.
use esp_idf_hal::delay::BLOCK;
use esp_idf_hal::gpio;
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::prelude::*;
use esp_idf_hal::uart::*;
๐ Note
Each of the crate imports needs to have a corresponding dependency in the Cargo.toml file. These are typically provided by the template.
๐ Peripheral Configuration Code
Ahead of our application code, peripherals are configured through the following steps:
1๏ธโฃ Obtain a handle for the device peripherals: In embedded Rust, as part of the singleton design pattern, we first have to take
the device peripherals. This is done using the take()
method. Here I create a device peripheral handler named peripherals
as follows:
let peripherals = Peripherals::take().unwrap();
๐ Note
While I didn't mention it explicitily, in the
std
template for theesp_idf
aesp_idf_sys::link_patches();
line exists at the beginning of themain
function. This line is necessary to make sure patches to the ESP-IDF are linked to the final executable.
2๏ธโฃ Obtain handles for the tx and rx pins: for convenience, we obtain handles for the pins that are going to be configured as tx
and rx
in the UART peripheral. In the ESP32C3, any pin can be used for rx
and tx
. As such, I chose gpio5
to be used as the tx
pin and gpio6
will be used as the rx
pin:
let tx = peripherals.pins.gpio5;
let rx = peripherals.pins.gpio6;
Note that at this point the pins are not configured yet, we merely created handles for them. They yet need to be configured to be connected to the internal UART peripheral of the ESP32.
3๏ธโฃ Obtain a handle and configure the UART peripheral: In order to configure UART, there is a UartDriver
abstraction in the esp-idf-hal that contains a new
method to create a UART instance. The new
method has the following signature:
pub fn new<UART: Uart>(
uart: impl Peripheral<P = UART> + 'd,
tx: impl Peripheral<P = impl OutputPin> + 'd,
rx: impl Peripheral<P = impl InputPin> + 'd,
cts: Option<impl Peripheral<P = impl InputPin> + 'd>,
rts: Option<impl Peripheral<P = impl OutputPin> + 'd>,
config: &Config
) -> Result<Self, EspError>
as shown, the new
method has 6 parameters. The first uart
parameter is a UART
peripheral type. The second tx
parameter is a transmit OutputPin
type. The third rx
parameter is a receive OutputPin
type. The fourth cts
and fifth rts
parameters are for pins used for control flow which we won't be using. Finally, the sixth parameter is a UART configuration type Config
. As such, we create an instance for uart1
with handle name uart
as follows:
let config = config::Config::new().baudrate(Hertz(115_200));
let uart = UartDriver::new(
peripherals.uart1,
tx,
rx,
Option::<gpio::Gpio0>::None,
Option::<gpio::Gpio1>::None,
&config,
)
.unwrap();
A few things to note:
Notice that
uart1
is being used. This is becauseuart0
is typically used for loading firmware and logging. Thereforeuart1
is the one recommended to use in an application.The
cts
andrts
parameters require anOption
, here turbofish syntax is used to help the compiler infer a type. Anygpio
type can be used here since theNone
option is selected. Alternatively, one can also use theAnyIOPin
generic pin type in thegpio
module.Config
is auart::config
type that provides configuration parameters for UART.Config
contains a methodnew
to create an instance with default parameters. Afterward, there are configuration parameters adjustable through various methods. A full list of those methods can be found in the documentation. In this case, I only used thebaudrate
method to change the baud rate configuration.
That's it for configuration.
๐ฑ Application Code
Following the design described earlier, there is a couple of things we need to do before building the application. First, is to set up constants for the MESSAGE
string that is going to be sent and the KEY
used for the cipher:
// Message to Send
const MESSAGE: &str = "Hello";
// Key
const KEY: u8 = 212;
For the KEY
any value between 1 and 255 would work. I randomly picked 212
. Also, since we're going to be sending and receiving byte-size data, the receiver would need to buffer incoming bytes until a full transmission is complete. As such, we need to instantiate a Vector that I gave the handle name rec
as follows:
// Create a Vector Instance to buffer the recieved bytes
let mut rec = Vec::new();
Next, I print to the console the message that will be sent in text and also encoded values for each of the characters using the as_bytes
method:
// Print Message to be Sent
println!("Sent Message in Text: {}", MESSAGE);
println!("Sent Message in Values: {:?}", MESSAGE.as_bytes());
Before sending the message over UART, recall that that XOR cipher needs to be applied. For that, I used iterators to break down the message into bytes and the map
method to apply a bitwise XOR with the KEY
for each of the bytes. The resulting u8
Vector is bound to the gmsg
handle and the new "garbled" values print to the console:
// Garble Message
let gmsg: Vec<u8> = MESSAGE.as_bytes().iter().map(|m| m ^ KEY).collect();
// Print Garbled Message
println!("Sent Garbled Message Values: {:?}", gmsg);
Now we can send the garbled values over UART using the uart
handle instantiated earlier. However, we need to sent the message one byte at a time until the full message is received. As such, we're going to need to iterate over gmsg
transmit each byte u8
value using the uart
write
method. Then the read
method can be used to receive the transmitted byte. However, the received byte needs to be buffered in the rec
Vector until the transmission of the full message is complete. This is the code that achieves that:
// Send Garbled Message u8 Values One by One until Full Array is Sent
for letter in gmsg.iter() {
// Send Garbled Message Value
uart.write(&[*letter]).unwrap();
// Recieve Garbled Message Value
let mut buf = [0_u8; 1];
uart.read(&mut buf, BLOCK).unwrap();
// Buffer Recieved Message Value
rec.extend_from_slice(&buf);
}
Some notes:
The
read
method requires a mutableu8
slice and au32
delay value as arguments. The delay value reflects how long theread
method needs to block operations before theread
operation is complete.buf
is the handle created for the slice. On the other hand,BLOCK
is passed for the delay.BLOCK
is essentially au32
const
that reflects a really large value to keep blocking operations until theread
operation is completed.extend_from_slice
is aVec
method that allows us to append slice torec
.
Following the complete transmit/receive operation, to confirm that the message was received correctly, I print it to the console:
// Print Recieved Garbled Message Values
println!("Recieved Garbled Message Values: {:?}", rec);
Then the encoded message needs to be decoded in the same manner it was encoded:
// UnGarble Message
let ugmsg: Vec<u8> = rec.iter().map(|m| m ^ KEY).collect();
println!("Ungarbled Message in Values: {:?}", ugmsg);
In the final step, the recovered plaintext message is printed:
// Print Recovered Message
if let Ok(rmsg) = std::str::from_utf8(&ugmsg) {
println!("Recieved Message in Text: {:?}", rmsg);
};
here the from_utf8
method in std::str
is used. from_utf8
allows us to convert Vec<u8>
type or slice of bytes to a string slice &str
. Also the if let
syntax is used as an alternative concise option for match
. Here we're pattern-matching only the Ok
option from the Result
enum returned by from_utf8
. For the interested, more on if let
here.
๐ฑ 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.
use esp_idf_hal::delay::BLOCK;
use esp_idf_hal::gpio;
use esp_idf_hal::peripherals::Peripherals;
use esp_idf_hal::prelude::*;
use esp_idf_hal::uart::*;
// Message to Send
const MESSAGE: &str = "Hello";
// Key Value (Can be any value from 1 to 255)
const KEY: u8 = 212;
fn main() {
esp_idf_sys::link_patches();
let peripherals = Peripherals::take().unwrap();
let tx = peripherals.pins.gpio5;
let rx = peripherals.pins.gpio6;
let config = config::Config::new().baudrate(Hertz(115_200));
let uart = UartDriver::new(
peripherals.uart1,
tx,
rx,
Option::<gpio::Gpio0>::None,
Option::<gpio::Gpio1>::None,
&config,
)
.unwrap();
let mut rec = Vec::new();
// Print Message to be Sent
println!("Sent Message in Text: {}", MESSAGE);
println!("Sent Message in Values: {:?}", MESSAGE.as_bytes());
// Garble Message
let gmsg: Vec<u8> = MESSAGE.as_bytes().iter().map(|m| m ^ KEY).collect();
// Print Garbled Message
println!("Sent Garbled Message Values: {:?}", gmsg);
// Send Garbled Message u8 Values One by One until Full Array is Sent
for letter in gmsg.iter() {
// Send Garbled Message Value
uart.write(&[*letter]).unwrap();
// Recieve Garbled Message Value
let mut buf = [0_u8; 1];
uart.read(&mut buf, BLOCK).unwrap();
// Buffer Recieved Message Value
rec.extend_from_slice(&buf);
}
// Print Recieved Garbled Message Values
println!("Recieved Garbled Message Values: {:?}", rec);
// UnGarble Message
let ugmsg: Vec<u8> = rec.iter().map(|m| m ^ KEY).collect();
println!("Ungarbled Message in Values: {:?}", ugmsg);
// Print Recovered Message
if let Ok(rmsg) = std::str::from_utf8(&ugmsg) {
println!("Recieved Message in Text: {:?}", rmsg);
};
loop {}
}
Conclusion
In this post, a UART serial communication application with a simple XOR cipher was created. The application leverages the UART peripheral for the ESP32C3 microcontroller. The code was created using an embedded std
development environment supported by the esp-idf-hal
. Have any questions? Share your thoughts in the comments below ๐.