Project Overview
The "SPI-based Two-way Data Exchange" project introduces a robust communication framework designed to bridge the gap between high-performance ARM-based microcontrollers. Specifically, it enables seamless full-duplex data synchronization between an Arduino DUE (acting as the SPI Controller) and an Arduino MKR 1010 WiFi (acting as the Peripheral). This library solves a common engineering hurdle: the lack of native SPI peripheral (slave) mode support in the standard SPI.h library for SAMD21 and SAM3X architectures.
In this project I made an SPI-based protocol for 2-way data-message exchange between an Arduino DUE as SPI Controller (or Master) and an Arduino MKR 1010 WiFi as the SPI Peripheral (or Slave). I recommend the reader to be familiar with the basics of the SPI protocol. The software provided here can be easily adapted to other Arduinos. In fact, both DUE and MKR are among the more difficult Arduinos for SPI because the standard Arduino SPI.h does not cover them in SPI_peripheral mode.
In our mechanism, before a transaction, the SPI Controller has a MOSI message with data, and an empty MISO message, while the SPI Peripheral has an empty MOSI message and a MISO message with data. The SPI transaction then synchronises the messages. Contrary to what one might expect in SPI, the MISO array can be larger than the MOSI. Furthermore, the typical SPI trickiness of “lost” first MISO byte(s) is avoided.
The code presented here consists of a demo of four different data-exchange examples, plus the necessary library code. The examples run from DUE’s setup() function, so to run just press the reset button of the DUE.
In the above, “message” is a uint8_t data-array wrapped up with some header data, as defined below.
It may be useful to recall SPI terminology: a “transfer” refers to the exchange of a single byte (or two bytes). A “transaction” refers to a series of transfers.
The transfers are executed as uint8_t (i.e. byte). Other data-types can be transferred by type-casts of the array pointers. I have arrived at transmission speeds of about 60 kBytes / s, for a two-way transaction on arrays of 8192 bytes.
The project use case concerns an electro-mechanical clock which is controlled by the DUE. The requirements seems fairly common: to collect routine data from a system, to send commands to control that system, and, using the commands, collect specific status or operational data. The clock project, or at least the prototype of it, is described in: https://projecthub.arduino.cc/bentubbing/a-clock-pendulum-with-parametric-drive-506479 .
The cover photo shows the detector. It will be mounted on the wall somewhere near the pendulum. It contains two quad photo-diodes to monitor a moving laser spot, mounted on a rail to allow various spacings. Visible are the two Arduinos, mounted on top of each other. For this type of construction, I prefer to remove the headers. Not visible are a small PCB with the analog detection electronics plus a Leadshine stepper-motor driver. All the enclosure parts are 3D-printed on a Bambu-Lab X1.
The DUE is ideal for my clock because of its powerful 32-bit timer-counters. In order to connect the clock wirelessly to a PC control application, I use the MKR 1010 WiFi as an intermediary. The MKR will also provide some additional functionality. Both DUE and MKR are 3.3V boards, so no level shift is required.
For the demos in this article, only a subset of connections is required. These are shown in the diagram under "Documentation". They are the standard SPI lines plus a single "SPI-ready" connection (see below).
The DUE acts as the SPI controller. It uses the standard SPI.h library, with wires connected to the dedicated SPI header next to the chip, and pin 10 for SS. The MKR, acting as the peripheral, is more complicated: the SPI.h library doesn't cover this board as peripheral. Its SAM D21 chip features six Serial Communication Interfaces (SERCOMs). Luckily, Arduino provides a library "SercomSPISlave", developed by "lenvm" - to whom I am grateful - that allows to configure and do basic SPI via any of these six SERCOMs.
- Documentation and sample code are found on GitHub: https://github.com/lenvm/SercomSPISlave
- The library is referenced in the forum: https://forum.arduino.cc/t/spi-slave-on-mkr-gsm-1400/696361/2
- An application note for SAM D21 SERCOM configuration is available: https://ww1.microchip.com/downloads/en/DeviceDoc/00002465A.pdf
However, for the MKR 1010 WiFi board, the choice of SERCOM is, realistically, limited to SERCOM3: this one is, by default, wired to the SPI pins of the board - MOSI = 8, SCK = 9, MISO = 10. To function as peripheral, it also needs an SS (slave select) pin. Using the SercomSPISlave library, this is provided by routing SS to pin 6. The library also provides sample code for the SERCOM interrupt handler.
Technical Deep-Dive & Engineering Strategy
The core innovation of this project lies in its custom hardware-driver abstraction, which enables precise control over the SAMD21's SERCOM registers on the MKR 1010.
- SERCOM3 Multiplexing: Unlike AVR-based Arduinos where SPI is fixed to specific pins via hardware, the SAMD21 allows flexible pin mapping through Serial Communication Interfaces (SERCOM). This project leverages the
SercomSPISlavelibrary to override default configurations and establish the MKR 1010 as a reliable SPI slave. - Encapsulated Logic: The software is architected into three primary classes:
CBT_SPIMessage: A data container that handles byte-array pointers and metadata (ID, Type, Length).CBT_SPIController: Manages the DUE's master-side SPI hardware, including transaction timing and inter-byte delays.CBT_Sercom3Per: A low-level driver that handles the MKR's SPI interrupts and register-level data writes.
- The Lag Problem (N+2): A critical engineering discovery made during development was the two-byte lag in the MKR's MISO register response. The system accounts for this by injecting latency-compensation dummy bytes at the start of each transaction, ensuring that MOSI and MISO headers align perfectly for the higher-level application.
- Full-Duplex Flow Control: To prevent data overrun (where the controller sends data faster than the slave can process it), an additional physical signal line—the SPI-Ready Pin—acts as a hardware handshake. The master polls this pin to ensure the slave's internal buffer is purged and ready before initiating a new burst.
In this project, the uint8_t data-arrays are wrapped up in a message class: CBT_SPIMessage. It contains the data-array pointer and data-array size (in bytes), plus a few useful header elements. The data-arrays themselves are responsibility of the user. The header data (which is in uint16_t) are as follows:
- m_ID: the user can pass an ID number to a message which instructs the system how to handle it.
- m_type: the user can define a type of message. In my case I use the convention 10 for binary, 20 for char, 30 for JSON. But there is nothing coded using the type
- m_refID: the user can refer to another message. That allows some form of request / response
- m_dataCount: for MOSI, the number of uint8_t's to send. For MISO after transaction, the number of uint8_t's received. In other words, the byte-size of useful data in the array
On the controller, the message class interacts with a class CBT_SPIController that encapsulates the SPI.h functionality. On the peripheral, it interacts with the a class CBT_Sercom3Per that encapsulates the SERCOMSPISlave library. This separation of functionality is made to keep CBT_SPIMessage independent of whatever boards it is used on, while CBT_Sercom3Per is a small class that is quite simple to port to other boards.
In basic SPI, only the controller can initiate a transaction. That can be a disadvantage in cases where the controller cannot know when the peripheral has data available, or when the controller can't know when the peripheral is finished processing a message. Therefore, I added an additional wire connection (peripheral OUTPUT, controller INPUT) by which the peripheral can pass a "busy / ready" signal. This wire can either be polled by the controller (as in this demo), or it can raise an interrupt on the controller.
Performance and Reliability
The system has been stress-tested with data arrays up to 8,192 bytes, achieving a sustained transfer rate of 60 kBytes/s. For high-reliability environments, the project recommends an SPI clock speed of 1MHz to minimize signal integrity issues over jumper wires. The protocol also includes automated corruption detection by comparing sent and received array sizes within the CBT_SPIMessage headers.
The demo here provides four use examples:
- a simple two-way exchange of a few uint8_t values, with MISO data-count exceeding MOSI data-count.
- a simple exchange of char data
- an exchange of uint32_t data, showing the casts of the array pointers. This will be equivalent for other C++ data types
- an exchange of uint8_t arrays of 8192 bytes. The transaction time is measured and any data-corruption is detected
Please note that further information and details are provided in the sketch file, and in the header files of the classes. I assume that the user will copy the header and implementation files of the classes (CBT_SPIMessage, CBT_SPIController, CBT_Sercom3Per) into the Arduino libraries directory. Furthermore, the Sercom3Slave library should be available, possibly through the Arduino libraries manager.
Timing is a key issue. While transferring, the controller doesn't know when the peripheral interrupt handler is finished handling a given byte transfer. If the controller sends the next byte too soon, data corruption is inevitable. To handle this, I introduced an inter-transfer delay of a couple of micro-seconds: CBT_SPIController m_transferDelay. If, for example for debugging, any extra code is introduced in the peripheral interrupt handler, this delay must be increased. In particular, if Serial is used for debugging, it may need to be several ms.
Secondly, a priori the controller doesn't know when the peripheral is finished with a transaction. If it starts the next transaction too early, there will be data corruption - and this is not easy to interpret. We have two ways to handle this:
- Introduce sufficient delays between transactions
- Or use the "peripheral busy / ready" wire connection
Low levels of data corruption are not necessarily systematic. One may see one transaction going ok, then corruption on a repeat. It is important to repeat tests a good number of times - and take some margin. Note that in this demo, the MKR had nothing else to do. Running other code, or getting other interrupts during SPI transactions, may make things worse. It may require more conservative setting of the inter-transfer delay or the SPI clock speed- this will require experimentation. In other words, if one sees any data corruption -> first step is to increase controller delays and/or reduce the SPI clock speed in SPI settings: for me 1Mhz has been fine.
Some final notes:
The DUE is set for SPI mode 0. To my understanding, on the MKR that should correspond to setting the CPOL (clock polarity) and CPHA (clock phase) registers to 0. However, it turns out that I had to set CPOL = 1 and CPHA = 1 to have correct transfers: see function CBT_Sercom3Per::begin. I fail to understand this. Meanwhile, looking with a scope shows that indeed the signals correspond to mode 0.
The Sercom provides an interrupt flag "Data Register Empty" whenever its data register is empty. It is important always to handle this interrupt by writing something to the register SERCOM3->SPI.DATA.reg = some byte). If not, the interrupt keeps looping and the board becomes unresponsive.
If the MKR board becomes unresponsive it may not even respond to an UPLOAD from the IDE. The reset sequence (also see the Arduino forum) is as follows: reset the MKR by double-pressing the reset button; the board will reset and connect to the IDE, but on a different port; in the IDE, select that new port; upload; after upload, the board will return to the original port and all should be well.
In basic SPI, when the peripheral sets a MISO value in byte-transfer N, this value will be read by the controller in a subsequent byte-transfer. I found that for the MKR, the lag is two byte-transfers, so N + 2. (There is a SERCOM setting to allow a pre-load, but I never got that to work). In order to synchronise the MOSI and MISO messages, the solution is for the controller to send a handful of dummy bytes before starting to send MOSI message-data. Meanwhile, the peripheral starts sending the MISO message data in advance, - by the lag bytes - of the MOSI. That way, MOSI and MISO messages are perfectly in sync. I note that, in fact, the first of the dummy bytes passes the number of dummy bytes (i.e. the 4) to let the peripheral know. Whereas the lag (i.e. the 2) is set as a property of the peripheral class. For a different board, it may be 1?
In basic SPI transactions, MISO has to be smaller than MOSI. In the code here, that is not the case. Whenever the MISO data count exceeds the MOSI data count, the controller will transfer 0's up to the MISO data count.
This project is finished in the sense that this code is operational in my clock. I am using JSON for all messages (seconds, minutes, hours, clock status, and so forth from DUE to MKR, and commands (manual operation, time-setting) from MKR to DUE. Meanwhile, MKR connects by WiFi to a dedicated application running on a PC. For my purposes, JSON is easy and type independent, while the transfer speed is more than enough for the modest message sizes in question.
I did this project because I couldn't find a similar solution around, and in part as a self-education exercise. I like to write / use code that features some level of generality. I have tested a bit beyond the four demos, but I really can’t exclude that there may be remaining bugs or less-than-optimal coding – I am not expert. I hope that some readers may find this approach useful, either to use the classes directly, or to use them as inspiration for better approaches.
Thank you for reading.