MCU land, part 2: mysteries of the SPI bus
Understanding the Serial Peripheral Interface and using it to talk to memories, displays, wifi modules, and SD cards.
In the previous article, I disused the surprising ease of interfacing a modern OLED display to a bare microcontroller. My thesis was simple: in most embedded applications, a fully-fledged Linux SoC platform — such as a Raspberry Pi — is not only unnecessary, but can create more problems than it purports to solve.
A possible objection to my thesis might be that the case of the OLED module must’ve been special. Surely, the task of adding wireless connectivity or an external flash module to an MCU would take considerably more work.
There is no single answer, but I’d posit that the OLED exercise was more difficult than most. In situations where gigabit transfer speeds are unnecessary, embedded peripherals usually employ a wire protocol known as the Serial Peripheral Interface (SPI) — a dead simple full-duplex bus that can easily reach transfer rates in excess of 50 Mbit/sec. Plus, they usually don’t come with gotchas such as the aforementioned OLED’s illogical memory ordering scheme.
The SPI protocol can be explained on a napkin. It is controlled by the MCU; whenever the MCU wants to transmit or receive data, it pulls low the “chip select” (CS-) pin of the corresponding peripheral, and then supplies a clock signal to the SCK (“serial clock”) line of the bus. The MCU then drives its “serial out” (MOSI) line to transmit raw data bit-by-bit, usually on the leading edge of every clock tick. The peripheral can send its data parallel, via the microcontroller’s “serial in” (MISO) pin:
The clock stops after transmitting a single byte or a multiple thereof. If the MCU wants to receive data but not transmit, it can send dummy bytes while listening on the input pin; the same goes for a peripheral that has nothing to say.
Although this is hardly necessary, many microcontrollers provide a hardware driver for the SPI bus. On an ATmega328P chip, for example, there is a special SPI data register (SPDR). Whenever you write a byte to this location, it is automatically pushed out in the background on the SPI bus — and then replaced with whatever came in via the MISO line. The microcontroller sets the “SPI finished” (SPIF) flag in the SPI status register (SPSR) as soon as the deed is done.
Let’s say I want to interface my ATmega328P to a tiny, multi-voltage 128 kB SRAM module dubbed 23LC1024. The module features just eight pins: two for the supply voltage (anywhere between 2.5 and 5.5V), four for the basic SPI interface, and two that aren’t needed in my setup. To get things going, the chip’s “serial in” (SI) pin must be routed to the MCU MOSI line; “serial out” (SO) goes to MISO; and the clock (SCK) lines are tied together. The final “chip select” (CS-) pin can be wired to any output line of the MCU; in my example, I’ll be using bit 0 of port B.
With this out of the way, I need to go back to the microcontroller and configure MOSI and SCK pins as outputs, and MISO as an input; this is done via a bitmap in the DDRB register, as outlined before. Next, I set two flags in the SPI configuration register (SPCR): “SPI enable” (SPE) and “master mode” (MSTR):
DDRB = 0b11101111; SPCR = (1 << SPE) | (1 << MSTR);
There are some other settings available, for example to select a specific bus speed, but the defaults are OK for experimentation. With the configuration in place, a simple routine to exchange a byte with the memory controller can look like this:
uint8_t spi_rxtx_byte(uint8_t val) { SPDR = val; while (!(SPSR & (1 << SPIF))); return SPDR; }
The application-level protocol is trivial too. First, I send a write command (0x02), followed by a three-byte address to write to. After that, any number of bytes can be streamed into the external RAM at maximum speed. To end the process, the MCU simply needs to pull the CS- line high:
While the diagram may seem busy, the code that implements this functionality is simple and sweet:
void write_ext_ram_bytes(uint32_t addr, const uint8_t* ptr, uint16_t len) { PORTB &= ~1; /* CS- down */ spi_rxtx_byte(0x02); spi_rxtx_byte(addr >> 16); spi_rxtx_byte(addr >> 8); spi_rxtx_byte(ext_addr); while (len--) spi_rxtx_byte(*(ptr++)); PORTB |= 1; /* CS- up */ }
Reading the memory works about the same, except the MCU transmits a “read” command (0x03) and then keeps sending dummy bytes while storing the responses received from the memory chip:
void read_ext_ram_bytes(uint32_t addr, uint8_t* ptr, uint16_t len) { PORTB &= ~1; /* -CS down */ spi_rxtx_byte(0x03); spi_rxtx_byte(addr >> 16); spi_rxtx_byte(addr >> 8); spi_rxtx_byte(addr); while (len--) *(ptr++) = spi_rxtx_byte(0); PORTB |= 1; /* -CS up */ }
These semantics don’t change greatly whether you’re talking to an SRAM chip, to a non-volatile flash controller, to an SD memory card, or to a $2 wifi + TCP/IP module of the kind made by Espressif Systems.
In fact, here’s a hilarious tidbit: the application-level language used by these wifi modules is a simple dialect of the Hayes modem language that originated in the 1980s. You can even use old-school “AT” commands to make HTTP requests — how cool (and weird) is that?
For the third installment of the series — a primer on a cool 32-bit MCU — click here. To review the entire series of articles on digital and analog electronics, check out this page.
Having used all sizes of AVRs in the past and also struggled to clock out timing sensitive data on an RPi I’m enjoying the series! Also very happy to have come across your Substack. Long time fan after your inspirational articles on DIY mould milling and getting all the air bubbles as the epoxy sets.
It is probably worth noting that the SPI bus is ancient - dating back to 1979 - so it acquired a couple of flavors that are rarely encountered today, but might crop up in some contexts, including as a source of confusion in various tutorials and reference docs.
The first SPI variation is clock phase (CPHA). Normal phase means that serial lines are read on the leading edge of a clock cycle, as shown on the figure in the article. Shifted phase means that the data is latched on the tailing edge. Again, this is uncommon in modern-day chips.
The second variation is clock polarity (CPOL). Again, normal polarity is that the clock idles at 0V and goes high and then back down with every tick. Reverse polarity has the clock idling at high.
SPI controllers default to CPHA=0 and CPOL=0., and this is known as "mode 0". Shifted phase (CPHA=1) is known as "mode 1". Inverted clock (CPOL=1) is known as "mode 2". A combination of the two is "mode 3".
Also, for the curious: "MISO" stands for "master in slave out" and "MOSI" stands for "master out slave in". Putting aside the nowadays slightly controversial nomenclature, the mnemonics are rather confusing and I try to avoid them whenever I can.