RP2040 学习笔记(二):各种低速外设

0x00 RP2040 外设概述

上一篇文章中,我们已经从 data brief 得知 RP2040 拥有两个串口、两个 SPI、两个 I2C、16 个 PWM、一个 USB 1.1 以及 8 个 PIO。本文将讨论一些常用的外设。从下图中可以看到,这些外设一方面与 GPIO 连接,一方面连到 bus fabric,以与处理器通讯。

根据文档,bus fabric 内部是如此实现的:AHB-Lite crossbar 在 4 个上游(处理器核、DMA)与 10 个下游(内置 ROM、6 个 SRAM、APB Brigdge、Flash XIP、AHB Lite Splitter)之间传输地址和数据,每个时钟周期内可以发生 4 次传输。APB Bridge 连接到 UART、SPI 等低速外设。

💡
尽管 RP2040 是无状态的,但它内部仍然有一小块 ROM,用于存储固化的 bootloader 代码,这些代码支撑了 uf2 下载、高速除法等功能。
RP2040 的 SRAM 分为 6 个 bank,其中 4 个 64kB 的 bank 是主内存。从 0x20000000 开始的地址空间,会被以 4 byte 为单位轮流映射到这 4 个 bank 中,以提升并行能力。具体可参考 datasheet 2.6. Memory 章节。
Single-cycle IO 并未接入 bus fabric。它是与处理器直接连接的。

RP2040 的地址空间如下表:

设备 地址 备注
ROM 0x00000000 RP2040 的内置 16kB ROM
XIP 0x10000000 外置 flash
SRAM 0x20000000 6个SRAM bank
APB Peripherals 0x40000000 低速外设(UART、I2C 等)
AHB-Lite Peripherals 0x50000000 高速外设(USB、PIO、XIP)
IOPORT Registers 0xd0000000 Single-cycle IO
Cortex-M0+ internal registers 0xe0000000

本文将对这些低速外设进行实验。

0x01 串口

得益于 pico sdk,我们可以直接使用 printf 等函数来在串口上输出,如同运行在电脑上的普通程序那样。代码如下:

#include "pico/stdlib.h"
#include <cstdio>

int main() {
    stdio_init_all();

    puts("Hello, world!");

    while (true) {
        tight_loop_contents();
    }
}

将 debug probe 的 RX/TX 连接到开发板的 TX/RX,使用 minicom -b 115200 -D /dev/ttyACM0gtkterm 以查看串口。

特殊地,pico sdk 还帮我们实现了基于 usb 协议的串口。要打开这个功能,我们只需修改 CMakelists.txt,加入这一行:

pico_enable_stdio_usb([项目名] 1)

此时,stdio_init_all() 就会帮我们初始化 usb 串口。

💡
按照文档,usb 串口不能与 swd 调试一同使用。

stdio_init_all() 函数的作用是初始化所有串口信道,包括 uart 和 usb。开发者也可以通过 stdio_uart_init()stdio_usb_init() 来自行初始化。

scanf() 也是可用的,于是我们可以从电脑向 RP2040 发送数据:

while(true) {
    printf("input name: ");
    scanf("%s", name);
    printf("\nHello, %s\n\n", name);
}
💡
reddit 回答说 pico sdk 并没有官方支持 scanf()。更保险的做法是使用 getchar_timeout_us()

0x02 ADC 和温度传感器

RP2040 中有一个 ADC,它有五个输入源:GPIO26~29,以及内置的温度传感器。ADC 采样率为 500 kS/s(事实上可以超频,见这篇文章),分辨率为 12 bit,带一个 FIFO(能容纳 4 条结果)。片内只有一个 ADC,不过我们可以轮询读取各个输入源。

写个代码测试一下 GPIO26 ADC。笔者手头没有信号发生器,所以直接测悬空引脚的电压,测量过程中用手按压引脚。代码:

#include "pico/stdlib.h"
#include "hardware/adc.h"
#include <cstdio>

int main() {
    stdio_init_all();

    adc_init();

    // 取消 GPIO26 上的其他功能,也取消数字输入功能
    adc_gpio_init(26);

    // 0~3: GPIO26~29; 4: 内置温度传感器
    adc_select_input(0);

    while(true) {
        auto res = adc_read();
        printf("res = %d\n", res);
        sleep_ms(100);
    }
}
💡
注意需要在 cmake 中指定链接 hardware_adc 库:
target_link_libraries(adc pico_stdlib hardware_adc)

如果要用 ADC 测量芯片温度,则需要将输入源设为 4,并启用温度传感器。温度值可以通过文档中给出的公式计算。代码如下:

int main() {
    stdio_init_all();
    adc_init();

    // 使能温度传感器
    adc_set_temp_sensor_enabled(true);
    adc_select_input(4);

    while(true) {
        auto res = adc_read();
        auto t = 27 - (1.0 * res / (1<<12) * 3.3 - 0.706) / 0.001721;
        printf("T = %2.04f ℃\n", t);
        sleep_ms(100);
    }
}

0x03 PWM

前一篇文章,我们已经软件实现了 PWM。然而,由软件来做这件事并不可靠,因为 cpu 可能会突然跑去处理中断;如果使用了 RTOS,还会切换任务。使用 PWM 外设则不存在这些问题,它不占用 cpu 时间。

RP2040 有 8 个 PWM slice,每个 slice 可以驱动两条输出信号,也能测量输入信号的频率或占空比。详细的机制可以参考 datasheet。简而言之,普通情况下,PWM 有一个计数器,随着时钟周期而增加;当计数器低于一个预设的 level 时,输出高电平,否则输出低电平。可参考文档中的图:

每个 GPIO 都可以被 PWM 驱动。PWM channel 的分配表如下:

现在我们试试利用 PWM 来快速翻转 GPIO6,从表中可知它对应的 PWM channel 是 3A。代码如下:

// GPIO6: channel 3A
gpio_set_function(6, GPIO_FUNC_PWM);

// 计数器范围:[0, 1]
pwm_set_wrap(3, 1);
// cnt < 1 时,输出高电平,否则输出低电平
pwm_set_chan_level(3, PWM_CHAN_A, 1);

// 使能
pwm_set_enabled(3, true);

使用 1GHz 逻辑分析仪(阈值电压 1.7V)可以看到,输出的波形周期为 16ns,每次高电平/低电平持续时长是 8ns。

RP2040 的默认主频是 125MHz,时钟周期是 8ns,所以 PWM 实际上在每个时钟周期都完成了一次翻转。这比我们软件实现要可靠许多。

现在,利用 PWM 外设来重新实现上一篇文章做过的呼吸灯。要调整占空比为 $x\%$,只需要将计数器范围设为 $[0, 100]$,将 level 设为 $x$。代码如下:

#include "pico/stdlib.h"
#include "hardware/pwm.h"
#include "pico/stdlib.h"

// GPIO25 是 LED,GPIO6 接到逻辑分析仪
static const uint pins[] = {6, 25};

// 通过 PWM 外设控制亮度
void set_bright(uint bright) {
    for(auto pin: pins) {
        auto slice_num = pwm_gpio_to_slice_num(pin);
        auto chan = pwm_gpio_to_channel(pin);
        pwm_set_chan_level(slice_num, chan, bright);
    }
}

void keep_bright_for_10ms(uint bright) {
    auto stop_time = make_timeout_time_ms(10);

    while(!time_reached(stop_time)) {
        set_bright(bright);
    }
}

int main() {
    for(auto pin: pins) {
        auto slice_num = pwm_gpio_to_slice_num(pin);
        auto chan = pwm_gpio_to_channel(pin);
        gpio_set_function(pin, GPIO_FUNC_PWM);
        pwm_set_wrap(slice_num, 100);
        pwm_set_chan_level(slice_num, chan, 0);
        pwm_set_enabled(slice_num, true);
    }

    while(true) {
        // 每 10ms 改变一次亮度级别
        for(uint b=1; b<=99; b++) {
            keep_bright_for_10ms(b);
        }
        for(uint b=99; b>=1; b--) {
            keep_bright_for_10ms(b);
        }
    }
}

这份代码输出的波形,实测周期是 808ns,比前一篇文章的 100us 细得多,且 PWM 外设不会像 sleep_us() 那样阻塞 cpu,现在配置完之后就可以去干别的活。

0x04 时钟

RP2040 可以将自己的时钟信号输出到 GPIO,以便为其他设备提供时钟信号。下面的代码把用于 usb 的 48MHz 信号输出到 GPIO21:

clock_gpio_init(21, CLOCKS_CLK_GPOUT0_CTRL_AUXSRC_VALUE_CLK_USB, 1);

这个方法也可以用于输出 125MHz 的主时钟、12MHz 的晶振等,支持分频。

0x05 I2C

现在来讨论 I2C 外设。笔者用一块 0.96 英寸 OLED 屏作为实验对象,这块屏幕使用 SSD1315 芯片,分辨率是 128x64。先跑一遍 pico-examples 里面的 I2C 总线扫描代码:

int main() {
    // Enable UART so we can print status output
    stdio_init_all();

    // This example will use I2C0 on the default SDA and SCL pins (GP4, GP5 on a Pico)
    i2c_init(i2c_default, 100 * 1000);
    gpio_set_function(PICO_DEFAULT_I2C_SDA_PIN, GPIO_FUNC_I2C);
    gpio_set_function(PICO_DEFAULT_I2C_SCL_PIN, GPIO_FUNC_I2C);
    gpio_pull_up(PICO_DEFAULT_I2C_SDA_PIN);
    gpio_pull_up(PICO_DEFAULT_I2C_SCL_PIN);
    // Make the I2C pins available to picotool
    bi_decl(bi_2pins_with_func(PICO_DEFAULT_I2C_SDA_PIN, PICO_DEFAULT_I2C_SCL_PIN, GPIO_FUNC_I2C));

    printf("\nI2C Bus Scan\n");
    printf("   0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F\n");

    for (int addr = 0; addr < (1 << 7); ++addr) {
        if (addr % 16 == 0) {
            printf("%02x ", addr);
        }

        // Perform a 1-byte dummy read from the probe address. If a slave
        // acknowledges this address, the function returns the number of bytes
        // transferred. If the address byte is ignored, the function returns
        // -1.

        // Skip over any reserved addresses.
        int ret;
        uint8_t rxdata;
        if (reserved_addr(addr))
            ret = PICO_ERROR_GENERIC;
        else
            ret = i2c_read_blocking(i2c_default, addr, &rxdata, 1, false);

        printf(ret < 0 ? "." : "@");
        printf(addr % 16 == 15 ? "\n" : "  ");
    }
    printf("Done.\n");

    while(true) {
        tight_loop_contents();
    }
}

I2C 协议使用两条线(时钟 SCL、数据 SDA),两条线都上拉。通讯采用请求 - 响应模式,具体如下:

  1. 主机拉低 SDA,随后拉低 SCL,表示通讯开始(这是 start 信号)
  2. 传递若干个 frame(注意不是边沿触发,而是电平触发,在 SCL 从上升到下降的全程间,SDA 都需要保持不变)
  3. 主机拉高 SCL,随后拉高 SDA(这是 stop 信号)
▲ 一次 I2C 通讯。图片来源:TI

每次通讯,首先发一个 address frame,然后是若干个 data frame。每个 frame 都是 8bit,接收完成之后需要拉低 SDA 表示 ACK。address frame 包含了 7bit 的从机地址和 1bit 的 R/W 标签,1 表示读取,0 表示写入。因此,要扫描 I2C 总线上的 slave 设备,只需要对每个地址发送一个读取请求,看看有无 ACK 信号。

查阅 SSD1315 手册,发现我们屏幕的 I2C 地址是 0b0111100。试试从这个设备读取 2 字节:

i2c_read_blocking(i2c_default, slave_addr, rxdata, 2, false);

可见 RP2040 的 I2C 外设似乎不会给最后一个 frame 回复 ACK 信号。发送以下代码可以点亮屏幕:

const uint8_t cmd_init[] = {
    0xae,0x00,0x10,0x40,0x81,0xcf,0xa1,0xc8,0xa6,0xa8,
    0x3f,0xd3,0x00,0xd5,0x80,0xd9,0xf1,0xda,0x12,0xdb,
    0x40,0x20,0x00,0x8d,0x14,0xa4,0xa6,0xaf
};
        
i2c_write_blocking(i2c_default, slave_addr, cmd_init, sizeof(cmd_init), false);
💡
这个指令序列似乎有些问题,只能让屏幕显示随机的黑白像素。暂且没时间管这个,以后需要用到 OLED 屏时再讨论。