Hello! Welcome back. In our last lesson, we explored the I2C protocol, a powerful 2-wire bus for connecting multiple devices. We learned how to configure it as a master, communicate with a sensor, and design a C++ class to manage the interactions.
Today, we'll dive into the third major serial protocol: SPI (Serial Peripheral Interface). This lesson directly addresses the learning outcome: Configure and use SPI master mode for high-speed peripheral communication.
SPI is the protocol of choice when speed is critical. While I2C is great for managing a network of slow-to-medium speed devices like sensors, SPI is often used for peripherals that demand higher data throughput, such as SD cards, high-resolution displays, and fast analog-to-digital converters.
We will cover:
- The fundamental principles of the SPI protocol and how it differs from I2C.
- The ESP-IDF API for configuring the ESP32 as an SPI master.
- A practical code walkthrough using a simple loopback test.
- How to structure a C++ wrapper class for clean and reusable SPI communication.
1. SPI Protocol Fundamentals
Like I2C, SPI is a synchronous, master-slave protocol. However, it uses a different architecture to achieve higher speeds and full-duplex communication.
A key difference is that SPI uses more wires—typically four:
- SCK (Serial Clock): The clock signal generated by the master to synchronize data transfer.
- MOSI (Master Out Slave In): The data line from the master to the slave.
- MISO (Master In Slave Out): The data line from the slave back to the master.
- CS (Chip Select) or SS (Slave Select): A line used by the master to enable a specific slave. Each slave needs its own dedicated CS line.
Because data from master-to-slave (MOSI) and slave-to-master (MISO) happens on separate lines, communication can occur in both directions simultaneously. This is called full-duplex communication and is a major advantage of SPI.
To get a solid overview of the protocol, let's watch this excellent video from Rohde & Schwarz. It clearly explains the role of each wire and introduces the key concepts.
Please watch from the beginning up to 03:48. Focus on understanding: The function of the four primary SPI wires (MOSI, MISO, SCK, CS). How the Chip Select (CS) line is used to activate a specific slave device. The concept of full-duplex communication. The master's role in generating the clock signal.
As you saw, the master selects a slave by pulling its corresponding CS line low. Then, for each tick of the SCK clock, the master sends a bit on the MOSI line and, at the same time, the slave sends a bit on the MISO line. This is essentially a shift register operation happening between the master and slave.

SPI Modes (Clock Polarity and Phase)
A critical aspect of SPI configuration is the "mode," which defines how the clock signal works. The mode is determined by two parameters:
- Clock Polarity (CPOL): Defines the idle state of the clock line.
CPOL=0: Clock is idle LOW.CPOL=1: Clock is idle HIGH.
- Clock Phase (CPHA): Defines on which clock edge the data is sampled.
CPHA=0: Data is sampled on the first (leading) edge of the clock pulse.CPHA=1: Data is sampled on the second (trailing) edge of the clock pulse.
Combining these gives four possible SPI modes (0, 1, 2, 3). The master and slave must be configured to use the same mode to communicate correctly. You will always find the required mode in the peripheral's datasheet.
The same video provides a fantastic explanation of these modes. This is a crucial concept for successfully connecting any SPI device.
Please watch the segment from 05:55 to 08:45. Pay close attention to the diagrams that show how CPOL and CPHA combine to create the four different SPI modes.
2. The ESP-IDF SPI Master API
Good news! The ESP-IDF API for SPI master mode follows the exact same bus-device architectural pattern that we learned for I2C. This makes it much easier to learn. The process is:
- Configure and Initialize the SPI Bus: Define the physical pins for MOSI, MISO, and SCK.
- Add a Device to the Bus: Attach a logical slave device to the bus, specifying its unique properties like the CS pin, clock speed, and SPI mode.
- Perform Transactions: Send and/or receive data with the configured device.
Let's look at the key components from the ESP-IDF.
ESP32 SPI || Configure & Transmit
This article from ControllersTech provides a clear, concise overview of the configuration structures and functions. It's a great reference for the API.
Please read the section titled 'THE CODE', focusing on the 'SPI Initialization' part. Do not worry about the transmit code just yet. Pay attention to: The spi_bus_config_t struct, which defines the mosi_io_num, miso_io_num, and sclk_io_num pins. The spi_bus_initialize() function, which installs the bus driver. The spi_device_interface_config_t struct, which defines the device-specific settings like clock_speed_hz, mode, and spics_io_num. The spi_bus_add_device() function, which registers the device with the bus.
To perform a data transfer, you use a spi_transaction_t struct. This struct is very flexible and allows you to specify pointers to transmit (tx_buffer) and receive (rx_buffer) buffers, and the length of the transaction in bits.
Once the transaction struct is populated, you call spi_device_transmit() to execute it.
3. Practical Walkthrough: A Loopback Test
The simplest way to test an SPI master implementation without needing an external slave device is to perform a loopback test. This is done by physically connecting the ESP32's own MOSI pin to its MISO pin. Any data you transmit will be immediately received. This is a perfect way to verify that your bus configuration and transaction logic are working correctly.
10 ESP32 SPI master with ESP IDF and Embedded C
The following video provides a complete, step-by-step walkthrough of setting up an ESP-IDF project to perform an SPI loopback test. It brings together all the API concepts we just discussed.
Watch the video from 01:38 to 11:15. As you watch the code being written, identify these key stages: Pin Definitions (01:38): Defining the GPIOs for MOSI, MISO, SCK, and CS. Bus & Device Configuration (02:42): Populating the spi_bus_config_t and spi_device_interface_config_t structs. Initialization (05:43): Calling spi_bus_initialize() and spi_bus_add_device(). Transaction Setup (07:23): Populating the spi_transaction_t struct with transmit and receive buffers and the data length. Execution (09:10): Calling spi_device_transmit() within the main loop. Hardware Test (11:15): The demonstration shows the result on the serial monitor when the MOSI and MISO pins are shorted together.
Test your understanding!
Imagine you are using SPI to send commands to a graphics display. The display only receives data; it never sends anything back to the master. How would you modify the spi_transaction_t struct from the video for this "write-only" scenario?
Show answer
For a write-only transaction, you are not interested in the data coming back on the MISO line. You would configure the spi_transaction_t struct by setting the rx_buffer member to nullptr and rxlength to 0. The tx_buffer and length members would be set as usual to point to your command data and specify its length.
4. Encapsulating SPI in a C++ Class
Just as with I2C, wrapping the low-level C API in a C++ class is an excellent practice. It provides abstraction, makes your main application code cleaner, and improves reusability. The bus-device model of the API lends itself well to a C++ design.
A good approach is to create a Spi class that manages both the bus and device handles. Its methods would provide a high-level interface like ReadRegister() and WriteRegister(), hiding the details of configuring spi_transaction_t and calling spi_device_transmit().
ESP-IDF C++ SPI Driver - EmbeddedTutorials
This tutorial from EmbeddedTutorials demonstrates how to create exactly this kind of C++ wrapper for the ESP-IDF SPI driver. We will use it as a blueprint for a robust C++ implementation.
Please read through the sections 'Define the C++ SPI class in the header file' and 'Implement the C++ SPI class in the source file'. Focus on the structure and logic: Header (cppspi.h): Note the private member variables for storing handles and configuration structs (spi_bus_config_t, spi_device_interface_config_t, spi_device_handle_t). The public methods like Init, RegisterDevice, and ReadRegister provide the clean user interface. Source (cppspi.cpp): See how the Init and RegisterDevice methods wrap the spi_bus_initialize and spi_bus_add_device calls. Pay special attention to the transferByte and transferMultiplesBytes helper methods, which are the core of the data transfer logic, encapsulating the setup and call to spi_device_transmit.
By following this class structure, you can create a component that you can easily drop into any future project. In your application, you would instantiate your Spi object, call Init to set up the bus, call RegisterDevice for the specific peripheral you're using, and then use the simple read/write methods to communicate.
Conclusion
You have now covered the fundamentals of SPI and how to implement it on the ESP32. You have a solid grasp of the three main serial protocols used in embedded systems: UART, I2C, and SPI.
Key Takeaways:
- SPI is a synchronous, full-duplex protocol known for its high speed.
- It uses a 4-wire interface (MOSI, MISO, SCK, CS), with a dedicated CS line for each slave.
- The SPI mode (0-3), defined by clock polarity (CPOL) and phase (CPHA), is a critical configuration parameter that must match between master and slave.
- The ESP-IDF API uses a bus-device model (
spi_bus_initialize,spi_bus_add_device) that is consistent with other peripherals. - All transfers are managed via a
spi_transaction_tstruct and executed withspi_device_transmit. - Encapsulating SPI logic in a C++ class is the recommended approach for creating clean, reusable, and abstract code.
In our next lesson, we will switch our focus back to I2C to tackle a more advanced scenario: parsing multi-byte data from a complex environmental sensor. This will build on your I2C knowledge and prepare you for integrating more sophisticated peripherals into your projects.