ChibiOS/HAL’s Serial Driver Explained

Introduction

In ChibiOS/HAL, multiple drivers can leverage the Universal Asynchronous Receiver Transmitter (often abbreviated UART). The simplest method to utilize this peripheral is via the Serial Driver. This driver utilizes I/O Queues for buffering input and output streams, providing several benefits.

This article, building upon UART 101: the blueprint of Serial Communications, will explore practical applications of the Serial Driver. We will explain its API and demonstrate how to implement this driver in real-world scenarios. UARTs are commonly employed to stream strings to and from microcontrollers, serving as a crucial interface for data exchange. A typical application involves printing out strings from the microcontroller, which can then be read through a terminal on a PC or processed by a software application, such as a Python script. Beyond basic data communication, UARTs are the preferred communication protocol for specific chips, supporting protocols like AT commands, widely used in modem communications, or the NMEA protocol for GPS devices.

Preliminary concept

Before diving into the article, it is important to note that in ChibiOS/HAL.

Overview of the drivers that are based on UART

The UART peripheral can be interfaced with via various drivers. A key point to remember is that a peripheral can be allocated to only one driver at any given time, necessitating users to discern which driver best suits their application’s needs. Currently, there are three drivers available that can utilize the UART peripheral:

  • Serial Driver: The driver discussed in this article, is a high-level abstraction in ChibiOS over the physical UART hardware. It offers a stream-like interface, incorporating buffering for both input and output data. This design simplifies the process of serial communication by abstracting the underlying hardware complexities. It is particularly useful for applications that require a straightforward method to send and receive data serially without the need to directly manage hardware-level details.
  • UART Driver: The UART driver is a lower-level interface compared to the Serial driver. It provides a more granular level of control over UART communications compared to the Serial driver. It allows users to associate callbacks with each individual interrupt source of the UART hardware. This capability enables precise management of UART events, such as data reception, transmission completion, and errors, making it ideal for applications that require detailed control over serial communication events and timing.
  • SIO (Serial Input/Output) Driver: This is a relatively newer addition, designed to provide an efficient and lightweight interface for input/output operations over UART. The SIO driver aims to be simpler and more resource-efficient than the Serial driver, making it a good choice for scenarios where resources are limited or when high performance with minimal overhead is required. The SIO is by default unbuffered but it is possible to extend it with buffer and obtain the bSIO (buffered SIO) that behaviourally is very similar to the Serial Driver itself.

Before diving into the next section, a note: It is crucial to distinguish between ChibiOS/HAL’s UART Driver and the UART peripheral of a microcontroller, ensuring clarity in their respective functions and roles.

Buffered streams

Discussing the Serial Driver and bSIO Driver, we touched on the concepts of buffers and streams, which are crucial for effectively using these drivers in applications. In programming, a Stream is an abstraction that represents a sequence of data elements made available over time. Imagine a stream as a water pipe. Data, like water, flows through the stream from one place to another: on the extremities of the water pipe there are a producer and a consumer that often run with their own cadency and are not synchronized one another. If the consumer runs in average faster than the producer the pipe will never overflow and the pipe itself is going to work as a sort of buffer so that the water is collected even when the consumer is not ready to receive it.

UART is designed to handle data frames ranging from 5 to 9 bits, though 8-bit data frames are most common, making it ideally suited for transmitting characters encoded in Extended ASCII. This characteristic renders UART highly effective for implementing character streams. Back to the analogy of water pipes, our characters represents water drops.

The representation of a Character Stream

Now, streams have directionality and we can differentiate in Input and Output streams, and in this scenario, the Serial Driver utilizes the UART peripheral as a transport layer and implements both input and output streams.

Input stream

An Input Stream buffers characters received from the UART RX line. When the microcontroller receives a character from an external source, an interrupt is triggered, requiring the application to address the IRQ and retrieve the character from the UART’s input register. If the IRQ is not addressed promptly and another character arrives, it may overwrite the previous one, leading to data loss. However, from an application standpoint, there’s often a need to read multiple characters simultaneously rather than focusing on individual characters. This is where the concept of a buffer becomes relevant. Imagine a FIFO buffer that fills up through the UART receiving ISR and that offers an API to extract an arbitrary number of characters: this is what we call an Input Stream.

The Serial Driver Input Stream

Output Stream

Similarly, an Output Stream buffers characters designated for transmission via the UART TX line. Characters are sent individually: the system loads a character into the output register, starts the transmission, and awaits an interrupt that indicates the completion of this transmission before proceeding to the next character. For applications, the intricate details of this hardware process are often irrelevant. The primary aim is to output a string seamlessly, abstracted from the actual transmission process. Imagine a buffer ready to be filled with characters for transmission, where the sending of characters is managed in the background, synchronized with the ISR. This setup is what we define as an Output Stream.

The Serial Driver Output Stream

Preparing to use the Serial Driver

One MCU, Multiple UART Peripherals

In many applications, an MCU may require the use of several UARTs. Some UARTs are dedicated to debugging, others to controlling devices with AT commands, and some for transferring data. Unlike other serial interfaces, UARTs are not inherently designed to be shared among multiple devices.

Nonetheless, today’s microcontrollers are equipped with several UART peripherals, often including built-in FIFOs to reduce the load on the CPU during data exchanges. Each UART peripheral operates independently, with its own control registers and connects to the outside world via separate GPIOs, allowing for concurrent operation through software control.

Block diagram of an MCU architecture highlighting the integration of UART peripherals within the system

Thus, modern microcontrollers provide numerous UART channels, and the software API we are going to describe allows for selecting and configuring one of the UARTs via Serial Driver. This leads to a frequent question from those new to the field: given that all UARTs are fundamentally similar and readily accessible, which one is best to use? The decision hinges on a variety of factors.

Pickings a UART

If your intention is to use an UART to print out strings to debug your application or to read out some data that you are reading from some sensor connected to your MCU then the Debugger Serial Port may suffice. Modern microcontroller kits are often equipped with on-board debugging probes: these are specialized microcontrollers that have been programmed with firmware to function as a debuggers. It’s commonplace for a modern debugging probe to create a virtual COM port on the host PC, which facilitates a bridge to the target’s UART interface.

If you plan to use a UART for debugging purposes, such as printing strings to monitor your application or print out data from sensors connected to your MCU, then the Debugger Serial Port might be sufficient. Many modern microcontroller kits come with built-in debugging probes. These probes are essentially specialized microcontrollers programmed to act as debuggers. A common feature of modern debugging probes is their ability to expose a virtual COM port to the host PC. This port is then wired to one of the UARTs of the MCU offering a ready to use communication interface, simplifying the process of data communication and debugging.

The connection diagram of a modern debugging probe exposing multiple USB interfaces with focus on the UART interface

This means that if we look at the schematic of our evaluation kit there should be some GPIOs connected to the debugging probe and some of them should be reroutable to an UART. For example, in the case of the SDP-K1 these PINs would be PC12 and PD2 which can be internally rerouted to the UART5. The following table summarizes the connections.

This implies that by examining the schematic of our evaluation kit, we should identify certain MCU’s GPIOs linked to the debugging probe which are internally reroutable to an UART. Taking the SDP-K1 as an instance, the pins PC12 and PD2 are connected to the debugging probe and are internally reroutable to UART5. Below is a table summarizing these connections.

Debugger labelSTM32 GPIO labelModeFunction
DAP_VCP_RXPC12AF8UART5 TX
DAP_VCP_TXPD2AF8UART5 RX
Debugging UART pin map for the SDP-K1

If the user needs to connect the UART to some other device then another easy choice is to pick an evaluation kit equipped with an Arduino Uno connector. Indeed the pin Digital 0 and Digital 1 are always connected to PINs of the Microcontroller that are re-routable to an UART. We could also use these PINs to interface a PC in case our evaluation kit is not having a readily connected debugging UART using an USB to UART breakout board.

The connection diagram of a USB to UART breakout board to a development kit equipped with Arduino UNO R3 connector.

Once again if we consider the SDP-K1 as an example board then D0 and D1 can be rerouted to the UART4 according to the following table

Arduino Connector LabelSTM32 GPIO labelModeFunction
ARD_D1PA0AF8UART4 TX
ARD_D0PA1AF8UART4 RX
Arduino Uno connector UART pin map for the SDP-K1

Pin assignment

GPIO rerouting is an important consideration when utilizing a microcontroller’s internal peripherals, a concept we previously explored in the context of ChibiOS/HAL’s Port Abstraction Layer. MCUs are equipped with a variety of internal peripherals that necessitate external pin connections. Given the disparity between the number of internal lines and available external pins, MCUs offer the capability to reroute various internal lines to a single external pin. This flexibility, typically orchestrated by the GPIO peripheral, is accessible in software via the PAL driver or board configuration files.

When an evaluation kit includes a UART linked to the onboard debugging probe, it is reasonable to expect the board’s configuration file to have already set the corresponding GPIO pin to function as UART lines. A common principle with ChibiOS board files is that they are supposed to preconfigure GPIOs connected to peripherals, leaving those that are unconnected in their default state.

Nonetheless, it is prudent to verify these configurations. Revisiting the SDP-K1 example, the pins of interest are PC12 and PD2, which are essential to review for their UART functionalities.

The Pinout of the SDP-K1 with focus on the debugger Virtual COM Port

Examining the board files for this evaluation kit, it is evident that the pins in question, specifically PC12 and PD2, are indeed correctly configured. This ensures they are ready for use as intended, facilitating UART communication without the need for additional setup by the user.

#ifndef BOARD_H
#define BOARD_H
/*===========================================================================*/
/* Driver constants.                                                         */
/*===========================================================================*/
/*
 * Setup for Analog Devices SDP-CK1Z board.
 */
/*
 * Board identifier.
 */
#define BOARD_ADI_EVAL_SDP_CK1Z
#define BOARD_NAME                  "Analog Devices SDP-CK1Z"
...
/*
 * IO lines assignments.
 */
...
#define LINE_DAP_VCP_RX             PAL_LINE(GPIOC, 12U)
#define LINE_DAP_VCP_TX             PAL_LINE(GPIOD, 2U)
...
/*
 * GPIOC setup:
 ...
 * PC12 - DAP_VCP_RX                (alternate 8).
 ...
 */
...
/*
 * GPIOD setup:
 ...
 * PD2  - DAP_VCP_TX                (alternate 8).
 ...
 */
...
#endif /* BOARD_H */

For the lines ARD_D0 and ARD_D1, the situation differs. They are set to their default mode (input pull-up) since they are not connected to any specific function. Consequently, if our intention is to utilize UART4 in our application with the SDP-K1, it will be necessary to configure these pins in our code before we can successfully employ the Serial Driver for communication. This step is crucial to ensure that ARD_D0 and ARD_D1 are correctly prepared for UART communication.

  palSetLineMode(LINE_ARD_D0, PAL_MODE_ALTERNATE(8) | PAL_STM32_OSPEED_HIGHEST);
  palSetLineMode(LINE_ARD_D1, PAL_MODE_ALTERNATE(8) | PAL_STM32_OSPEED_HIGHEST);

In the subsequent sections, we will shift our focus away from this secondary port and concentrate on the debugger port. This decision allows us to delve deeper into the functionalities and advantages offered by the debugger port, optimizing our exploration and application of the evaluation kit’s capabilities.

Enabling the Serial Driver

ChibiOS/HAL offers an array of device drivers, each affecting the system’s code size, memory allocation, and CPU load. Recognizing that not all projects require the full suite of drivers, ChibiOS/HAL adopts a modular design. This approach allows for the individual activation or deactivation of each driver via the halconf.h file.

Typical ChibiOS demo projects often activate the PAL and Serial drivers by default, supporting basic operations such as LED blinking and serial output of test results. Should you begin with one of these demo projects, your halconf.h may already have the Serial Driver enabled. However is always a good idea to double-check. The initial section of this file lists numerous options (formatted as HAL_USE_SOMETHING) where setting an option to TRUE includes its associated driver in the build. While these options are generally sorted alphabetically, HAL_USE_PAL often precedes others due to its frequent use across various projects.

/**
 * @brief   Enables the PAL subsystem.
 */
#if !defined(HAL_USE_PAL) || defined(__DOXYGEN__)
#define HAL_USE_PAL                         TRUE
#endif
/**
 * @brief   Enables the ADC subsystem.
 */
#if !defined(HAL_USE_ADC) || defined(__DOXYGEN__)
#define HAL_USE_ADC                         FALSE
#endif
// ... (other driver switches) ...
/**
 * @brief   Enables the SERIAL subsystem.
 */
#if !defined(HAL_USE_SERIAL) || defined(__DOXYGEN__)
#define HAL_USE_SERIAL                      TRUE
#endif
// ... (other driver switches) ...


Disabling the HAL_USE_SERIAL switch means the Serial Driver’s API becomes inaccessible to the compiler, leading to numerous compilation errors as if the Serial Driver API were missing.

Errors in ChibiStudio IDE due to undeclared Serial Driver, indicating the Serial Driver subsystem has not been enabled in the halconf.h.

Delving into the screenshot above reveals that the compilation process was interrupted when the compiler tried to process a the line 65. This interruption was due to the Serial Driver subsystem being turned off, rendering the API sdStart and the SD5 driver undiscoverable by the compiler. The variety of error messages you might see depends on your code’s structure and the compiler’s configuration. Yet, encountering errors about undeclared functions or absent structures, assuming your code is free of typographical errors, should immediately signal a missed step: activating the required driver in halconf.h.

Assigning Peripherals to the Serial Driver

Once the Serial Driver is activated, the subsequent step involves designating one or more peripheral instances to it. Allocating a peripheral to a driver grants it complete control over that peripheral, encompassing the management of its Interrupt Service Routines and the configuration of the hardware through its APIs.

By default, drivers are not automatically assigned peripherals, a decision made to optimize resource usage. Assigning a peripheral to a driver introduces a new global variable for that driver instance, enhancing flexibility but also increasing the codebase size, RAM usage, and necessitating additional code for object initialization and ISR setup. This strategy also considers the multifunctional nature of many hardware peripherals. As mentioned an UART peripheral instance can be used with three different drivers, the Serial Driver, the UART Driver or the SIO Driver.

Peripheral assignment settings are deeply specific to the microcontroller in use. Each MCU varies in the number of Serial instances it supports, how these instances are linked to DMA, and even the ISR’s location within the vector table. These details are specified in the mcuconf.h file, which is tailored to the MCU model used in the evaluation kit.

Therefore, when accessing the mcuconf.h file for a project like the SDP-K1, the document begins by clearly indicating the MCU family and the specific part number it addresses.

/*
 * STM32F4xx drivers configuration.
 * The following settings override the default settings present in
 * the various device driver implementation headers.
 * Note that the settings for each driver only have effect if the whole
 * driver is enabled in halconf.h.
 *
 * IRQ priorities:
 * 15...0       Lowest...Highest.
 *
 * DMA priorities:
 * 0...3        Lowest...Highest.
 */
#define STM32F4xx_MCUCONF
#define STM32F469_MCUCONF
#define STM32F479_MCUCONF

As we scroll down the file and search alphabetically, we will find a section related to the SPI driver. Here’s a snippet from the mcuconf.h of the SDP-K1 specifically related to the SPI.

 * SERIAL driver system settings.
 */
#define STM32_SERIAL_USE_USART1             FALSE
#define STM32_SERIAL_USE_USART2             FALSE
#define STM32_SERIAL_USE_USART3             FALSE
#define STM32_SERIAL_USE_UART4              FALSE
#define STM32_SERIAL_USE_UART5              TRUE
#define STM32_SERIAL_USE_USART6             FALSE

This particular MCU features up to six UART peripheral instances, allowing for the configuration of each to be linked with the Serial Driver using the STM32_SERIAL_USE_UARTx/USARTx settings. In the case of STM32, there is a specific distinction made between UART and USART, with the latter indicating the capability of some UARTs to operate in a synchronous mode, ultimately behaving as additional SPIs instances. This differentiation is labeled with an “S” for Synchronous in USART. While this detail is not crucial for the focus of this article, it is an interesting aspect to highlight.

Additionally, it is noted in the configuration that UART5 is designated for use with the Serial Driver, as evidenced by the activation of STM32_SERIAL_USE_UART5. Enabling this setting makes the SD5 global structure accessible within our application:

/** @brief UART5 serial driver identifier.*/
#if STM32_SERIAL_USE_UART5 || defined(__DOXYGEN__)
SerialDriver SD5;
#endif

This object represents the instance of the UART peripheral adopted as a Serial Driver in software, and any operations performed on this object will affect the UART5 peripheral. The association is straightforward: if we would enable STM32_SERIAL_USE_USART3 , then the driver SD3 will be made available.

It’s important to note that if the Serial Driver is enabled but no peripheral is assigned, the compile process will return an error.

Errors in the ChibiStudio IDE resulting from the absence of peripheral assignment to the Serial Driver, signifying a misconfiguration in mcuconf.h

If a driver subsystem is enabled, but no peripheral is assigned to it, ChibiOS/HAL generates a clear error message during compile time. In this case, the error message states: “Serial Driver activated but no peripheral assigned.” This situation typically indicates that either the Serial Driver is not genuinely in use and should be disabled in the halconf.h, or the user has forgotten to assign the peripheral in mcuconf.h.

Serial Driver API Overview

After setting up the driver, assigning peripherals, and rerouting connections internally, we can now explore the specific APIs provided by the Serial Driver. Every function from this driver needs a pointer to the SerialDriver being used as its first input. This way, the function knows which hardware instance to work with. This design mimics an object-oriented approach in a procedural programming language like C.

Think of the SerialDriver as an object that operates a state machine, with the driver’s functions acting as the object’s methods.

Here’s an example with a made-up API showing how it works with two different hardware peripherals:

/* Performing some operation on SD4. */
sdDoSomething(&SD4);
/* Performing some operation on SD5. */
sdDoSomething(&SD5);

For a clearer picture, let’s look at the state machine, based on ChibiOS documentation but with a PLAY Embedded touch.

The state machine diagram for the Serial Driver in ChibiOS/HAL.

This state machine outlines three key states for the SerialDriver:

  • SD_UNINIT: The initial state before the driver is set up. This changes once we initialize the system with halInit() with the Serial Driver enabled and the peripheral assigned.
  • SD_STOP: In this state, the driver is inactive. The UART peripheral isn’t receiving any power from the clock tree, saving energy by being in a low-power state.
  • SD_READY: The driver is either ready to start or already in operation. The clock tree is active, and the UART is set up and ready for data transfer.

Understanding these states is vital for effectively utilizing the Serial Driver’s APIs and handling UART communications in your project. As mentioned, the Serial driver incorporates internal I/O Queues to facilitate stream implementation. The size of these queues is adjustable within the halconf.h file under the section labeled “SERIAL driver related settings”, by altering the SERIAL_BUFFERS_SIZE parameter.

/*===========================================================================*/
/* SERIAL driver related settings.                                           */
/*===========================================================================*/
/**
 * @brief   Default bit rate.
 * @details Configuration parameter, this is the baud rate selected for the
 *          default configuration.
 */
#if !defined(SERIAL_DEFAULT_BITRATE) || defined(__DOXYGEN__)
#define SERIAL_DEFAULT_BITRATE              38400
#endif
/**
 * @brief   Serial buffers size.
 * @details Configuration parameter, you can change the depth of the queue
 *          buffers depending on the requirements of your application.
 * @note    The default is 16 bytes for both the transmission and receive
 *          buffers.
 */
#if !defined(SERIAL_BUFFERS_SIZE) || defined(__DOXYGEN__)
#define SERIAL_BUFFERS_SIZE                 16
#endif

Activating and deactivating the Serial Driver

Let’s dive into the first set of APIs for the Serial Driver, including sdStart and sdStop. These functions are essential for setting up and shutting down the Serial Driver, facilitating the transition between the SD_STOP and SD_READY states as depicted in the state machine.

sdStart

The sdStart function is designed to prepare the Serial peripheral for use. It requires a pointer to the SerialDriver object in operation and a pointer to the configuration intended for future communications. Here’s the prototype:

The sdStart API is used to configure the Serial Driver Peripheral before performing any operations with it. It requires a pointer to the SPIDriver object in use and a pointer to the configuration that you intend to adopt for the upcoming data exchanges. Here’s the function signature:

/**
 * @brief   Configures and starts the driver.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] config    the architecture-dependent serial driver configuration.
 *                      If this parameter is set to @p NULL then a default
 *                      configuration is used.
 * @return              The operation status.
 *
 * @api
 */
msg_t sdStart(SerialDriver *sdp, const SerialConfig *config)

For example, this is what activating and setting up the Serial Driver 5 looks like:

/* Example: Activating and configuring the Serial Driver 5. */
sdStart(&SD5, &my_serialcfg);

Beginners might find the sdStart function challenging, not due to the function itself, but because of the intricate details required in the configuration structure. This structure’s format depends on the microcontroller being used and specific settings in halconf.h. Unfortunately, while ChibiOS does an excellent job of documenting the HAL API, it doesn’t provide the same level of detail for hardware-specific aspects. However, rest assured, we will offer both a concise solution and an in-depth explanation of this topic in this article and through related examples.

If a specialized configuration for the serial driver isn’t required, we can simply pass NULL as the parameter, rather than a detailed configuration structure, to activate the driver with a default setup. This default setup typically includes an 8-bit data size, no parity, no flow control, and a baud rate of 38400 bps. However, it’s worth noting that these default settings can be customized in the halconf.h file of your project to better suit your needs

/*===========================================================================*/
/* SERIAL driver related settings.                                           */
/*===========================================================================*/
/**
 * @brief   Default bit rate.
 * @details Configuration parameter, this is the baud rate selected for the
 *          default configuration.
 */
#if !defined(SERIAL_DEFAULT_BITRATE) || defined(__DOXYGEN__)
#define SERIAL_DEFAULT_BITRATE              38400
#endif

The sdStart function can be repeatedly used within your code to not only activate the driver but also to modify its settings. For example, to adjust the serial speed, you could define two different configurations and dynamically switch between them by calling sdStart again with the new configuration:

/* Example: Enabling and configuring Serial Driver 5 with the default configuration. */
sdStart(&SD5, NULL);
/* Writing hello world with the speed specified in halconf.h. */
sdWrite(&SD5, "Hello World\r\n", 13);
...
/* Example: Enabling and configuring Serial Driver 5 with configuration myserial_cfg. */
sdStart(&SD5, &myserial_cfg);
/* Writing hello world with the speed specified in my configuration. */
sdWrite(&SD5, "Hello World\r\n", 13);

sdStop

The sdStop API serves as the counterpart to sdStart, providing a straightforward method for turning off the Serial Driver:

/**
 * @brief   Stops the driver.
 * @details Any thread waiting on the driver's queues will be awakened with
 *          the message @p MSG_RESET.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 *
 * @api
 */
void sdStop(SerialDriver *sdp);
/* Example: deactivating the Serial Driver 5. */
sdStop(&SD5);

This function simply needs a pointer to the SerialDriver to turn off the corresponding peripheral and transition it to a low-power state.

The Serial Driver Configuration

The Serial Driver configuration is a structure passed to sdStart to activate and set up the driver. The structure’s format depends partially on the microcontroller in use. If our goal is to change only the speed of the UART the easy way is to use the default configuration and modify the speed in the halconf.h

/*===========================================================================*/
/* SERIAL driver related settings.                                           */
/*===========================================================================*/
/**
 * @brief   Default bit rate.
 * @details Configuration parameter, this is the baud rate selected for the
 *          default configuration.
 */
#if !defined(SERIAL_DEFAULT_BITRATE) || defined(__DOXYGEN__)
#define SERIAL_DEFAULT_BITRATE              38400
#endif

When it comes to STM32 the configuration structure is pretty much the same. The following code is an example for a Serial configuration operating at 38400 bps 8-bit data size, no parity, 1 stop bit (often abbreviated 8N1) with no flow control.

/*
 * Serial configuration (38400 bps, 8N1, no flow control).
 */
const SerialConfig serialcfg = {
  .speed = 38400,
  .cr1 = 0U,
  .cr2 = 0U,
  .cr3 = 0U
};

Adjusting the speed is straightforward: simply update the .speed field’s value. For additional adjustments, modify the values of .cr1, .cr2, and .cr3, which correspond to the UART’s control registers in your MCU. To change these, you can use the bitmasks found in the CMSIS headers located at [ChibiOS root]\os\common\ext. For instance, for the SDP-K1, reference the header at [ChibiOS root]\os\common\ext\ST\STM32F4xx\stm32f469xx.h.

For example, to configure the Serial to operate at 115200 bps with 8-bit odd parity and 2 stop bits, the setup would be:

/*
 * Serial configuration (115200 bps, 8-bit odd parity, 2 stop bits, no flow control).
 */
const SerialConfig serialcfg = {
  .speed = 115200,
  .cr1 = USART_CR1_PCE | USART_CR1_PS,  // Enables parity check and sets odd parity
  .cr2 = USART_CR2_STOP_1,              // Configures 2 stop bits
  .cr3 = 0U                             // No additional settings
};

Consulting the STM32F469 reference manual alongside the CMSIS header file reveals the need to enable the Parity Control Enable (USART_CR1_PCE) for parity checking and set the Parity Selection (USART_CR1_PS) for odd parity in Control Register 1.

The description of the STOP bitfield from the STM32F469 Reference Manual

Additionally, setting 2 stop bits is achieved by setting bit one of the bitfield STOP in the Control Register 2 (USART_CR2_STOP_1).

The description of the PS and PCE bitfields from the STM32F469 Reference Manual

It is crucial to remember that these settings are highly dependent on the hardware, and bit definitions may vary across different STM32 models. For MCUs from another family, the SerialConfig structure definition may change completely. Indeed the UART of another microcontroller family may offer different registers for configuration purposes.

The most effective approach to address the differences in configuration is to examine the SerialConfig structure specific to the microcontroller we are using. This can be found in the serial_lld.h file associated to the port of HAL for our MCU. To locate the correct file, we need to start from the makefile by identifying the path of platform.mk under “Project, target, sources and paths.” For instance, in a project using the SDP-K1, the makefile contains the following line:

include $(CHIBIOS)/os/hal/ports/STM32/STM32F4xx/platform.mk

Opening this file reveals the path to the UART’s associated low level driver, where the serial_lld.h file is located:

include $(CHIBIOS)/os/hal/ports/STM32/LLD/USARTv1/driver.mk

In this case, the file we are looking for is located at [ChibiOS root]\os\hal\ports\STM32\LLD\USARTv1\serial_lld.h. Within this file, we will find the SerialConfig definition valid for some of the STM32 families (including the STM32F4 series), which is crucial for configuring and initiating serial driver operations.

/**
 * @brief   STM32 Serial Driver configuration structure.
 * @details An instance of this structure must be passed to @p sdStart()
 *          in order to configure and start a serial driver operations.
 * @note    This structure content is architecture dependent, each driver
 *          implementation defines its own version and the custom static
 *          initializers.
 */
typedef struct hal_serial_config {
  /**
   * @brief Bit rate.
   */
  uint32_t                  speed;
  /* End of the mandatory fields.*/
  /**
   * @brief Initialization value for the CR1 register.
   */
  uint16_t                  cr1;
  /**
   * @brief Initialization value for the CR2 register.
   */
  uint16_t                  cr2;
  /**
   * @brief Initialization value for the CR3 register.
   */
  uint16_t                  cr3;
} SerialConfig;

Synchronous API

Once the Serial Driver is enabled and configured using sdStart, the input stream will automatically start to be filled in in case data comes from the UART RX line. Similarly also the output stream is ready to go and we can start to push characters in it: these carachters will be automatically be transmitted over the UART TX line in accordance to the time requirement of the hardware. The most common way to send or receive data via UART is synchronously, which means that when a thread tries to send data over the UART, it gets suspended until all the data isn’t pushed into the the stream. Similarly, when a thread tries to read X characters from the stream, it gets suspended until the right amount of characters are received.

The following code snippet provides an example of the implementation of an echo over the UART

/* Waiting for a character. */
char token = sdGet(&SD5);
/* Printing out the same character. */
sdPut(&SD5, token);

In the following we are going to take a closer look to this API.

sdPut

The sdPut API is designed for transmitting a single character over the serial interface. This function is synchronous, meaning the calling thread is suspended until the character is placed into the output stream.

/**
 * @brief   Direct write to a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          writes directly on the output queue. This is faster but cannot
 *          be used to write to different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         the byte value to be written in the output queue
 * @return              The operation status.
 * @retval MSG_OK       if the operation succeeded.
 * @retval MSG_RESET    if the @p SerialDriver has been stopped.
 *
 * @api
 */
#define sdPut(sdp, b) oqPut(&(sdp)->oqueue, b)

It is critical to understand that the thread being resumed is not a confirmation that the character is transmitted but it merely indicates that the character has been queued for transmission: the thread might resume operations while the character remains in the queue, pending actual transmission over the UART TX line. If the output stream is having at least an empty space this function will not suspend the thread at all.

Here is an example that shows how to use sdPut to send a character:

/* Assuming SD5 is already started and configured. */
/* Sending the character 'A' over UART. */
sdPut(&SD5, 'A');
/* At this point, the thread may resume, but 'A' might still 
   be in the stream, not yet transmitted. */

sdGet

The sdGet API is intended for receiving a single character from the serial interface. This function operates synchronously, meaning the calling thread is suspended until a character is received from the input stream.

/**
 * @brief   Direct read from a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          reads directly from the input queue. This is faster but cannot
 *          be used to read from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @return              A byte value from the input queue.
 * @retval MSG_RESET    if the @p SerialDriver has been stopped.
 *
 * @api
 */
#define sdGet(sdp) iqGet(&(sdp)->iqueue)

Note that a thread calling this function will be suspended only if the queue is empty. In this case, as soon as a new character is received from the UART RX line, the thread will become ready. If the input stream contains at least one character when this function is called, the thread will not be suspended at all.

Here is an example demonstrating how to use sdGet to receive a character:

/* Assuming SD5 is already started and configured. */
/* Receiving a character over UART. */
char token = sdGet(&SD5);
/* At this point, the thread has resumed, and 'token' contains 
   the character received from the stream. */

sdWrite

The sdWrite API enhances the core functionality of sdPut by enabling the transmission of not just a single character but an entire sequence of characters over the serial interface. Conceptually, both functions share the same operational foundation: synchronously enqueuing data into the output stream. While sdPut is designed to handle individual characters, sdWrite extends this capability to accommodate strings or arrays of characters, allowing the application to conveniently handle the transmission of strings.

/**
 * @brief   Direct blocking write to a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          writes directly to the output queue. This is faster but cannot
 *          be used to write from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         pointer to the data buffer
 * @param[in] n         the maximum amount of data to be transferred, the
 *                      value 0 is reserved
 *
 * @api
 */
#define sdWrite(sdp, b, n) oqWriteTimeout(&(sdp)->oqueue, b, n, TIME_INFINITE)

This function suspends the calling thread until there is sufficient space in the output stream to accommodate the entire sequence of characters. If the output stream already has enough space for the sequence at the time of the call, the thread will not be suspended. However, if only a portion of the bytes can be enqueued, the thread will be suspended. During this time, the UART TX ISR continues to transmit characters freeing space in the queue. As more space becomes available in the output stream, characters are progressively added until the entire sequence is in the stream, at which point the calling thread is made ready again.

Similar to sdPut, the resumption of the thread does not confirm that all the characters have been transmitted but it only indicates that all the characters have been queued for transmission.

Here is an example demonstrating how to use sdWrite to send a string:

/* Assuming SD5 is already started and configured. */
/* Sending a string over UART. */
const char *msg = "Hello, World!";
sdWrite(&SD5, (const uint8_t *)msg, strlen(msg));
/* At this point, the thread may resume, but the string might 
   still be in the stream, not yet transmitted. */

sdRead

The sdRead API complements the functionality of sdGet by facilitating the reception of a sequence of characters, rather than just a single character, from the serial interface. At their core, both functions operate on the same principle: synchronously retrieving data from the input stream. While sdGet works for single-character operations, sdRead expands this capability to manage strings or arrays of characters, enabling the application to syncronize on the reception of a specific amount of characters.

/**
 * @brief   Direct blocking read from a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          reads directly from the input queue. This is faster but cannot
 *          be used to read from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         pointer to the data buffer
 * @param[in] n         the maximum amount of data to be transferred, the
 *                      value 0 is reserved
 *
 * @api
 */
#define sdRead(sdp, b, n) iqReadTimeout(&(sdp)->iqueue, b, n, TIME_INFINITE)

This function suspends the calling thread until the specified number of characters is received and stored in the provided buffer. If the input stream contains enough characters at the time of the call, the thread will not be suspended. Conversely, if the available data is insufficient, the thread is suspended. As soon as the UART RX ISR deposits more characters into the input stream, these are moved into the receiving buffer. The process continues until the buffer is fully populated, signaling the thread to become ready once again.

Here is an example showcasing how to use sdRead to receive a string:

/* Assuming SD5 is already started and configured. */
/* Preparing a buffer to receive a string. */
uint8_t buffer[20];
/* Receiving a string over UART. */
sdRead(&SD5, buffer, sizeof(buffer));
/* At this point, the thread has resumed, and 'buffer' 
   contains the received data. */

Timeout API

In addition to the synchronous APIs, the Serial Driver offers enhanced counterparts designed to manage operations with timeouts. These APIs, identified by the suffix Timeout extend the functionality of their synchronous versions by introducing an additional parameter that specifies the number of system ticks before the operation times out.

If the timeout parameter is set to TIME_INFINITE, the behavior of these timeout-enabled functions mirrors their synchronous counterparts, effectively never timing out.

Setting the timeout parameter to TIME_IMMEDIATE modifies the function to only process data immediately available in the stream, without waiting. This means as example that a get function will return a character only if it is already available in the input stream. Similarly if a write is required to push 20 characters in the output stream but only 8 spots are available, it will push the first 8 characters and return.

For operations where a specific timeout duration is desirable, the API requires this duration in system ticks. Convenient helper macros are available for converting seconds, milliseconds, and microseconds, respectively, into system ticks, facilitating easy timeout specification.

/**
 * @brief   Seconds to time interval.
 * @details Converts from seconds to system ticks number.
 * @note    The result is rounded upward to the next tick boundary.
 * @note    Use of this macro for large values is not secure because
 *          integer overflows, make sure your value can be correctly
 *          converted.
 *
 * @param[in] secs      number of seconds
 * @return              The number of ticks.
 *
 * @api
 */
#define TIME_S2I(secs)
/**
 * @brief   Milliseconds to time interval.
 * @details Converts from milliseconds to system ticks number.
 * @note    The result is rounded upward to the next tick boundary.
 * @note    Use of this macro for large values is not secure because
 *          integer overflows, make sure your value can be correctly
 *          converted.
 *
 * @param[in] msecs     number of milliseconds
 * @return              The number of ticks.
 *
 * @api
 */
#define TIME_MS2I(msecs)
/**
 * @brief   Microseconds to time interval.
 * @details Converts from microseconds to system ticks number.
 * @note    The result is rounded upward to the next tick boundary.
 * @note    Use of this macro for large values is not secure because
 *          integer overflows, make sure your value can be correctly
 *          converted.
 *
 * @param[in] usecs     number of microseconds
 * @return              The number of ticks.
 *
 * @api
 */
#define TIME_US2I(usecs)

To discern whether a thread has resumed due to the completion of an operation or because a timeout occurred, users can interpret the return values of the timeout functions.

sdPutTimeout

The sdPutTimeout function is an advanced variant of sdPut, designed to write a single byte to a SerialDriver with the option to specify a timeout.

/**
 * @brief   Direct write to a @p SerialDriver with timeout specification.
 * @note    This function bypasses the indirect access to the channel and
 *          writes directly on the output queue. This is faster but cannot
 *          be used to write to different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         the byte value to be written in the output queue
 * @param[in] t         the number of ticks before the operation timeouts,
 *                      the following special values are allowed:
 *                      - @a TIME_IMMEDIATE immediate timeout.
 *                      - @a TIME_INFINITE no timeout.
 *                      .
 * @return              The operation status.
 * @retval MSG_OK       if the operation succeeded.
 * @retval MSG_TIMEOUT  if the specified time expired.
 * @retval MSG_RESET    if the @p SerialDriver has been stopped.
 *
 * @api
 */
#define sdPutTimeout(sdp, b, t) oqPutTimeout(&(sdp)->oqueue, b, t)

Using TIME_INFINITE as the timeout parameter makes sdPutTimeout functionally identical to sdPut, indefinitely waiting to place the character in the output stream. Specifying a different timeout duration allows the function to potentially return before the character is enqueued, depending on the queue’s status and the timeout duration.

To determine the outcome of a call to sdPutTimeout, the return message provides clear feedback. The following example demonstrates how to effectively use the function’s return value to manage different execution outcomes.

/* Example for sdPutTimeout */
msg_t ret = sdPutTimeout(&SD5, 'A', TIME_MS2I(50));
if (ret == MSG_OK) {
  /* Operation was successful, character was transmitted */
}
else if (ret == MSG_TIMEOUT ) {
  /* Operation timed out */
}
else { /* ret == MSG_RESET */
  /* The Serial Driver has been stopped while the function was waiting */
}

sdGetTimeout

The sdGetTimeout function is a specialized variant of sdGet, designed for reading a single byte from a SerialDriver with a defined timeout period.

/**
 * @brief   Direct read from a @p SerialDriver with timeout specification.
 * @note    This function bypasses the indirect access to the channel and
 *          reads directly from the input queue. This is faster but cannot
 *          be used to read from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] t         the number of ticks before the operation timeouts,
 *                      the following special values are allowed:
 *                      - @a TIME_IMMEDIATE immediate timeout.
 *                      - @a TIME_INFINITE no timeout.
 *                      .
 * @return              A byte value from the input queue.
 * @retval MSG_TIMEOUT  if the specified time expired.
 * @retval MSG_RESET    if the @p SerialDriver has been stopped.
 *
 * @api
 */
#define sdGetTimeout(sdp, t) iqGetTimeout(&(sdp)->iqueue, t)

Setting the timeout parameter to TIME_INFINITE causes sdGetTimeout to behave similarly to sdGet, waiting indefinitely for a character to be available in the input stream. A different timeout value, however, allows the function to return earlier if a character is not received within the specified period, based on the input queue’s status and the specified timeout.

The outcome of a sdGetTimeout call can be determined by the return value, which provides clear feedback on whether the operation timed out or was successful. The example below illustrates how to use this return value to distinguish between different execution outcomes:

/* Example for sdGetTimeout */
msg_t ret = sdGetTimeout(&SD5, TIME_MS2I(50));
if (ret == MSG_TIMEOUT) {
  /* Operation timed out, no character received within 50ms */
} else {
  /* Operation was successful, character was received */
}

sdWriteTimeout

The sdWriteTimeout function enhances the capabilities of sdWrite, allowing for the transmission of a data buffer to a SerialDriver with an explicit timeout setting.

/**
 * @brief   Direct blocking write to a @p SerialDriver with timeout
 *          specification.
 * @note    This function bypasses the indirect access to the channel and
 *          writes directly to the output queue. This is faster but cannot
 *          be used to write to different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         pointer to the data buffer
 * @param[in] n         the maximum amount of data to be transferred, the
 *                      value 0 is reserved
 * @param[in] t         the number of ticks before the operation timeouts,
 *                      the following special values are allowed:
 *                      - @a TIME_IMMEDIATE immediate timeout.
 *                      - @a TIME_INFINITE no timeout.
 *                      .
 * @return              The number of bytes effectively transferred.
 *
 * @api
 */
#define sdWriteTimeout(sdp, b, n, t)                                        \
  oqWriteTimeout(&(sdp)->oqueue, b, n, t)

Utilizing TIME_INFINITE as the timeout parameter causes sdWriteTimeout to behave similarly to sdWrite, attempting to transmit the entire buffer without timing constraints. Specifying a different timeout, however, modifies the function’s behavior to potentially return before completing the transfer, dependent on the queue’s capacity and the timeout duration. The function will return the number of bytes effectively pushed in the output stream allowing to discern if the operation was successful or timed out before all characters could be transmitted.

/* Example for sdWriteTimeout */
const char *message = "Hello";
size_t ret = sdWriteTimeout(&SD5, (const uint8_t *)message, strlen(message), 
                               TIME_MS2I(50));
if (ret == strlen(message)) {
  /* Operation was successful, entire message was transmitted */
} else {
  /* Operation timed out before all characters could be transmitted */
}

sdReadTimeout

The sdReadTimeout function extends the functionality of sdRead, introducing the ability to read a specified amount of data into a buffer from a SerialDriver within a given timeout period.

/**
 * @brief   Direct blocking read from a @p SerialDriver with timeout
 *          specification.
 * @note    This function bypasses the indirect access to the channel and
 *          reads directly from the input queue. This is faster but cannot
 *          be used to read from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         pointer to the data buffer
 * @param[in] n         the maximum amount of data to be transferred, the
 *                      value 0 is reserved
 * @param[in] t         the number of ticks before the operation timeouts,
 *                      the following special values are allowed:
 *                      - @a TIME_IMMEDIATE immediate timeout.
 *                      - @a TIME_INFINITE no timeout.
 *                      .
 * @return              The number of bytes effectively transferred.
 *
 * @api
 */
#define sdReadTimeout(sdp, b, n, t) iqReadTimeout(&(sdp)->iqueue, b, n, t)

Using TIME_INFINITE makes the operation behave like sdRead, blocking indefinitely until the specified amount of data is received. Conversely, specifying a finite timeout alters the function’s behavior to return either when the specified amount of data has been received or when the timeout period has elapsed, whichever occurs first. The function will return the number of bytes effectively read from the input stream allowing to discern if the operation was successful or timed out before all characters could be transmitted.

/* Example for sdReadTimeout */
uint8_t buffer[20];
uint8_t n = 11
size_t ret = sdReadTimeout(&SD5, buffer, n, TIME_MS2I(50));
if (ret == n) {
  /* Operation was successful, at least some characters were received */
} 
else {
  /* Operation timed out without receiving any characters */
}

I-class API

The Serial Driver in ChibiOS/HAL also offers an I-Class version of its API, specifically designed for interrupt service routines.

/**
 * @brief   Direct write to a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          writes directly on the output queue. This is faster but cannot
 *          be used to write to different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         the byte value to be written in the output queue
 * @return              The operation status.
 * @retval MSG_OK       if the operation succeeded.
 * @retval MSG_TIMEOUT  if the queue is full.
 *
 * @iclass
 */
#define sdPutI(sdp, b) oqPutI(&(sdp)->oqueue, b)
/**
 * @brief   Direct read from a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          reads directly from the input queue. This is faster but cannot
 *          be used to read from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @return              A byte value from the input queue.
 * @retval MSG_TIMEOUT  if the queue is empty.
 *
 * @iclass
 */
#define sdGetI(sdp) iqGetI(&(sdp)->iqueue)
/**
 * @brief   Direct non-blocking write to a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          writes directly to the output queue. This is faster but cannot
 *          be used to write from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         pointer to the data buffer
 * @param[in] n         the maximum amount of data to be transferred, the
 *                      value 0 is reserved
 * @return              The number of bytes effectively transferred.
 *
 * @iclass
 */
#define sdWriteI(sdp, b, n) oqWriteI(&(sdp)->oqueue, b, n)
/**
 * @brief   Direct non-blocking read from a @p SerialDriver.
 * @note    This function bypasses the indirect access to the channel and
 *          reads directly from the input queue. This is faster but cannot
 *          be used to read from different channels implementations.
 *
 * @param[in] sdp       pointer to a @p SerialDriver object
 * @param[in] b         pointer to the data buffer
 * @param[in] n         the maximum amount of data to be transferred, the
 *                      value 0 is reserved
 * @return              The number of bytes effectively transferred.
 *
 * @iclass
 */
#define sdReadI(sdp, b, n) iqReadI(&(sdp)->iqueue, b, n)

These functions are non-blocking and return immediately, operating exclusively with data that is already available in the input queue or fitting into the output queue. Designed for use within interrupt service routines, these APIs ensure efficient data handling with minimal delay.

Conclusions

While the Serial Driver API provides a robust foundation for serial communication, it is apparent that its general-purpose design does not directly translate to specific high-level tasks, such as outputting formatted strings or implementing command-line interfaces. These operations often require additional layers of implementation of functionality beyond direct byte or buffer management.

Recognizing this gap, ChibiOS offers a series of libraries designed to build upon the Serial Driver API, introducing enhanced capabilities to fulfill these more sophisticated communication needs. These libraries offer tools for formatted output, parsing, and interaction, streamlining the development of user interfaces and complex data exchanges in embedded applications.

In our forthcoming articles, we will turn our focus to these advanced libraries, uncovering how they extend the Serial Driver’s basic communication framework to address tasks like formatted printing and CLI implementation.

Be the first to reply at ChibiOS/HAL’s Serial Driver Explained

Leave a Reply