Direct Digital Synthesis (DDS) in a Timer Interrupt on the RP2040

V. Hunter Adams (vha3@cornell.edu)

Objective and page organization

This project contains a simple example of setting up a timer interrupt and an SPI channel on the RP2040. In particular, the RP2040 communicates with an SPI DAC, and uses Direct Digital Synthesis to generate an 800Hz sine thru the DAC.

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.

Please see the C SDK guide and the RP2040 datasheet.

All the code

/**
 * V. Hunter Adams
 * DDS of sine wave on MCP4822 DAC w/ ISR
 * 
 * Modified example code from Raspberry Pi
 * Copyright (c) 2020 Raspberry Pi (Trading) Ltd.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *
   GPIO 5 (pin 7) Chip select
   GPIO 6 (pin 9) SCK/spi0_sclk
   GPIO 7 (pin 10) MOSI/spi0_tx
   3.3v (pin 36) -> VCC on DAC 
   GND (pin 3)  -> GND on DAC 
 */

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

//DDS parameters
#define two32 4294967296.0 // 2^32 
#define Fs 40000
// the DDS units:
volatile unsigned int phase_accum_main;
volatile unsigned int phase_incr_main = (800.0*two32)/Fs ;//

// DDS sine table
#define sine_table_size 256
volatile int sin_table[sine_table_size] ;

// SPI data
uint16_t DAC_data ; // output value

//DAC parameters
// A-channel, 1x, active
#define DAC_config_chan_A 0b0011000000000000
// B-channel, 1x, active
#define DAC_config_chan_B 0b1011000000000000

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

// Timer ISR
bool repeating_timer_callback(struct repeating_timer *t) {
    // DDS phase and sine table lookup
    phase_accum_main += phase_incr_main  ;
    DAC_data = (DAC_config_chan_A | ((sin_table[phase_accum_main>>24] + 2048) & 0xffff))  ;

    // Write data to DAC
    spi_write16_blocking(SPI_PORT, &DAC_data, 1) ;

    return true;
}

int main() {
    // Initialize stdio
    stdio_init_all();
    printf("Hello, DAC!\n");

    // 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);

    // Map SPI signals to GPIO ports
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
    gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);
    gpio_set_function(PIN_CS, GPIO_FUNC_SPI) ;

    // === build the sine lookup table =======
    // scaled to produce values between 0 and 4096
    int ii;
    for (ii = 0; ii < sine_table_size; ii++){
         sin_table[ii] = (int)(2047*sin((float)ii*6.283/(float)sine_table_size));
    }

    // Create a repeating timer that calls repeating_timer_callback.
    // If the delay is >0 then this is the delay between the previous callback ending and the next starting.
    // If the delay is negative then the next call to the callback will be exactly x us after the
    // start of the call to the last callback
    struct repeating_timer timer;

    // Negative delay so means we will call repeating_timer_callback, and call it again
    // 25us (40kHz) later regardless of how long the callback took to execute
    add_repeating_timer_us(-25, repeating_timer_callback, NULL, &timer);
    while(1){
    }
    return 0;
}

Stepping thru the code

Let us step through each section of code, explaining and justifying each line.

Includes

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

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 include pulls in the hardware interface library for the SPI hardware peripheral. This hardware interface library is not included in pico/stdlib.h and must therefore be included separately. Don't forget to link these in the CMakeLists.txt file!

DDS globals

//DDS parameters
#define two32 4294967296.0 // 2^32 
#define Fs 40000

// the DDS units:
volatile unsigned int phase_accum_main;
volatile unsigned int phase_incr_main = (800.0*two32)/Fs ;//

// DDS sine table
#define sine_table_size 256
volatile int sin_table[sine_table_size] ;

The next chunk of code defines and declares a series of variables which will be used for Direct Digital Synthesis. For an extended explanation of the DDS algorithm, please see this webpage. The sine table is populated in main().

SPI globals

// SPI data
uint16_t DAC_data ; // output value

//DAC parameters
// A-channel, 1x, active
#define DAC_config_chan_A 0b0011000000000000
// B-channel, 1x, active
#define DAC_config_chan_B 0b1011000000000000

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

We declare a uint16_t variable which will be used to communicate over the SPI channel to the DAC (the DAC expects 16-bit transfers, hence the type uint16_t). The next lines of code sets the top 4 DAC configuration bits for both channels A and B. You can read about these configuration bits in the DAC datasheet, but note that the bottom 12 bits of DAC_config_chan_A and DAC_config_chan_B are all 0's. In the Timer ISR, 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. 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. We can determine which GPIO's are available for mapping to which hardware peripherals by looking at Fig. 2 of the Raspberry Pi Pico datasheet (also included below) or at the Function Select Table on page 111 of the C SDK manual.

missing
Output waveforms from DAC

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).

Timer callback function

// Timer ISR
bool repeating_timer_callback(struct repeating_timer *t) {
    // DDS phase and sine table lookup
    phase_accum_main += phase_incr_main  ;
    DAC_data = (DAC_config_chan_A | ((sin_table[phase_accum_main>>24] + 2048) & 0xffff))  ;

    // Write data to DAC
    spi_write16_blocking(SPI_PORT, &DAC_data, 1) ;

    return true;
}

Above is a callback function associated with the timer alarms on core 0. In this callback, the phase accumulator is incremented and used to index into the sine table and a single SPI write is performed.

Main

int main() {
    // Initialize stdio
    stdio_init_all();
    printf("Hello, DAC!\n");

    // 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);

    // Map SPI signals to GPIO ports
    gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
    gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
    gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);
    gpio_set_function(PIN_CS, GPIO_FUNC_SPI) ;

    // === build the sine lookup table =======
    // scaled to produce values between 0 and 4096
    int ii;
    for (ii = 0; ii < sine_table_size; ii++){
         sin_table[ii] = (int)(2047*sin((float)ii*6.283/(float)sine_table_size));
    }

    // Create a repeating timer that calls repeating_timer_callback.
    // If the delay is >0 then this is the delay between the previous callback ending and the next starting
    // If the delay is negative then the next call to the callback will be exactly x us after the
    // start of the call to the last callback
    struct repeating_timer timer;

    // Negative delay so means we will call repeating_timer_callback, and call it again
    // 25us (40kHz) later regardless of how long the callback took to execute
    add_repeating_timer_us(-25, repeating_timer_callback, NULL, &timer);
    while(1){
    }
    return 0;
}
Initialize UART
// Initialize stdio
stdio_init_all();
printf("Hello, DAC!\n");

The entry point for core 0 is main(). This function calls stdio_init_all() to setup stdio/uart on the RP2040 and prints a greeting.

SPI initialization and configuration
// 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);

To setup the SPI channel, we first call 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. For more information about SPI, please see here.

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.

GPIO Mapping
// Map SPI signals to GPIO ports
gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);
gpio_set_function(PIN_CS, 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. Not every pin can be mapped to every function! Double check the datasheet and pinout!

Building the sine table
// === build the sine lookup table =======
// scaled to produce values between 0 and 4096
int ii;
for (ii = 0; ii < sine_table_size; ii++){
     sin_table[ii] = (int)(2047*sin((float)ii*6.283/(float)sine_table_size));
}

This chunk of code populates the sin_table array. For convenience, this sine table is scaled to [-2047, 2047]. In the Timer ISR, we add 2047 to this sine table to send a value to the DAC that is in the range [0, 4096].

Declare a repeating timer

The next line of code in main() declares an object of type repeating_timer.

// Create a repeating timer that calls repeating_timer_callback.
struct repeating_timer timer;

The timer peripheral on the RP2040 has four alarms. Each of these alarms interrupt on a match of the lower 32 bits of the 64-bit counter. Each alarm outputs a separate interrupt. The particular interrupt that fires for a particular alarm is (it seems) whichever timer interrupt is associated with that alarm (TIMER_IRQ_0, TIMER_IRQ_1, TIMER_IRQ_2, TIMER_IRQ_3). Note that there may be more than one alarm associated with each of these underlying hardware alarms - so you can have up to four alarm pools. The callbacks for a particular alarm pool take place on the core from which the alarm pool was created, and they are called from the hardware alarm IRQ handler. Note that the repeating_timer library uses the default alarm pool. Unless the default configurations are changed, the default alarm pool is on core 0, allows for up to 16 separate concurrent timers (this could be configured up to 256), and utilizes hardware alarm 3.

Note that these callbacks are configured to take, as an argument, an object of type repeating_timer. The declaration of the repeating_timer struct is shown below, copied from the source code. Note also that the function which sets up these callbacks, described in the next section, is alarm_pool_add_repeating_timer_us(), as documented on page 217 of the C SDK guide.

/**
 * \brief Information about a repeating timer
 * \ingroup repeating_timer
 * \return
 */
struct repeating_timer {
    int64_t delay_us;
    alarm_pool_t *pool;
    alarm_id_t alarm_id;
    repeating_timer_callback_t callback;
    void *user_data;
};
Add repeating timer to alarm pool
// Negative delay so means we will call repeating_timer_callback, and call it again
// 25us (40kHz) later regardless of how long the callback took to execute
add_repeating_timer_us(-25, repeating_timer_callback, NULL, &timer);

We then add the repeating timer to the alarm pool which has just been created. As arguments, the function add_repeating_timer_us() function takes:

  1. The delay time in microsec. Note that a negative delay time means that the callback function will be called again in 25 us, regardless of how long the callback took to execute. If this were a positive value, the callback would be called again 25 us from the time that it exits the the callback on the previous call.
  2. The repeating timer callback function
  3. Any user data. to pass to store in the repeating_timer structure for use by the callback
  4. A pointer to the user owned structure to store the repeating timer info in. Beware this storage location must outlive the repeating timer, so be careful of using stack space.
Spin forever

Finally, we enter a while(1) loop from which we never exit.

while(1){
}
return 0;

It’s quite common for the main() function not to return. The return code of main() is ignored by the SDK runtime, and the default behavior is to hang the processor on exit.

CMakeLists.txt

Note that we have added hardware_spi to the linked libraries!

add_executable(dactest dactest.c)

pico_add_extra_outputs(dactest)

target_link_libraries(dactest pico_stdlib hardware_spi)

Download

Project available here.

Documentation