Leveraging ChibiOS/HAL’s SPI for Real-Time Applications

Introduction

In this article, we explore the practical use of the Serial Peripheral Interface (often abbreviated SPI) with ChibiOS/HAL. The SPI, a synchronous, full-duplex communication protocol, is widely used in microcontroller data exchange, allowing for simultaneous data send and receive operations in a master-slave setup. Building on our previous guide, SPI 101: A beginner’s guide, we now focus on implementing SPI in real-world scenarios using ChibiOS/HAL.

The goal of this article is to clearly demonstrate how to set up and utilize SPI buses within ChibiOS/HAL, explaining how to handle multiple SPI instances. We will cover both synchronous and asynchronous APIs, providing real-life examples of their application.

Transport Layer and Communication Protocol

When discussing SPI, it’s easy to confuse the transport layer with the protocol that operates on top of it. The SPI transport layer establishes the physical connections and basic rules for data transfer between a host and a device. On the other hand, the protocol built on SPI handles higher-level interactions, such as data formats, command structures, and specific sequences for controlling devices. Grasping this distinction is crucial for understanding how SPI functions across different devices and systems.

The contrast between the transport layer and protocol layer in SPI communication

The SPI is a versatile and efficient bus, commonly used in MEMS, ADCs, and other devices. These devices usually act as slaves in a network, while the MCU assumes the master role. The MCU offers extensive configurability to accommodate different devices, whereas the communication protocol is defined by the device.

A typical protocol includes a Register Map and specific SPI sequences for reading and writing to device registers. This facilitates control and data transfer between the MCU and the device. Each register, having a unique function and address, allows for a variety of operations and configurations.

This article is dedicated to explaining the use of the ChibiOS/HAL API for managing the SPI transport layer. The implementation of an SPI communication protocol will be covered in a subsequent article, where we will expand upon the concepts introduced here to demonstrate communication with a real device: the suggestion is that readers should focus on using SPI as a means to exchange data between the host and devices, without delving into the significance of the data and its influence on the application at this stage.

Preparing to use the SPI

One MCU, Multiple SPI Peripherals

It is quite common in applications for an MCU to communicate with multiple devices via the SPI bus. To manage this, the bus can be time-divided between multiple devices using either Independent or Cooperative mode. However, when timing is critical, having access to multiple SPI buses is more advantageous.

To meet this need, modern microcontrollers feature multiple instances of the SPI peripheral. These peripherals are often supported by DMA to minimize CPU usage during data transfers. Each instance is essentially a duplicate, with separate control registers and interfacing the external world using distinct GPIOs. This design allows each instance to be operated independently and simultaneously through software.

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

Modern microcontrollers, therefore, offer multiple SPI interfaces, and the software API we will be using provides a mechanism to select which SPI interface we intend to configure and operate. This raises a common question among newcomers: if all SPI interfaces are essentially the same and equally accessible, which one should be used? The answer to this depends on various factors.

Pickings an SPI

If you are using a device that’s already connected to the MCU (e.g. a MEMS on your evaluation kit that’s linked to the MCU through PCB traces), then your choice depends on the board’s schematic. In this case, you should consult the schematic to identify which MCU pins the device connects to, and then refer to the MCU’s manual to determine which SPI can be internally rerouted to those pins.

A MAX32625 connected with jump wires to an ADXL355

If the device you plan to use is on an external board and you need to wire device and host together, you should first review the schematic of your evaluation kit to locate the free GPIOs. Essentially, these are GPIOs not already connected to any device that could interfere with push-pull communication. After identifying a list of available GPIOs, consult the MCU’s manual again to find four GPIOs (among the free ones) that can be rerouted to an SPI interface.

This process is somewhat simpler if your evaluation kit includes an Arduino Uno connector. This is because this connector is always designed to route an SPI interface to it.

Arduino Uno pinout diagram highlighting analog, power, and digital connector assignments including SPI, I2C and UART

As the previous image shows, pins D10 to D13 are always connected to an SPI peripheral of the MCU.

Pin assignment

We’ve mentioned GPIO rerouting several times, a concept introduced earlier in our discussion about the Port Abstraction Layer of ChibiOS/HAL. MCUs possess numerous internal peripherals that require pins for external interfacing. Since internal lines outnumber available pins, MCUs provide ways to internally reroute multiple lines to a single pin. This configurability, typically managed by the GPIO peripheral, is made accessible in software through the PAL driver or Board files.

In our upcoming code examples, we will use the SDP-K1 board, which features an STM32F469 MCU and connects SPI1 to the Arduino connector.

The Pinout map of the Analog Devices EVAL-SDP-CKZ1 (SDP-K1)

As detailed previously, each Arduino pin is physically connected to an STM32 GPIO. The pins from D10 to D13 can be rerouted to SPI1 if they are configured in Alternate Mode 5. The following table summarizes this, derived from cross-referencing the SDP-K1 and datasheet of the STM32F469NI.

Arduino Connector labelSTM32 GPIO labelAlternate FunctionFunction
ARD_D10PA15AF5SPI Chip Select
ARD_D11PA7AF5SPI1 Master Output Slave Input
ARD_D12PB4AF5SPI1 Master Input Slave Output
ARD_D13PB3AF5SPI1 Clock
SPI pin map for the SDP-K1

Later, we’ll see that the CS (Chip Select) can be handled in software mode, requiring configuration of that pin as a normal digital output line. The other pins need to be set in Alternate Mode, and for optimal SPI speed, it’s advisable to set the output speed to the highest rate. To use SPI1 of the SDP-K1 on these pins, we need to reconfigure them as follows:

  palSetLineMode(LINE_ARD_D10, PAL_MODE_OUTPUT_PUSHPULL);
  palSetLineMode(LINE_ARD_D11, PAL_MODE_ALTERNATE(5) | PAL_STM32_OSPEED_HIGHEST);
  palSetLineMode(LINE_ARD_D12, PAL_MODE_ALTERNATE(5) | PAL_STM32_OSPEED_HIGHEST);
  palSetLineMode(LINE_ARD_D13, PAL_MODE_ALTERNATE(5) | PAL_STM32_OSPEED_HIGHEST);

By now, you should know which SPI, on which pins, and with what PAL configuration you are going to use. This information is crucial for the upcoming steps.

Enabling the SPI driver

ChibiOS/HAL provides a wide range of different device drivers, each of which has an impact on resources in terms of code size, allocated memory, and CPU usage. However, not every application requires all of these drivers. For this reason, ChibiOS/HAL is designed with a modular approach. Each driver can be individually enabled or disabled in the halconf.h configuration file.

In many default ChibiOS demos, the two drivers often enabled are PAL and Serial. These demos typically involve tasks like blinking LEDs and printing test suite results over a serial connection. If you start with one of these demos, it’s highly likely that you’ll need to adjust your halconf.h file. The first half of this file contains a long list of switches, each with the same naming format (i.e. HAL_USE_SOMETHING). If a specific switch is set to TRUE, it means that the entire codebase related to that driver will be included in the final build. These switches are primarily organized alphabetically (except for HAL_USE_PAL, which is usually at the top of the list since it’s commonly used in most applications).

/**
 * @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 SPI subsystem.
 */
#if !defined(HAL_USE_SPI) || defined(__DOXYGEN__)
#define HAL_USE_SPI                         FALSE
#endif
// ... (other driver switches) ...

If the HAL_USE_SPI switch is not enabled, the API of the SPI driver will not be available to the compiler. This will result in a series of errors, making it seem as if the SPI API is not defined at all.

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

If we take a closer look to the previous screenshot, it becomes evident that the build process halted when the compiler attempted to compile line 87. This halt occurred because, with the SPI subsystem disabled, both the structure SPIConfig and the driver SPID1 cannot be found by the compiler. Depending on the sequence of your code and your compiler settings, you may encounter various error messages when a subsystem is disabled. However, errors related to undeclared functions or missing structures that should be present (assuming there are no typos in your code) should immediately serve as a clear indicator: you’ve forgotten to enable the driver in halconf.h

Assigning Peripherals to the SPI Driver

Once we have enabled the SPI driver, the next step is to assign one or more instances of the peripheral to it. When a driver is assigned a peripheral, it gains full control over that peripheral. In other words, the driver will handle the Interrupt Service Routines of that specific peripheral and take care of configuring the hardware when we use its APIs.

By default, no peripheral is assigned to a driver. This approach is primarily for resource optimization reasons. Assigning a peripheral creates a new global variable representing that specific driver instance. While this provides more flexibility, it also increases codebase and RAM consumption, along with requiring extra lines of code to initialize the object and the ISR.

There is another crucial reason behind this approach: many hardware peripherals can serve multiple functions. For example, on the STM32 microcontroller, the SPI peripheral can also operate as an I2S bus. These two functionalities are mutually exclusive. Therefore, if we assign an SPI peripheral instance to the SPI Driver, we cannot simultaneously assign it to the I2S Driver.

All the configurations related to peripheral assignment are highly MCU-specific. Each MCU has a different count of SPI instances, the way SPI is connected to the DMA may vary, and even the position of the ISR in the vector table could be different. Consequently, these configurations are contained in the mcuconf.h file. This file is specific to the MCU used in the evaluation kit.

So, if we open the mcuconf.h of a project for the SDP-K1, it will start by clearly highlighting the family and the specific part number of the MCU it refers to.

/*
 * 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.

/*
 * SPI driver system settings.
 */
#define STM32_SPI_USE_SPI1                  FALSE
#define STM32_SPI_USE_SPI2                  FALSE
#define STM32_SPI_USE_SPI3                  FALSE
#define STM32_SPI_USE_SPI4                  FALSE
#define STM32_SPI_USE_SPI5                  FALSE
#define STM32_SPI_USE_SPI6                  FALSE
#define STM32_SPI_SPI1_RX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 0)
#define STM32_SPI_SPI1_TX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 3)
#define STM32_SPI_SPI2_RX_DMA_STREAM        STM32_DMA_STREAM_ID(1, 3)
#define STM32_SPI_SPI2_TX_DMA_STREAM        STM32_DMA_STREAM_ID(1, 4)
#define STM32_SPI_SPI3_RX_DMA_STREAM        STM32_DMA_STREAM_ID(1, 0)
#define STM32_SPI_SPI3_TX_DMA_STREAM        STM32_DMA_STREAM_ID(1, 7)
#define STM32_SPI_SPI4_RX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 0)
#define STM32_SPI_SPI4_TX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 1)
#define STM32_SPI_SPI5_RX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 3)
#define STM32_SPI_SPI5_TX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 4)
#define STM32_SPI_SPI6_RX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 6)
#define STM32_SPI_SPI6_TX_DMA_STREAM        STM32_DMA_STREAM_ID(2, 5)
#define STM32_SPI_SPI1_DMA_PRIORITY         1
#define STM32_SPI_SPI2_DMA_PRIORITY         1
#define STM32_SPI_SPI3_DMA_PRIORITY         1
#define STM32_SPI_SPI4_DMA_PRIORITY         1
#define STM32_SPI_SPI5_DMA_PRIORITY         1
#define STM32_SPI_SPI6_DMA_PRIORITY         1
#define STM32_SPI_SPI1_IRQ_PRIORITY         10
#define STM32_SPI_SPI2_IRQ_PRIORITY         10
#define STM32_SPI_SPI3_IRQ_PRIORITY         10
#define STM32_SPI_SPI4_IRQ_PRIORITY         10
#define STM32_SPI_SPI5_IRQ_PRIORITY         10
#define STM32_SPI_SPI6_IRQ_PRIORITY         10
#define STM32_SPI_DMA_ERROR_HOOK(spip)      osalSysHalt("DMA failure")

As we can see, this specific MCU has up to six SPI instances. The configuration allows us to assign each one of them to the SPI Driver (STM32_SPI_USE_SPIx) and configure which DMA streams are used for each peripheral, along with DMA and IRQ priorities. In most cases, these configurations related to DMA streams and priorities are suitable for generic applications. However, in specific cases where timing is critical, we may need to delve deeper into these configurations.

What’s great about ChibiOS/HAL is that it works out of the box with minimal configurations. Still, it offers deep configuration options in case you need to handle special cases. However, our current focus is on assigning peripherals to the driver. For example, when using the SDP-K1, we would need to set:

/*
 * SPI driver system settings.
 */
#define STM32_SPI_USE_SPI1                  TRUE
#define STM32_SPI_USE_SPI2                  FALSE
#define STM32_SPI_USE_SPI3                  FALSE
#define STM32_SPI_USE_SPI4                  FALSE
#define STM32_SPI_USE_SPI5                  FALSE

Once we assign SPI1 to the SPI Driver, a new global structure becomes available:

/** @brief SPI1 driver identifier.*/
#if STM32_SPI_USE_SPI1 || defined(__DOXYGEN__)
SPIDriver SPID1;
#endif

This object represents the instance of the SPI peripheral in software, and any operations performed on this object will affect the SPI1 peripheral. The association is straightforward: if we would enable STM32_SPI_USE_SPI4, then the driver SPID4 will be made available.

It’s important to note that if the SPI 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 SPI 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: “SPI driver activated but no SPI peripheral assigned.” This situation typically indicates that either the SPI 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.

SPI API Overview

Now that we have enabled the driver, assigned peripherals, and rerouted the internal connections, we can focus on the specific APIs that the SPI driver offers. All the functions exported by the driver require, as their first parameter, a pointer to the SPIDriver in use. This parameter allows the function to distinguish which specific hardware instance it should control. This approach follows an object-oriented style within a procedural language like C.

In this context, the SPIDriver can be thought of as an object that implements a state machine, and the functions exported by the SPIDriver can be seen as methods of the object itself.

For example, the following fictitious API performs an operation on two different hardware peripherals:

/* Performing some operation on SPI1. */
spiDoSomething(&SPID1);
/* Performing some operation on SPI5. */
spiDoSomething(&SPID5);

To provide a more concrete understanding, let’s examine the state machine, which is adapted from the official ChibiOS documentation with a touch of PLAY Embedded style.

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

The state machine highlights five possible states for an SPIDriver:

  • SPI_UNINIT: This is the default state of the driver when it is not initialized at all. This state automatically changes when we call halInit() with the SPI Driver enabled and the peripheral assigned.
  • SPI_STOP: The driver is stopped. The clock tree does not feed the SPI peripheral, and the hardware is completely disabled, putting the SPI in low-power mode.
  • SPI_READY: The driver is ready for operation. The clock tree is enabled, and the SPI cell is configured for operation. However, no data exchange is happening at the moment.
  • SPI_ACTIVE: The driver is currently engaged in data exchange.
  • SPI_COMPLETE: An asynchronous operation has been completed. This is a temporary state that triggers a callback, which will ultimately bring the driver back to the ready or active state.

Understanding these states is essential for effectively using the SPI driver’s APIs and managing the SPI communication within your application.

Activating and Deactivating the SPI

Let’s explore the first set of SPI APIs, which includes spiStart and spiStop. These APIs are used to configure and deconfigure the SPI Driver, and they play a crucial role in transitioning the driver between the SPI_STOP and SPI_READY states, as indicated in the state machine.

spiStart

The spiStart API is used to configure the SPI 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 activates the SPI peripheral.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] config            pointer to the @p SPIConfig object
 * @return                      The operation status.
 *
 * @api
 */
msg_t spiStart(SPIDriver *spip, const SPIConfig *config);

As example, this is how it would look enabling and configuring the SPI Driver 1:

/* Example: enabling and configuring the SPI Driver 1. */
spiStart(&SPID1, &my_spicfg);

Newcomers often encounter challenges with the spiStart API, not because of the API itself, but due to the complexity of the configuration structure it requires. The structure’s format varies depending on the microcontroller in use and some configuration settings in halconf. 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.

The spiStart function can be called multiple times within our code, as it serves the purpose of both enabling the driver and reconfiguring it. For instance, if we intend to use the same SPI peripheral with two different devices, we may need to call spiStart to reconfigure the SPI before each transaction.

Here’s an example illustrating how to enable and configure SPI Driver 1 for communication with Device A, followed by communication with Device B:

/* Example: Enabling and configuring SPI Driver 1 for communication with Device A. */
spiStart(&SPID1, &deva_spicfg);
/* Exchanging data with Device A. */
spiSelect(&SPID1);
spiStartExchange(&SPID1, 1, deva_txbuf, deva_rxbuf);
spiUnselect(&SPID1);
...
/* Example: Enabling and configuring SPI Driver 1 for communication with Device B. */
spiStart(&SPID1, &devb_spicfg);
/* Sending data to Device B. */
spiSelect(&SPID1);
spiStartExchange(&SPID1, 1, devb_txbuf, devb_rxbuf);
spiUnselect(&SPID1);

spiStop

The spiStop API is the counterpart of spiStart and is used to deactivate the SPI peripheral. It’s relatively simple to use:

/**
 * @brief   Deactivates the SPI peripheral.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @api
 */
void spiStop(SPIDriver *spip);
/* Example: stopping the SPI Driver 1. */
spiStop(&SPID1);

This API only requires a pointer to the driver to deactivate the associated peripheral and put it in low-power mode. It should not be called while the driver is operating, and you should either wait for the exchange to finish or forcibly abort the operation. Calling spiStop when the driver is already stopped will have no effect.

The SPI configuration

The SPI configuration is a structure that you provide to spiStart to enable and configure the driver. The format of this configuration structure depends on halconf.h, and some of its fields depend on the platform in use, making it challenging for newcomers to understand. Here are some tips and tricks for approaching SPI configuration:

  • ChibiOS includes demos for the HAL drivers stored under testhal. The best approach is to go through the demo for your platform and examine the proposed SPI configuration. Most of the time, the only things you need to change are some bitfields in the low-level configuration to adjust the SPI mode (CPOL and CPHA) and the SPI clock speed.
  • For STM32 platforms, the demos can be a bit trickier as they come with a unified demo under testhal\STM32\multi\SPI. The configuration structures for these platforms can be found in portab.c in the cfg folder.

Here’s an example snippet of configuration from the file testhal\STM32\multi\SPI\cfg\stm32f407_discovery\portab.c.

/*
 * Maximum speed SPI configuration (21MHz, CPHA=0, CPOL=0, MSb first).
 */
const SPIConfig hs_spicfg = {
  .circular         = false,
  .slave            = false,
  .data_cb          = NULL,
  .error_cb         = spi_error_cb,
  .ssport           = GPIOB,
  .sspad            = 12U,
  .cr1              = 0U,
  .cr2              = 0U
};

This configuration is valid for all STM32F4 platforms. For a deeper understanding of the fields in this configuration, you can refer to Appendix A at the end of this article.

Synchronous API

Once the SPI is enabled and configured using spiStart, we are ready to perform data exchange. The most common way to exchange data over SPI is synchronously, which means that when a thread triggers the data exchange, it gets suspended until the communication is completed.

Synchronous data exchange makes sense, especially when the SPI is not used in circular mode (this can be achieved by setting the circular field to false in the SPIConfig structure). The following code snippet provides an example of a data exchange involving 4 words

/* Transmission and receiving data buffers */
static uint8_t txbuf[4];
static uint8_t rxbuf[4];
...
/* Data exchange. */
spiSelect(&SPID1);
spiExchange(&SPID1, 4, txbuf, rxbuf);
spiUnselect(&SPID1);

Let’s take a closer look at the API involved in this call.

spiSelect and spiUnselect

The spiSelect and spiUnselect are two APIs used to assert and de-assert the slave select signal (CS) in SPI communication. They are particularly useful when the user needs to have manual control over when the CS line is lowered and raised.

/**
 * @brief   Asserts the slave select signal and prepares for transfers.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @api
 */
void spiSelect(SPIDriver *spip);
/**
 * @brief   Deasserts the slave select signal.
 * @details The previously selected peripheral is unselected.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @api
 */
void spiUnselect(SPIDriver *spip);

In SPI communication, it is essential to assert and de-assert the CS line at the beginning and end of the communication with a specific slave. The CS line serves as an enable line in negated logic.

Example of an SPI communication frame.: the Chip Select denotes the begin and the end of the frame.

Most modern microcontrollers allow for automatic control of lowering and raising the CS line at the start and end of the communication cycle. However, there are situations where the user wants full control over the CS line, separating its management from the communication itself. For example, the slave device may have a low-power mode that disables the SPI interface, and to wake it up, a CS cycle and some delay may be required. This can be easily achieved using these APIs in combination with thread sleep. Another scenario is when the slave requires special framing, such as multiple exchanges with precise timing, where the CS line must remain low for the entire duration.

To gain full control over the CS line, the SPI_SELECT_MODE needs to be configured as SPI_SELECT_MODE_PAD, SPI_SELECT_MODE_PORT, or SPI_SELECT_MODE_LINE in the halconf. Additionally, the CS line should be configured as a normal GPIO in output push-pull mode.

In this configuration, spiSelect lowers the CS line, and spiUnselect raises it. These APIs require nothing more than a pointer to the driver because the driver knows which line is associated with CS from the configuration structure provided during the spiStart function (parameters ssport, sspad, ssmask, ssline of the SPIConfig).

Another advantage of driving the CS as a GPIO is that you can choose any pin with GPIO capability for this purpose. Finally, some STM32 pins can behave as self-driven CS. When configured in the associated alternate function mode, the SPI cell gains full control of this pin, automatically lowering it when spiExchange is called and raising it when the operation completes.

spiExchange

The spiExchange is the primary API for performing synchronous data exchange over an SPI bus. It takes four parameters: the driver in use, the number of words to be exchanged, and two pointers, one for the transmission buffer and one for the receive buffer.

/**
 * @brief   Exchanges data on the SPI bus.
 * @details This synchronous function performs a simultaneous transmit/receive
 *          operation.
 * @pre     In order to use this function the option @p SPI_USE_SYNCHRONIZATION
 *          must be enabled.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to be exchanged
 * @param[in] txbuf             the pointer to the transmit buffer
 * @param[out] rxbuf            the pointer to the receive buffer
 * @return                      The operation status.
 * @retval MSG_OK               if operation completed without errors.
 * @retval MSG_TIMEOUT          if synchronization timed out.
 * @retval MSG_RESET            if the transfer has been stopped.
 *
 * @api
 */
msg_t spiExchange(SPIDriver *spip, size_t n,
                 const void *txbuf, void *rxbuf)

The parameter n represents the number of words to be exchanged. It’s important to note that the word size can often be configurable, depending on the microcontroller in use. For instance, some MCUs have a default word size of 8 bits, but modern MCUs allow configurability of the word size. As an example, the STM32F4 series permits selecting a word size of 8 or 16 bits by configuring the Data Frame Format field in the control register 1. Therefore, the size of the word in use depends on how you configured the SPI driver during the initialization.

Both the transmit and receive buffers should be large enough to accommodate n words. If the word size is 8 bits and you intend to exchange 12 words, the expectation is that the two buffers are declared as follows:

static uint8_t txbuf[12], rxbuf[12];

However, the driver doesn’t impose strict restrictions on how you declare your buffers, as long as they have enough space. Therefore, the following declarations can also work for the example presented earlier:

static uint16_t txbuf[6], rxbuf[6];
static uint32_t txbuf[3], rxbuf[3];

If we decide to go this way, accessing individual words in these buffers may become more complex in C, so it’s advisable to keep it simple.

Regarding the synchronous nature of the spiExchange function, it suspends the calling thread until the operation is complete. If the SPI driver is configured in non-circular mode, after exchanging n words, the thread will be set as ready and eventually resumed. If the SPI is set in circular mode, the thread will never wake up unless another part of the software aborts the operation or stops the SPI driver. The latter scenario is uncommon, and if you find yourself in such a situation, it may be worth reconsidering your design to find a more efficient way to use the API.

Since SPI is full-duplex, while sending a word, one is received in return. This brings us to the concept of exchange: it’s essential for the user to prepare the transmission buffer before calling spiExchange. When the spiExchange function returns, the receiving buffer will be populated with the data received from the slave device.

spiSend and spiReceive

ChibiOS offers two additional APIs, spiSend and spiReceive, for sending or receiving data over the SPI bus. They are defined as follows:

/**
 * @brief   Sends data over the SPI bus.
 * @details This synchronous function performs a transmit operation.
 * @pre     In order to use this function the option @p SPI_USE_SYNCHRONIZATION
 *          must be enabled.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to send
 * @param[in] txbuf             the pointer to the transmit buffer
 * @return                      The operation status.
 * @retval MSG_OK               if operation completed without errors.
 * @retval MSG_TIMEOUT          if synchronization timed out.
 * @retval MSG_RESET            if the transfer has been stopped.
 *
 * @api
 */
msg_t spiSend(SPIDriver *spip, size_t n, const void *txbuf);
/**
 * @brief   Receives data from the SPI bus.
 * @details This synchronous function performs a receive operation.
 * @pre     In order to use this function the option @p SPI_USE_SYNCHRONIZATION
 *          must be enabled.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to receive
 * @param[out] rxbuf            the pointer to the receive buffer
 * @return                      The operation status.
 * @retval MSG_OK               if operation completed without errors.
 * @retval MSG_TIMEOUT          if synchronization timed out.
 * @retval MSG_RESET            if the transfer has been stopped.
 *
 * @api
 */
msg_t spiReceive(SPIDriver *spip, size_t n, void *rxbuf);

It’s important to note that even though these APIs are labeled as “send” and “receive,” the SPI communication remains full duplex. When you use spiSend, it still performs an exchange at the low level, but the received data is intentionally ignored. Similarly, when you use spiReceive, it sends some data during the operation, but it sends out a dummy word, typically composed of all ones or zeros, depending on the low-level driver implementation.

Like other SPI-related functions, these APIs are synchronous. They will suspend the calling thread until the operation is completed. However, spiSend and spiReceive open up interesting use cases that we will explore further in the examples section.

spiIgnore

The spiIgnore API allows you to ignore data on the SPI bus by performing the transmission of a series of idle words while disregarding the received data. It is a synchronous function designed for situations where the communication protocol requires the transmission of idle words, typically mandated by the slave device for specific reasons.

/**
 * @brief   Ignores data on the SPI bus.
 * @details This synchronous function performs the transmission of a series of
 *          idle words on the SPI bus and ignores the received data.
 * @pre     To use this function, the option @p SPI_USE_SYNCHRONIZATION
 *          must be enabled.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to be ignored
 * @return                      The operation status.
 * @retval MSG_OK               if the operation completed without errors.
 * @retval MSG_TIMEOUT          if synchronization timed out.
 * @retval MSG_RESET            if the transfer has been stopped.
 *
 * @api
 */
msg_t spiIgnore(SPIDriver *spip, size_t n);

As the API is synchronous, the calling thread will be suspended for the entire transaction. Use spiIgnore when your SPI communication protocol necessitates the transmission of idle words, as specified by the slave device.

Asynchronous API

In certain scenarios, suspending a thread during SPI communication may not be desirable, and ChibiOS offers an Asynchronous API to address this. When using this API, SPI operations are initiated without suspending the thread, allowing for the execution of additional operations in parallel. One clear example of such a scenario is when the SPI is configured to operate in circular mode. In circular mode, the SPI continuously sends data from the transmission buffer while simultaneously filling the receiving buffer. In such cases, suspending the thread during this activity may not be practical.

There are special cases where continuous circular reads are essential. For instance, when reading data from an external ADC connected via SPI while the ADC is sampling, or when feeding an audio codec writing over SPI. But, what is the benefit of circularly exchanging data if the received data in the transmission buffer is not consumed or if the data on the transmission buffer remains unchanged? The answer lies in the application’s ability to interact with the buffers using the data callback set during the SPI configuration (the data_cb field of SPIConfig). On platforms like STM32, in circular mode, this callback is triggered twice per loop: once at the halfway point of the buffer and again at the end of the buffer. This allows the application to continuously process real-time activities while the SPI exchange is ongoing.

The asynchronous API also makes sense even when circular mode is disabled. For example, if you need to trigger two SPI buses simultaneously in the same thread, the asynchronous API can be invaluable. The operation callback is triggered once, when the entire exchange is completed, and at that point, the SPI is stopped. In this scenario, the data_cb can be utilized to de-assert the slave devices and initiate data processing. The Asynchronous API provides various modes to accommodate real-world scenarios effectively. Detailed examples of its usage will be provided in the following chapter.

spiStartExchange, spiStartSend and spiStartReceive

The Asynchronous API provides flexibility in SPI communication by initiating operations without suspending the calling thread. These APIs, namely spiStartExchange, spiStartSend, and spiStartReceive, have the same format as their synchronous counterparts, but they differ in that they do not suspend the calling thread during the operation.

/**
 * @brief   Exchanges data on the SPI bus.
 * @details This asynchronous function starts a simultaneous transmit/receive
 *          operation.
 * @pre     A slave must have been selected using @p spiSelect() or
 *          @p spiSelectI().
 * @post    At the end of the operation the configured callback is invoked.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to be exchanged
 * @param[in] txbuf             the pointer to the transmit buffer
 * @param[out] rxbuf            the pointer to the receive buffer
 * @return                      The operation status.
 *
 * @api
 */
msg_t spiStartExchange(SPIDriver *spip, size_t n,
                      const void *txbuf, void *rxbuf);
/**
 * @brief   Sends data over the SPI bus.
 * @details This asynchronous function starts a transmit operation.
 * @pre     A slave must have been selected using @p spiSelect() or
 *          @p spiSelectI().
 * @post    At the end of the operation the configured callback is invoked.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to send
 * @param[in] txbuf             the pointer to the transmit buffer
 * @return                      The operation status.
 *
 * @api
 */
msg_t spiStartSend(SPIDriver *spip, size_t n, const void *txbuf);
/**
 * @brief   Receives data from the SPI bus.
 * @details This asynchronous function starts a receive operation.
 * @pre     A slave must have been selected using @p spiSelect() or
 *          @p spiSelectI().
 * @post    At the end of the operation the configured callback is invoked.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to receive
 * @param[out] rxbuf            the pointer to the receive buffer
 * @return                      The operation status.
 *
 * @api
 */
msg_t spiStartReceive(SPIDriver *spip, size_t n, void *rxbuf);

One important point to note is that these asynchronous APIs cannot be immediately followed by a call to spiUnselect. Doing so would raise the Chip Select line in the middle of the SPI communication, disrupting the framing of the SPI transaction.

Additionally, these APIs, as they are non-blocking, are suitable to be called from Interrupt Service Routines (ISRs). This is almost true, in reality in ChibiOS, API functions that are safe to be called from ISR context have an “I” prefix. This leads us to three more APIs:

/**
 * @brief   Exchanges data on the SPI bus.
 * @details This asynchronous function starts a simultaneous transmit/receive
 *          operation.
 * @pre     A slave must have been selected using @p spiSelect() or
 *          @p spiSelectI().
 * @post    At the end of the operation the configured callback is invoked.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to be exchanged
 * @param[in] txbuf             the pointer to the transmit buffer
 * @param[out] rxbuf            the pointer to the receive buffer
 * @return                      The operation status.
 *
 * @iclass
 */
msg_t spiStartExchangeI(SPIDriver *spip, size_t n,
                       const void *txbuf, void *rxbuf);
/**
 * @brief   Sends data over the SPI bus.
 * @details This asynchronous function starts a transmit operation.
 * @pre     A slave must have been selected using @p spiSelect() or
 *          @p spiSelectI().
 * @post    At the end of the operation the configured callback is invoked.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to send
 * @param[in] txbuf             the pointer to the transmit buffer
 * @return                      The operation status.
 *
 * @iclass
 */
msg_t spiStartSendI(SPIDriver *spip, size_t n, const void *txbuf);
/**
 * @brief   Receives data from the SPI bus.
 * @details This asynchronous function starts a receive operation.
 * @pre     A slave must have been selected using @p spiSelect() or
 *          @p spiSelectI().
 * @post    At the end of the operation the configured callback is invoked.
 * @note    The buffers are organized as uint8_t arrays for data sizes below
 *          or equal to 8 bits else it is organized as uint16_t arrays.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] n                 number of words to receive
 * @param[out] rxbuf            the pointer to the receive buffer
 * @return                      The operation status.
 *
 * @iclass
 */
msg_t spiStartReceiveI(SPIDriver *spip, size_t n, void *rxbuf);

These ISR-safe APIs can be particularly useful when calling them in the data callback function, as the data callback typically executes in ISR context, often triggered by a DMA operation completion. This enables the initiation of back-to-back SPI transactions in real-time scenarios.

The Asynchronous API provides versatility for handling SPI communication in both non-blocking and ISR contexts, catering to a wide range of application requirements.

spiStopTransfer and spiAbort

When working with asynchronous SPI operations, especially in circular mode, it becomes essential to have an API that allows you to halt ongoing operations. This is where the spiStopTransfer API comes into play.

/**
 * @brief   Stops the ongoing SPI operation, if any.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[out] sizep            pointer to the counter of frames not yet
 *                              transferred or @p NULL
 * @return                      The operation status.
 *
 * @api
 */
msg_t spiStopTransfer(SPIDriver *spip, size_t *sizep);

The purpose of spiStopTransfer is to terminate any ongoing SPI transfer and provide information about the number of frames that have not yet been transferred. This API is also available in an I-class version, spiStopTransferI, which is intended for use in Interrupt Service Routines (ISRs) or from within SPI callbacks.

/**
 * @brief   Stops the ongoing SPI operation.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[out] sizep            pointer to the counter of frames not yet
 *                              transferred or @p NULL
 * @return                      The operation status.
 *
 * @iclass
 */
msg_t spiStopTransferI(SPIDriver *spip, size_t *sizep);

For compatibility with older versions of the SPI driver, spiStopTransfer has been redefined as spiAbort. The spiAbort API serves the same purpose as spiStopTransfer, allowing you to halt ongoing SPI operations. However, it does not provide information about the non-transferred frames.

/**
 * @brief   Compatibility API with SPI driver v1.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @iclass
 */
#define spiAbortI(spip)         spiStopTransferI(spip, NULL)
/**
 * @brief   Compatibility API with SPI driver v1.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @api
 */
#define spiAbort(spip)          spiStopTransfer(spip, NULL)

Operation complete callback

One of the mandatory fields in the SPI configuration provides the option to assign a data transfer operation complete callback. This callback is particularly useful when working with the asynchronous API. It is a void-returning function that receives the SPIDriver itself as a parameter, allowing you to perform various operations such as raising the chip select, stopping the transfer, or scheduling the next one.

void my_spi_datacb(SPIDriver *spip) {
  /* Your operations here. */
}
const SPIConfig hs_spicfg = {
  /* Other fields. */
  .data_cb          = my_spi_datacb,
  /* Other fields. */
};

On certain platforms like the STM32, when the SPI is configured in circular mode, this callback is triggered twice for each iteration: once at half transfer and once at the end of it. This means that if a transfer of 512 words was initiated, the callback will be triggered every 256 words exchanged. Therefore, when taking action in the callback, the user should be able to understand the current state of the transfer. This leads to the following API, designed for ISR context and specifically for this purpose. The spiIsBufferComplete macro checks if the SPI state is SPI_COMPLETE or not.

/**
 * @brief   Buffer state.
 * @note    This function is meant to be called from the SPI callback only.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @return                      The buffer state.
 * @retval false                if the driver filled/sent the first half of
 *                              the buffer.
 * @retval true                 if the driver filled/sent the second half of
 *                              the buffer.
 *
 * @special
 */
#define spiIsBufferComplete(spip) ((bool)((spip)->state == SPI_COMPLETE))

Additionally, this callback also allows for Chip Select management using the I-Class versions of spiSelect and spiUnselect. These macros enable you to assert and deassert the slave select signal, respectively.

/**
 * @brief   Asserts the slave select signal and prepares for transfers.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @iclass
 */
#define spiSelectI(spip)
/**
 * @brief   Deasserts the slave select signal.
 * @details The previously selected peripheral is unselected.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @iclass
 */
#define spiUnselectI(spip)

These features make the operation complete callback a powerful tool for managing asynchronous SPI transfers and handling Chip Select signals in an efficient manner.

spiSynchronize

In certain scenarios, after initiating an SPI operation asynchronously to perform a rapid sequence of operations, the application may need to re-synchronize the calling thread with the ongoing SPI operation. This is where the spiSynchronize API comes into play, and it is described in the following section.

/**
 * @brief   Synchronizes with current transfer completion.
 * @note    This function can only be called by a single thread at time.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 * @param[in] timeout           synchronization timeout
 * @return                      The synchronization result.
 * @retval MSG_OK               if operation completed without errors.
 * @retval MSG_TIMEOUT          if synchronization timed out.
 * @retval MSG_RESET            if the transfer has been stopped.
 *
 * @api
 */
msg_t spiSynchronize(SPIDriver *spip, sysinterval_t timeout);

Calling this API will suspend the calling thread until the ongoing SPI operation is completed or until the specified timeout is reached. The timeout should be specified in system ticks, but the RTOS provides some convenient configuration helpers.

If you want to wait indefinitely until the completion of the operation, you can use the TIME_INFINITE option:

/**
 * @brief   Infinite interval specification for all functions with a timeout
 *          specification.
 * @note    Not all functions accept @p TIME_INFINITE as timeout parameter,
 *          see the specific function documentation.
 */
#define TIME_INFINITE       ((sysinterval_t)-1)

For specifying a timeout in milliseconds, you can use the TIME_MS2I macro, which converts milliseconds to the corresponding number of system ticks:

 /**
 * @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) 

Examples of how to use spiSynchronize in practical synchronization scenarios will be covered in the examples section.

Sharing an SPI bus across threads

In some cases, due to hardware limitations or design requirements, the same SPI bus may be used to drive different devices. A natural choice in this situation is to handle each device with a separate thread. However, a potential issue arises when multiple threads attempt to use the same SPI bus simultaneously, leading to conflicts and communication breakdowns.

This is a classic scenario of a race condition, and ChibiOS provides a solution for handling it using mutexes, as explained in Avoiding Race Conditions in ChibiOS/RT: a guide to Mutex. If the SPI_USE_MUTUAL_EXCLUSION switch is enabled in halconf.h, the SPI driver offers two APIs that internally use a mutex to lock and unlock the bus.

/**
 * @brief   Gains exclusive access to the SPI bus.
 * @details This function tries to gain ownership to the SPI bus, if the bus
 *          is already being used then the invoking thread is queued.
 * @pre     In order to use this function the option @p SPI_USE_MUTUAL_EXCLUSION
 *          must be enabled.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @api
 */
void spiAcquireBus(SPIDriver *spip);
/**
 * @brief   Releases exclusive access to the SPI bus.
 * @pre     In order to use this function the option @p SPI_USE_MUTUAL_EXCLUSION
 *          must be enabled.
 *
 * @param[in] spip              pointer to the @p SPIDriver object
 *
 * @api
 */
void spiReleaseBus(SPIDriver *spip);

In the next chapter, we will demonstrate how to use these APIs to safely share the same SPI bus among multiple threads. This ensures that only one thread can access the SPI bus at a time, preventing conflicts and ensuring smooth communication.

Practical Examples

Reading a MEMS register synchronously using the spiExchange

To provide more context for the spiExchange function, let’s consider an example of communication with a device where we want to read a register. In this case, we will exchange two 8-bit words to achieve the desired communication. The following example illustrates such a communication scenario with the ADXL355 MEMS accelerometer over SPI:

Signal diagram illustrating the sequence for reading a register from an ADXL355 accelerometer using SPI communication.

How does it work? We send the first word which consists of a command that informs the slave device that we want to read a specific register. As we are in an exchange, we simultaneously receive a word back, but this initial word is meaningless and can be ignored since the slave doesn’t yet understand our intentions. The second word is used to receive the actual data from the register.

Here is the code that demonstrates how an exchange to read such a register would look:

/* Buffer declaration. */
uint8_t txbuf[2], rxbuf[2];
/* Preparing the first word with the command to read the REVID register. */
txbuf[0] = (ADXL355_AD_DEVID_AD << 1) | ADXL355_RW;
  
spiSelect(&SPID1);
spiExchange(&SPID1, 2, txbuf, rxbuf);
spiUnselect(&SPID1);
  
/* The content of the register is in the second word received back in the exchange. */
chprintf(chp, "The register value is 0x%02X", rxbuf[1]);

This code initializes two buffers, txbuf and rxbuf, to exchange data with the MEMS accelerometer. It prepares the first word with the command to read the register Device ID, initiates the SPI communication, and retrieves the register value from the second word received in the exchange.

To complete the understanding of this example, let’s take a look at how the SPI communication appears on an oscilloscope.

In the SPI 101 article, we highlighted the inherent delay between the Chip Select going low and the start of the first clock cycle. This oscilloscope screenshot exemplifies that delay. Specifically, we see a lag of approximately 2.2µs from the frame start to the first clock pulse, and it takes nearly 11µs for CS to go high after communication ceases. The SPI transaction’s clock speed is set to 6.25MHz, which is 160µs per clock cycle, and the total communication duration is only 2.56µs, accentuating the CS to SCLK delay. However, the reader should focus on the fact that during this exchange, two 8-bit words are transferred back-to-back without any interbyte delay. This is expected since the transaction is backed by DMA, which completes the transfer in one go and only triggers an interrupt after the entire 16-clock cycle sequence.

Reading a MEMS register synchronously using spiSend and spiReceive

In this example, we will implement the same scenario as before, where we read a register from the ADXL355. However, this time we will use the spiSend and spiReceive functions instead of spiExchange. This approach allows us to explicitly handle the communication steps without needing to ignore or send dummy words during the exchange.

Here’s the code to achieve this:

/* Buffer declaration. */
uint8_t txbuf, rxbuf;
/* Preparing the first word with the command to read the REVID register. */
txbuf = (ADXL355_AD_DEVID_AD << 1) | ADXL355_RW;
  
spiSelect(&SPID1);
spiSend(&SPID1, 1, &txbuf);
spiReceive(&SPID1, 1, &rxbuf);
spiUnselect(&SPID1);
  
/* The content of the register is in the second word received back in the exchange. */
chprintf(chp, "The register value is 0x%02X", rxbuf);

In this code, we declare a single-byte buffer for both transmission (txbuf) and reception (rxbuf). We prepare the first byte with the command to read the desired register and use the spiSend function to send it. Then, we use the spiReceive function to receive the response byte.

One advantage of this method is that we can use smaller buffers since we don’t need to account for dummy bytes. The communication frame is still correct due to the software handling of the chip select. However, depending on the driver implementation and hardware specifics, this method may introduce some interbyte delay because the two words are not exchanged atomically; they result from two separate DMA transactions.

For a visual representation of the SPI communication using spiSend and spiReceive, refer to the oscilloscope shots below:

In this detailed examination, we zoom in to discern the nuances of the SPI communication. The outcome is identical to the previous example where we used the spiExchange, but the underlying process is markedly different due to the utilization of separate APIs for sending and receiving data. Here, we execute two distinct transactions. The delay between the first and second word is apparent; the system needs to handle the interrupt generated by the DMA upon completion of the first word, resume the thread (post spiSend), and initiate the subsequent transaction (spiReceive). This second call internally prepares and activates the DMA for the new data transfer.

The reason both methods yield the same result lies in the framing: the slave device interprets the two operations as related because the CS remains low throughout the entire sequence.

If this communication were conducted in polling mode without DMA support, we would likely observe a similar pattern with a significant interbyte delay due to the time required by the software to handle interrupt requests after each word exchange.

Circular SPI example

In certain scenarios, it’s necessary to continuously read from or transmit data over the SPI bus to stream information between a microcontroller and a device in real-time. One common use case involves dealing with an audio codec connected over SPI, where the microcontroller must provide a continuous clock and data stream using the CLK and MOSI lines.

Let’s break down a fictitious application that illustrates the concept of circular SPI communication. At the heart of this application is the data callback function.

#include "ch.h"
#include "hal.h"
/* SPI Transmission buffer. */
static uint8_t txbuf[512];
/* Event source for buffer handling. */
static event_source_t bufferEvent;
/* Callback function for SPI in circular mode. */
void spi_circular_cb(SPIDriver *spip) {
  if (spiIsBufferComplete(spip)) {
    /* Signal event to handle the second half of the buffer. */
    chEvtBroadcastFlags(&bufferEvent, EVENT_MASK(1));
  } else {
    /* Signal event to handle the first half of the buffer. */
    chEvtBroadcastFlags(&bufferEvent, EVENT_MASK(0));
  }
}
/* SPI error callback. */
void spi_error_cb(SPIDriver *spip) {
  (void)spip;
  chSysHalt("SPI error");
}
/* SPI Configuration. */
const SPIConfig spicfg = {
  .circular         = true, 
  .slave            = false,
  .data_cb          = spi_circular_cb,
  .error_cb         = spi_error_cb,
  .ssport           = GPIOD,
  .sspad            = 14U,
  .cr1              = SPI_CR1_BR_1 | SPI_CR1_BR_0,
  .cr2              = 0U
};
/* Buffer handling thread. */
static THD_WORKING_AREA(waThread, 128);
static THD_FUNCTION(Thread, arg) {
  (void)arg;
  while (true) {
    eventmask_t events = chEvtWaitAny(ALL_EVENTS);
    if (events & EVENT_MASK(0)) {
      /* Handle first half of the buffer. */
      prepareAndSetBuffer(txbuf, 0, 256);
    }
    if (events & EVENT_MASK(1)) {
      /* Handle second half of the buffer. */
      prepareAndSetBuffer(txbuf, 256, 256);
    }
  }
}
/* Main function. */
int main(void) {
  halInit();
  chSysInit();
  /* Initialize the event source. */
  chEvtObjectInit(&bufferEvent);
  /* Start the buffer handling thread. */
  chThdCreateStatic(waThread, sizeof(waThread), NORMALPRIO, Thread, NULL);
  /* Other initializations and SPI start. */
  setBufferFirstTime(txbuf, 512);
  spiStart(&SPID1, &spicfg);
  spiSelect(&SPID1);
  spiStartSend(&SPID1, 512, txbuf);
  while (true) {
    palToggleLine(LINE_LED_GREEN);
    chThdSleepMilliseconds(500);
  }
}

In this application, the main function initiates an asynchronous send of 512 words after fully populating the transmission buffer. It then enters the main loop, where its primary task is to blink an LED. From this point onwards, the application relies on the data callback and a second thread to fill the buffer in a timely manner.

The STM32 microcontroller generates two interrupts per cycle: one at the half-buffer point and another at the end of the buffer transmission. When the interrupt request occurs at the half-buffer point, it indicates that the SPI is beginning to send the second half of the buffer. This presents an opportunity for us to populate the first half of the buffer with fresh data. Conversely, when the IRQ occurs at the end of the buffer, it signifies that the SPI is starting to transmit the first half of the buffer, prompting us to prepare and populate the second half in advance.

The SPI in circular mode: data callbacks and buffer segments being prepared and transmitted simultaneously.

In our application, the second thread waits for an event to occur. Depending on the event, it decides which half-buffer to prepare and fill. These events are triggered by the SPI callback, effectively directing the producer thread.

The challenge of continuous transmission lies in the fact that this thread must be capable of preparing the half-buffer faster than the time it takes for the SPI to send that half. Failure to meet this timing requirement can result in a loss of real-time performance.

Triggering two SPI busses at the same time

Imagine a scenario where the system needs to independently trigger two devices of the same type simultaneously. To achieve this, we can trigger two SPI buses at the same time or as close to simultaneously as possible. Asynchronous communication plays a key role in solving this problem.

The following example illustrates this concept:

#include "ch.h"
#include "hal.h"
/* SPI Transmission buffers for two devices. */
static uint8_t txbuf1[1];
static uint8_t txbuf2[1];
/* SPI Configuration for the first device (SPID1). */
const SPIConfig spicfg1 = {
  .circular         = false,
  .slave            = false,
  .ssport           = GPIOD,   // Chip Select port for SPID1
  .sspad            = 14U,     // Chip Select pad for SPID1
  .cr1              = SPI_CR1_BR_1 | SPI_CR1_BR_0,
  .cr2              = 0U
};
/* SPI Configuration for the second device (SPID2). */
const SPIConfig spicfg2 = {
  .circular         = false,
  .slave            = false,
  .ssport           = GPIOE,   // Chip Select port for SPID2
  .sspad            = 10U,     // Chip Select pad for SPID2
  .cr1              = SPI_CR1_BR_1 | SPI_CR1_BR_0,
  .cr2              = 0U
};
/* Main function. */
int main(void) {
  /* System initialization. */
  halInit();
  chSysInit();
  /* Starting the SPI drivers for the two devices. */
  spiStart(&SPID1, &spicfg1);
  spiStart(&SPID2, &spicfg2);
  while (true) {
    /* Prepare the transmission buffers with a trigger command. */
    txbuf1[0] = TRIGGER_CMD;  // Command for device 1
    txbuf2[0] = TRIGGER_CMD;  // Command for device 2
    
    /* Selecting SPI devices to start transmission. */
    spiSelect(&SPID1);
    spiSelect(&SPID2);
    /* Start sending data over the two SPIs. */
    spiStartSend(&SPID1, 1, txbuf1);  // Send on SPI1
    spiStartSend(&SPID2, 1, txbuf2);  // Send on SPI2
    
    /* Wait for the transmission to complete on both SPIs. */
    spiSynchronize(&SPID1);
    spiSynchronize(&SPID2);
    
    /* Release the SPI devices after transmission. */
    spiUnselect(&SPID1);
    spiUnselect(&SPID2);
    
    /* Read back or process data should go here, a delay is added for simplicity. */
    chThdSleepMilliseconds(10);
  }
}

In this example, we assume that triggering a device involves sending a special byte to the device, which then triggers the internal ADC of the device. Asynchronous SPI communication helps send the two trigger commands with minimal delay. However, after triggering the SPIs, the thread needs to wait for the SPI activities to raise the chip selects and proceed with reading the data acquired. This is where the spiSynchronize function comes into play.

Sharing the same SPI between two threads

In some scenarios, your system may have limited pins, and only one SPI interface is available. However, you need to communicate with two different SPI devices independently. To handle this situation, you can use the same SPI interface from two different threads. Each thread takes care of one device, and the devices are connected in independent mode, sharing the MISO, MOSI, and Clock lines but having dedicated Chip Select lines.

To ensure proper communication across the shared SPI interface, you need to protect it using the spiAcquireBus and spiReleaseBus functions. Additionally, since the two devices may have different configurations, the thread needs to reconfigure the SPI interface every time it acquires the bus using spiStart.

Here’s an example of how to handle this situation:

#include "ch.h"
#include "hal.h"
/* SPI Transmission and Receiving buffers for two devices. */
static uint8_t txbuf[2], rxbuf[2];
/* SPI Configuration for the first device (Device 1). */
const SPIConfig spicfg1 = {
  .circular         = false,
  .slave            = false,
  .ssport           = GPIOD,   // Chip Select port for Device 1
  .sspad            = 14U,     // Chip Select pad for Device 1
  .cr1              = SPI_CR1_BR_1 | SPI_CR1_BR_0,
  .cr2              = 0U
};
/* SPI Configuration for the second device (Device 2). */
const SPIConfig spicfg2 = {
  .circular         = false,
  .slave            = false,
  .ssport           = GPIOE,   // Chip Select port for Device 2
  .sspad            = 10U,     // Chip Select pad for Device 2
  .cr1              = SPI_CR1_BR_2 | SPI_CR1_BR_0,
  .cr2              = 0U
};
/* Thread function for handling Device 1. */
static THD_WORKING_AREA(waThread1, 128);
static THD_FUNCTION(Thread1, arg) {
  (void)arg;
  while (true) {
    /* Preparing the txbuf. */
    prepareCommand(txbuf);
    /* Acquire the SPI bus and configure it for Device 1. */
    spiAcquireBus(&SPID1);
    
    spiStart(&SPID1, &spicfg1);
    spiSelect(&SPID1);
    /* Perform SPI communication for Device 1. */
    spiExchange(&SPID1, 2, txbuf, rxbuf);
    /* Release the SPI bus. */
    spiUnselect(&SPID1);
    spiReleaseBus(&SPID1);
    /* Here, the data resulting from the
       previous transaction should be processed. */
    chThdSleepMilliseconds(500);
  }
}
/* Thread function for handling Device 2. */
static THD_WORKING_AREA(waThread2, 128);
static THD_FUNCTION(Thread2, arg) {
  (void)arg;
  while (true) {
    
    /* Preparing the txbuf. */
    prepareCommand(txbuf);
    /* Acquire the SPI bus and configure it for Device 2. */
    spiAcquireBus(&SPID1);
    spiStart(&SPID1, &spicfg2);
    spiSelect(&SPID1);
    /* Perform SPI communication for Device 2. */
    spiExchange(&SPID1, 2, txbuf, rxbuf);
    /* Release the SPI bus. */
    spiUnselect(&SPID1);
    spiReleaseBus(&SPID1);
    /* Here, the data resulting from the
       previous transaction should be processed. */
    chThdSleepMilliseconds(500);
  }
}
/* Main function. */
int main(void) {
  /* System initialization. */
  halInit();
  chSysInit();
  /* Starting the threads for handling two devices. */
  chThdCreateStatic(waThread1, sizeof(waThread1), NORMALPRIO, Thread1, NULL);
  chThdCreateStatic(waThread2, sizeof(waThread2), NORMALPRIO, Thread2, NULL);
  while (true) {
    /* Main thread operation. */
    chThdSleepMilliseconds(1000);
  }
}

Please note that to minimize scheduling impact, it’s crucial to keep mutual exclusion zones as concise as possible. Additionally, data preparation and post-processing should occur outside of the mutual exclusion zones.

Conclusions

In conclusion, this article has demonstrated the effective utilization of the SPI in ChibiOS for real-time, multithreaded applications. We have explored how the ChibiOS/HAL’s API facilitates SPI usage, enhancing data handling in multitasking systems. This understanding is invaluable for developers who aspire to create resilient and responsive embedded systems using ChibiOS and SPI.

Appendix A: In-depth view of the SPI configuration

The SPI configuration is a crucial aspect of working with SPI in ChibiOS, and it can be found in the “hal_spi_v2.h” header file located under “os\hal\include.” This configuration structure consists of two parts:

  1. Mandatory Fields: These are generic fields common across different platforms.
  2. Low-Level Driver Configuration Fields: These fields are platform-specific and are defined by the platform driver.

Here is a snippet that provides an overview of the SPI configuration structure: the mandatory fields are listed and visible while the LLD configuration fields are represented by the spi_lld_config_fields macro, macro that is defined in the platform driver.

/**
 * @brief   Type of a SPI driver configuration structure.
 */
typedef struct hal_spi_config SPIConfig;
/**
 * @brief   Driver configuration structure.
 */
struct hal_spi_config {
#if (SPI_SUPPORTS_CIRCULAR == TRUE) || defined(__DOXYGEN__)
  /**
   * @brief   Enables the circular buffer mode.
   */
  bool                      circular;
#endif
#if (SPI_SUPPORTS_SLAVE_MODE == TRUE) || defined(__DOXYGEN__)
  /**
   * @brief   Enables the slave mode.
   */
  bool                      slave;
#endif
  /**
   * @brief   Operation data callback or @p NULL.
   */
  spicb_t                   data_cb;
  /**
   * @brief   Operation error callback or @p NULL.
   */
  spicb_t                   error_cb;
#if (SPI_SELECT_MODE == SPI_SELECT_MODE_LINE) || defined(__DOXYGEN__)
  /**
   * @brief   The chip select line.
   * @note    Only used in master mode.
   */
  ioline_t                  ssline;
#elif SPI_SELECT_MODE == SPI_SELECT_MODE_PORT
  /**
   * @brief   The chip select port.
   * @note    Only used in master mode.
   */
  ioportid_t                ssport;
  /**
   * @brief   The chip select port mask.
   * @note    Only used in master mode.
   */
  ioportmask_t              ssmask;
#elif SPI_SELECT_MODE == SPI_SELECT_MODE_PAD
  /**
   * @brief   The chip select port.
   * @note    Only used in master mode.
   */
  ioportid_t                ssport;
  /**
   * @brief   The chip select pad number.
   * @note    Only used in master mode.
   */
  uint_fast8_t              sspad;
#endif
  /* End of the mandatory fields.*/
  spi_lld_config_fields;
};

The mandatory fields are influenced by the capabilities of the SPI driver and the “halconf” file. Let’s discuss the meaning of some of these fields:

NameInfluence byTypeDescription
circularSPI_SUPPORTS_CIRCULAR (LLD)booleanA flag indicating whether the SPI should operate in circular mode. This setting is only available if the particular SPI implementation supports DMA circular transactions. In circular mode, the SPI operates continuously until manually stopped. It continuously sends data from a transmission buffer and fills a receiving buffer, treating both buffers as circular arrays. This allows for uninterrupted and continuous data transfer.
slaveSPI_SUPPORTS_SLAVE_MODE (LLD)booleanA flag that indicates whether the SPI should operate as a slave. This setting is only possible if the low-level driver supports the slave mode.
data_cbspicb_t or NULLA callback that is executed every time a data transfer has been completed. Typically, this callback occurs at the end of the entire SPI exchange, but its behavior depends on the hardware implementation. For example, on STM32, this callback could be called twice, once in the middle of the transaction and once at the end of it. This callback is particularly useful when operating the SPI in circular mode or asynchronous mode. If the user does not intend to use this callback, they can set the field to NULL.
error_cbspicb_t or NULL
A callback that is executed every time the SPI encounters an error. Some hardware SPI implementations provide interrupts when an error occurs during a transaction. This callback gives the application an opportunity to handle such errors.
sslineSPI_SELECT_MODE (halconf.h)ioline_tThe line associated with the chip select. This field is available only if SPI_SELECT_MODE is set to SPI_SELECT_MODE_LINE in the halconf.h.
ssportSPI_SELECT_MODE (halconf.h)ioportid_tThe port associated with the chip select. This field is available only if SPI_SELECT_MODE is set to SPI_SELECT_MODE_PORT or SPI_SELECT_MODE_PAD in the halconf.h.
ssmaskSPI_SELECT_MODE (halconf.h)ioportmask_tThe mask associated with the chip select. This field is available only if SPI_SELECT_MODE is set to SPI_SELECT_MODE_PORT in the halconf.h. In this mode, the SPI is configured to potentially select multiple chip selects on the same port, but this mode has very niche applications.
sspadSPI_SELECT_MODE (halconf.h)uint_fast8_tThe mask associated with the chip select. This field is available only if SPI_SELECT_MODE is set to SPI_SELECT_MODE_PAD in the halconf.h. In this mode, the chip select is chosen with a combination of port and pad instead of using a dedicated line.
The list of the mandatory fields of the SPI Config

Before delving into the low-level driver fields, let’s take a moment to understand the two-layer structure used by HAL:

  • The high-level layer defines an API that contains common functionality across different platforms.
  • The low-level driver layer implements the specific functionality of the driver for the platform in use.

The high-level layer internally calls the low-level driver. However, there can be multiple low-level driver implementations, each tailored to a different platform. The application includes the appropriate platform layer via the makefile, ensuring that the API remains consistent across platforms. To accommodate hardware-specific details, the configuration structure with its hardware-specific fields comes into play.

When dealing with the low-level driver fields, the challenge is to understand what they represent for a given platform. While you can obtain a sample configuration from the demo in testhal, finding the header where these fields are defined and accessing some documentation requires a bit of navigation. In the following example, I’ll use the SDP-K1 demo to illustrate how to locate the header file containing these fields. We’ll start by examining the makefile of the demo to determine which platform it includes.

##############################################################################
# Project, target, sources and paths
#
# Define project name here
PROJECT = ch
# Target settings.
MCU  = cortex-m4
# Imported source files and paths.
CHIBIOS  := ../../..
CONFDIR  := ./cfg
BUILDDIR := ./build
DEPDIR   := ./.dep
# Licensing files.
include $(CHIBIOS)/os/license/license.mk
# Startup files.
include $(CHIBIOS)/os/common/startup/ARMCMx/compilers/GCC/mk/startup_stm32f4xx.mk
# HAL-OSAL files (optional).
include $(CHIBIOS)/os/hal/hal.mk
include $(CHIBIOS)/os/hal/ports/STM32/STM32F4xx/platform.mk
include $(CHIBIOS)/os/hal/boards/ADI_EVAL_SDP_CK1Z/board.mk
include $(CHIBIOS)/os/hal/osal/rt-nil/osal.mk
# RTOS files (optional).
include $(CHIBIOS)/os/rt/rt.mk
include $(CHIBIOS)/os/common/ports/ARMv7-M/compilers/GCC/mk/port.mk
# Auto-build files in ./source recursively.
include $(CHIBIOS)/tools/mk/autobuild.mk
# Other files (optional).
include $(CHIBIOS)/os/test/test.mk
include $(CHIBIOS)/test/rt/rt_test.mk
include $(CHIBIOS)/test/oslib/oslib_test.mk
# Define linker script file here
LDSCRIPT= $(STARTUPLD)/STM32F469xI.ld

Among these inclusions, there is also the HAL platform inclusion, which, as expected, is related to the STM32F4 platform. This inclusion is important as it provides the necessary HAL files for the STM32F4 platform.

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

To get a list of all the drivers included in the STM32F4 platform, you would need to open the HAL platform file for STM32F4. This file typically contains references to various drivers and configurations specific to the STM32F4 platform. You can explore this file to see which drivers are included and configured for the platform.

# Required platform files.
PLATFORMSRC := $(CHIBIOS)/os/hal/ports/common/ARMCMx/nvic.c \
               $(CHIBIOS)/os/hal/ports/STM32/STM32F4xx/stm32_isr.c \
               $(CHIBIOS)/os/hal/ports/STM32/STM32F4xx/hal_lld.c  \
               $(CHIBIOS)/os/hal/ports/STM32/STM32F4xx/hal_efl_lld.c
# Required include directories.
PLATFORMINC := $(CHIBIOS)/os/hal/ports/common/ARMCMx \
               $(CHIBIOS)/os/hal/ports/STM32/STM32F4xx
# Optional platform files.
ifeq ($(USE_SMART_BUILD),yes)
# Configuration files directory
ifeq ($(HALCONFDIR),)
  ifeq ($(CONFDIR),)
    HALCONFDIR = .
  else
    HALCONFDIR := $(CONFDIR)
  endif
endif
HALCONF := $(strip $(shell cat $(HALCONFDIR)/halconf.h | egrep -e "\#define"))
else
endif
# Drivers compatible with the platform.
include $(CHIBIOS)/os/hal/ports/STM32/LLD/ADCv2/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/CANv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/CRYPv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/DACv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/DMAv2/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/EXTIv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/GPIOv2/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/I2Cv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/MACv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/OTGv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/QUADSPIv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/RTCv2/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/SPIv1/driver_v2.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/SDIOv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/SYSTICKv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/TIMv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/USARTv1/driver.mk
include $(CHIBIOS)/os/hal/ports/STM32/LLD/xWDGv1/driver.mk
# Shared variables
ALLCSRC += $(PLATFORMSRC)
ALLINC  += $(PLATFORMINC)

And here we can see that the driver we are looking for is

include $(CHIBIOS)/os/hal/ports/STM32/LLD/SPIv1/driver_v2.mk

From here, it’s straightforward to determine that the header file for the low-level SPI driver for the STM32F4 is located at os\hal\ports\STM32\LLD\SPIv1\hal_spi_v2_lld.h. Upon inspecting this file and conducting a keyword search for spi_lld_config_fields, we can locate the relevant configuration fields.

/**
 * @brief   Low level fields of the SPI configuration structure.
 */
#define spi_lld_config_fields                                               \
  /* SPI CR1 register initialization data.*/                                \
  uint16_t                  cr1;                                            \
  /* SPI CR2 register initialization data.*/                                \
  uint16_t                  cr2

When it comes to programming the SPI, especially for the STM32F4 microcontroller, it’s essential to refer to the device’s Reference Manual, which provides detailed information on how to manipulate the SPI registers. If you’re not familiar with using bitmasks, I highly recommend reading the article on Registers and bitmask. Utilizing bitmasks to compose the values of these registers in your code makes it more understandable and maintainable.

The bitmasks related to the MCU registers are typically found in what are known as CMSIS headers. These headers are provided by the vendor and are included by ChibiOS/HAL. You can locate these headers under the “os\common\ext” directory. Fortunately, the article on bitmasks provides an example of how to modify bitfields using bitmasks, using the SPI CR1 register of the STM32F4 as an illustrative example, which can greatly simplify your coding tasks.

Now, let’s explore the configurability that these fields should expose, which largely depends on the hardware (HW). In the case of the STM32F4, here are some of the most commonly used configurations:

  1. SPI Mode: This involves setting Clock Polarity and Clock Phase (CPOL, CPHA) to determine the SPI mode.
  2. Baud Rate: This configuration allows you to control the speed of the SPI clock, crucial for data transfer rates.
  3. Endianness: You can specify whether the SPI should transmit the most significant or least significant bit first, depending on your requirements.
  4. Data Size: This setting defines the size of each word transmitted over the SPI, which is essential for correctly interpreting data.

Additionally, these registers may contain specific bits that need to be accessed and modified at the right time to perform transactions. For example, the SPE bit enables the SPI cell and triggers the DMA. Importantly, these low-level bits are internally managed by the driver, so you don’t need to set them explicitly. If you attempt to do so, the driver will automatically handle them, ensuring correct operation.

One final note pertains to configuring the Baud rate of the SPI, which is typically achieved through a prescaler. This allows you to determine the prescaler value for the peripheral clock during a specific transaction. However, understanding this configuration can be challenging, as the peripheral clock depends on the overall clock tree configuration. To navigate this complexity, I previously authored an article titled ARM Cortex clock tree 101: Navigating clock domains, which provides both theoretical insights and practical examples, particularly focusing on the SPI configuration for the STM32F4 microcontroller.

Be the first to reply at Leveraging ChibiOS/HAL’s SPI for Real-Time Applications

Leave a Reply