Using the Raspberry Pi Pico C/C++ SDK

A dissection of the Blinky example code

V. Hunter Adams (vha3@cornell.edu)


Purpose of this webpage

The purpose of this webpage is to provide a brief and practical introduction to the Raspberry Pi Pico SDK. The focus of this webpage is to describe how to use the SDK. Please see this webpage and (even better) the SDK guide for a description of how it works.


What is an SDK, and why do we need one?

The RP2040 microcontroller, like nearly all microcontrollers, contains a CPU (or, in the case of the RP2040, two CPU's) and a whole collection of hardware peripherals. These hardware peripherals include circuits and simple processors that are separate from the main CPU, but that can be configured to communicate with the main CPU. Programming a microcontroller is an exercise in hardware/software co-design, where "hardware" not only includes that which you build and interface with the GPIO ports, but also includes the internal peripherals.

The program that we write for the microcontroller not only describes the set of instructions that the CPU should execute, it also configures the CPU, and configures/controls the hardware peripherals. Performing these configurations, for nearly all microcontrollers, means setting the values of registers.

The RP2040 datasheet describes every register in the RP2040 and it's various configurations. For example, see page 111. The register CH0_READ_ADDR, which is at address 0x50000000, sets the read address for DMA channel 0. On page 542 of the datasheet, you can see that the register TIMERLR, located at 0x4005400c, contains the lower 32 bits of a 64-bit internal timer. There are a ton of registers, and the functions of many of the registers are coupled. For CPU's as configurable as the Arm Cortex-M0's in the RP2040, getting all these register settings right could get very cumbersome indeed.

So, the Raspberry Pi company has created an SDK (Software Development Kit). This SDK is a library of C macros and functions that abstract register settings into function calls. This makes configuring and programming the RP2040 way easier. This webpage is intended to give you a practical understanding of how to use the Raspberry Pi Pico C/C++ SDK. A separate webpage describes how the SDK works in more technical detail. All information on this webpage comes from the C SDK guide.


A brief summary of the SDK architecture

The SDK for the Pico is arranged heirarchically. This structure is described in detail on this webpage and in chapter 2 of the C SDK guide, but let me briefly summarize that heirarchy here.

Registers

At the bottom of this heirarchy are the registers. In the SDK, the hardware_regs library is a complete set of include files for all the RP2040 registers. If you click through some of the files in this library, you'll find long lists of #define statements which name each register. For example, here are the registers which configure and control the timers. Compare the registers described in this file with the list of registers on page 540 of the RP2040 datasheet. You can see that they are all represented.

Registers are organized into structs

The SDK organizes all of these registers into structs. Here are all the structs. And here is the particular struct that organizes timer registers, called timer_hw_t. Note that this struct organizes these registers in precisely the same order that they are organized in the datasheet. How thoughtful!

Structs have their fields manipulated by hardware support libraries

The SDK contains a collection of C libraries which define functions that manipulate the fields of these structs. These libraries abstract low-level register manipulations into function calls. So, the programmer can perform these configurations using code that looks like this:

dma_timer_set_fraction(uint timer, uint16_t numerator, uint16_t denominator);

rather than this:

dma_hw->timer[timer] = (((uint32_t)numerator) << DMA_TIMER0_X_LSB) | (((uint32_t)denominator) << DMA_TIMER0_Y_LSB);

High-level API's include a collection of hardware support libraries

For convenience, the SDK has some high-level API's which include a collection of the hardware support libraries. All of these high-level API's have names that look like pico_xxxx (e.g. pico_multicore or pico_stdlib). You could individually include each of the hardware support libraries into your project instead of including a high-level API, but they can make your code more tidy.


A brief description of CMake

The SDK uses CMake to manage project builds. Each project will contain a file called CMakeLists.txt, and that file specifies the the header files to which the project should link, any source files that it should include, and other build specifications. The primary modifications that you will make to example CMakeLists.txt files will include modifications to the target_link_libraries arguments, modifications to the executable names, and modifications to the target_sources arguments. We will demonstrate all these via an example in the Dissecting Blinky section.


How do you interact with the SDK?

The SDK levels of abstraction with which you will interact are the hardware support libraries and the high-level API's. To you as the application programmer, these appear as #include's in your C program and as linked libraries in your CMakeLists.txt file (we'll look at an example in just a moment). But how do you know which hardware support libraries and high-level API's to include? In general, you'll go through the following process:

  1. Read the RP2040 datasheet to learn the details of a particular system feature or hardware peripheral.
  2. Open the RP2040 C SDK guide table of contents, and find the hardware support library associated with your peripheral or system feature of interest.
  3. Read the associated section of the SDK guide! And read through the available functions for that particular peripheral.
  4. To include a hardware support library in your project, add a #include "hardware/peripheralname.h to your C file, and add hardware_peripheralname to the list of linked libraries in your CMakeLists.txt file. For instance, if we wanted to include the SPI hardware support library, we would add #include hardware/spi.h to our C file and we would include hardware_spi to our list of linked libraries in CMakeLists.txt.
  5. Use the functions from the SDK in your program!

The best way to illustrate this is via an example.


Dissecting Blinky

The best way to introduce the SDK is via an example, and the simplest example to consider is one which simply blinks the onboard LED. The program which accomplishes this (available here) consists of a single C file and a CMakeLists.txt file. Both are shown in their entirety below. We will consider each, one line at a time.

Full Program

C Program

/**
 * V. Hunter Adams (vha3@cornell.edu)
 */

#include "pico/stdlib.h"

// The LED is connected to GPIO 25
#define LED_PIN 25

// Main (runs on core 0)
int main() {
    // Initialize the LED pin
    gpio_init(LED_PIN);
    // Configure the LED pin as an output
    gpio_set_dir(LED_PIN, GPIO_OUT);
    // Loop
    while (true) {
        // Set high
        gpio_put(LED_PIN, 1);
        // Sleep
        sleep_ms(250);
        // Set low
        gpio_put(LED_PIN, 0);
        // Sleep
        sleep_ms(250);
    }
}

CMakeLists.txt

add_executable(blinky)

target_sources(blinky PRIVATE blinky.c)

# Pull in our pico_stdlib which pulls in commonly used features
target_link_libraries(blinky pico_stdlib)

# create map/bin/hex file etc.
pico_add_extra_outputs(blinky)

Dissecting Blinky C Code

Includes

#include "pico/stdlib.h"

The first line of the C program includes a high-level API called pico/stdlib. This high-level API library includes about 40 other source files to be compiled, including (among others) the hardware/gpio.h library (which gives us access to gpio_init() and gpio_put) and pico/time.h (which gives us access to sleep_ms()).

Defines

#define LED_PIN 25

The next line of code uses a #define to allow for us to substitute the text LED_PIN for the text 25 in the rest of our code. This is for readability. On the Pico, the onboard LED is attached to GPIO number 25.

Main()

int main() {
    // Initialize the LED pin
    gpio_init(LED_PIN);
    // Configure the LED pin as an output
    gpio_set_dir(LED_PIN, GPIO_OUT);
    // Loop
    while (true) {
        // Set high
        gpio_put(LED_PIN, 1);
        // Sleep
        sleep_ms(250);
        // Set low
        gpio_put(LED_PIN, 0);
        // Sleep
        sleep_ms(250);
    }
}

As with nearly all C programs, your code starts executing at main(). The first thing that occurs in main() is that we call gpio_init(LED_PIN). As described on page 118 of the SDK manual, gpio_init() initializes a GPI0, sets it as an input, and clears any output value. Because gpio_init() sets the GPIO as an input, the next line of the C program specifies that it should be an output. Then, within a while loop that never exits, we set the pin high, wait 250 ms, set it low, and then wait another 250 ms. This blinks the LED on and off.

Note that the gpio_init and gpio_put functions come from the hardware/gpio.h library, which was included by pico/stdlib.h. And note that sleep_ms comes from pico/time.h which was also included by pico/stdlib.h.

Dissecting Blinky CMakeLists.txt

The SDK uses CMake to manage the build. CMake allows for all sorts of build configuration, but on this webpage I'm just going to talk about the configurations that will be most critical to your productivity.

Executable

add_executable(blinky)

The first line of the CMakeLists.txt file declares that a program called blinky should be built from the C files that will be specified in the target_sources. This is also the name that will be used to build the program. In the Hunter-Adams-RP2040 Demos/build directory you can run make blinky and this particular project will build. For this reason, every project in a repository like pico-examples or Hunter-Adams-RP2040-Demos should have a different executable name. When you add your own projects to the demos repository, you will need to change the executable name.

Sources

target_sources(blinky PRIVATE blinky.c)

The next line in the CMakeLists.txt file specifies the source files from which the project should be built. The first argument is the name of the project executable, PRIVATE specifies the scope of compiler definitions to be limited to this particular project, and then all the source files are listed. For blinky, there is only one source file. However, if we were to create a library for a sensor, or a VGA display, or an actuator that included its own header and source files, then we would add the associated source files to this list. For example, see the CMakeLists.txt file for the VGA library demo.

Libraries

target_link_libraries(blinky pico_stdlib)

Anytime that we include an SDK library in the C program, we must also link it in the CMakeLists.txt file for our project. For this particular project, we only include pico/stdlib.h. If we were to include other hardware support libraries or high-level API's, they would be added to this list. For example, see the CMakeLists.txt file for the VGA library demo.

Creating uf2 for programming via USB

pico_add_extra_outputs(blinky)

If we ended the CMake file without including the above line, then it would create an ELF (executable linkable format) file which would be loaded onto the RP2040 through the Serial Wire Debug port with a debugger. Often, however, it's more convenient to load programs through the USB connection. To do this, we need a uf2 file. Including the above line in the CMakeLists.txt file tells the compiler to create a uf2 file for USB.


Building Blinky

The precise process by which you build a project depends on whether you're working on a Windows, Mac, or Linux machine. Please see the Getting Started with Raspberry Pi Pico document. The two pages linked below provide explicit instructions for Windows and Mac users. For Linux users, setup and build is easy (see chapters 1 and 2 of the Getting Started document linked previously).