ESP-IDF C++ SPI Driver

Create an ESP-IDF C++ SPI driver wrapper

SPI, or Serial Peripheral Interface, is a synchronous serial communication protocol. SPI communications are full-duplex in a master-slave architecture, with one master and one or more slaves. The slaves are selected with an ss pin. SPI uses more electrical connections than I2C, however, SPI has a significant speed advantage over I2C.

In this tutorial, we are going to create a wrapper class to simplify the use of SPI on the ESP32 device.

ESP-IDF SPI C++

Parts used in this tutorial

BME280 Module
NodeMCU32s
Breadboard
Breadboard Link Wires

More about SPI

The SPI standard has several parts to it:

  • Command This is the first bits send to the slave device. This determines what the slave device must do with the next bits or bytes. In the case of the BME280, the command part is only one 1 bit large and only indicates whether you intend to read or write from a specific register. Then, for example, the NRF24L01 uses 8 command bits.
  • Register This follows the command byte and represents the register where the data will be read to or read from. Some devices incorporate the address into the command bits. In that case, the register size should be set to zero.
  • Data This is followed by the register bits if used. This will be the data read from and written to depending on the command bits.

About this project

Unlike previous tutorials, we are not going to create a project that we can compile. We are going to create a component that we will be able to copy into the components folder of any future project that needs to use SPI.

Normally we would create the SPI component as part of another project that needs SPI so that we can compile and debug the library. I’m creating this tutorial in this way to have the SPI and I2C as separate tutorials.

The BME280 tutorial will go live at the same time as this SPI and the I2C tutorial to demonstrate how to use both SPI and I2C to read sensor data from the BME280 device.

Create the component

Folder and file structure

Create a folder named CPPSPI. Inside this folder create a text file named CMakeLists.txt as well as 2 folders called include and src. Inside both the src and include folders create a folder named CPPSPI, the same as the root folder name.

Now finally in the folder CPPSPI/include/CPPSPI create cppspi.h. In CPPSPI/src/CPPSPI create cppspi.cpp.

CPPSPI component folder structure
CPPSPI component folder structure

Configure CMakeLists.txt

Open up CMakeLists.txt in the root folder and add the following:

set(SOURCES src/CPPSPI/cppspi.cpp)
            
idf_component_register(SRCS ${SOURCES}
                    INCLUDE_DIRS include)

Now any project can use this component as long as EXTRA_COMPONENT_DIRS is set properly.

Define the C++ SPI class in the header file

Open cppspi.h in the include/CPPSPI folder and add the following code:

#pragma once

#include "driver/spi_common.h"
#include "driver/spi_master.h"

namespace CPPSPI
{
    class Spi
    {
    private:
        spi_bus_config_t _spi_bus_cfg{};
        spi_device_interface_config_t _spi_interface_cfg{};
        spi_device_handle_t _handle{};
        spi_host_device_t _spi_peripheral{};       
        
        spi_transaction_t _spi_transaction{};

        esp_err_t transferByte(const uint8_t reg_addr, const uint8_t data, const uint8_t command = 0);
        esp_err_t transferMultiplesBytes(const uint8_t reg_addr, uint8_t* tx_buf, uint8_t* rx_buf, size_t data_length, const uint8_t command = 0);

    public:
        esp_err_t Init(const spi_host_device_t spi_peripheral, const int pin_miso, const int pin_mosi, const int pin_sclk);
        esp_err_t RegisterDevice(const uint8_t mode, const int ss, const int addr_length, const int command_length, const int bus_speed = 1000);
        uint8_t ReadRegister(const uint8_t reg_addr, const uint8_t command = 0);
        esp_err_t WriteRegister(const uint8_t reg_addr, const uint8_t reg_data, const uint8_t command = 0);
        esp_err_t WriteRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command = 0);
        esp_err_t ReadRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command = 0);
        spi_device_handle_t GetHandle(void);

    }; // class Spi
} // namespace CPPSPI

Compared to some other classes, there is not much to this one… Let’s take a closer look at the parts.

#pragma once

#include "driver/spi_common.h"
#include "driver/spi_master.h"

namespace CPPSPI
{
    class Spi
    {

The normal include guard followed by the spi driver includes. We are not going to implement spi slave mode in this tutorial, we might expand this driver to include support for slave in a future tutorial.

I always add my own namespace, that way I can use any class and variable names without having to worry about variable name conflicts.

    private:
        spi_bus_config_t _spi_bus_cfg{};
        spi_device_interface_config_t _spi_interface_cfg{};
        spi_device_handle_t _handle{};
        spi_host_device_t _spi_peripheral{};
        
        spi_transaction_t _spi_transaction{};

We declare several private configuration variables here. Of special note is _spi_transaction. In the implementation, we also have a local variable named spi_transaction_multibyte. You may ask why one is created as a persistent variable and the other as a local variable that will move out of scope after each method call. The reason is convenience. This is an easy way to have ReadRegister return the read data as a byte.

        esp_err_t transferByte(const uint8_t reg_addr, const uint8_t data, const uint8_t command = 0);
        esp_err_t transferMultiplesBytes(const uint8_t reg_addr, uint8_t* tx_buf, uint8_t* rx_buf, size_t data_length, const uint8_t command = 0);

These are the two methods where all the communication happens. All the public methods wrap different ways to call these methods to get the desired results. Notice how there are now read or write methods, all of these are transfer methods, that is because SPI is full-duplex.

    public:
        esp_err_t Init(const spi_host_device_t spi_peripheral, const int pin_miso, const int pin_mosi, const int pin_sclk);
        esp_err_t RegisterDevice(const uint8_t mode, const int ss, const int addr_length, const int command_length, const int bus_speed = 1000);
        uint8_t ReadRegister(const uint8_t reg_addr, const uint8_t command = 0);
        esp_err_t WriteRegister(const uint8_t reg_addr, const uint8_t reg_data, const uint8_t command = 0);
        esp_err_t WriteRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command = 0);
        esp_err_t ReadRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command = 0);
        spi_device_handle_t GetHandle(void);

These are all the public methods available to the end-user. Most are self-explanatory from the method names, however, I want to talk about some of the methods;

  • Init this method sets up the global SPI settings. Here only the SPI module and physical pins are selected. You can look at this as setting up the master.
  • RegisterDevice Here you register a slave device to the master device. We can register multiple slave devices to the same master device, each with its own slave-specific settings. I’ll discuss the RegisterDevice method in more detail when we implement the method.

Implement the C++ SPI class in the source file

Open cppspi.cpp in the src/CPPSPI folder and add the following code:

#include "CPPSPI/cppspi.h"

namespace CPPSPI
{
    esp_err_t Spi::transferByte(const uint8_t reg_addr, const uint8_t data, const uint8_t command)
    {
        _spi_transaction.flags = SPI_TRANS_USE_RXDATA | SPI_TRANS_USE_TXDATA;
        _spi_transaction.cmd = command;
        _spi_transaction.length = 8;
        _spi_transaction.addr = reg_addr;
        _spi_transaction.tx_data[0] = data;

        return spi_device_transmit(_handle, &_spi_transaction);
    }

    esp_err_t Spi::transferMultiplesBytes(const uint8_t reg_addr, uint8_t* tx_buf, uint8_t* rx_buf, size_t data_length, const uint8_t command)
    {
        spi_transaction_t spi_transaction_multibyte;        // spi_transaction_t to use the tx and rx buffers

        if (data_length < 1) { data_length = 1; }

        spi_transaction_multibyte.flags = 0;
        spi_transaction_multibyte.length = (8*data_length);
        spi_transaction_multibyte.rxlength = 0;
        spi_transaction_multibyte.cmd = command;
        spi_transaction_multibyte.addr = reg_addr;
        spi_transaction_multibyte.tx_buffer = tx_buf;
        spi_transaction_multibyte.rx_buffer = rx_buf;        

        return spi_device_transmit(_handle, &spi_transaction_multibyte);
    }

    esp_err_t Spi::Init(const spi_host_device_t spi_peripheral, const int pin_miso, const int pin_mosi, const int pin_sclk)
    {
        esp_err_t status = ESP_OK;

        _spi_peripheral = spi_peripheral;

        _spi_transaction.tx_buffer = nullptr;
        _spi_transaction.rx_buffer = nullptr;

        _spi_bus_cfg.mosi_io_num = pin_mosi;
        _spi_bus_cfg.miso_io_num = pin_miso;
        _spi_bus_cfg.sclk_io_num = pin_sclk;
        _spi_bus_cfg.quadwp_io_num = -1;
        _spi_bus_cfg.quadhd_io_num = -1;

        status |= spi_bus_initialize(spi_peripheral, &_spi_bus_cfg, SPI_DMA_CH_AUTO);

        return status;
    }

    esp_err_t Spi::RegisterDevice(const uint8_t mode, const int ss, const int addr_length, const int command_length, const int bus_speed)
    {
        esp_err_t status = ESP_OK;

        _spi_interface_cfg = {};
        _spi_interface_cfg.command_bits = command_length; 
        _spi_interface_cfg.address_bits = addr_length;
        _spi_interface_cfg.mode = mode;
        _spi_interface_cfg.clock_speed_hz = bus_speed;
        _spi_interface_cfg.spics_io_num = ss; // IO pin 5 is my chip select
        _spi_interface_cfg.queue_size = 5;

        status |= spi_bus_add_device(_spi_peripheral, &_spi_interface_cfg, &_handle);

        return status;
    }

    uint8_t Spi::ReadRegister(const uint8_t reg_addr, const uint8_t command)
    {
        transferByte(reg_addr, 0, command);

        return _spi_transaction.rx_data[0];
    }

    esp_err_t Spi::WriteRegister(const uint8_t reg_addr, const uint8_t reg_data, const uint8_t command)
    {
        esp_err_t status{ESP_OK};

        status |= transferByte(reg_addr, reg_data, command);

        return status;
    }

    esp_err_t Spi::WriteRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command)
    {
        return transferMultiplesBytes(reg_addr, reg_data_buffer, nullptr, byte_count, command);
    }

    esp_err_t Spi::ReadRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command)
    {   
        return transferMultiplesBytes(reg_addr, nullptr, reg_data_buffer, byte_count, command);
    }

    spi_device_handle_t Spi::GetHandle(void)
    {
        return _handle;
    }

} // namespace CPPSPI

Let’s look at the methods.

    esp_err_t Spi::transferByte(const uint8_t reg_addr, const uint8_t data, const uint8_t command)
    {
        _spi_transaction.flags = SPI_TRANS_USE_RXDATA | SPI_TRANS_USE_TXDATA;
        _spi_transaction.cmd = command;
        _spi_transaction.length = 8;
        _spi_transaction.addr = reg_addr;
        _spi_transaction.tx_data[0] = data;

        return spi_device_transmit(_handle, &_spi_transaction);
    }

This method is used for single-byte transitions. This method uses the _spi_transaction private variable. The reason we use a private variable declared in the header is that it allows us to directly access _spi_transaction.rx_data[0] directly after calling this method.

For more information on the spi_transaction_t struct, please see the official ESP-IDF documentation here.

    esp_err_t Spi::transferMultiplesBytes(const uint8_t reg_addr, uint8_t* tx_buf, uint8_t* rx_buf, size_t data_length, const uint8_t command)
    {
        spi_transaction_t spi_transaction_multibyte;        // spi_transaction_t to use the tx and rx buffers

        if (data_length < 1) { data_length = 1; }

        spi_transaction_multibyte.flags = 0;
        spi_transaction_multibyte.length = (8*data_length);
        spi_transaction_multibyte.rxlength = 0;
        spi_transaction_multibyte.cmd = command;
        spi_transaction_multibyte.addr = reg_addr;
        spi_transaction_multibyte.tx_buffer = tx_buf;
        spi_transaction_multibyte.rx_buffer = rx_buf;        

        return spi_device_transmit(_handle, &spi_transaction_multibyte);
    }

Now with this method, I create a new spi_transaction_t type named spi_transaction_multibyte. You may be wondering why I do not just reuse _spi_transaction. I tried that initially and found that the SPI_TRANS_USE_RXDATA and SPI_TRANS_USE_TXDATA flags would not clear and this caused the method to fail. Creating a new transaction struct with the flags cleared solved the problem. It does mean I use a few extra bytes of memory, but I save significant resources with more efficient IO.

    esp_err_t Spi::Init(const spi_host_device_t spi_peripheral, const int pin_miso, const int pin_mosi, const int pin_sclk)
    {
        esp_err_t status = ESP_OK;

        _spi_peripheral = spi_peripheral;

        _spi_transaction.tx_buffer = nullptr;
        _spi_transaction.rx_buffer = nullptr;

        _spi_bus_cfg.mosi_io_num = pin_mosi;
        _spi_bus_cfg.miso_io_num = pin_miso;
        _spi_bus_cfg.sclk_io_num = pin_sclk;
        _spi_bus_cfg.quadwp_io_num = -1;
        _spi_bus_cfg.quadhd_io_num = -1;

        status |= spi_bus_initialize(spi_peripheral, &_spi_bus_cfg, SPI_DMA_CH_AUTO);

        return status;
    }

This method initializes the SPI on the ESP32. Here we select which SPI peripheral and which pins to use for SPI.

    esp_err_t Spi::RegisterDevice(const uint8_t mode, const int ss, const int addr_length, const int command_length, const int bus_speed)
    {
        esp_err_t status = ESP_OK;

        _spi_interface_cfg = {};
        _spi_interface_cfg.command_bits = command_length; 
        _spi_interface_cfg.address_bits = addr_length;
        _spi_interface_cfg.mode = mode;
        _spi_interface_cfg.clock_speed_hz = bus_speed;
        _spi_interface_cfg.spics_io_num = ss; // IO pin 5 is my chip select
        _spi_interface_cfg.queue_size = 5;

        status |= spi_bus_add_device(_spi_peripheral, &_spi_interface_cfg, &_handle);

        return status;
    }

This method is used to register slave devices to the SPI. Here we define all of the aspects of the slave device, except for the ss pin that the ESP32 will use, but again this pin will be used exclusively for this slave device.

The clock_speed_hz member might be misleading when reading the documentation. When I first read the documentation I understood it to be a divider which is incorrect. It’s much simpler, we can simply insert the desired SPI frequency in Hz.

    uint8_t Spi::ReadRegister(const uint8_t reg_addr, const uint8_t command)
    {
        transferByte(reg_addr, 0, command);

        return _spi_transaction.rx_data[0];
    }

    esp_err_t Spi::WriteRegister(const uint8_t reg_addr, const uint8_t reg_data, const uint8_t command)
    {
        esp_err_t status{ESP_OK};

        status |= transferByte(reg_addr, reg_data, command);

        return status;
    }

    esp_err_t Spi::WriteRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command)
    {
        return transferMultiplesBytes(reg_addr, reg_data_buffer, nullptr, byte_count, command);
    }

    esp_err_t Spi::ReadRegisterMultipleBytes(const uint8_t reg_addr, uint8_t* reg_data_buffer, const uint8_t byte_count, const uint8_t command)
    {   
        return transferMultiplesBytes(reg_addr, nullptr, reg_data_buffer, byte_count, command);
    }

These methods call transferByte or transferMultiplesBytes with varying combinations of arguments to provide the intended result. SPI communication only really needs these 4 methods.

    spi_device_handle_t Spi::GetHandle(void)
    {
        return _handle;
    }

This method returns the SPI handle. This is actually an artifact method left over from when I was debugging this driver, I decided to leave it in the driver.

Use the library

That’s it for this tutorial. To use this SPI driver simply copy it into the components folder of a project setup using the method described in this tutorial and include “CPPSPI/cppspi.h” in main.cpp.

For an example where this SPI library is used, please see ESP-IDF Weather Station with BME280 C++

This library is available with other components from my GitHub here.

Thanks for reading.

Similar Posts