SPI and the Pi Pico - Part 2 (W5100S)

Overview

In Part 1, we looked at the operating principles behind SPI. In Part 2, we'll look at the basics of using the Pi Pico SDK to communicate with devices connected via SPI. Specifically, we'll look at the WIZnet Ethernet HAT, a wired Ethernet controller that has the same footprint as the Pi Pico. When used with appropriate connectors, the Pico and the HAT can be plugged directly together, eliminating the need for breadboarding for the purposes of the examples we will explore. The WIZnet Ethernet HAT is cheap to buy, though quite complicated in its operation. However, its complexity gives us the chance to explore various features of the Pi Pico hardware whose use might not be warranted in simpler cases. 

WIZnet Ethernet Hat and Pi Pico
WIZnet Ethernet HAT with Pi Pico

The Ethernet HAT is based on the W5100S embedded Ethernet controller chip whose datasheet can be found here. The chip can be used to send Ethernet frames directly, but also contains a full TCP/IP stack to simplify application development. For a full understanding of its operation, you'll need to read the datasheet, but for this article we'll try to concentrate on demonstrating the SPI communication and leave the details of Ethernet communication to a subsequent post.

The W5100S and SPI 

The W5100S chip can be connected to its embedded controller either via a parallel bus or via SPI. Only the SPI connection is available on the WIZnet Ethernet HAT. The W5100S supports both 10Mbit/sec and 100Mbit/sec Ethernet links and its SPI connection is specified to operate at up to 70Mbit/sec with an 8-bit word size. However, there is an Errata Sheet which reminds users of the timing constraints listed in the datasheet which mean that the SPI can only operate at such high speeds with specific delays to allow for data to become valid. For the purposes of this article, we'll run the SPI at a much lower speed to avoid running into these constraints.

The W5100S is modeled as a 32KB address space that can be read and written via the SPI. The lower addresses represent common registers that monitor and control the operation of the entire device (for example, controlling interrupts). From address 0x0400 are control registers for four separate "sockets". A "socket" is used for each logical communication channel (for example, for an individual TCP/IP connection). Addresses above 0x4000 are reserved for transmit and receive buffers. The basic principle is that you set up the device using the common registers, set up and control each individual connection using the socket registers, copying the data between the Pi Pico's main memory and the transmit and receive buffers on the HAT. You can see the memory map below.

W5100S Memory Map
W5100S Memory Map
 

The W5100S defines a protocol which operates over the SPI to access its 32KB address space. For a read operation (see section 5.1.3 of the datasheet):

  • The Pi Pico sends 0x0f on MOSI to indicate a read operation is in progress.
  • It then sends two address bytes on MOSI to indicate the address to read from.
  • During this period, the W5100S sends 0x00, 0x01 and 0x02 successively on MISO which the Pi Pico can simply ignore.
  • As long as the clock continues to run, the W5100S sends the contents of the addressed location and then the contents of successive locations on MISO. 

For a write operation (see section 5.1.2 of the datasheet):

  • The Pi Pico sends 0xf0 on MOSI to indicate a write operation is in progress.
  • It then sends two address bytes on MOSI to indicate the address to write to.
  • During this period, the W5100S sends 0x00, 0x01 and 0x02 successively on MISO which the Pi Pico can simply ignore.
  • As long as the clock continues to run, the data on MOSI will be written to the W5100S starting at the addressed location and continuing in successive locations. 
  • While the data is being written, the W5100S will transmit 0x00 on MISO.

The ability to read and write a stream of bytes is a feature of the W5100S that was not present in its predecessor, the W5100, and helps mitigate the protocol overhead - without it each byte read or written would require 4 bytes to be exchanged over SPI.

Programming basics

The Pi Pico contains two hardware SPI ports, each of which can be mapped to a number of different (mutually exclusive) sets of GPIO pins. It's not necessary to map all 4 control signals, though you will need at least SCLK and one of MISO or MOSI.

The ports contain FIFOs to buffer the data between the SPI hardware and the CPU to allow the strict SPI timing requirements to be met in the event that the CPU is temporarily otherwise occupied. The ports may also be linked to a DMA controller so that transfers can proceed autonomously.

To save accessing the SPI hardware directly, the C SDK for the Pi Pico contains 3 simple functions that programs can use to access SPI devices. These are:

  1. spi_read_blocking
  2. spi_write_blocking 
  3. spi_write_read_blocking  

The first reads a series of bytes from the SPI device whilst sending a repeated constant value on the MOSI line: some devices expect the transmitter to send a certain value while the device responds. The second transmits a series of bytes from the Pico to the device, while ignoring any data that is received. The third transmit a series of bytes and simultaneously saves the data it receives.

It's important to note that if you allow the SPI hardware to control CS, each of these three functions will create a separate bus transaction - that is, CS will be asserted at the start and return high at the end. A lot of SPI devices expect to receive a command and then send a response as a single bus transaction and expect CS to remain asserted throughout: if you call spi_write_blocking followed by spi_read_blocking the device will not respond as CS will have changed state between the two calls. If you use spi_write_read_blocking in this scenario, you will have to transmit not only the command, but also as many additional bytes are in the expected response and that the data you receive back will also contain whatever data the device sent while the command was being sent to it: SPI is a full duplex channel, even if you operate a half-duplex protocol over it.

If you're happy to use the blocking function calls, it's probably easier to control CS manually, in which case spi_write_blocking followed by spi_read_blocking will be possible within the same bus transaction. If you want to use DMA, you might need to leave it all to the hardware, depending on your other timing constraints. 

Simple Program

At address 0x80, the W5100S has a "version" register, that confirms the chip type and should contain the value 0x51. Our first program can simply attempt to read the value of the version register and confirm that the correct value is returned.

Using the headers supplied and plugging the Pi Pico and HAT boards together, header pin 21 (GPIO 16) is MISO, header pin 22 (GPIO 17) is CS, header pin 24 (GPIO 18) is SCLK and header pin 25 (GPO 19) is MOSI (there are a couple other connections we will ignore for now. These pins are only available to hardware SPI channel 0, so that's the one we have to use.

The complete program is shown below.

Lines 6-10 define constants indicating which SPI port and which GPIO pins are being used.  In lines 46-49 we set up the SPI hardware for port 0 to run at 5MHz and to operate in the default mode (mode 0). We set the pins corresponding to MISO, MOSI and SCLK to be controlled by the SPI hardware (GPIO_FUNC_SPI) but we leave the CS under program control and set it inactive (high).

At line 20, we define a function, ws5100s_read, to read a sequence of bytes from the W5100S. This makes use of the spi_write_blocking and spi_read_blocking functions provided by the SDK. In lines 27-29 we construct in a local buffer the command to read data from the address passed into the function by the caller. At line 31, we assert CS and in the following lines we send the command and then read the response. At line 34, we deassert CS once again.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/spi.h"

// SPI Defines
#define SPI_PORT spi0
#define PIN_MISO 16
#define PIN_CS   17
#define PIN_SCK  18
#define PIN_MOSI 19

//
// Read a sequence of bytes from the WS5100S
//  address         location in WS5100S address space to begin
//  buffer          pointer to buffer space
//  count           length of supplied buffer
//
// Returns number of bytes read
//
int ws5100s_read (int address , uint8_t *buffer, int count)
    {
    int n;
    const uint8_t WS5100CMDREAD = 0x0f;                     // READ command

    uint8_t ws5100cmd[3];                                   // Command buffer

    ws5100cmd[0] = WS5100CMDREAD;                           // Construct READ cmd
    ws5100cmd[1] = address >> 8;                            // ... address upper byte
    ws5100cmd[2] = address & 0xff;                          // ... address lower byte     

    gpio_put(PIN_CS, 0);                                    // Assert chip select
    spi_write_blocking(SPI_PORT, &ws5100cmd[0], 3);         // Send command
    n = spi_read_blocking(SPI_PORT, 0, buffer, count);      // Receive response
    gpio_put(PIN_CS, 1);                                    // Deassert chip select

    return n;
    }

int main()
    {
    uint8_t buffer[1];                                      // Data buffer
 
    stdio_init_all();

    // SPI initialisation.
    spi_init(SPI_PORT, 5000*1000);                          // Speed 5 MHz
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);             // MISO GPIO
    gpio_set_function(PIN_SCK,  GPIO_FUNC_SPI);             // SCLK GPIO
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);             // MOSI GPIO
 
    gpio_init(PIN_CS);                                      // Chip select high
    gpio_set_dir(PIN_CS, GPIO_OUT);
    gpio_put(PIN_CS, 1);

    sleep_ms(10*1000);                                      // Initial pause

    while (true) 
        {
        ws5100s_read (0x0080, &buffer[0], 1);
        printf("Version is %02x\n", buffer[0]);

        sleep_ms(2*1000);
        }
    }

If we compile and run this program, we would expect to see repeated lines of output similar to the following: 

Version is 51

Of course, using the blocking functions provided by the SDK, the CPU is mostly in a tight loop while the data is being transmitted and received, but the time is bounded by the SPI clock frequency and the number of bytes being exchanged.

Hardware Control of CS 

As previously mentioned, if you want the SPI hardware to control CS, the problem becomes slightly more complicated - the back-to-back function calls won't work and we need to use spi_write_read_blocking instead and to do this we need transmit and receive buffers of the same size as we will be sending and receiving the same number of bytes. Furthermore, the response we want won't be at the start of the receive buffer. What we will transmit and receive is shown in the following diagram:

Simultaneous SPI Write and Read

Our transmit buffer has to contain not only the READ command, but an additional byte to send while the W5100S version number is being received. Similarly, we will receive the data 0x00, 0x01 and 0x02 while the READ command is being transmitted. To create a function such as ws5100s_read we would have to resize our transmit buffer to match the amount of data we expect to receive - and we also have to take into account that there will be more data in the receive buffer than is actually required. Fortunately, we can cheat a little. If the caller is prepared to give us a buffer big enough to contain all of the received data - including the meaningless bytes that are sent in the command phase - we can use the same buffer for transmit and receive. The spi_write_read_blocking function puts the first few bytes to be transmitted into the SPI hardware FIFO before any data is received (the FIFO is 8 bytes and the command is only 3) and the W5100S doesn't care what data is on the MOSI line once the command has been processed, so this is all perfectly safe.

We can now modify our program as shown. 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/spi.h"

// SPI Defines
#define SPI_PORT spi0
#define PIN_MISO 16
#define PIN_CS   17
#define PIN_SCK  18
#define PIN_MOSI 19

//
// Read a sequence of bytes from the WS5100S
//  address         location in WS5100S address space to begin
//  buffer          pointer to buffer space (must be 3 bytes larger than required data)
//  buflen          length of supplied buffer
//  count           number of bytes to be read (must be <= buflen -3)
//
// Returns pointer within buffer to received data, or 0 
//
uint8_t *ws5100s_read_hwcs (int address , uint8_t *buffer, size_t buflen, int count)
    {
        int n;
        const uint8_t WS5100CMDREAD = 0x0f;
       
        if (buflen < count + 3)
            return 0;

        buffer[0] = WS5100CMDREAD;                          // Construct READ cmd
        buffer[1] = address >> 8;                           // Send address upper byte
        buffer[2] = address & 0xff;                         // Send address lower byte     

        n = spi_write_read_blocking(SPI_PORT, buffer, buffer, count+3);
        return &buffer[3];                                  // Start of received data
    }

int main()
    {
    uint8_t buffer[4];                                                      // Data buffer
    uint8_t *p;

    stdio_init_all();

    // SPI initialisation.
    spi_init(SPI_PORT, 5000*1000);                                          // Speed 5 MHz
    spi_set_format(SPI_PORT, 8, SPI_CPOL_1, SPI_CPHA_1, SPI_MSB_FIRST);     // 8 bits, mode 3
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);                             // MISO GPIO
    gpio_set_function(PIN_SCK,  GPIO_FUNC_SPI);                             // SCLK GPIO
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);                             // MOSI GPIO
    gpio_set_function(PIN_CS,   GPIO_FUNC_SPI);                             // CS GPIO

    sleep_ms(10*1000);                                                      // Initial pause

    while (true) 
        {
        p = ws5100s_read_hwcs (0x0080, &buffer[0], 4, 1);
        if (p)
            printf("Version is %02x\n", p[0]);

        sleep_ms(2*1000);
        }
    }

The new function ws5100s_read_hwcs now distinguishes between the size of the buffer it has been passed and the number of bytes to be retrieved from the W5100S. There has to be room in the buffer for 3 additional bytes, which are used to store the READ command that has to be transmitted. The command is stored in the first 3 bytes of the buffer and spi_write_read_blocking is called to cause the transmit and receive operations to take place simultaneously as part of one bus transaction. Instead of returning the count of bytes read, the routine returns a pointer to the position in the buffer where the requested data is located.

To handle the CS control, line 50 now changes the function of the GPIO pin to GPIO_FUNC_SPI. Line 46 is a little more mysterious. As previously discussed, the Pico hardware SPI pulses the CS line in SPI mode 0 (the default) and the W5100S doesn't like this. However, it also supports mode 3 in which the CS line remains low - so we need to change the mode in which the SPI hardware operates otherwise there will be no response from the device.

Using DMA

Finally in this part, just a brief overview of using the SPI hardware in conjunction with DMA. We can use some of the ideas from our previous example to devise code that will allow the DMA controller to handle the transfer and leave the CPU free to get on with other work. The advantage may not be obvious in this rather trivial example, but if you have to copy a full-sized Ethernet frame from the W5100S to the Pico for processing, the blocking calls may significantly reduce the potential throughput.

We have to use two separate DMA channels, one to transmit and one to receive, but we can once again share one buffer for both purposes: the DMA is paced by the channel FIFO and so the command bytes will be moved from the buffer to the transmit FIFO long before they are overwritten by the data that is received. 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/spi.h"
#include "hardware/dma.h"

// SPI Defines

#define SPI_PORT spi0
#define PIN_MISO 16
#define PIN_CS   17
#define PIN_SCK  18
#define PIN_MOSI 19

uint8_t *ws5100_read_dma (int address , uint8_t *buffer, size_t buflen, int count)
    {
    const uint8_t WS5100CMDREAD = 0x0f;
    uint dma_tx, dma_rx;

    if (buflen < count + 3)
            return 0;

    dma_tx = dma_claim_unused_channel(true);
    dma_rx = dma_claim_unused_channel(true);

    dma_channel_config ct = dma_channel_get_default_config(dma_tx);
    dma_channel_config cr = dma_channel_get_default_config(dma_rx);

    buffer[0] = WS5100CMDREAD;
    buffer[1] = address >> 8;
    buffer[2] = address & 0xff;

    channel_config_set_transfer_data_size(&ct, DMA_SIZE_8);
    channel_config_set_dreq(&ct, spi_get_dreq(spi_default, true));
    dma_channel_configure
            (
            dma_tx,                                     // Transmit channel
            &ct,
            &spi_get_hw(spi_default)->dr,               // Write address
            buffer,                                     // Read address
            count+3,                                    // Transfer count
            false                                       // Don't start
            ); 

    channel_config_set_transfer_data_size(&cr, DMA_SIZE_8);
    channel_config_set_dreq(&cr, spi_get_dreq(spi_default, false));
    channel_config_set_read_increment(&cr, false);
    channel_config_set_write_increment(&cr, true);
    dma_channel_configure
            (
            dma_rx, 
            &cr,
            buffer,                                     // Write address
            &spi_get_hw(spi_default)->dr,               // Read address
            count+3,                                    // Transfer count
            false                                       // Don't start
            ); 

    dma_start_channel_mask((1u << dma_tx) | (1u << dma_rx));

    dma_channel_wait_for_finish_blocking(dma_rx);
    dma_channel_wait_for_finish_blocking(dma_tx);
    dma_channel_cleanup(dma_tx);
    dma_channel_cleanup(dma_rx);
    dma_channel_unclaim(dma_tx);
    dma_channel_unclaim(dma_rx);

    return &buffer[3] ;
    }

  int main()
    {
    stdio_init_all();

    spi_init(SPI_PORT, 5000*1000);                                          // Speed 5 MHz
    spi_set_format(SPI_PORT, 8, SPI_CPOL_1, SPI_CPHA_1, SPI_MSB_FIRST);     // 8 bits, mode 3
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);                             // MISO GPIO
    gpio_set_function(PIN_SCK,  GPIO_FUNC_SPI);                             // SCLK GPIO
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);                             // MOSI GPIO
    gpio_set_function(PIN_CS,   GPIO_FUNC_SPI);                             // CS GPIO

    sleep_ms(10*1000);                                                      // Initial pause
  
    while (true) 
        {
        int n;
        uint8_t buffer[4], *p;

        p = ws5100_read_dma (0x0080, &buffer[0], 4, 1);
        if (p)
            printf("Version is %02x\n", p[0]);

        sleep_ms(2*1000);
        }   
    }

The mechanics of the DMA transfer are fairly straightforward. In lines 22-26 we get two unused DMA channels and set the default configuration. In lines 32-33 we set the transmit channel to work byte by byte and configure the correct DREQ to pace the it. Lines 34-42 set up the channel so it transfers data from the buffer to the SPI hardware. Lines 44 to 56 similarly prepare the receive channel. Line 58 starts both channels simultaneously.

In a real world situation, there would only be any point in using DMA if the main thread continued to do useful work while the DMA engine was active, perhaps later responding to a completion interrupt. In this case, we simply await the DMA completion (lines 60 and 61) and then clean up (lines 62-65).

Summary

We've looked at the basics of using the SPI hardware on the Pi PIco to communicate with a real SPI peripheral. We've looked at the various methods of controlling the CS line and seen how to use the SDK blocking read and write functions and a simple example of using DMA instead. 

In a future post, there will be more information on using the W5100S as an Ethernet interface rather than simply as a tutorial example.

Comments