ESP32 SPI Master Slave Communication with ESP-IDF

In this tutorial, we will learn about SPI communication and how to perform master slave communication between two ESP32 development boards using ESP-IDF. We will use SPI Slave and SPI Master drivers to demonstrate SPI master (Host) and SPI slave (Device) communication between each other.

Before we move ahead, make sure you have the latest version of VS Code installed on your system with the ESP-IDF extension configured.

SPI Communication Introduction

SPI communication also knows as Serial Peripheral Interface, is a serial full duplex and synchronous interface. Both the master and the slave can exchange data with each other simultaneously Therefore, it requires a click signal for the transmission and reception of data. This clock signal is synchronized between both the master and the slave. In this communication protocol, the clock signal plays an important role as it controls the transmission and reception of data at both sides. The Host starts SPI transmissions over the bus, and acts as an SPI Master. It has control over the clock and thereby provides a clock signal to all slave devices connected with it. Data transmission is dependent on the clock signal and can not occur without it. Moreover, data exchange can occur between both the master and the slave.

SPI Connection Between Two Devices

For SPI communication, both the devices are connected via a 4 wire interface. The block diagram below illustrates the SPI connections between a master and slave.

SPI Connections between Master and Slave Block Diagram

The table below describes the four signal pins that are required for this communication protocol.

SCLK or SCKThis is the serial clock signal which is generated by the master(Host) to keep the transmission of the data bits in synchronization. As we mentioned before, only the master can control the clock signal hence this pin will provide a clock to the slaves which will be monitored by the master. When there is no operation, this pin is in an idle state (in-active or tri-state).
SS or CSThis is the chip select or slave select signal pin that is used to select the slave connected to the bus to which the master (Host) wants to send or receive data.
MOSIThis is the Master Out Slave In signal pin. It is responsible for sending the data from the master(Host) to the slave(Device).
MISOThis is the Master In Slave Out signal pin. It is responsible for sending data from the slave(Device) to the master(Host).
SPI Connections between Master and Slave
SPI Connections between Master and Slave

In SPI communication protocol, the master starts the data frame and selects the slave to which the data is to be transferred through the SS/CS pin. To transmit or receive data from the slave, the master changes the state of the clock signal from active LOW to active HIGH. The data is sent by the master via the MOSI line and received by the slave on tfuhe MISO line.

ESP32 SPI Pins

The ESP32 development board comes with two general purpose SPI controllers. These SPI controllers can be configured to be used as slave nodes which are driven by an off chip SPI master. These are known as SPI2 (HSPI) and SPI3 (VSPI). Both of them have separate signal buses. Hence, we can use them separately either as a master or slave. Each bus can control up to three SPI devices or slaves in the controller mode.

The table below shows the default ESP32 SPI pins for both the channels.

SPI ChannelMOSIMISOSCK/CLKCS/SS
VSPIGPIO23 GPIO19GPIO18GPIO5
HSPIGPIO13GPIO12GPIO14GPIO15

ESP32 SPI Slave Driver APIs

Now let us discuss the SPI Slave driver library and its functions provided by ESP-IDF that will be used in this tutorial. ESP-IDF provides driver/spi_slave.h library that controls SPI peripherals of ESP32 when they act as a slaves.

This is the header file that is required to include the SPI Slave driver:

#include "driver/spi_slave.h"

Initialization

To initialize a SPI bus as a slave interface, we call the function spi_slave_initialize(). It takes in four parameters.

  1. The first parameter is the ‘host’ which is the SPI peripheral that is used as a SPI slave interface.
  2. The second parameter is the ‘bus_config’ which is a pointer to a spi_bus_config_t structure that depicts the initialization of the host.
  3. The third parameter is the ‘slave_config’ which is a pointer to a spi_slave_interface_config_t structure that consists of the slave interface details.
  4. The last parameter is the ‘dma_chan’ which is the DMA channel that is selected by the user. It allows transactions on the bus with size only limited by the amount of internal memory. It can be either of the three options:
  • SPI_DMA_DISABLED: This restricts the size of transactions.
  • SPI_DMA_DISABLED: This is selected if only the SPI flash uses this bus.
  • SPI_DMA_CH_AUTO: The Driver allocates the DMA channel.
esp_err_t spi_slave_initialize(spi_host_device_t host, const spi_bus_config_t *bus_config, const spi_slave_interface_config_t *slave_config, spi_dma_chan_t dma_chan)

Transmit

To do a SPI transaction performed by the slave side, the following function, spi_slave_transmit() is called. It takes in three parameters.

  1. The first parameter is ‘host’ which is the SPI peripheral that is used as the SPI interface.
  2. The second parameter is ‘trans_desc’ which is a pointer to the variable which contains a pointer to the description of the transaction that is being carried out. It is not const as there may be a need to write the status back into the transaction description.
  3. The third parameter is ‘ticks_to_wait’ which is the ticks to wait until there is a return present. We can use portMAX_DELAY to never time out.
esp_err_t spi_slave_transmit(spi_host_device_t host, spi_slave_transaction_t *trans_desc, TickType_t ticks_to_wait)

ESP32 SPI Master Driver APIs

Now let us discuss the SPI Master driver library and its functions provided by ESP-IDF that will be used in this tutorial. ESP-IDF provides driver/spi_master.h library that controls SPI peripherals of ESP32 when they act as a masters.

This is the header file that is required to include the SPI Master driver:

#include "driver/spi_master.h"

Initialization

Firstly, we start off by initializing the SPI. The following function, spi_bus_initialize() is used. It takes in three parameters.

  1. The first parameter is ‘host_id’ which is the SPI peripheral that is responsible for controlling the bus.
  2. The second parameter is ‘bus_config’ which is the pointer to a spi_bus_config_t struct that denotes the initialization of the host.
  3. The third parameter is ‘dma_chan’ which is the DMA channel that is selected by the user. It allows transactions on the bus with size only limited by the amount of internal memory. It can be either of the three options:
  • SPI_DMA_DISABLED: This restricts the size of transactions.
  • SPI_DMA_DISABLED: This is selected if only the SPI flash uses this bus.
  • SPI_DMA_CH_AUTO: The Driver allocates the DMA channel.
esp_err_t spi_bus_initialize(spi_host_device_t host_id, const spi_bus_config_t *bus_config, spi_dma_chan_t dma_chan)

Register Slave Device

To assign a device on the SPI bus, we will use the function, spi_bus_add_device(). This function will register the slave device by initializing its internal structures and allocating the CS pin. As mentioned previously, SPI master can control up to three SPI devices or slaves. This function takes in three parameters.

  • The first parameter is ‘host_id’ which is the SPI peripheral to allocate device on.
  • The second parameter is ‘dev_config’ which is the SPI interface protocol configuration for the device.
  • The third parameter is ‘handle’ which is the pointer to variable that contains the device handle.
esp_err_t spi_bus_add_device(spi_host_device_t host_id, const spi_device_interface_config_t *dev_config, spi_device_handle_t *handle)

Transmit

The spi_device_transmit() function is used to send a SPI transaction. After sending the transaction, it waits until it is completer then it returns. This function takes in two parameters.

  • The first parameter is ‘handle’ which is the device handle. It is acquired by using spi_host_add_dev.
  • The second parameter is ‘trans_desc’ which is the description of the transaction to implement.
esp_err_t spi_device_transmit(spi_device_handle_t handle, spi_transaction_t *trans_desc)

ESP-IDF SPI Slave Example ESP-IDF

In this section, we will build and test a project using the SPI slave and SPI master drivers described previously. We will use the SPI slave example provided by ESP-IDF under Peripherals. It consists of two examples: sender and receiver which will be flashed on separate ESP32 boards. The boards will be connected with each other via the SPI communication pins that will be set in code. The SPI master driver will be used in the sender example and the SPI slave driver will be used in the receiver example respectively. Both examples have to be uploaded to the respected ESP32 boards which are connected with each other, in order for the data transfer to occur vis SPI communication. Moreover, this SPI Slave example also incorporates a handshaking line that will allow the master to only poll the slave when it is actually ready to parse a transaction.

Create Example Project Sender

Open your VS Code and head over to View > Command Palette. Type ESP-IDF: New Project in the search bar and press enter.

Specify the project name and directory. We have named our project ‘SPI_SLAVE_SENDER.’ For the ESP-IDF board, we have chosen the custom board option. For ESP-IDF target, we have chosen ESP32 module. Click ‘Choose Template’ button to proceed forward.

ESP32 SPI Slave Example Sender using ESP-IDF 1

In the Extension, select ESP-IDF option:

ESP-IDF in VS Code New Project 2

We will click the ‘sender’ under the spi_slave tab for Peripherals. Now click ‘Create project using template sender.’

ESP32 SPI Slave Example Sender using ESP-IDF 2

You will get a notification that the project has been created. To open the project in a new window, click ‘Yes.’

This opens our SPI_SLAVE_SENDER project that we created, inside the EXPLORER tab. There are several folders inside our project folder. This is the same for every project which you will create through ESP-IDF Explorer. Lets head over to the app_main.c file. Go to main > app_main.c and open it. The following code open up.

ESP-IDF Code: spi_slave sender

/* SPI Slave example, sender (uses SPI master driver)

   This example code is in the Public Domain (or CC0 licensed, at your option.)

   Unless required by applicable law or agreed to in writing, this
   software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
   CONDITIONS OF ANY KIND, either express or implied.
*/
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"

#include "lwip/sockets.h"
#include "lwip/dns.h"
#include "lwip/netdb.h"
#include "lwip/igmp.h"

#include "esp_wifi.h"
#include "esp_system.h"
#include "esp_event.h"
#include "nvs_flash.h"
#include "soc/rtc_periph.h"
#include "driver/spi_master.h"
#include "esp_log.h"
#include "esp_spi_flash.h"

#include "driver/gpio.h"
#include "esp_intr_alloc.h"


/*
SPI sender (master) example.

This example is supposed to work together with the SPI receiver. It uses the standard SPI pins (MISO, MOSI, SCLK, CS) to
transmit data over in a full-duplex fashion, that is, while the master puts data on the MOSI pin, the slave puts its own
data on the MISO pin.

This example uses one extra pin: GPIO_HANDSHAKE is used as a handshake pin. The slave makes this pin high as soon as it is
ready to receive/send data. This code connects this line to a GPIO interrupt which gives the rdySem semaphore. The main
task waits for this semaphore to be given before queueing a transmission.
*/


/*
Pins in use. The SPI Master can use the GPIO mux, so feel free to change these if needed.
*/
#if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2
#define GPIO_HANDSHAKE 2
#define GPIO_MOSI 12
#define GPIO_MISO 13
#define GPIO_SCLK 15
#define GPIO_CS 14

#elif CONFIG_IDF_TARGET_ESP32C3
#define GPIO_HANDSHAKE 3
#define GPIO_MOSI 7
#define GPIO_MISO 2
#define GPIO_SCLK 6
#define GPIO_CS 10

#endif //CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2


#ifdef CONFIG_IDF_TARGET_ESP32
#define SENDER_HOST HSPI_HOST

#elif defined CONFIG_IDF_TARGET_ESP32S2
#define SENDER_HOST SPI2_HOST

#elif defined CONFIG_IDF_TARGET_ESP32C3
#define SENDER_HOST SPI2_HOST

#endif


//The semaphore indicating the slave is ready to receive stuff.
static xQueueHandle rdySem;

/*
This ISR is called when the handshake line goes high.
*/
static void IRAM_ATTR gpio_handshake_isr_handler(void* arg)
{
    //Sometimes due to interference or ringing or something, we get two irqs after eachother. This is solved by
    //looking at the time between interrupts and refusing any interrupt too close to another one.
    static uint32_t lasthandshaketime;
    uint32_t currtime=esp_cpu_get_ccount();
    uint32_t diff=currtime-lasthandshaketime;
    if (diff<240000) return; //ignore everything <1ms after an earlier irq
    lasthandshaketime=currtime;

    //Give the semaphore.
    BaseType_t mustYield=false;
    xSemaphoreGiveFromISR(rdySem, &mustYield);
    if (mustYield) portYIELD_FROM_ISR();
}

//Main application
void app_main(void)
{
    esp_err_t ret;
    spi_device_handle_t handle;

    //Configuration for the SPI bus
    spi_bus_config_t buscfg={
        .mosi_io_num=GPIO_MOSI,
        .miso_io_num=GPIO_MISO,
        .sclk_io_num=GPIO_SCLK,
        .quadwp_io_num=-1,
        .quadhd_io_num=-1
    };

    //Configuration for the SPI device on the other side of the bus
    spi_device_interface_config_t devcfg={
        .command_bits=0,
        .address_bits=0,
        .dummy_bits=0,
        .clock_speed_hz=5000000,
        .duty_cycle_pos=128,        //50% duty cycle
        .mode=0,
        .spics_io_num=GPIO_CS,
        .cs_ena_posttrans=3,        //Keep the CS low 3 cycles after transaction, to stop slave from missing the last bit when CS has less propagation delay than CLK
        .queue_size=3
    };

    //GPIO config for the handshake line.
    gpio_config_t io_conf={
        .intr_type=GPIO_INTR_POSEDGE,
        .mode=GPIO_MODE_INPUT,
        .pull_up_en=1,
        .pin_bit_mask=(1<<GPIO_HANDSHAKE)
    };

    int n=0;
    char sendbuf[128] = {0};
    char recvbuf[128] = {0};
    spi_transaction_t t;
    memset(&t, 0, sizeof(t));

    //Create the semaphore.
    rdySem=xSemaphoreCreateBinary();

    //Set up handshake line interrupt.
    gpio_config(&io_conf);
    gpio_install_isr_service(0);
    gpio_set_intr_type(GPIO_HANDSHAKE, GPIO_INTR_POSEDGE);
    gpio_isr_handler_add(GPIO_HANDSHAKE, gpio_handshake_isr_handler, NULL);

    //Initialize the SPI bus and add the device we want to send stuff to.
    ret=spi_bus_initialize(SENDER_HOST, &buscfg, SPI_DMA_CH_AUTO);
    assert(ret==ESP_OK);
    ret=spi_bus_add_device(SENDER_HOST, &devcfg, &handle);
    assert(ret==ESP_OK);

    //Assume the slave is ready for the first transmission: if the slave started up before us, we will not detect
    //positive edge on the handshake line.
    xSemaphoreGive(rdySem);

    while(1) {
        int res = snprintf(sendbuf, sizeof(sendbuf),
                "Sender, transmission no. %04i. Last time, I received: \"%s\"", n, recvbuf);
        if (res >= sizeof(sendbuf)) {
            printf("Data truncated\n");
        }
        t.length=sizeof(sendbuf)*8;
        t.tx_buffer=sendbuf;
        t.rx_buffer=recvbuf;
        //Wait for slave to be ready for next byte before sending
        xSemaphoreTake(rdySem, portMAX_DELAY); //Wait until slave is ready
        ret=spi_device_transmit(handle, &t);
        printf("Received: %s\n", recvbuf);
        n++;
    }

    //Never reached.
    ret=spi_bus_remove_device(handle);
    assert(ret==ESP_OK);
}

How the Code Works?

Firstly, we will start by including the necessary libraries that includes the FreeRTOS libraries to generate delays, create queues and semaphores, driver/gpio.h as we have to assign the SPI communication pins to GPIO pins of ESP32, driver/spi_master.h for the SPI master functionality, esp_log.h as the logging library to print informational logs etc.

#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"

#include "lwip/sockets.h"
#include "lwip/dns.h"
#include "lwip/netdb.h"
#include "lwip/igmp.h"

#include "esp_wifi.h"
#include "esp_system.h"
#include "esp_event.h"
#include "nvs_flash.h"
#include "soc/rtc_periph.h"
#include "driver/spi_master.h"
#include "esp_log.h"
#include "esp_spi_flash.h"

#include "driver/gpio.h"
#include "esp_intr_alloc.h"

These are the ESP32 GPIO pins that will be used in SPI communication. Use these GPIO pins to connect the two ESP32 boards together. You may change these pins as well.

#if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2
#define GPIO_HANDSHAKE 2
#define GPIO_MOSI 12
#define GPIO_MISO 13
#define GPIO_SCLK 15
#define GPIO_CS 14

For ESP32, we are using HSPI as the SPI interface.

#ifdef CONFIG_IDF_TARGET_ESP32
#define SENDER_HOST HSPI_HOST

We will use Semaphore to create a software interrupt whereby a function is run whenever another function runs. The Semaphore will monitor when the slave is ready to parse a transaction. Inside the gpio_handshake_isr_handler() we use the function xSemaphoreGiveFromISR() for giving a semaphore for interrupt. It takes in two arguments. The first argument is the Semaphore handle and the second argument is the priority. Moreover, to ensure there is a single interrupt request and no two IRQs are close to each other, the time between interrupts is monitored and only valid interrupt requests are considered.


static xQueueHandle rdySem;

static void IRAM_ATTR gpio_handshake_isr_handler(void* arg)
{

    static uint32_t lasthandshaketime;
    uint32_t currtime=esp_cpu_get_ccount();
    uint32_t diff=currtime-lasthandshaketime;
    if (diff<240000) return; 
    lasthandshaketime=currtime;


    BaseType_t mustYield=false;
    xSemaphoreGiveFromISR(rdySem, &mustYield);
    if (mustYield) portYIELD_FROM_ISR();
}

app_main()

Inside the app_main() function, first the SPI bus configuration parameters are set. These are shown below:

   spi_bus_config_t buscfg={
        .mosi_io_num=GPIO_MOSI,
        .miso_io_num=GPIO_MISO,
        .sclk_io_num=GPIO_SCLK,
        .quadwp_io_num=-1,
        .quadhd_io_num=-1
    };

The configuration parameters for the slave are specified as well. This includes the clock speed, duty cycle, mode etc.

    spi_device_interface_config_t devcfg={
        .command_bits=0,
        .address_bits=0,
        .dummy_bits=0,
        .clock_speed_hz=5000000,
        .duty_cycle_pos=128,        //50% duty cycle
        .mode=0,
        .spics_io_num=GPIO_CS,
        .cs_ena_posttrans=3,        //Keep the CS low 3 cycles after transaction, to stop slave from missing the last bit when CS has less propagation delay than CLK
        .queue_size=3
    };

The following structure holds the GPIO configuration for the handshake line. This includes the GPIO interrupt type, the GPIO mode which is set as input or output, GPIO pull-up and the GPIO pin that is set with bit mask where each bit maps to a GPIO.

    gpio_config_t io_conf={
        .intr_type=GPIO_INTR_POSEDGE,
        .mode=GPIO_MODE_INPUT,
        .pull_up_en=1,
        .pin_bit_mask=(1<<GPIO_HANDSHAKE)
    };

Then we create the binary semaphore using xSemaphoreCreateBinary(). It does not take any argument inside it. It returns a variable of type SemaphoreHandle_t. rdySem is a global variable that we are using to store the semaphore.

 rdySem=xSemaphoreCreateBinary();

The following lines of code configure the handshake line interrupt.

    gpio_config(&io_conf);
    gpio_install_isr_service(0);
    gpio_set_intr_type(GPIO_HANDSHAKE, GPIO_INTR_POSEDGE);
    gpio_isr_handler_add(GPIO_HANDSHAKE, gpio_handshake_isr_handler, NULL);

It starts by calling the function gpio_config() which is used to configure the GPIO common configuration. It takes in a single parameter which is a pointer to the GPIO configuration structure.

gpio_config(&io_conf);

Next, we call the function gpio_install_isr_service() to install the interrupt service routine service. It also takes in a single parameter which is the interrupt allocation flag.

gpio_install_isr_service(0);

Then we set the interrupt trigger type using gpio_set_intr_type(). This takes in two parameters. The first parameter is the GPIO pin number which will be used to set the interrupt type which is GPIO2 in this case. The second parameter is the interrupt type which is specified as ‘GPIO_INTR_POSEDGE’ in this case. Hence, an interrupt will be triggered on the positive edge (low to high) of the clock signal.

 gpio_set_intr_type(GPIO_HANDSHAKE, GPIO_INTR_POSEDGE);

Lastly, to add the ISR handler for our handshake GPIO, we will call the function gpio_isr_handler_add(). This function takes in three parameters. The first parameter is the GPIO number. The second parameter is the ISR handler for this particular GPIO pin and the third parameter is the parameter for the ISR handler which is NULL in this case.

gpio_isr_handler_add(GPIO_HANDSHAKE, gpio_handshake_isr_handler, NULL);

Initialize and Register Device

Next, we initialize the SPI bus and register the slave device.

    ret=spi_bus_initialize(SENDER_HOST, &buscfg, SPI_DMA_CH_AUTO);
    assert(ret==ESP_OK);
    ret=spi_bus_add_device(SENDER_HOST, &devcfg, &handle);
    assert(ret==ESP_OK);

To initialize the SPI bus, we call the function spi_bus_initialze(). This takes in three parameters.

ret=spi_bus_initialize(SENDER_HOST, &buscfg, SPI_DMA_CH_AUTO);
  • The first parameter is the SPI instance that is being used. As we mentioned previously, ESP32 has four SPI instances: SPI0, SPI1, SPI2, and SPI3. However, we can only use SPI2 and SPI3 as the rest are reserved for the flash memory. In this case, we have specified the first parameter as ‘SENDER_HOST’ which is defined as HSPI_HOST. Hence, SPI2 is being used in this case.
  • The second parameter is the pointer to a constant configuration structure of type spi_bus_config_t which we defined previously. It holds the information regarding the GPIO pins used for SPI communication.
    spi_bus_config_t buscfg={
        .mosi_io_num=GPIO_MOSI,
        .miso_io_num=GPIO_MISO,
        .sclk_io_num=GPIO_SCLK,
        .quadwp_io_num=-1,
        .quadhd_io_num=-1
    };
  • The third parameter indicates the DMA channel which is being used. This feature allows the SPI instance to acquire the RAM memory as a transfer buffer. In our case, it is set as ‘SPI_DMA_CH_AUTO’ which suggests that the driver will allocate the DMA channel itself.

To register the slave, we call the function spi_bus_add_device(). It takes in three parameters.

ret=spi_bus_add_device(SENDER_HOST, &devcfg, &handle);
  • The first parameter is the SPI instance that is being used which is SPI2.
  • The second parameter is the pointer to a constant configuration structure of type spi_device_interface_config_t which we defined previously. It holds the information about the slave including the clock speed, mode, the CS pin allocated etc.
    spi_device_interface_config_t devcfg={
        .command_bits=0,
        .address_bits=0,
        .dummy_bits=0,
        .clock_speed_hz=5000000,
        .duty_cycle_pos=128,        //50% duty cycle
        .mode=0,
        .spics_io_num=GPIO_CS,
        .cs_ena_posttrans=3,        //Keep the CS low 3 cycles after transaction, to stop slave from missing the last bit when CS has less propagation delay than CLK
        .queue_size=3
    };
  • The third parameter is the pointer to a variable of type spi_device_handle_t
 spi_device_handle_t handle;

Then the semaphore is released.

 xSemaphoreGive(rdySem);

Inside the infinite loop, the data is transferred to the slave device using spi_device_transmit().

while(1) {
        int res = snprintf(sendbuf, sizeof(sendbuf),
                "Sender, transmission no. %04i. Last time, I received: \"%s\"", n, recvbuf);
        if (res >= sizeof(sendbuf)) {
            printf("Data truncated\n");
        }
        t.length=sizeof(sendbuf)*8;
        t.tx_buffer=sendbuf;
        t.rx_buffer=recvbuf;

        xSemaphoreTake(rdySem, portMAX_DELAY); 
        ret=spi_device_transmit(handle, &t);
        printf("Received: %s\n", recvbuf);
        n++;
    }

Then, to remove the device from the SPI bus, we call spi_bus_remove_device(). It takes in a single parameter which is the device handle.

    ret=spi_bus_remove_device(handle);
    assert(ret==ESP_OK);

Compiling the Sketch

To flash your chip, type the following command in the serial terminal. Remember to replace the COM port with the one through which your board is connected.

idf.py -p COMX flash monitor

Create Example Project Receiver

Open your VS Code and head over to View > Command Palette. Type ESP-IDF: New Project in the search bar and press enter.

Specify the project name and directory. We have named our project ‘SPI_SLAVE_RECEIVER.’ For the ESP-IDF board, we have chosen the custom board option. For ESP-IDF target, we have chosen ESP32 module. Click ‘Choose Template’ button to proceed forward.

ESP32 SPI Slave Example Receiver using ESP-IDF 1

In the Extension, select ESP-IDF option:

ESP-IDF in VS Code New Project 2

We will click the ‘receiver’ under the spi_slave tab for Peripherals. Now click ‘Create project using template receiver.’

ESP32 SPI Slave Example Receiver using ESP-IDF 2

You will get a notification that the project has been created. To open the project in a new window, click ‘Yes.’

This opens our SPI_SLAVE_RECEIVER project that we created, inside the EXPLORER tab. There are several folders inside our project folder. This is the same for every project which you will create through ESP-IDF Explorer. Lets head over to the app_main.c file. Go to main > app_main.c and open it. The following code open up.

ESP-IDF Code: spi_slave receiver

/* SPI Slave example, receiver (uses SPI Slave driver to communicate with sender)

   This example code is in the Public Domain (or CC0 licensed, at your option.)

   Unless required by applicable law or agreed to in writing, this
   software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
   CONDITIONS OF ANY KIND, either express or implied.
*/
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"

#include "lwip/sockets.h"
#include "lwip/dns.h"
#include "lwip/netdb.h"
#include "lwip/igmp.h"

#include "esp_wifi.h"
#include "esp_system.h"
#include "esp_event.h"
#include "nvs_flash.h"
#include "soc/rtc_periph.h"
#include "driver/spi_slave.h"
#include "esp_log.h"
#include "esp_spi_flash.h"
#include "driver/gpio.h"




/*
SPI receiver (slave) example.

This example is supposed to work together with the SPI sender. It uses the standard SPI pins (MISO, MOSI, SCLK, CS) to
transmit data over in a full-duplex fashion, that is, while the master puts data on the MOSI pin, the slave puts its own
data on the MISO pin.

This example uses one extra pin: GPIO_HANDSHAKE is used as a handshake pin. After a transmission has been set up and we're
ready to send/receive data, this code uses a callback to set the handshake pin high. The sender will detect this and start
sending a transaction. As soon as the transaction is done, the line gets set low again.
*/

/*
Pins in use. The SPI Master can use the GPIO mux, so feel free to change these if needed.
*/
#if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2
#define GPIO_HANDSHAKE 2
#define GPIO_MOSI 12
#define GPIO_MISO 13
#define GPIO_SCLK 15
#define GPIO_CS 14

#elif CONFIG_IDF_TARGET_ESP32C3
#define GPIO_HANDSHAKE 3
#define GPIO_MOSI 7
#define GPIO_MISO 2
#define GPIO_SCLK 6
#define GPIO_CS 10

#endif //CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2


#ifdef CONFIG_IDF_TARGET_ESP32
#define RCV_HOST    HSPI_HOST

#elif defined CONFIG_IDF_TARGET_ESP32S2
#define RCV_HOST    SPI2_HOST

#elif defined CONFIG_IDF_TARGET_ESP32C3
#define RCV_HOST    SPI2_HOST

#endif



//Called after a transaction is queued and ready for pickup by master. We use this to set the handshake line high.
void my_post_setup_cb(spi_slave_transaction_t *trans) {
    WRITE_PERI_REG(GPIO_OUT_W1TS_REG, (1<<GPIO_HANDSHAKE));
}

//Called after transaction is sent/received. We use this to set the handshake line low.
void my_post_trans_cb(spi_slave_transaction_t *trans) {
    WRITE_PERI_REG(GPIO_OUT_W1TC_REG, (1<<GPIO_HANDSHAKE));
}

//Main application
void app_main(void)
{
    int n=0;
    esp_err_t ret;

    //Configuration for the SPI bus
    spi_bus_config_t buscfg={
        .mosi_io_num=GPIO_MOSI,
        .miso_io_num=GPIO_MISO,
        .sclk_io_num=GPIO_SCLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
    };

    //Configuration for the SPI slave interface
    spi_slave_interface_config_t slvcfg={
        .mode=0,
        .spics_io_num=GPIO_CS,
        .queue_size=3,
        .flags=0,
        .post_setup_cb=my_post_setup_cb,
        .post_trans_cb=my_post_trans_cb
    };

    //Configuration for the handshake line
    gpio_config_t io_conf={
        .intr_type=GPIO_INTR_DISABLE,
        .mode=GPIO_MODE_OUTPUT,
        .pin_bit_mask=(1<<GPIO_HANDSHAKE)
    };

    //Configure handshake line as output
    gpio_config(&io_conf);
    //Enable pull-ups on SPI lines so we don't detect rogue pulses when no master is connected.
    gpio_set_pull_mode(GPIO_MOSI, GPIO_PULLUP_ONLY);
    gpio_set_pull_mode(GPIO_SCLK, GPIO_PULLUP_ONLY);
    gpio_set_pull_mode(GPIO_CS, GPIO_PULLUP_ONLY);

    //Initialize SPI slave interface
    ret=spi_slave_initialize(RCV_HOST, &buscfg, &slvcfg, SPI_DMA_CH_AUTO);
    assert(ret==ESP_OK);

    WORD_ALIGNED_ATTR char sendbuf[129]="";
    WORD_ALIGNED_ATTR char recvbuf[129]="";
    memset(recvbuf, 0, 33);
    spi_slave_transaction_t t;
    memset(&t, 0, sizeof(t));

    while(1) {
        //Clear receive buffer, set send buffer to something sane
        memset(recvbuf, 0xA5, 129);
        sprintf(sendbuf, "This is the receiver, sending data for transmission number %04d.", n);

        //Set up a transaction of 128 bytes to send/receive
        t.length=128*8;
        t.tx_buffer=sendbuf;
        t.rx_buffer=recvbuf;
        /* This call enables the SPI slave interface to send/receive to the sendbuf and recvbuf. The transaction is
        initialized by the SPI master, however, so it will not actually happen until the master starts a hardware transaction
        by pulling CS low and pulsing the clock etc. In this specific example, we use the handshake line, pulled up by the
        .post_setup_cb callback that is called as soon as a transaction is ready, to let the master know it is free to transfer
        data.
        */
        ret=spi_slave_transmit(RCV_HOST, &t, portMAX_DELAY);

        //spi_slave_transmit does not return until the master has done a transmission, so by here we have sent our data and
        //received data from the master. Print it.
        printf("Received: %s\n", recvbuf);
        n++;
    }

}

How the Code Works?

Firstly, we will start by including the necessary libraries that includes the FreeRTOS libraries to generate delays, create queues and semaphores, driver/gpio.h as we have to assign the SPI communication pins to GPIO pins of ESP32, driver/spi_slave.h for the SPI slave functionality, esp_log.h as the logging library to print informational logs etc.

#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"

#include "lwip/sockets.h"
#include "lwip/dns.h"
#include "lwip/netdb.h"
#include "lwip/igmp.h"

#include "esp_wifi.h"
#include "esp_system.h"
#include "esp_event.h"
#include "nvs_flash.h"
#include "soc/rtc_periph.h"
#include "driver/spi_slave.h"
#include "esp_log.h"
#include "esp_spi_flash.h"
#include "driver/gpio.h"

These are the ESP32 GPIO pins that will be used in SPI communication. Use these GPIO pins to connect the two ESP32 boards together. These are the same pins we configured as SPI pins for the ESP32 master previously.

#if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2
#define GPIO_HANDSHAKE 2
#define GPIO_MOSI 12
#define GPIO_MISO 13
#define GPIO_SCLK 15
#define GPIO_CS 14

For ESP32, we are using HSPI as the SPI interface.

#ifdef CONFIG_IDF_TARGET_ESP32
#define RCV_HOST    HSPI_HOST

This is the callback function that will be called when the master is ready to pickup the transaction. It sets the handshake GPIO which is GPIO2 in this case to a HIGH state.

void my_post_setup_cb(spi_slave_transaction_t *trans) {
    WRITE_PERI_REG(GPIO_OUT_W1TS_REG, (1<<GPIO_HANDSHAKE));
}

Similarly, the my_post_trans_cb() callback function is called when the transaction is sent. It in returns sets the handshake GPIO to a LOW state.

void my_post_trans_cb(spi_slave_transaction_t *trans) {
    WRITE_PERI_REG(GPIO_OUT_W1TC_REG, (1<<GPIO_HANDSHAKE));
}

app_main()

Inside the app_main() function, first the SPI bus configuration parameters are set. These are shown below:

    spi_bus_config_t buscfg={
        .mosi_io_num=GPIO_MOSI,
        .miso_io_num=GPIO_MISO,
        .sclk_io_num=GPIO_SCLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
    };

The configuration parameters for the slave are specified as well. This includes the allocated CS pin, queue size, mode etc.

    spi_slave_interface_config_t slvcfg={
        .mode=0,
        .spics_io_num=GPIO_CS,
        .queue_size=3,
        .flags=0,
        .post_setup_cb=my_post_setup_cb,
        .post_trans_cb=my_post_trans_cb
    };

The following structure holds the GPIO configuration for the handshake line. This includes the GPIO interrupt type, the GPIO mode which is set as input or output and the GPIO pin that is set with bit mask where each bit maps to a GPIO.

   gpio_config_t io_conf={
        .intr_type=GPIO_INTR_DISABLE,
        .mode=GPIO_MODE_OUTPUT,
        .pin_bit_mask=(1<<GPIO_HANDSHAKE)
    };

Then we configure the handshake line as an output. This is initiated by first calling the function gpio_config() which is used to configure the GPIO common configuration. It takes in a single parameter which is a pointer to the GPIO configuration structure.

gpio_config(&io_conf);

Next, we configure pull-up resistors on MOSI, MISO and CS pins. This is achieved by calling the function gpio_set_pull_mode(). This function takes in two parameters. The first parameter is the GPIO pin number and the second parameter is the pull-up/down mode. Calling this function will ensure that no false signals are detected when the master is connected with the slave.


    gpio_set_pull_mode(GPIO_MOSI, GPIO_PULLUP_ONLY);
    gpio_set_pull_mode(GPIO_SCLK, GPIO_PULLUP_ONLY);
    gpio_set_pull_mode(GPIO_CS, GPIO_PULLUP_ONLY);

The next step is to initialize the slave SPI interface. This is done by calling spi_slave_initialize(). It takes in four parameters.

 ret=spi_slave_initialize(RCV_HOST, &buscfg, &slvcfg, SPI_DMA_CH_AUTO);
 assert(ret==ESP_OK);
  • The first parameter is the ‘RCV_HOST’ which is the SPI peripheral that is used as a SPI slave interface. It is SPI2 in our case.
  • The second parameter is a pointer to a spi_bus_config_t structure that depicts the initialization of the host which we previously defined.
    spi_bus_config_t buscfg={
        .mosi_io_num=GPIO_MOSI,
        .miso_io_num=GPIO_MISO,
        .sclk_io_num=GPIO_SCLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
    };
  • The third parameter is a pointer to a spi_slave_interface_config_t structure that consists of the slave interface details.
    spi_slave_interface_config_t slvcfg={
        .mode=0,
        .spics_io_num=GPIO_CS,
        .queue_size=3,
        .flags=0,
        .post_setup_cb=my_post_setup_cb,
        .post_trans_cb=my_post_trans_cb
    };
  • The last parameter is the DMA channel that is selected by the user. It is set as ‘SPI_DMA_CH_AUTO’ which allows the driver to allocate the DMA channel.
Transmit

Inside the infinite loop, SPI slave transaction occurs using spi_slave_transmit(). The send buffer contains the message “This is the receiver, sending data for transmission number %04d.” 128 bytes are set up for the transaction. Using spi_slave_transmit(), the SPI interface is set up to send or receive data to the sendbuf and recvbuf respectively. As soon as the transaction is ready, the callback function my_post_setup_cb() will be called which will set the handshake GPIO pin as HIGH. This will let the master know that it is free to transfer data. The data received from the master will be printed in the receiver’s terminal.

WORD_ALIGNED_ATTR char sendbuf[129]="";
    WORD_ALIGNED_ATTR char recvbuf[129]="";
    memset(recvbuf, 0, 33);
    spi_slave_transaction_t t;
    memset(&t, 0, sizeof(t));

    while(1) {
        memset(recvbuf, 0xA5, 129);
        sprintf(sendbuf, "This is the receiver, sending data for transmission number %04d.", n);


        t.length=128*8;
        t.tx_buffer=sendbuf;
        t.rx_buffer=recvbuf;
    
        ret=spi_slave_transmit(RCV_HOST, &t, portMAX_DELAY);

        printf("Received: %s\n", recvbuf);
        n++;
    }

Compiling the Sketch

To flash your chip, type the following command in the serial terminal. Remember to replace the COM port with the one through which your board is connected.

idf.py -p COMX flash monitor

In our case, the sender ESP32 board is connected with COM5 and the receiver ESP32 board is connected with COM14.

Connect both the ESP32 boards with the SPI communication pins defined in code as well as GPIO2 which acts as the handshaking GPIO. Follow the table below to connect the sender and receiver boards together.

SignalESP32 SenderESP32 Receiver
HandshakeGPIO2GPIO2
MOSIGPIO12GPIO12
MISOGPIO13GPIO13
SCLKGPIO15GPIO15
CSGPIO14GPIO14

This is the output we get on the ESP32 receiver’s terminal:

ESP32 SPI Slave Example Receiver Terminal

This is the output we get on the ESP32 sender’s terminal:

ESP32 SPI Slave Example Sender Terminal

If you are using Arduino instead of ESP-IDF, you can refer to this guide:

You may also like to read:

2 thoughts on “ESP32 SPI Master Slave Communication with ESP-IDF”

Leave a Comment