In this article, we’ll walk through the workings of the Serial Peripheral Interface (SPI) protocol. SPI is a fast communication protocol that’s commonly used with memory ICs and graphics displays. We’ll learn how SPI works, configure our PIC to speak it, and read and write to an EEPROM memory chip. We’ll also cover how to use a super-cheap logic analyzer to view our SPI signals and diagnose data errors.
Make sure you understand everything we covered in the previous UART tutorial before continuing — we’ll make use of the code we wrote in the UART and timer sections in this article.
What’s wrong with UART?
UART seemed to work perfectly fine. Why do we need another protocol?
Recall that UART is asynchronous — there’s no clock line to synchronize the data. We know the data rate ahead of time (ex. 9600 bits/sec), which tells us the bit length (1/9600 = 10.4ms per bit). So from the start bit, we wait 10.4ms in between reading each bit. This allows us to read the voltage in the middle of each bit.
This technique works great if we’re waiting for the correct amount of time. But what if our clock is off just a little bit? What if we’re actually waiting for 10.3ms? We wouldn’t be reading in the middle — every time we read the next bit, we’d be off just a little bit more.
To make a silly analogy, suppose that a blind person wanted to walk exactly 20 feet in some direction. If they know that every step they take moves them forward 2 feet, all they’d have to do is to count 10 steps. But if each step they take is actually 2.1 feet, every step they take would land them just a little bit further from where they expect to be. What if every step they take is 2.5 feet? They’d end up way off. UART suffers from the same problem — if the clock rate is off, the UART device has no way of knowing how off it is.
This isn’t to say that UART doesn’t work. UART works beautifully — so long as the clocks of the transmitting and receiving devices are delaying for the same durations. UART can tolerate clock mismatches of about 10% (if one device is using 9600 baud, the other device needs a baud rate of between approximately 8640 and 10,560). Beyond that, data errors become more frequent. The higher the baud rate, the harder it is for both devices to clock at the same speed. Because of this, UART typically isn’t used for baud rates higher than a few hundreds of kilobits per second (115200 baud is the highest rate commonly used).
So what’s the solution? Instead of having each device use a separate clock, we can share a single clock. One of the two devices, which we’ll call the master, generates a clock and transmits it. Each time the clock transitions — it goes from high to low, or low to high — the slave device reads a data bit. In other words, the master device tells the slave device exactly when to sample each bit. Since the communication is synchronized, we don’t have to worry about clock mismatch errors, which allows us to communicate at much higher data rates.
How SPI works
A SPI master and slave are connected with a bus of 4 signals, which are called MOSI, MISO, SCK, and SS. MOSI (master out, slave in) and MISO (master in, slave out) are the data lines — like the TX and RX lines in a UART bus. SCK (serial clock) is the clock line generated by the master to tell the slave when each bit is ready. We’ll discuss the SS (slave select) line in a bit.
Note that these signals are sometimes given slightly different names. On a slave device’s datasheet, for example, MOSI might be called “SI”, for “slave in”, and MISO might be called “SO”. Both master and slave devices sometimes called their pins “SDO” (serial data out) and “SDI” (serial data in). Be aware that while the MOSI of the master device is connected to the MOSI of the slave device, a pin called “SDO” on one device connects to the SDI on the other (they’d be crisscrossed, like how TX connects to RX). Also, the SS line is often called “CS” (chip select).
Like UART, SPI is a full duplex protocol — data transmission and reception occur simultaneously. On every clock transition, the master sends a bit to the slave and the slave sends a bit back to the master. This might seem confusing — what if you want to read data from the slave, but you don’t have anything you want to write? In that case, you simply send a garbage byte (it doesn’t matter what it is), which causes the SPI module to generate clock pulses — which indicates to the slave that it needs to send data back. Similarly, if you want to write data to the slave when it’s not supposed to respond, the slave sends a garbage byte back, which you can ignore.
What about the SS pin? Unlike a UART bus, on which only 2 devices can communicate, a SPI master can be connected with multiple slave devices. The master has one SS (slave select) pin for each slave device. These are GPIO output pins. SS pins are active low, which means that setting a SS pin low tells the slave it’s connected to that it’s selected. The master sets all of the SS pins high, except the pin connected to the slave device being communicated with. When a slave device’s SS pin gets pulled low, it makes that device active; all slave devices with SS pins set high are inactive, and ignore any data and clock pulses. Nice and easy!
If we only have a single slave device on the SPI bus, does that mean we can simply tie its SS pin to ground? Not so fast. For many devices, we can do this, but some devices (like the one we’re going to use) require transitions on the SS pin to signal that the device should do something.
Our SPI slave
We’ll learn SPI by interfacing our PIC to an EEPROM chip, which is a type of nonvolatile memory. The chip we’re using is the 25AA128, which has 128kbit (16,384 bytes) of memory. Take your time to read through the datasheet; see if you can understand it on your own before reading ahead.
How do we read and write to the 25AA128 IC? It has its own instruction set. Page 7 of its datasheet has a table which lists all of the device’s instructions. For example, we can start writing data to device memory by issuing the WRITE command, which is listed as 0b00000010 (0x02). Scroll to the next page and take a look at the “Byte write sequence”, a timing diagram that shows how to use the WRITE command. First we send the command, then the two bytes that compose the address, then the data byte. The READ command works similarly; we send the command and the address bytes, then send a byte of garbage data (to generate 8 clock pulses) and read in the data byte.
Not so fast, though. The datasheet’s “Functional Description” chapter has a section on the write sequence (Section 2.3, on page 6) which describes the chip’s “write enable latch”, a feature to prevent unintentional writes to memory. To set the latch and enable writing, we send another instruction, the WREN (write enable) command, then set the CS pin high to enable writing. This section also tells us that the latch will be cleared after each write, so we’ll need to re-enable writes each time we want to write data. Of course, it’d be time-consuming to have to set the latch every time we want to write a byte, so the 25AA128 supports writing to an entire page (64 bytes) with a single WRITE command.
The code
First, we need to implement our SPI library. If you can’t find the chapter for “SPI” in the PIC datasheet, don’t worry — PIC microcontrollers have a “MSSP” (master synchronous serial port) peripheral that is used for implementing both SPI and I2C. Of course, you’re an old pro at reading datasheets and using peripherals by now (you’ve read my last few articles, right? Bueller? Bueller?), so we’ll just cover the gotchas. If you’re really stuck, do’t worry — the full code will be at the bottom of the article, as usual.
- If needed, make sure you use the PPS module to remap your input pin (SDI) and output pins (SDO and SCK). For SS, use a separate GPIO pin.
- The SSPM bit of the SSPxCON1 register allows us to set the clock rate. I’d recommend selecting a low clock speed, and increasing it after you’re done testing all your code (this is a good idea with any communication protocol, as it helps avoid errors and simplifies debugging).
- The EEPROM chip samples the data we’re sending (on the MOSI line) when the clock goes from low to high — so we need to send each data bit before the clock transitions. Similarly, we want our PIC to sample the MISO line on each clock transition. The MSSP module has 2 configuration bits for selecting these settings, see if you can find them.
- As previously discussed, we read and write simultaneously in SPI — so instead of having separate readSPI() and writeSPI() functions, we’ll just have a exchangeSPIbytes() function that takes the unsigned char being sent and returns the unsigned char that’s received.
With the MSSP module configured in SPI mode, we can move ahead and write some code to interface with the EEPROM chip. Before we write any functions, though, let’s define constants for the EEPROM’s instruction set.
#define READ_25AA128 (0b00000011) //read from memory #define WRITE_25AA128 (0b00000010) //write to memory #define WRDI_25AA128 (0b00000100) //disable write enable latch #define WREN_25AA128 (0b00000110) //enable write enable latch #define RDSR_25AA128 (0b00000101) //read STATUS reg #define WRSR_25AA128 (0b00000001) //write STATUS reg
We need a function to clear the 25AA128’s write enable latch by sending the WREN command, and another one to query the STATUS register so we can verify that the write enable was successful. Remember that after sending WREN, we need to bring SS high again to complete the write enable.
void setSSstate(bool state) { LATCbits.LATC2 = state; } void enable25AA128write() { setSSstate(LOW); exchangeSPIbytes(WREN_25AA128); setSSstate(HIGH); } unsigned char get25AA128status() { setSSstate(LOW); exchangeSPIbytes(RDSR_25AA128); unsigned char status = exchangeSPIbytes(0x00); //send a byte of garbage data to generate //clock pulses so we can read in the STATUS byte setSSstate(HIGH); return status; }
Try calling the get25AA128status() function and printing the result to the UART port (or check the value using the debugger) — it should be 0. Then set the write enable latch and check the STATUS again. The write enable latch (WEL) bit should now be set (the STATUS should be 0b00000010).
Now that we have the ability to enable writes, let’s make a function to write a byte to a given address. Go back and take a look at the Byte Write Sequence on page 8. This tells us all the information we need to write a byte. First, we need to send the WRITE instruction, followed by the 16-bit address. Then we send the data byte. One tricky detail is that a delay must occur after SS goes high at the end of the write sequence — see the part that says “Twc” in the top-right corner of the diagram? If we glance at the DC/AC characteristics tables near the start of the datasheet (pages 2-4), we learn that “Twc” is the “Internal Write Cycle Time”, which lasts for 5ms. This is not obvious! So, we need to wait for at least 5ms after completing our write cycle for the write to take place.
void write25AA128byte(unsigned short addr, unsigned char byte) { //set the write enable latch enable25AA128write(); //we split the 16-bit address into its upper and lower byte unsigned char addr_high = addr>>8; unsigned char addr_low = addr & 0xFF; //perform the write cycle setSSstate(LOW); exchangeSPIbytes(WRITE_25AA128); exchangeSPIbytes(addr_high); exchangeSPIbytes(addr_low); exchangeSPIbytes(byte); setSSstate(HIGH); //delay for Twc (internal write cycle time of >=5ms) delayMs(6); }
How do we know if our write to memory was successful? We need a read function! Look at the read sequence diagram and you’ll see that reading is very similar to writing — the only difference is that instead of sending a byte at the end, we send a garbage byte and read in the response. We also don’t need to wait for the internal write cycle time.
unsigned char read25AA128byte(unsigned short addr) { //split the 16-bit address into its upper and lower byte unsigned char addr_high = addr>>8; unsigned char addr_low = addr & 0xFF; //perform the read sequence setSSstate(LOW); exchangeSPIbytes(READ_25AA128); exchangeSPIbytes(addr_high); exchangeSPIbytes(addr_low); unsigned char byte = exchangeSPIbytes(0x00); //send a garbage byte to read the response setSSstate(HIGH); return byte; }
With our read and write functions squared away, we can test all of our code — let’s write some data to memory, then read it back and see if it’s correct:
//write some data to memory const char *memorytest = "hello world!\r\n"; unsigned short addr = 0; for(const char *p=memorytest; *p != '\0'; p++) { write25AA128byte(addr, *p); addr++; } //read it back and print it to the UART terminal writeUARTstring("reading back data from EEPROM memory:\r\n"); for(addr=0; addr<13; addr++) { char c = read25AA128byte(addr); writeUARTchar(c); }
If you see the message printed in the serial terminal, everything’s working! You can read and write to the EEPROM chip. But what if you aren’t reading the data back?
Using a logic analyzer
Logic analyzers are fantastic. Without them, debugging communication protocols would be a real pain (I would’ve introduced them to you in the UART tutorial, but I didn’t want to overload you… sorry!) but fortunately, they make our life much easier! There are some pretty cheap ones out there, and luckily for us we can use Saleae’s free logic analyzer software, which is a really well-designed and easy-to-use tool. We’ll use the logic analyzer to view our SPI signals and make sure they’re as we expect.
One quick note: a logic analyzer is a digital device that records and displays digital signals (obviously). This means that as useful as it is, one thing it can’t do it display analog signal characteristics, like signal integrity problems. If you wanted to assess your signal quality, an oscilloscope would be the tool of choice. Fortunately, we’re using a reasonably low clock speed, so we’re avoiding most of the signal integrity problems that can degrade a signal.
Download and run Logic, connect the logic analyzer to the MOSI/MISO/SCK/SS/GND lines, and plug the analyzer in to your computer. Before we can record data, let’s configure the software. First, press one of the arrows next to the big green “Start” button. A wizard pops up allowing us to select the sample rate (anything that’s a few times your SPI clock rate will work fine — I’m using 12MHz) and the recording length. Next, find the “Analyzers” area in the toolbar on the right-hand side of the GUI, click the ‘+’ button to add a SPI analyzer (or look through the “Show more analyzers” section… so many options!) and select the appropriate pin settings. The other settings’ default options are all correct.
Once the configuration is done, we’re ready to rock. Program your PIC with the previous code to test memory access, then turn off the power supply. Then start a recording with the logic analyzer, and while it’s running, power up the microcontroller again. Voila! We have data!
If you don’t see anything on your screen, don’t worry. The bottom-right of the GUI has a section with the decoded values of our data capture. You can scroll through this to see every byte we’ve just exchanged. For every transmission, you should see a ‘6’ (the WREN instruction), a ‘2’ (the WRITE instruction), a pair of bytes that increment for every transmission (the address we’re writing to) and a character from the string “hello world!\r\n”. You can also click on any of the decoded bytes and the main screen will zoom into its signal capture, along with labels on each byte.
Going further
You’re doing great! We learned how to use SPI — a protocol that’s used with a wide variety of ICs — and interface with an EEPROM chip, which is applicable to many practical devices. We can store configuration data and device parameters, record sensor voltages, and anything else we’d like. Note that many other EEPROM chips are accessed with the same interface, so our code can easily be repurposed for another IC (if, say, we need a different amount of memory).
So what else can we do with our newfound knowledge of SPI and EEPROM devices?
- SPI is a very popular protocol — lots of devices use it! Search on Digikey and find some other parts to play with. Graphics displays, standalone ADC chips (which can read faster and with a higher resolution than our PIC’s internal ADCs), and some sensors use SPI, so find a part, rip through the datasheet, and start talking to it!
- There’s plenty of ground we didn’t cover with our 25AA128 IC:
- The device supports page writes and reads — instead of sending the instruction and address bytes for every byte to read or write, we can access sequential bytes with a single read/write sequence, allowing us to access memory much faster. See if you can implement it.
- Instead of hard-coding a string to write to memory, as we did in our example, wouldn’t it be nice to have a generic EEPROM writer utility? Write a program that reads in data from the serial terminal and writes it to the EEPROM — then you can paste text into the terminal to be written.
- It’d also be nice to have a hex dump program that allows us to print EEPROM memory, showing the hex and ASCII representation of the data.
- Now that we’re comfortable using a logic analyzer, try it with UART communication — see how easy it is to see the data going back and forth? God, I just love logic analyzers…
Debugging
Now that we’re armed with a logic analyzer, how do we proceed if our SPI code doesn’t work? When I write code for any communication protocol and nothing happens, I look at the signals — half the time, there’s nothing there at all. If that’s the case, there’s a good chance that something is wrong with your peripheral configurations: you didn’t enable the MSSP module, the PPS routing is wrong, or you’re not writing data you want to transmit to the correct register (SSP1BUF).
If a signal is there, and it looks correct (the right bytes are being exchanged, the bit rate is as expected, etc) then you need to revisit the datasheet. Does the device you’re talking with have any special timing requirements? Maybe the clock needs to be inverted (there’s a bit for that)? Or maybe you’re just sending the wrong bytes entirely. This can be tricky — some datasheets are pretty exhausting to decipher. If you keep on it, you’ll eventually figure out what’s wrong. Usually, it’s the silliest thing possible.
Sample code