We already know how to compile, reflash and debug our firmware. Now, we can start making it useful and do real things! But how will we know what it is sensing and what actions it is performing? We need to be able to talk with it, and we both need to speak the same language. In other words, we need a physical communication medium over which we send data in either direction. On each end of it will be running software allowing to exchange meaningful information. This is very important because you will firstly need to know the state of your device. Secondly, you will want to command it, as well as, remotely inspect when things go wrong. To achieve that we will implement communication stack using UART.
This time, we will connect our Linux machine with the discovery board over UART. Since laptops do not provide an UART port you will need to use an USB-UART converter. We will use one made by Waveshare supporting USB Type-A. However, converters supporting other USB types are also available from Waveshare.
After creating a physical connection we will look into communication software on both ends. Our focus will be on implementing UART driver driven by UART interrupts and DMA, and writing embedded communication stack. We will closely inspect UART signals on the scope to understand the protocol and how different baudrates affect it. I will also introduce serial-bridge tool which exchanges data over serial port and sends telemetry over UDP. Then, we will use plotjuggler to visualise telemetry and will send commands to operate an LED with serial-bridge.
- UART Connection
- UART Driver – Part I
- Testing communication with Picocom
- Communication Driver: Timing
- Communication Driver: Data Flow
- Communication Driver: Initialisation
- Communication Driver: UART Interrupts (Transmission)
- Communication Driver: UART Interrupts (Reception)
- Communication Driver: DMA Definitions
- Communication Driver: DMA Transmission
- Communication Driver: DMA Reception
- Communication Stack – Part II
- Linux Serial Port Driver
- Let’s Talk Bytes – Plotting Telemetry with Plotjuggler and Sending Commands
UART Connection
First, let’s understand how UART operates and which features we will use in this tutorial. Once we move on to implementation and testing sections, we will look at actual signals to confirm the theory. Basically, UART (Universal Asynchronous Receiver-Transmitter) is a bi-directional communication protocol, which does not use clock synchronisation opposed to its counterpart USART. In addition to signals listed below, USART transmits a clock from Master to Slave.
- TX – Transmission line
- RX – Reception line
- RTS – Request to Send (used for hardware flow control)
- CTS – Clear to Send (used for hardware flow control)
- GND
Tx/Rx lines are cross-coupled, and so are RTS with CTS. RTS and CTS signals synchronise communication between devices in hardware control mode. For example, when Device 1 wants to receive data, it pulls RTS line high which Device 2 receives as a Clear to Send signal. In which case, Device 2 will start transmission. After some time , when Device 1 decides it cannot keep up with the amount of data, it pulls RTS line high, and Device 2 stops transmitting.
In this tutorial, we will not use the hardware flow control. Therefore, we will only need 3 lines for UART communication – Rx, Tx and GND. UART hardware samples Rx line to receive data. Firstly, data serially moves into the shift register and then into a RDR (Rx Data Register). At that point, firmware gets access to it. Analogously, firmware writes outgoing data to a TDR (Tx Data Register). After previous transmission completes, data move to the shift register, and finally leaves UART serially on a Tx line.
Protocol
So how do two devices understand each other over UART? Each device sends a stream of individual packets which all have a specific format. This format is defined by a set of configuration registers such as baudrate, parity, stop bits and data word length. On stm32f407xx data word length can be programmed to either 8 or 9 bits, where the most significant bit could be set to indicate odd or even parity. Let’s explain it on an example below.
Figure 2 shows a single packet with an ASCII character ‘Y’. The baudrate is set to 115200 bits per second, parity is set to odd, stop bits are equal 1 and the word length is equal 8. Before the transmission starts, Tx line is high. The packet transmission starts with a single start bit being low.
The next 7 bits correspond to bits of ‘Y’ (101 1001′ ) where the first bit is the least significant one. The baudrate of 115200bps means that sending every bit takes about 8.7us. Since an odd parity was selected, the 7th bit of a 8-bit frame will indicate the parity.
But what is actually the parity bit and how do we use it? When parity is odd and the data word has an even number of 1s, the parity bit sets to 1 so that there is an odd number of 1s in the packet. Otherwise, when the parity is set to even, and the number of 1s in the sent word is odd, then the parity will be set to 1.
Parity detects an erroreous bit change in the packet, however, it only detects an error when an odd number of bits change. This is the reason why CRC checking is necessary to improve the reliability of communication. Since ‘Y’ bitwise is equal to ‘0101 1001’ , parity bit will be 1. It is important to remember that on stm32f407xx the parity always occupies the most significant bit of the sent word, no matter if the word length is 8 or 9 bit long.
The packet finishes with a single stop bit being high.
Hardware Setup
To get your setup running and start looking into the communication code, you need to connect your PC’s USB port with UART on the microcontroller. Since USB and UART are different channels you need to get an USB-UART adapter, e.g. Waveshare supporting USB Type-A. You will need to connect your adapter to USART1 pins PB6(RX) and PB7(TX). You can follow the diagram below to wire everything up, and move on to the next section to start playing with the code.
UART Driver – Part I
At this point you should have this tutorial’s repository cloned. The related code is in tut4, and is split into two parts. Part 1 will focus on writing UART driver driven by UART interrupts and DMA. Part 2 will talk about communication stack on the microcontroller, telemetry, commands, as well as, software running on the host, which will send received telemetry over UDP to Plotjuggler in JSON format.
Testing communication with Picocom
When writing a driver it will be useful to have a serial port software running on the host allowing us to simply test different UART configuration, and whether reception and transmission work fine. There are many open-source solutions such as minicom, picocom, cu, screen or putty. You are free to choose your favourite. I am mainly using minicom but picocom is a lightweight version of it, can be easily invoked on a terminal with full configuration. Therefore, we will use picocom in this tutorial.
Let’s first install picocom
sudo apt-get install picocom
You can then open the serial port specifying its configuration with the following command
picocom -b <baudrate> -y <parity> -d <databits> -p <stop bits> <serial port>
Therefore, to open /dev/ttyUSB0 port with baudrate set to 115200 bps, an odd parity, 7-bit data frame and a single stop, you can call a following command
picocom -b 115200 -y o -d 7 -p 1 /dev/ttyUSB0
It is important to remember that most serial port terminals have a different definition of data frame size and parity than our microcontroller’s. In picocom case, data size of 7 bits and odd/even parity is the same as 8-bit data frame with odd/even parity on STM32F407xx.
In case you do not know which serial port your USB-UART adapter is connected to, you can plug your device and call dmesg utility.
You might also add yourself to a dialout group so that you have access to serial devices
sudo adduser $USER dialout
Now, you should be able to run picocom with USB-UART adapter connected. You can exit it by pressing CTRL+A followed by CTRL+X.
Communication Driver: Timing
Let’s first understand the structure of comms_driver – how it is used from the main and how it uses UART to receiver and send data. In this tutorial, we do not use a method of polling to receive and send data as it wastes CPU time. Instead, we first look into UART interrupts to send data and then we use DMA to save even more CPU time and optimise this process.
SysTick interrupt primarily synchronises transmission and reception of UART data on the microcontroller. You can go back to tutorial 2 if you are not sure what SysTick is. Briefly speaking, when the SysTick triggers, a timer value increments, which will enable execution of main tasks at desired frequency.
main_part1.c
93 void SysTick_Handler(void) {
94 /// toggle C1 to measure SysTick frequency on the scope
95 if (++time_counter == TASKS_FREQUENCY_IN_MS) {
96 time_counter = 0;
97 background_processed = false;
98 }
99 }
You can then adjust the TASKS_FREQUENCY_IN_MS constant and effectively run the main loop at different frequencies.
main_part1.c
82 while(1) {
83 if (!background_processed) {
84 /// run all background tasks at TASKS_FREQUENCY_IN_MS frequency
85 HAL_GPIO_TogglePin(GPIOC, measure_gpio.Pin);
86 HAL_GPIO_TogglePin(GPIOD, led_blinky_gpio.Pin);
87 comms_driver_send_data((uint8_t*)&msg, sizeof(msg));
88 background_processed = true;
89 }
90 }
Communication Driver: Data Flow
Our message to be sent is stored in a msg array and will be transmitted at chosen frequency by calling comms_driver_send_data function. Similarly to tutorial 2, we will monitor measure_gpio pin (GPIOC_1) on the scope to make sure that communication does not affect main timing. On the other hand, the received message that fullfills our expected size will asynchronously trigger comms_driver_handle_data_cb callback function, which is implemented by the user in the main
main_part1.c
109 void comms_driver_handle_data_cb(uint8_t* payload, uint8_t payload_size) {
110 static uint16_t cmd;
111 if (payload_size == INCOMING_PAYLOAD_SIZE) {
112 cmd = ((payload[0] << 8) | payload[1]);
113 switch (cmd) {
114 case CMD_TURN_BLUE_LED_ON: {
115 /// correspond to "XA"
116 HAL_GPIO_WritePin(GPIOD, led_blue_gpio.Pin, GPIO_PIN_SET);
117 break;
118 }
119 case CMD_TURN_BLUE_LED_OFF: {
120 /// correspond to "FY"
121 HAL_GPIO_WritePin(GPIOD, led_blue_gpio.Pin, GPIO_PIN_RESET);
122 break;
123 }
124 }
125 }
126 }
Figure 5 visualises the relation between main or application layer and the communication driver. Application initialises the driver, sends the data and receives data asynchronosuly through a callback implemented by the user.
The user also initialises the driver with a desired payload size, baudrate, parity and mode of operation driven by either UART or DMA interrupts.
main_part1.c
68 /// configure comms driver
69 comms_driver_config_t comms_driver_config = {
70 .payload_size = INCOMING_PAYLOAD_SIZE,
71 .baudrate = COMMS_BAUDRATE,
72 .parity = COMMS_DRIVER_PARITY_ODD,
73 .mode = COMMS_DRIVER_MODE_DMA_IT,
74 };
75 comms_driver_initialise(comms_driver_config);
In the next section, we go into more details on how comms_driver operates, and I will describe UART interrupts mode of operation.
Communication Driver: Initialisation
To use UART we first need to initialise a bunch of registers. This is all done in the comms_driver_initialise function. Firstly, we enable clocks for GPIOB, USART1 and DMA2 (if used). How do we know this? Because we will enable USART1_TX pin through PB6, and USART1_RX pin through PB7. Alternatively, we can use USART1 with pins PA10 and PA9, which are listed in the STM32F40xx pin definitions in the datasheet [1]. To use USART1 as an UART we do not select to use the clock.
comms_driver.c
135 /// hardware setup
136 /// clock for USART1, USART1_TX(PB6) and USART1_RX(PB7)
137 __HAL_RCC_GPIOB_CLK_ENABLE();
138 __HAL_RCC_USART1_CLK_ENABLE();
139 /// clock for DMA2
140 __HAL_RCC_DMA2_CLK_ENABLE();
Next, we initialise desired GPIOs in an alternative mode so that they can be multiplexed properly to the USART1 module.
comms_driver.c
116 static GPIO_InitTypeDef uart1_tx_gpio = {
117 .Pin = GPIO_PIN_6,
118 .Mode = GPIO_MODE_AF_PP,
119 .Pull = GPIO_PULLUP,
120 .Speed = GPIO_SPEED_FREQ_HIGH,
121 .Alternate = GPIO_AF7_USART1
122 };
123
124 static GPIO_InitTypeDef uart1_rx_gpio = {
125 .Pin = GPIO_PIN_7,
126 .Mode = GPIO_MODE_AF_PP,
127 .Pull = GPIO_PULLUP,
128 .Speed = GPIO_SPEED_FREQ_HIGH,
129 .Alternate = GPIO_AF7_USART1
130 };
...
141 /// initialise TX & RX GPIOs
142 HAL_GPIO_Init(GPIOB, &uart1_tx_gpio);
143 HAL_GPIO_Init(GPIOB, &uart1_rx_gpio);
After that we can initialise USART1 using HAL libraries.
comms_driver.c
170 /// initialise UART
171 init_result = (bool)HAL_UART_Init(&comms_driver_data.uart_handler);
This will enable USART1 module itself and mainly configure Control Register 1 and 2. These registers contain configuration information such as parity, flow control, oversampling, clock setup, stop bits, data length and interrupt sources. All of which we could adjust within comms_driver_initialise function. However, comms_driver only allows to dynamically modify baudrate and parity from UART options right now. You can find all details about USART registers in the Reference Manual [2].
Lastly, we enable reception flag in the control register of USART1 so that it triggers USART1 interrupt, and we enable USART1_IRQHandler interrupt itself. You can find all interrupts in the assembly file described in tutorial 1.
comms_driver.c
173 /// setup configuration depending on the operation mode (UART IT or DMA IT)
174 if (config.mode == COMMS_DRIVER_MODE_UART_IT) {
175 SET_BIT(comms_driver_data.uart_handler.Instance->CR1, USART_CR1_RXNEIE);
176 HAL_NVIC_EnableIRQ(USART1_IRQn);
177 }
You might be wondering, why are we only enabling reception flag for USART1 interrupt? The reason is that after comms_driver is initialised, we want to receive data at any time. On the other hand, flags related to transmission are only enabled for the time of transmission at desired frequency as I mentioned earlier. In case a DMA is to be used, we initialise DMA structures, its related interrupts for transmission and reception, and enable DMA operations in Control Register 3 of USART1.
comms_driver.c
177 } else if (config.mode == COMMS_DRIVER_MODE_DMA_IT) {
178 /// initialise DMAs for Rx & Tx
179 HAL_DMA_Init(&comms_dma_tx_handle);
180 HAL_DMA_Init(&comms_dma_rx_handle);
181
182 /// configure DMA interrupts for Rx (DMA2_Stream5_IRQn) & Tx (DMA2_Stream7_IRQn)
183 HAL_NVIC_EnableIRQ(DMA2_Stream7_IRQn);
184 HAL_NVIC_EnableIRQ(DMA2_Stream5_IRQn);
185
186 HAL_DMA_Start_IT(&comms_dma_rx_handle, (uint32_t)&comms_driver_data.uart_handler.Instance->DR, (uint32_t)rx_data, 2);
187 /// clear transmission complete bit, as it is set by default
188 CLEAR_BIT(comms_driver_data.uart_handler.Instance->SR, USART_SR_TC);
189
190 /// turn DMA/UART reception on
191 SET_BIT(comms_driver_data.uart_handler.Instance->CR3, USART_CR3_DMAR);
192 }
Communication Driver: UART Interrupts (Transmission)
Let’s first initialise the driver in the UART Interrupts driven mode such as below
main_part1.c
59 #define COMMS_BAUDRATE (uint32_t)115200
...
68 /// configure comms driver
69 comms_driver_config_t comms_driver_config = {
70 .payload_size = INCOMING_PAYLOAD_SIZE,
71 .baudrate = COMMS_BAUDRATE,
72 .parity = COMMS_DRIVER_PARITY_ODD,
73 .mode = COMMS_DRIVER_MODE_UART_IT,
74 };
You can also adjust the message to be sent to “XY” and the tasks period to 10 ms (100Hz)
main_part1.c
32 #define TASKS_FREQUENCY_IN_MS (uint16_t)10
62 static char msg[] = {"XY"};
This will mean that every 10 ms, “XY” stream will be sent over USART1 after calling comms_driver_send_data function within the main loop. By checking this function, you will see that we assign the pointer to an array of uin8_ts, the number of bytes left to send, clear Transmission Complete bit and enable TXEIE register in CR1 (Control Register 1).
comms_driver.c
201 if (comms_driver_data.mode == COMMS_DRIVER_MODE_UART_IT) {
202 /// transmission with UART interrupts
203 comms_driver_data.tx_curr_data = data;
204 comms_driver_data.tx_bytes_left_to_send = data_size;
205 CLEAR_BIT(comms_driver_data.uart_handler.Instance->SR, USART_SR_TC);
206 SET_BIT(comms_driver_data.uart_handler.Instance->CR1, USART_CR1_TXEIE);
207 } else if (comms_driver_data.mode == COMMS_DRIVER_MODE_DMA_IT) {
The transmission continues in a following way:
- The initial all-ones-valued (Idle) TDR moves to the shift register and is transmitted next
- When TDR is emptied, TXE flag is set and USART interrupt is triggered if TXEIE is enabled
- In the interrupt when there is still data left to send, we keep loading next data packets into TX DR
comms_driver.c
221 if ((status_reg & USART_SR_TXE) && (cr1its & USART_CR1_TXEIE)) {
222 if (comms_driver_data.tx_bytes_left_to_send) {
223 /// load next byte to data register
224 comms_driver_data.uart_handler.Instance->DR =
225 (uint8_t)(*comms_driver_data.tx_curr_data++ & (uint8_t)0xFF);
226 if (--comms_driver_data.tx_bytes_left_to_send == 0) {
227 /// disable TXE and wait for TC
228 CLEAR_BIT(comms_driver_data.uart_handler.Instance->CR1, USART_CR1_TXEIE);
229 SET_BIT(comms_driver_data.uart_handler.Instance->CR1, USART_CR1_TCIE);
230 }
231 }
- When the last data packet is to be sent, we clear TXEIE bit and only set TCIE bit corresponding to transmission complete. Once the last byte is transmitted, TC (transmission complete) will trigger an interrupt, and we will finish sending the whole stream of data. After the last transmission, all-zeros value will be sent (Break)
comms_driver.c
232 } else if ((status_reg & USART_SR_TC) && (cr1its & USART_CR1_TCIE)) {
233 comms_driver_data.tx_bytes_left_to_send = 0;
234 comms_driver_data.tx_curr_data = NULL;
235 CLEAR_BIT(comms_driver_data.uart_handler.Instance->CR1, USART_CR1_TCIE);
236 }
You can then compile the code, and reflash the board using stlink. After resetting the board, you should start receiving “XY” streams, which you should be able to see with picocom as shown in Figure 6.
# from embeddedTutorial/tut4_uart_comms
make
make stlink_texane_reflash_part1
picocom -b 115200 -y o -d 7 -p 1 /dev/ttyUSB0
We can also inspect how such a datagram looks on the scope, and confirm with our theoretical packet from Figure 2.
Communication Driver: UART Interrupts (Reception)
Receiving data is even more straightforward than sending. Firstly, UART is initialised, RXNEIE bit is enabled in CR1 and USART1_IRQn is activated. After than, an interrupt will be triggered every time data moves from the shift register to RDR. At that point, we can push the received packet into an array of a defined size. We also need to handle packets which are 8-bit-long but their MSB contains a parity bit. This is done on line 269 by only taking the 7 LSB. This would not work with 9-bit word length with parity, but you can take the code to make it work for this scenario yourself as an exercise.
comms_driver.c
260 /// insert new data into the buffer unless it is full
261 if (comms_driver_data.rx_buffer_items < comms_driver_data.rx_payload_size) {
262 if (comms_driver_data.uart_handler.Init.Parity == UART_PARITY_NONE) {
263 /// get the full 8-bit data register when parity is none
264 *(comms_driver_data.rx_buffer + comms_driver_data.rx_buffer_items) =
265 (uint8_t)READ_REG(comms_driver_data.uart_handler.Instance->DR);
266 } else if ((comms_driver_data.uart_handler.Init.Parity == UART_PARITY_EVEN)
267 || (comms_driver_data.uart_handler.Init.Parity == UART_PARITY_ODD)) {
268 /// get the 7 lsbs if parity is odd or even
269 *(comms_driver_data.rx_buffer + comms_driver_data.rx_buffer_items) =
270 (uint8_t)(READ_REG(comms_driver_data.uart_handler.Instance->DR)) & 0x7F;
271 }
272
273 /// handle incoming payload in a callback when it is full
274 if (++comms_driver_data.rx_buffer_items == comms_driver_data.rx_payload_size) {
275 comms_driver_handle_data_cb(comms_driver_data.rx_buffer, comms_driver_data.rx_payload_size);
276 clean_rx_data(&comms_driver_data);
277 }
278 }
During comms_driver initialisation, we assign an expected size of an incoming data to a certain size.
main_part1.c
58 #define INCOMING_PAYLOAD_SIZE (uint8_t)2
...
68 /// configure comms driver
69 comms_driver_config_t comms_driver_config = {
70 .payload_size = INCOMING_PAYLOAD_SIZE,
71 .baudrate = COMMS_BAUDRATE,
72 .parity = COMMS_DRIVER_PARITY_ODD,
73 .mode = COMMS_DRIVER_MODE_UART_IT,
74 };
Therefore, once we receive INCOMING_PAYLOAD_SIZE number of packets then we process the whole stream in a comms_driver_handle_data_cb callback. The current code expectes two uint8_ts and turns on the blue led upon reception of “XA”, whereas a stream of “FY” turns the same led off
main_part1.c
109 void comms_driver_handle_data_cb(uint8_t* payload, uint8_t payload_size) {
110 static uint16_t cmd;
111 if (payload_size == INCOMING_PAYLOAD_SIZE) {
112 cmd = ((payload[0] << 8) | payload[1]);
113 switch (cmd) {
114 case CMD_TURN_BLUE_LED_ON: {
115 /// correspond to "XA"
116 HAL_GPIO_WritePin(GPIOD, led_blue_gpio.Pin, GPIO_PIN_SET);
117 break;
118 }
119 case CMD_TURN_BLUE_LED_OFF: {
120 /// correspond to "FY"
121 HAL_GPIO_WritePin(GPIOD, led_blue_gpio.Pin, GPIO_PIN_RESET);
122 break;
123 }
124 }
125 }
126 }
Now you can run the same program again on the microcontroller and send packets from picocom. You could even change settings such as baudrates and parity or add your own commands. If you would like to increase the baudrate to 921.6 kbps an use even parity you have to change initialisation structure of comms_driver. With such a baudrate sending a single bit takes approximately 1.085 us.
main_part1.c
59 #define COMMS_BAUDRATE (uint32_t)9216000
...
68 /// configure comms driver
69 comms_driver_config_t comms_driver_config = {
70 .payload_size = INCOMING_PAYLOAD_SIZE,
71 .baudrate = COMMS_BAUDRATE,
72 .parity = COMMS_DRIVER_PARITY_EVEN,
73 .mode = COMMS_DRIVER_MODE_UART_IT,
74 };
Then compile, reflash and run picocom with the next settings. Within picocom, you can type “XA” or “FY” to toggle the blue led.
picocom -b 9210000 -y e -d 7 -p 1 /dev/ttyUSB0
Communication Driver: DMA Definitions
The largest drawback of using UART interrupts is that we need to handle every single byte received, as well as, every byte to send. Although this is better than polling, we still need to dedicate some of our CPU’s time for this. This becomes critical when we want to send data at higher frequencies, and also execute some important tasks between communication cycles. And that’s when DMA comes in handy.
DMA (Direct Memory Access) subsystem can move bytes from one part of our microcontroller’s memory to the other, as long as it has access to it. It can do so without intervening CPU, and therefore, can save computational time on memory-to-memory (or peripheral) operations. On top of that, it can be setup to move data from our buffer to TDR or from RDR to the memory based on USART flags indicating reception and transmission readiness. We can use DMA by configuring it during comms_driver initialisation.
Let’s start with declaring DMA structures – one for reception and one for transmission. We will use DMA HAL library for this.
comms_driver.c
82 static DMA_HandleTypeDef comms_dma_tx_handle = {
83 .Instance = DMA2_Stream7,
84 .Init = {
85 .Channel = DMA_CHANNEL_4,
86 .Direction = DMA_MEMORY_TO_PERIPH,
87 .PeriphInc = DMA_PINC_DISABLE,
88 .MemInc = DMA_MINC_ENABLE,
89 .PeriphDataAlignment = DMA_PDATAALIGN_BYTE,
90 .MemDataAlignment = DMA_MDATAALIGN_BYTE,
91 .Mode = DMA_NORMAL,
92 .Priority = DMA_PRIORITY_VERY_HIGH,
93 .FIFOMode = DMA_FIFOMODE_DISABLE,
94 .MemBurst = DMA_MBURST_SINGLE,
95 .PeriphBurst = DMA_PBURST_SINGLE,
96 }
97 };
98
99 static DMA_HandleTypeDef comms_dma_rx_handle = {
100 .Instance = DMA2_Stream5,
101 .Init = {
102 .Channel = DMA_CHANNEL_4,
103 .Direction = DMA_PERIPH_TO_MEMORY,
104 .PeriphInc = DMA_PINC_DISABLE,
105 .MemInc = DMA_MINC_ENABLE,
106 .PeriphDataAlignment = DMA_PDATAALIGN_BYTE,
107 .MemDataAlignment = DMA_MDATAALIGN_BYTE,
108 .Mode = DMA_CIRCULAR,
109 .Priority = DMA_PRIORITY_VERY_HIGH,
110 .FIFOMode = DMA_FIFOMODE_DISABLE,
111 .MemBurst = DMA_MBURST_SINGLE,
112 .PeriphBurst = DMA_PBURST_SINGLE,
113 }
114 };
The list below explains certain parts of DMA_HandleTypeDef structure to understand DMA’s behaviour.
- Instance (DMA_Stream_TypeDef) – pointer to memory where DMA’s Stream registers are located; we are using DMA2 with Stream 5 and 7 because they are interconnected with USART1 Rx and Tx, respectively [3]
- Init (DMA_InitTypeDef) – settings for DMA Instance
- Channel – request channel; channel 4 will connect both DMA Streams 5 and 7 with USART1 requests
- Direction – this definies flow of data such as from memory to memory, memory to peripheral or peripheral to memory; DMA Stream for transmission moves data from memory to peripheral (TRD), and DMA Stream for reception moves data from peripheral (RDR) to memory
- PeriphInc – can keep incrementing peripheral address while transferring memory; we want to keep peripheral addresses constant
- MemInc – memory will keep increasing as we are accessing buffers
- PeriphDataAlignment – data size alignment for peripheral addresses – needs to be set to 1 byte as we are transferring bytes from USART
- MemDataAlignment – data size alignment for internal memory addresses – has to be the same as PeriphDataAlignment in our case
- Mode – normal mode for tx will only trigger a DMA interrupt when a desired number of packets were transmitted e.g. per a datagram; circular mode for rx will allow trigerring a DMA interrupt every time we receive the whole datagram
- Priority – can be used to prioritise DMA Streams; we ues the same priority for both Tx and Rx
- FIFOMode – data can be buffered in a FIFO; we will not use it
- MemBurst – set to a single transfer but we could choose to send multiple data chunks
- PeriphBurst – set to a single transfer but we could choose to send multiple data chunks
To initialise DMA, we enable clock for DMA2, setup DMA Streams and their interrupts, and configure DMA reception bit in USART1 CR3 so that we can immediately start receiving data from USART1 RXD
comms_driver.c
139 /// clock for DMA2
140 __HAL_RCC_DMA2_CLK_ENABLE();
...
177 } else if (config.mode == COMMS_DRIVER_MODE_DMA_IT) {
178 /// initialise DMAs for Rx & Tx
179 HAL_DMA_Init(&comms_dma_tx_handle);
180 HAL_DMA_Init(&comms_dma_rx_handle);
181
182 /// configure DMA interrupts for Rx (DMA2_Stream5_IRQn) & Tx (DMA2_Stream7_IRQn)
183 HAL_NVIC_EnableIRQ(DMA2_Stream7_IRQn);
184 HAL_NVIC_EnableIRQ(DMA2_Stream5_IRQn);
185
186 HAL_DMA_Start_IT(&comms_dma_rx_handle, (uint32_t)&comms_driver_data.uart_handler.Instance->DR, (uint32_t)rx_data, 2);
187 /// clear transmission complete bit, as it is set by default
188 CLEAR_BIT(comms_driver_data.uart_handler.Instance->SR, USART_SR_TC);
189
190 /// turn DMA/UART reception on
191 SET_BIT(comms_driver_data.uart_handler.Instance->CR3, USART_CR3_DMAR);
192 }
We can start using comms_driver with an DMA by initialising it with COMMS_DRIVER_MODE_DMA_IT mode.
main_part1.c
69 /// configure comms driver
70 comms_driver_config_t comms_driver_config = {
71 .payload_size = INCOMING_PAYLOAD_SIZE,
72 .baudrate = COMMS_BAUDRATE,
73 .parity = COMMS_DRIVER_PARITY_EVEN,
74 .mode = COMMS_DRIVER_MODE_DMA_IT,
75 };
76 comms_driver_initialise(comms_driver_config);
Communication Driver: DMA Transmission
The only difference between USART transmission configured with DMA instead of UART interrupts is that we do not enable Transmission Complete and Transmission Data Register Empty interrupts. Instead, we enable DMA transmission bit in CR3 and wait until DMA triggers an interrupts to indicate that it sent requested number of packets over USART. We initiate transmission with the same function – comms_driver_send_data.
comms_driver.c
207 } else if (comms_driver_data.mode == COMMS_DRIVER_MODE_DMA_IT) {
208 /// transmission with DMA interrupts
209 HAL_DMA_Start_IT(&comms_dma_tx_handle, (uint32_t)data, (uint32_t)&comms_driver_data.uart_handler.Instance->DR, data_size);
210 CLEAR_BIT(comms_driver_data.uart_handler.Instance->SR, USART_SR_TC);
211 SET_BIT(comms_driver_data.uart_handler.Instance->CR3, USART_CR3_DMAT);
212 }
Then we can use HAL library to handle DMA2 Stream7 interrupt configured for USART1 TX.
comms_driver.c
283 /// Handle TX data with DMA
284 void DMA2_Stream7_IRQHandler() {
285 HAL_DMA_IRQHandler(&comms_dma_tx_handle);
286 }
Using DMA will only give an interrupt once per the whole transmission stream instead of multiple USART interrupts, which will free the CPU from some operations.
Communication Driver: DMA Reception
DMA reception is already configured in the comms_driver_initialise function, which also enables DMA2 reception interrupt. Once the DMA2_Stream5_IRQHandler triggers, we can handle the interrupt using HAL libraries and handle received data in a comms_driver_handle_data_cb callback.
comms_driver.c
288 /// Handle RX data with DMA
289 void DMA2_Stream5_IRQHandler() {
290 HAL_DMA_IRQHandler(&comms_dma_rx_handle);
291 if ((comms_driver_data.uart_handler.Init.Parity != UART_PARITY_NONE) &&
292 (comms_driver_data.uart_handler.Init.WordLength == UART_WORDLENGTH_8B)) {
293 static uint8_t data_index;
294 for (data_index = 0; data_index < comms_driver_data.rx_payload_size; data_index++) {
295 *(comms_driver_data.rx_buffer+data_index) &= (uint8_t)0x7F;
296 }
297 }
298 comms_driver_handle_data_cb(comms_driver_data.rx_buffer, comms_driver_data.rx_payload_size);
299 }
Therefore, we will only have a single reception interrupt per data stream. By setting DMA configurations to use a circular mode, the reception will be continuously working. This means, we will handle received data any time it arrives. This is improved in the next section, where comms module processes incoming data at the same frequency as the data is being sent.
Communication stack – Part II
This part will expand the communication stack further by adding an additional layer in the firmware responsible for coordinating communication, initialising the driver and declaring telemetry and command structures. Additionally, we will go through a serial-bridge software which handles serial communication on Linux host and sends received telemetry in JSON format over UDP. The recipient of that telemetry will be Plotjuggler acting as an UDP server and plotting incoming data.
Comms Module: Command
Let’s take a look at the structure of comms module. It basically consists of three parts: comms, command and telemetry. Command and telemetry files define structures incoming and outgoing over communication channel, which is UART. Therefore, instead of checking in the main whether the command had a specific word value, we only check if it contained an enumerated command, and implementation specifics are encapsulated within comms_command.c.
comms_command.c
25 /// CMD_TURN_BLUE_LED_ON = "XA"
26 /// CMD_TURN_BLUE_LED_OFF = "FY"
27 #define CMD_TURN_BLUE_LED_ON (uint16_t)0x5841
28 #define CMD_TURN_BLUE_LED_OFF (uint16_t)0x4659
29
30 /// 16-bit command construction:
31 /// CMD_BYTE_0 (MSB) | CMD_BYTE_1 (LSB)
32 #define CMD_LENGTH 2U
33 #define CMD_BYTE_0 0U
34 #define CMD_BYTE_1 1U
35
36 comms_cmd_t comms_get_cmd(uint8_t* buffer, uint8_t size) {
37 /// \todo add an assert here for size
38 comms_cmd_t new_cmd;
39 static uint16_t cmd_raw;
40 cmd_raw = ((uint16_t)buffer[0] << 8 | buffer[1]);
41 switch (cmd_raw) {
42 case CMD_TURN_BLUE_LED_ON: {
43 new_cmd.id = COMMS_CMD_TURN_LED_ON;
44 break;
45 }
46 case CMD_TURN_BLUE_LED_OFF: {
47 new_cmd.id = COMMS_CMD_TURN_LED_OFF;
48 break;
49 }
50 default: {
51 new_cmd.id = COMMS_CMD_INVALID;
52 }
53 }
54 return new_cmd;
55 }
28 typedef enum {
29 COMMS_CMD_TURN_LED_ON = 0U,
30 COMMS_CMD_TURN_LED_OFF,
31 COMMS_CMD_INVALID,
32 } comms_cmd_id_t;
33
34 typedef struct {
35 comms_cmd_id_t id;
36 } comms_cmd_t;
Therefore, when USART receives new datagram, comms_driver_handle_data_cb callback processes it inside comms.c. The callback converts received bytes to comms_cmd_t and saves it as the new command to be executed. The recently received command will now be executed from the main loop at desired frequency by a comms_handle call. This ensures that even though commands are received asynchronously, they are evaluated at the same frequency as other tasks.
main_part2.c
80 while(1) {
81 if (!background_processed) {
82 /// run all background tasks at TASKS_FREQUENCY_IN_MS frequency
83 HAL_GPIO_TogglePin(GPIOC, measure_gpio.Pin);
84 HAL_GPIO_TogglePin(GPIOD, led_blinky_gpio.Pin);
85 telemetry_update();
86 comms_handle();
87 background_processed = true;
88 }
89 }
Comms module: Telemetry
When the main loop invokes, we update the telemetry and handle communication (both tx and rx this time). The former involves updating the defined structure from comms_telemetry.h, and updating transmission stream with up-to-date telemetry values. Compiler’s packed attribute squeezes telemetry allocation in the memory. It aligns structure’s members to a 1-byte-boundary instead of a typical 4-byte-boundary for a 32-bit CPU such as Cortex-M4 [4]. This effectively minimises size of a telemetry structure and saves some space when sending telemetry out of our microcontroller.
comms_telemetry.h
30 typedef struct __attribute__((packed)) {
31 uint8_t uid;
32 uint32_t cookie;
33 uint8_t switch_on;
34 } telemetry_t;
Therefore, the packed structure will take 6 bytes of space, instead of a normally aligned 12 bytes. Figure 9 visualises telemetry_t structure in the memory with and without a packed attribute used. The drawback of packing a structure is the slower instruction access to these memory locations.
You can also notice that there are 3 fields inside telemetry:
- uid – unique id to identify telemetry message (uint8_t)
- cookie – number increasing every time we run the main loop (uint32_t)
- switch_on – reads an user button digital input on a discovery board (uint8_t)
main_part2.c
121 void telemetry_update(void) {
122 static telemetry_t data;
123 data.uid = TELEMETRY_UID;
124 data.cookie++;
125 data.switch_on = (uint8_t)HAL_GPIO_ReadPin(GPIOA, user_button.Pin);
126 comms_update_telemetry(data);
127 }
After current telemetry gets updated we send it over comms_driver and react upon the recently received command. This is repeated synchronously at our desired frequency defined in TASKS_FREQUENCY_IN_MS.
Linux serial port driver
The serial-bridge reads data from a serial port in a loop, and sends commands in ASCII. The received data gets converted to JSON format and transported over UDP so that other programs can consume telemetry. This section briefly explains how serial-bridge operates. You can clone the repository and use it with main_part2 firmware to receive data over USB-UART and send commands.
Terminal 1
git clone https://github.com/woookey/serial-bridge.git
cd serial-bridge
mkdir build && cd build
cmake .. && make install
./bin/serial_bridge
If you have main_part2 compiled and reflashed you should start receiving data. To send commands you should type command in ASCII and confirm with ENTER. For example, to blink LED you can do the following.
Terminal 1
# turn blue LED on
XY + ENTER
# turn it of
FY + ENTER
Your output should look like this.
serial-bridge communication loop
The crucial part of serial-bridge is to receive serial data available on a desired tty port and sending user’s commands. This can be easily modified to use your own structures for commands and telemetry. You can initialise serial-bridge instance with a serial port your device is connected to (typically /dev/ttyUSB0). Also, adjusting packet’s number of bits and parity is possible (parity is the MSB of a packet).
serial-bridge/src/main.cpp
37 /// create serial_bridge instance
38 std::unique_ptr<serial_bridge::serial_bridge> uart_to_json =
39 std::make_unique<serial_bridge::serial_bridge>("/dev/ttyUSB0", serial_bridge::BITS_8,
40 serial_bridge::PARITY_NONE, serial_bridge::BAUDRATE_115200);
41 /// run if initialised successfully
42 if (uart_to_json->is_initialised()) {
43 uart_to_json->start();
44 }
Once initialisation is successful, we start running the communication implemented in exchange_data thread. The thread will continuously wait until any serial input arrives with select function. Only then will it read all data packet by packet. Once it receives the expected size of telemetry, it will pack the telemetry in a JSON object. Lastly, it it will send a serialised JSON object over UDP connection. Packing JSON packet and serialisation uses nlohmann’s JSON library for C++.
serial-bridge/src/serial_bridge.cpp
299 /// reception
300 if ( FD_ISSET(serial_bridge_fd_, &rdset) ) {
301 /// read from port byte by byte
302 volatile int i =0;
303 do {
304 n = read(serial_bridge_fd_, &read_buffer_[i], 1);
305 i++;
306 } while (n != 0 && errno != EINTR);
...
312 if (static_cast<int>(read_buffer_[0]) != uid) {
313 std::cerr << "[Error] wrong init character\n";
314 } else {
315 /// update telemetry in JSON format
316 update_telemetry_in_json(telemetry);
...
321 /// send JSON over UDP
322 int n_bytes = ::sendto(sock, telemetry_string.c_str(), telemetry_string.lengt h(), 0, reinterpret_cast<sockaddr*>(&destination), sizeof(destination));
323 std::cout << n_bytes << " bytes sent" << std::endl;
324 }
325 }
Furthermore, a separate read_commands thread keeps reading user’s commands as input streams. Once it receives a valid command, the communication loop transmits it after data reception.
serial-bridge/src/serial_bridge.cpp
327 /// transmission
328 if ( FD_ISSET(serial_bridge_fd_, &wrset) ) {
329 /// write command over port
330 n = write(serial_bridge_fd_, curr_cmd, WRITE_SIZE);
331 if ( n <= 0 ) {
332 std::cerr << "[Error] write to port failed: " << strerror(errno) << std::endl ;
333 }
334 cmd_to_send = false;
335 }
Let’s talk bytes – plotting telemetry with Plotjuggler and sending commands
Finally, we can demonstrate the full stack execution with the following setup:
- main_part2 exchanging data from a discovery board over UART
- serial-bridge receiving telemetry and sending commands to blink the blue LED
- plotjuggler plotting telemetry
You can install plotjuggler with snap or build it yourself following instructions from the official repository
sudo snap install plotjuggler
Follow the video below to build and reflash main_part2, open serial-bridge and plot data with plotjuggler as shown below. Make sure that plotjuggler receives JSON packets as an UDP Server over port 9870.
References
[1] Datasheet pp.55 (STM32F40xx pin definitions)
[2] Reference Manual, pp.1007 (USART Registers)