Chained-DMA signal generator thru SPI DAC on RP2040 (Raspberry Pi Pico)

V. Hunter Adams (vha3@cornell.edu)

Objective and page organization

This project was meant to provide an objective thru which to build understanding of DMA and SPI channels on the RP2040. For this project, I chained two DMA channels. One of those channels is triggered by a timer which is configured to overflow at audio-rate ($\approx 44$ kHz). This channel moves data from a sine table to the SPI transmit buffer. The SPI channel is configured to automatically transmit any new data which appears in its transmit buffer.

The other DMA channel is chained to the first. When the first DMA channel finishes traversing the sine table, it triggers the second channel. This channel writes to the control registers of the first DMA channel. In particular, it writes to the READ_ADDR register associated with the other DMA channel. This control register sets the address from which the data channel should start reading.

Because the data channel is chained to the control channel (and vice versa), each starts as soon as the other finishes. So, these two channels ping-pong off one another. The first finishes and triggers the second, the second finishes and triggers the first. The consequence is a persistent sine wave output from the DAC, with no code executing. (Note that the same could be accomplished by writing to the DMA channels' trigger registers, but chaining is easier).

All of the code is provided in a listing in the first section of this page. The rest of the page walks through the C source file from top to bottom, explaining each line of code. Lastly, I've included some plots output plots from the oscilloscope and compared the measured output frequency to the expected output frequency. The two agree.

All the code

/**
 *  V. Hunter Adams (vha3)
    Code based on examples from Raspberry Pi Co
    Sets up two DMA channels. One sends samples at audio rate to the DAC,
    (data_chan), and the other writes to the data_chan DMA control registers (ctrl_chan).
    The control channel writes to the data channel, sending one period of
    a sine wave thru the DAC. The control channel is chained to the data
    channel, so it is re-triggered after the data channel is finished. The data
    channel is chained to the control channel, so it is restarted as soon as the control
    channel finishes. The data channel is paced by a timer to perform transactions
    at audio rate.
 */

#include <stdio.h>
#include <math.h>
#include "pico/stdlib.h"
#include "hardware/dma.h"
#include "hardware/spi.h"

// Number of samples per period in sine table
#define sine_table_size 256

// Sine table
int raw_sin[sine_table_size] ;

// Table of values to be sent to DAC
unsigned short DAC_data[sine_table_size] ;

// Pointer to the address of the DAC data table
unsigned short * address_pointer = &DAC_data[0] ;

// A-channel, 1x, active
#define DAC_config_chan_A 0b0011000000000000

//SPI configurations
#define PIN_MISO 4
#define PIN_CS   5
#define PIN_SCK  6
#define PIN_MOSI 7
#define SPI_PORT spi0

// Number of DMA transfers per event
const uint32_t transfer_count = sine_table_size ;


int main() {

    // Initidalize stdio
    stdio_init_all();

    // Initialize SPI channel (channel, baud rate set to 20MHz)
    spi_init(SPI_PORT, 20000000) ;

    // Format SPI channel (channel, data bits per transfer, polarity, phase, order)
    spi_set_format(SPI_PORT, 16, 0, 0, 0);

    // Map SPI signals to GPIO ports, acts like framed SPI with this CS mapping
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
    gpio_set_function(PIN_CS, GPIO_FUNC_SPI) ;
    gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);

    // Build sine table and DAC data table
    int i ;
    for (i=0; i<(sine_table_size); i++){
        raw_sin[i] = (int)(2047 * sin((float)i*6.283/(float)sine_table_size) + 2047); //12 bit
        DAC_data[i] = DAC_config_chan_A | (raw_sin[i] & 0x0fff) ;
    }

    // Select DMA channels
    int data_chan = 0;
    int ctrl_chan = 1;

    // Setup the control channel
    dma_channel_config c = dma_channel_get_default_config(ctrl_chan);   // default configs
    channel_config_set_transfer_data_size(&c, DMA_SIZE_32);             // 32-bit txfers
    channel_config_set_read_increment(&c, false);                       // no read incrementing
    channel_config_set_write_increment(&c, false);                      // no write incrementing
    channel_config_set_chain_to(&c, data_chan);                         // chain to data channel

    dma_channel_configure(
        ctrl_chan,                          // Channel to be configured
        &c,                                 // The configuration we just created
        &dma_hw->ch[data_chan].read_addr,   // Write address (data channel read address)
        &address_pointer,                   // Read address (POINTER TO AN ADDRESS)
        1,                                  // Number of transfers
        false                               // Don't start immediately
    );

    // Setup the data channel
    dma_channel_config c2 = dma_channel_get_default_config(data_chan);  // Default configs
    channel_config_set_transfer_data_size(&c2, DMA_SIZE_16);            // 16-bit txfers
    channel_config_set_read_increment(&c2, true);                       // yes read incrementing
    channel_config_set_write_increment(&c2, false);                     // no write incrementing
    // (X/Y)*sys_clk, where X is the first 16 bytes and Y is the second
    // sys_clk is 125 MHz unless changed in code. Configured to ~44 kHz
    dma_timer_set_fraction(0, 0x0017, 0xffff) ;
    // 0x3b means timer0 (see SDK manual)
    channel_config_set_dreq(&c2, 0x3b);                                 // DREQ paced by timer 0
    // chain to the controller DMA channel
    channel_config_set_chain_to(&c2, ctrl_chan);                        // Chain to control channel


    dma_channel_configure(
        data_chan,                  // Channel to be configured
        &c2,                        // The configuration we just created
        &spi_get_hw(SPI_PORT)->dr,  // write address (SPI data register)
        DAC_data,                   // The initial read address
        sine_table_size,            // Number of transfers
        false                       // Don't start immediately.
    );


    // start the control channel
    dma_start_channel_mask(1u << ctrl_chan) ;

    // Exit main.
    // No code executing!!

}

Stepping thru the code

Includes

The first lines of code in the C source file include some header files. Two of these are standard C headers (stdio.h and math.h) and the others are headers which come from the C SDK for the Raspberry Pi Pico. The first of these, pico/stdlib.h is what the SDK calls a "High-Level API." These high-level API's "provide higher level functionality that isn’t hardware related or provides a richer set of functionality above the basic hardware interfaces." The architecture of this SDK is described at length in the SDK manual. All libraries within the SDK are INTERFACE libraries. pico/stdlib.h in particular pulls in a number of lower-level hardware libraries, listed on page 196 of the C SDK guide.

The next two includes pull in hardware API's which are not already brought in by pico/stdlib.h. These include hardware/dma.h and hardware/spi.h. As the names suggest, these two interface libraries give us access to the API's associated with the DMA and SPI peripherals on the RP2040. Don't forget to link these in the CMakeLists.txt file!

#include <stdio.h>
#include <math.h>
#include "pico/stdlib.h"
#include "hardware/dma.h"
#include "hardware/spi.h"

Global declarations and defines

// Number of samples per period in sine table
#define sine_table_size 256

// Sine table
int raw_sin[sine_table_size] ;

// Table of values to be sent to DAC
unsigned short DAC_data[sine_table_size] ;

// Pointer to the address of the DAC data table
unsigned short * address_pointer = &DAC_data[0] ;

// A-channel, 1x, active
#define DAC_config_chan_A 0b0011000000000000

//SPI configurations
#define PIN_MISO 4
#define PIN_CS   5
#define PIN_SCK  6
#define PIN_MOSI 7
#define SPI_PORT spi0

// Number of DMA transfers per event
const uint32_t transfer_count = sine_table_size ;

The next section of code #define's some parameter values, and declares some variables and arrays in global space. sine_table_size represents the number of elements in our sine table. The sine table will contain amplitudes for a single period of a sine wave, sine_table_size sets the number of samples that we will store.

We next declare two arrays with lengths equal to sine_table_size. We will populate these arrays in main(), but note that raw_sin is an array of ints and DAC_data is an array of unsigned shorts. As the names suggest, we will store raw sine wave amplitudes in raw_sin, and we will store the formatted bits that will be send to the DAC through the SPI channel in DAC_data. As described in the DAC datasheet, the DAC expects 16-bit packets through the SPI channel. The most significant 4 of these bits are configuration and control bits, and the least significant 12 are data (i.e. the 12-bit number in the range [0, 4096] that we want for the DAC to convert to a voltage). So, each element in the DAC_data array will contain a formatted version of the corresponding element in the raw_sin array. In particular, the sine wave amplitudes will be truncated to 12 bits, and the DAC control bits will be masked to the top 4 bits of each element in the array. The result will be that each element in the DAC_data array will be an unsigned short, the top 4 bits of which are DAC control bits, and the bottom 12 bits of which are data.

Note that, in another example, we masked the DAC configuration bits onto the data when we did the SPI transaction in the ISR. The DMA channel requires that all the data that it shuffles to the SPI transmit buffer be already formatted.

The next line of code creates a pointer to the address of the start of the DAC array. As we'll see later on, this is passed to the DMA channel as its starting read address.

The next line of code sets the top 4 DAC configuration bits. You can read about these configuration bits in the DAC datasheet, but note that the bottom 12 bits of DAC_config_chan_A are all 0's. In main(), we will mask the DAC data into these bottom 12 bits, maintaining the top 4 control bits.

The next chunk of code gives some names to a handful of GPIO ports for later association with the SPI channel. Note that the numbers in these lines of code correspond to GPIO port number and not to pin numbers. See the GPIO port numbers in the image below. Note also that these pins are not chosen arbitrarily. We've chosen a particular set of GPIO ports which are all associated with the same SPI channel (SPI0), and named each according to its available function on that SPI channel (MISO/RX, MOSI/TX, CS, SCK). We could have chosen different GPIO ports for each of these functions, but not arbitrary ports, only those with the same signals mapped to them. The final line in this chunk specifies the SPI channel which we are using, which is spi0. spi0 is declared in the spi.h header file (pico-sdk\src\rp2_common\hardware_spi\include\hardware\spi.h).

Lastly, we declare a const unsigned int in which we store the length of the array. This will be necessary for configuring the DMA channel.

missing
GPIO port names and functions

Dropping into main()

Initializing UART

The first line in main() is a call to stdio_init_all(). This function initializes stdio to communicate thru either UART or USB, depending on the configurations in the CMakeLists.txt file.

SPI initialization and configuration

The next two lines are copied below. The first of these is a call to spi_init(), which takes two arguments. The first is the name of the spi channel (which we'd previously defined to spi0), and the second is the baud rate for the channel. Per the DAC datasheet, this is configured to 20MHz. Note that an SPI channel must be initialized before it is configured, which is what happens in the second line of code.

spi_set_format configures the SPI channel to whichever mode the device with which the RP2040 is communicating requires. static void spi_set_format (spi_inst_t *spi, uint data_bits, spi_cpol_t cpol, spi_cpha_t cpha, __unused spi_order_t order) takes 4 arguments. The first is the SPI instance specifier. The second is the number of data bits per transfer. Since the DAC expects 16 bit transfers, this is configured to 16. CPOL and CPHA set the SPI clock polarity and phase (i.e. the "mode"). Finally order is not presently configurable, but sets the endianness of the transfer. Sends MSB first.

// Initialize SPI channel (channel, baud rate set to 20MHz)
    spi_init(SPI_PORT, 20000000) ;
    // Format (channel, data bits per transfer, polarity, phase, order)
    spi_set_format(SPI_PORT, 16, 0, 0, 0);

GPIO mapping

// Map SPI signals to GPIO ports, acts like framed SPI with this CS mapping
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
    gpio_set_function(PIN_CS, GPIO_FUNC_SPI) ;
    gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);

The next chunk of code is a series of calls to gpio_set_function(). Each of these calls maps a particular GPIO port (#define'd above) to a particular function. This takes two arguments. The first is the GPIO number, and the second specifies the function. The function specifier comes from the enum listed below:

enum gpio_function { GPIO_FUNC_XIP = 0, GPIO_FUNC_SPI = 1, GPIO_FUNC_UART = 2, GPIO_FUNC_I2C = 3, GPIO_FUNC_PWM = 4, GPIO_FUNC_SIO = 5, GPIO_FUNC_PIO0 = 6, GPIO_FUNC_PIO1 = 7, GPIO_FUNC_GPCK = 8, GPIO_FUNC_USB = 9, GPIO_FUNC_NULL = 0xf }

Something to note! We are mapping the chip select line here. If instead we made the chip select line a digital output pin, we would need to toggle it in software before and after each SPI transmission. Configured as shown here, the SPI channel is setup in a way that is very similar to "Framed SPI Mode" for the PIC32." That is, the RP2040 will automatically toggle the chip select line if it is mapped using gpio_set_function.

Building the sine table

// Build sine table
    int i ;
    for (i=0; i<(sine_table_size); i++){
        raw_sin[i] = (int)(2047 * sin((float)i*6.283/(float)sine_table_size) + 2047); //12 bit
        DAC_data[i] = DAC_config_chan_A | (raw_sin[i] & 0x0fff) ;
    }

This chunk of code populates the raw_sin and DAC_data arrays. Note that DAC_data is populated with 12 data bits and the 4 DAC configuration bits.

Obtaining DMA channels

// Select DMA channels
    int data_chan = 0;
    int ctrl_chan = 1;

We claim two DMA channels. There are 12 DMA channels in total, we are choosing to use channels 0 and 1.

Configuring the control DMA channel

// Setup the control channel
    dma_channel_config c = dma_channel_get_default_config(ctrl_chan);   // default configs
    channel_config_set_transfer_data_size(&c, DMA_SIZE_32);             // 32-bit txfers
    channel_config_set_read_increment(&c, false);                       // no read incrementing
    channel_config_set_write_increment(&c, false);                      // no write incrementing
    channel_config_set_chain_to(&c, data_chan);                         // chain to data channel

We declare an object of type dma_channel_config and name that object c. This object is a struct and, initially, the fields of that struct are populated with those provided by the call to dma_channel_get_default_config(ctrl_chan). This function is defined in pico-sdk\src\rp2_common\hardware_dma\include\hardware\dma.h, and copied below:

/*! \brief  Get the default channel configuration for a given channel
 *  \ingroup channel_config
 *
 * Setting | Default
 * --------|--------
 * Read Increment | true
 * Write Increment | false
 * DReq | DREQ_FORCE
 * Chain to | self
 * Data size | DMA_SIZE_32
 * Ring | write=false, size=0 (i.e. off)
 * Byte Swap | false
 * Quiet IRQs | false
 * Channel Enable | true
 * Sniff Enable | false
 *
 * \param channel DMA channel
 * \return the default configuration which can then be modified.
 */
static inline dma_channel_config dma_channel_get_default_config(uint channel) {
    dma_channel_config c = {0};
    channel_config_set_read_increment(&c, true);
    channel_config_set_write_increment(&c, false);
    channel_config_set_dreq(&c, DREQ_FORCE);
    channel_config_set_chain_to(&c, channel);
    channel_config_set_transfer_data_size(&c, DMA_SIZE_32);
    channel_config_set_ring(&c, false, 0);
    channel_config_set_bswap(&c, false);
    channel_config_set_irq_quiet(&c, false);
    channel_config_set_enable(&c, true);
    channel_config_set_sniff_enable(&c, false);
    return c;
}

In the remainder of this code chunk, we make calls to a series of SDK functions to change some of these default channel configurations. Not all of these are strictly necessary (since they configure the channel to default settings), but are included for clarity. In particular, we set the transfer data size to 32 bits and turn off read/write incrementing. We also chain this channel to the data channel.

Associating the configured channel with the DMA control channel

dma_channel_configure(
        ctrl_chan,                          // Channel to be configured
        &c,                                 // The configuration we just created
        &dma_hw->ch[data_chan].read_addr,   // Write address (data channel read address)
        &address_pointer,                   // Read address (POINTER TO AN ADDRESS)
        1,                                  // Number of transfers
        false                               // Don't start immediately
    );

We now have a claimed dma channel (ctrl_chan) and we have a configured channel (c). We need to associate the two, and do the remaining DMA configurations for this channel. We do that with a call to dma_channel_configure. The first argument is the channel. The second is a pointer the the dma config structure. The third is the source address (for this channel, we are writing to the READ_ADDR register of the other DMA channel). The fourth argument is the source address, which is a pointer to the address of the beginning of the DAC data array. The fifth argument specifies the number of transfers to execute (1), and the last argument being false means don't start the channel right away.

Configure the data DMA channel

// Setup the data channel
    dma_channel_config c2 = dma_channel_get_default_config(data_chan);  // Default configs
    channel_config_set_transfer_data_size(&c2, DMA_SIZE_16);            // 16-bit txfers
    channel_config_set_read_increment(&c2, true);                       // yes read incrementing
    channel_config_set_write_increment(&c2, false);                     // no write incrementing
    // (X/Y)*sys_clk, where X is the first 16 bytes and Y is the second
    // sys_clk is 125 MHz unless changed in code. Configured to ~44 kHz
    dma_timer_set_fraction(0, 0x0017, 0xffff) ;
    // 0x3b means timer0 (see SDK manual)
    channel_config_set_dreq(&c2, 0x3b);                                 // DREQ paced by timer 0
    // chain to the controller DMA channel
    channel_config_set_chain_to(&c2, ctrl_chan);                        // Chain to control channel

Similarly to before, we declare an object of type dma_channel_config and name this one c2. We start with the default configurations, and then change some of these configurations with subsequent calls to various channel_config functions from the SDK.

We set the transfer data size to 16 bits, since that's what the DAC expects.

We enable read incrementing, and disable write incrementing. This means that the DMA channel will increment from one read address to the next between subsequent transfers, but it will always write to the same write address.

We configure the DMA timer 0 with a call to dma_timer_set_fraction(0, 0x0017, 0xffff) ;. As described in the SDK, the first argument specifies which DMA timer we are configuring (there are four from which we can select), the second argument is the numerator, and the third argument is the denominator. The timer runs at system_clock_freq*numerator/demonominator. In this case, we call it with the value 0x0017 for the numerator and ffff for the denominator. This will configure the timer to overflow at (0x0017/0xffff)*sys_clk Hz, or (23/65535)*sys_clk Hz. With a default sys_clk of 125MHz, this gives us (3.51e-4)*(125MHz) $\approx$ 43,870 Hz.

We set the transfer request signal to Timer 0 by making the second argument of channel_config_set_dreq 0x3b (see the SDK guide). There exists a whole library of DREQ signals from a variety of peripherals that can be used to pace DMA channels.

We chain the control channel to the data channel. By calling channel_config_set_chain_to(&c2, ctrl_chan);, the control dma channel will start automatically when the data channel finishes.

Associating the configured channel with the DMA data channel

dma_channel_configure(
        data_chan,                  // Channel to be configured
        &c2,                        // The configuration we just created
        &spi_get_hw(SPI_PORT)->dr,  // write address (SPI data register)
        DAC_data,                   // The initial read address
        sine_table_size,            // Number of transfers
        false                       // Don't start immediately.
    );

Very similar to before. The first argument is the channel to be configured. The second is a pointer to the dma config structure c2. The third is the destination address which, for the data channel, is the SPI channel 0 write buffer. The fourth argument is the initial source address, which points to the beginning of the DAC_data array (remember that we've configured this channel to increment thru read addresses, wrapping at the end of the array). The fifth argument is the number of transfers to execute (the size of the array). The final argument prevents the DMA channel from starting instantly.

Start the control channel

// start the control channel
    dma_start_channel_mask(1u << ctrl_chan) ;

We start the control DMA channel. This will write to a triggered control register of the data channel, starting the first transfer of the the sine table out to the DAC. When the data channel completes, it triggers the control channel again (because we've chained the two). This starts the process over, and the two DMA channels ping-pong off of one another indefinitely. Our program exits main, but these DMA channels continue to operate.

Expected and actual results

We have a 256-element sine table array, and the data channel is configured to send a new sample to the DAC at 43,870 Hz. So, one period of the sine wave will take $\frac{1}{43870} \cdot 256 = 5.835\text{ ms}$. Thus, the frequency of this output wave is $\frac{1}{5.835\times 10^{-3}} = 171.36 Hz.$

The scope trace below shows the output from the DAC. In tiny text at the bottom is a measure of the frequency of the wave, which is 171.4 Hz. We've obtained the expected result.

missing
Scope trace of DAC output

CMakeLists.txt

We linked the DMA and SPI hardware libraries.

add_executable(dma-demo dma-demo.c)

target_link_libraries(dma-demo pico_stdlib hardware_dma hardware_spi)

# create map/bin/hex file etc.
pico_add_extra_outputs(dma-demo)

Documentation

The resources for this project include the RP2040 C SDK user's guide and the RP2040 datasheet.

By the way

When this example was first assembled, there did not yet exist an SDK function for manipulating timer 0. But, the SDK is organized well enough that it's not too difficult to write a custom function. Here's how that's done.

Custom function for manipulating DMA timer register

The SDK function channel_config_set_dreq(dma_channel_config *c, uint dreq) allows the programmer to select a transfer request signal for a particular DMA channel. This is described on page 98 of the SDK guide and summarized here.

The first argument is a pointer to channel configuration data, and the second is the dreq source. To quote the SDK guide: "Sources for TREQ signals are internal (TIMERS) or external (DREQ, a Data Request from the system). 0x0 to 0x3a → select DREQ n as TREQ 0x3b → Select Timer 0 as TREQ 0x3c → Select Timer 1 as TREQ 0x3d → Select Timer 2 as TREQ (Optional) 0x3e → Select Timer 3 as TREQ (Optional) 0x3f → Permanent request, for unpaced transfers."

At present, there is no SDK function for manipulating the TIMER0, TIMER1, TIMER2, or TIMER3 registers. However, these registers are described on page 109 of the RP2040 datasheet, and they are mapped to the dma_hw_t struct in pico-sdk\src\rp2040\hardware_structs\include\hardware\structs\dma.h. The struct which organizes the DMA control registers is copied below from that document:

typedef struct {
    dma_channel_hw_t ch[NUM_DMA_CHANNELS];
    uint32_t _pad0[16 * (16 - NUM_DMA_CHANNELS)];
    io_ro_32 intr;
    io_rw_32 inte0;
    io_rw_32 intf0;
    io_rw_32 ints0;
    uint32_t _pad1[1];
    io_rw_32 inte1;
    io_rw_32 intf1;
    io_rw_32 ints1;
    io_rw_32 timer[4];
    io_wo_32 multi_channel_trigger;
    io_rw_32 sniff_ctrl;
    io_rw_32 sniff_data;
    uint32_t _pad2[1];
    io_ro_32 fifo_levels;
    io_wo_32 abort;
} dma_hw_t;

We can manipulate each of the TIMER registers by touching the timer[4] array in this struct. The next chunk of code creates a function for doing this, and is listed below:

/*! Added by Hunter
    Modifies the TIMER0 register of the dma channel
 */
static inline void dma_channel_set_timer0(uint32_t timerval) {
    dma_hw->timer[0] = timerval;
}

This modifies TIMER0 in particular. So, we'll configure the transfer request signal for the DMA channel to be TIMER0 by setting the dreq source to 0x3b, as described in the SDK manual and the text above. Page 108 of the RP2040 datasheet describes the TIMER0 registers as follows:

"Pacing (X/Y) Fractional Timer. The pacing timer produces TREQ assertions at a rate set by ((X/Y) * sys_clk). This equation is evaluated every sys_clk cycles and therefore can only generate TREQs at a rate of 1 per sys_clk (i.e. permanent TREQ) or less."

Note that, by default, the sys_clk for the RP2040 is 125 MHz.