FastArduino v1.10
C++ library to build fast but small Arduino/AVR projects
Loading...
Searching...
No Matches
Adding support for an SPI device

There are plenty of devices of all kinds, based on SPI interface, that you may want to connect to your Arduino or a board you created with an AVR ATmega or ATtiny MCU.

If you want to learn more about SPI concepts and vocabulary, you can find further information on Wikipedia.

Unfortunately, FastArduino obviously cannot provide specific support for all existing SPI devices.

However, based on a given device datasheet, it can be quite easy to add a FastArduino driver for any SPI device.

FastArduino provides all the necessary classes and methods for you to implement such a specific driver.

The following sections describe the FastArduino API for SPI device driver implementation, and list the steps to successfully implement such a driver.

FastArduino SPI driver API

The generic support for SPI device driver in FastArduino is quite simple, it is entirely embedded in 2 classes:

The important class here is spi::SPIDevice, this is a template class (with many parameters discussed later) which all actual SPI device drivers shall derive from.

The abstract base class AbstractSPIDevice contains most methods used in transferring (both ways) content to/from a device on the SPI bus. It exists solely for the sake of code size: it factors all methods that do not depend on any spi::SPIDevice template parameter, into a non-template class so that generated code is not repeated for each device driver. All its important methods are available directly from spi::SPIDevice and its children, as protected methods.

As you can see in the following diagrams, the drivers for SPI devices currently supported by FastArduino directly derive from spi::SPIDevice, sometimes enforcing template parameter values:

  1. Winbond SPI memory chip
  2. NRF24L01 SPI Radio-Frequency transmitter/receiver

Hence, creating a new driver for an SPI device is as simple as:

  1. Creating a spi::SPIDevice subclass; let's call it MySPIDevice in the rest of this page.
  2. Add proper public API on this MySPIDevice class, based on actual device features we want to use
  3. Implement this API through the basic protected API methods inherited from spi::SPIDevice

SPIDevice template parameters

The spi::SPIDevice template class is instantiated through the following template parameters:

  • CS: this board::DigitalPin is the most important parameter of the template; it defines on which digital pin of the MCU the targeted device "chip select" pin shall be connected; in MySPIDevice, this shall remain a template parameter because you never know in advance how the device will be connected to the MCU across different projects.
  • CS_MODE: this parameter defines if the CS pin is active HIGH or LOW (the default); this is specific to every SPI device and shall be forced to the proper value in MySPIDevice class definition.
  • RATE: this parameter fixes the SPI clock frequency to the maximum value supported by the actual device. This is actually a divider of the MCU clock, used to provide the actual SPI frequency. Note that the proper selection for this template parameter depends on the maximum transfer rate supported by the target device and the MCU frequency used in your project.
  • MODE: one of the 4 SPI modes (as explained here and there); typically a given device supports exactly one mode, you should thus enforce the proper mode for your target device.
  • ORDER: this parameter defines the order in which bits of a byte are transferred: MSB (most significant bit) first, or LSB (least significant bit) first; typically a given device supports exactly one bit transfer order, you should thus enforce the proper order for your target device.

SPIDevice API

Subclassing spi::SPIDevice gives MySPIDevice access to all low-level protected methods:

Any feature implementation in MySPIDevice will always consist in a sequence of calls to the methods above, like:

this->start_transfer();
this->transfer(0x01);
uint8_t result1 = this->transfer(0x80);
uint8_t result2 = this->transfer(0x00);
this->end_transfer();

Most SPI devices use codes to perform various features, either write-only (sending values to the device) or read-only (asking the device for some values).

In the sections below, we will sometimes refer to a simple SPI device, the MCP3008, an 8-channel Analog-Digital Converter, which communication protocol is super simple (because the number of features for such a chip is quite limited).

Debugging support for a new device (low-level)

In general, before developing a full-fledged driver for an SPI device, you need to learn how to use that device.

Based on the device datasheet, you first learn how to manipulate the device through the SPI bus.

For better understanding, you generally use a debugging example that helps demonstrate how the device works.

One easy way to develop such a debugging sample is to create a program with just one source code file containing:

  • proper #include directives
  • a PublicDevice class that derives from spi::SPIDevice but declares main() as a friend, which allows direct calls, from main(), to protected API of spi::SPIDevice, for easy testing
  • directly call SPI API on a PublicDevice instance, from main() and trace results to a console, through UART

FastArduino includes such a debugging sample in examples/spi/SPIDeviceProto example, copied hereafter:

1// Copyright 2016-2023 Jean-Francois Poilpret
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15/*
16 * This is a skeleton program to help connect, debug and understand how a given
17 * SPI device (not already supported by FastArduino) works.
18 * That helps creating a new specific support API for that device for reuse in
19 * other programs and potential integration to FastArduino project.
20 * To ease wiring and debugging, I suggest using a real Arduino UNO board
21 * and a small breadboard for connecting the SPI device.
22 *
23 * This example shows how to start debugging support for MCP3008 chip, an
24 * 8-channel Analog-Digital Converter, which communication protocol is super
25 * simple (because the number of features for such a chip is quite limited).
26 * In source code below, there are references to
27 * [MCP3008 datasheet](http://ww1.microchip.com/downloads/en/DeviceDoc/21295C.pdf).
28 *
29 * Wiring:
30 * - on ATmega328P based boards (including Arduino UNO):
31 * - D13 (SCK): connected to SPI device SCK pin
32 * - D12 (MISO): connected to SPI device MISO pin (sometimes called Dout)
33 * - D11 (MOSI): connected to SPI device MOSI pin (sometimes called Din)
34 * - D10 (SS): connected to SPI device CS pin
35 * - direct USB access (traces output)
36 */
37
38#include <fastarduino/spi.h>
39#include <fastarduino/time.h>
40#include <fastarduino/uart.h>
42
43// Define vectors we need in the example
45
46// UART for traces
47static constexpr const uint8_t OUTPUT_BUFFER_SIZE = 64;
48static char output_buffer[OUTPUT_BUFFER_SIZE];
49
50// SPI Device specific stuff goes here
51//=====================================
52
53// Spec §1.0 (Clock frequency max 3.6MHz for Vdd=5V)
54static constexpr const uint32_t SPI_CLOCK = 3'600'000UL;
55static constexpr const spi::ChipSelect CHIP_SELECT = spi::ChipSelect::ACTIVE_LOW;
56static constexpr const spi::DataOrder DATA_ORDER = spi::DataOrder::MSB_FIRST;
57static constexpr const spi::Mode MODE = spi::Mode::MODE_0;
58
59// For testing we use default SS pin as CS
60static constexpr const board::DigitalPin CS = board::DigitalPin::D10_PB2;
61
62static constexpr const spi::ClockRate CLOCK_RATE = spi::compute_clockrate(SPI_CLOCK);
63
64// Subclass SPIDevice to make protected methods available from main()
65class PublicDevice: public spi::SPIDevice<CS, CHIP_SELECT, CLOCK_RATE, MODE, DATA_ORDER>
66{
67public:
68 PublicDevice(): SPIDevice() {}
69 friend int main();
70};
71
72using streams::endl;
73using streams::dec;
74using streams::hex;
75
76int main()
77{
79 sei();
80
81 // Init UART output for traces
83 uart.begin(115200);
84 streams::ostream out = uart.out();
85 out.width(2);
86
87 // Start SPI interface
88 spi::init();
89 out << F("SPI initialized") << endl;
90
91 PublicDevice device;
92
93 // Start or init SPI device if needed
94
95 // Loop to read and show measures
96 while (true)
97 {
98 // Read measures and display them to UART
99
100 // On MCP3008 we will perform single-ended analog-digital conversion on channel CH0
101 out << F("Reading channel 0") << endl;
102 // Spec §5.0
103 device.start_transfer();
104 // Spec §6.1, figure 6.1: send a start bit as a byte (left filled with 0s)
105 device.transfer(0x01);
106 // Spec §6.1, figure 6.1: send 4 command bits as a byte (right filled with 0s), and capture result (2 MSB)
107 // Command bits are 1000 (single-ended input mode, channel CH0)
108 uint8_t result1 = device.transfer(0x80);
109 // Spec §6.1, figure 6.1: send an empty byte to capture returned result (8 LSB)
110 uint8_t result2 = device.transfer(0x00);
111 device.end_transfer();
112
113 // Trace intermediate results (for debugging)
114 out << F("Intermediate results:") << hex << result1 << ' ' << result2 << endl;
115 // Combine result
116 uint16_t value = utils::as_uint16_t(result1 & 0x03, result2);
117 out << F("Calculated value: ") << dec << value << endl;
118
119 time::delay_ms(1000);
120 }
121
122 // Stop SPI device if needed
123 out << F("End") << endl;
124}
Hardware serial transmitter API.
Definition: uart.h:241
void begin(uint32_t rate, Parity parity=Parity::NONE, StopBits stop_bits=StopBits::ONE)
Enable the transmitter.
Definition: uart.h:269
void width(uint8_t width)
Set minimum width used for displaying values.
Definition: ios.h:390
Output stream wrapper to provide formatted output API, a la C++.
Definition: streams.h:61
#define F(ptr)
Force string constant to be stored as flash storage.
Definition: flash.h:150
Defines all types and constants specific to support a specific MCU target.
Definition: empty.h:38
static void init()
Performs special initialization for the target MCU.
Definition: empty.h:43
Define API to define and manage SPI devices.
Definition: spi.h:35
void init()
This function must be called once in your program, before any use of an SPI device.
Definition: spi.cpp:20
void endl(FSTREAM &stream)
Manipulator for an output stream, which will insert a new-line character and flush the stream buffer.
Definition: streams.h:725
void hex(FSTREAM &stream)
Manipulator for an output or input stream, which will set the base, used to represent (output) or int...
Definition: ios.h:774
void dec(FSTREAM &stream)
Manipulator for an output or input stream, which will set the base, used to represent (output) or int...
Definition: ios.h:765
void delay_ms(uint16_t ms) INLINE
Delay program execution for the given amount of milliseconds.
Definition: time.h:346
constexpr uint16_t as_uint16_t(uint8_t high, uint8_t low)
Convert 2 bytes into an unsigned int.
Definition: utilities.h:327
SPI support for AVR MCU.
Simple time utilities.
Hardware serial API.
#define REGISTER_UATX_ISR(UART_NUM)
Register the necessary ISR (Interrupt Service Routine) for an serial::hard::UATX to work correctly.
Definition: uart.h:37
General utilities API that have broad application in programs.

This example demonstrates how to simply test the MCP3008 ADC chip. It is made of several parts:

Those lines include a few headers necessary (or just useful) to debug an SPI device.

// UART for traces
static constexpr const uint8_t OUTPUT_BUFFER_SIZE = 64;
static char output_buffer[OUTPUT_BUFFER_SIZE];

Then a buffer is defined for tracing through UART and the necessary UART ISR is registered.

// Spec §1.0 (Clock frequency max 3.6MHz for Vdd=5V)
static constexpr const uint32_t SPI_CLOCK = 3'600'000UL;
static constexpr const spi::ChipSelect CHIP_SELECT = spi::ChipSelect::ACTIVE_LOW;
static constexpr const spi::DataOrder DATA_ORDER = spi::DataOrder::MSB_FIRST;
static constexpr const spi::Mode MODE = spi::Mode::MODE_0;
ChipSelect
Active polarity of slave selection pin.
Definition: spi.h:138
@ ACTIVE_LOW
Slave device is active when SS pin is low.
DataOrder
Bit ordering per byte.
Definition: spi.h:88
@ MSB_FIRST
Most significant bit transferred first.
Mode
SPI transmission mode.
Definition: spi.h:110
@ MODE_0
SPI mode 0: CPOL = 0 and CPHA = 0.

Any specificity of the tested SPI device is defined as a constant in the next code section. These constants will be used for template parameters later on.

static constexpr const board::DigitalPin CS = board::DigitalPin::D10_PB2;
static constexpr const spi::ClockRate CLOCK_RATE = spi::compute_clockrate(SPI_CLOCK);
DigitalPin
Defines all available digital input/output pins of the target MCU.
Definition: empty.h:56
ClockRate
Define SPI clock rate as a divider of MCU clock frequency.
Definition: spi.h:48
constexpr ClockRate compute_clockrate(uint32_t frequency)
Calculate ClockRate for the given frequency.
Definition: spi.h:65

Here we set the UNO pin to be connected to the CS pin of the tested SPI device, then we define the proper SPI clock rate for this device, based on CPU frequency and device maximum SPI frequency.

class PublicDevice: public spi::SPIDevice<CS, CHIP_SELECT, CLOCK_RATE, MODE, DATA_ORDER>
{
public:
PublicDevice(): SPIDevice() {}
friend int main();
};
Base class for any SPI slave device.
Definition: spi.h:331

This is where we define a utility class to debug our SPI interface to the tested device. PublicDevice class does nothing but making all protected methods callable from main(), so that we can directly perform our code tests in main(), without thinking much about proper API design now.

int main()
{
sei();
// Init UART output for traces
uart.begin(115200);
streams::ostream out = uart.out();
out.width(2);

This is the main() function where it all happens. First we initialize the MCU and the UART for tracing.

Here we simply initialize SPI function on the UNO.

PublicDevice device;

We then declare the device variable that we will use for testing our SPI device.

Then we start an infinite loop that will read data from the SPI device and trace it:

// On MCP3008 we will perform single-ended analog-digital conversion on channel CH0
out << F("Reading channel 0") << endl;
// Spec §5.0
device.start_transfer();
// Spec §6.1, figure 6.1: send a start bit as a byte (left filled with 0s)
device.transfer(0x01);
// Spec §6.1, figure 6.1: send 4 command bits as a byte (right filled with 0s), and capture result (2 MSB)
// Command bits are 1000 (single-ended input mode, channel CH0)
uint8_t result1 = device.transfer(0x80);
// Spec §6.1, figure 6.1: send an empty byte to capture returned result (8 LSB)
uint8_t result2 = device.transfer(0x00);
device.end_transfer();
// Trace intermediate results (for debugging)
out << F("Intermediate results:") << hex << result1 << ' ' << result2 << endl;

In this code snippet, result1 and result2 each contain a part of the expected result (analog channel read on MCP3008), these must be used to calculate the actual value (based on the datasheet):

uint16_t value = utils::as_uint16_t(result1 & 0x03, result2);
out << F("Calculated value: ") << dec << value << endl;

Defining the driver API based on device features

At this level, you have already been able to debug how the device works and you have a good overview of what features you want to provide to developers (and to yourself as the first of all) who will want to use this device.

An easy way is to provide an API that maps every feature found in the datasheet to its dedicated method. This is what we would call a low-level API; that is the minimum your driver should provide.

Additionally MySPIDevice might implement a higher level API, based on the low-level one, but this is not mandatory; actually, this is not even advised generally, as this high-level API might be implemented in a distinct class. Using a separate class for high-level API allows other developers to develop their own high-level API without having to use yours if it does not fit their needs.

It is often advised to add begin() and end() methods to MySPIDevice when it makes sense. begin() would initialize the device before usage (most devices will require special setup before use).

In the MCP3008 example, the API could be rather simple; for instance, we could:

  • define an enum for the selection of channel to read (including single-ended Vs. differential input modes)
  • define a template class MCP3008Device as device driver keeping only CS as template parameter
  • add only one API method uint16_t read_channel()

Implementing the driver API

We proceed with the MCP3008 example. When implementing the API, you must scrupulously follow the device datasheet for every method!

Here is a simple implementation attempt for MCP3008 driver:

enum class MCP3008Channel : uint8_t
{
// singled-ended input
CH0 = 0x80,
CH1 = 0x90,
CH2 = 0xA0,
CH3 = 0xB0,
CH4 = 0xC0,
CH5 = 0xD0,
CH6 = 0xE0,
CH7 = 0xF0,
// differential input
CH0_CH1 = 0x00,
CH1_CH0 = 0x10,
CH2_CH3 = 0x20,
CH3_CH2 = 0x30,
CH4_CH5 = 0x40,
CH5_CH4 = 0x50,
CH6_CH7 = 0x60,
CH7_CH6 = 0x70
};
using namespace spi;
template<board::DigitalPin CS>
class MCP3008Device : public SPIDevice<CS, ChipSelect::ACTIVE_LOW, compute_clockrate(3600000UL), Mode::MODE_0, DataOrder::MSB_FIRST>
{
public:
MCP3008Device() = default;
uint16_t read_channel(MCP3008Channel channel)
{
this->start_transfer();
this->transfer(0x01);
uint8_t result1 = this->transfer(uint8_t(channel));
uint8_t result2 = this->transfer(0x00);
this->end_transfer();
return utils::as_uint16_t(result1 & 0x03, result2);
}
}

Note the implementation of read_channel() which is mainly the same as in the debugging example described earlier.

Of course, the MCP3008 is a very simple device which is easy to interact with through SPI, but there are many SPI devices with more complex capabilities (cameras, B&W and color display controllers, RF transmitters...) For those devices, the number of features can be large and this would result in dozens or even hundreds of API methods!

Support for ATtiny MCU

ATtiny MCU provides some support (through its USI feature) for SPI but it is quite limited in comparison to ATmega devices; hence FastArduino SPI support for ATtiny chips has similar limitations:

  1. The only spi::DataOrder supported is spi::DataOrder::MSB_FIRST
  2. The only spi::Modes supported are spi::Mode::MODE_0 and spi::Mode::MODE_1
  3. spi::ClockRate parameter is not used in spi::SPIDevice implementation, hence the maximum clock rate is always used, and is roughly equal to CPU frequency / 7, hence typically a bit more than 1MHz with common clock frequency used in ATtiny boards (internal 8MHz RC clock); this might be a problem for devices supporting only 1MHz SPI.

These limitations might prevent proper support, on ATtiny MCU, of some SPI devices.

If your device is in this situation, then you should add compile error checks (through static_assert(), or #if and #error) in your SPI device driver header file, so that it cannot compile for these unsupported ATtiny targets.

The last mile: add driver to FastArduino project!

Bravo! You successfully added FastArduino support, in your own project, for a specific SPI device!

The last mile would now consist in adding your valuable work to FastArduino library! You do not have to, of course, but this would be a good way to:

  • thank other people who provided FastArduino open source library to you
  • feel part of the community
  • get feedback on your work, potentially allowing it to be further improved
  • share your work with the rest of the world

However, like for a marathon, the last mile can be difficult! In order to run this last mile, you will have to:

  • first accept FastArduino Apache License 2.0 for your contribution, or discuss with FastArduino owner for another one, if compatible
  • follow FastArduino coding guidelines: this might impose some code rewrite or reformatting
  • add API documentation with doxygen: this is mandatory for all public methods, and advised for protected ones.
  • add one (or more) usage example and integrate it in the examples/spi directory; examples must be kept simple but still demonstrate the API usage; example circuits (connection pins) shall be described. These examples can be further used as "tests" before new releases of FastArduino.
  • optionally develop a tutorial for this device
  • prepare and propose a PR to FastArduino project

Important condition: in order to accept merging a PR to FastArduino, I must be able to check it by myself, hence I need to first have the new supported device available on my workbench; I will gladly buy one (or a few) if it is affordable and easy to find.