0x00 为何选择 RP2040

笔者近期打算学习 MCU 开发,以便未来在嵌入式设备 fuzz 领域做些研究,因此开启了《RP2040 学习笔记》系列,在本站记录一点经验。笔者以前接触过 STM32、STM8、ESP8266 和 ESP32,但属于 DIY 娱乐性质;而本次连载,希望学得深入一些,至少得掌握 FreeRTOS 等实践上常用的框架。那么,选择用哪个 MCU 呢?可以考虑的选项有:

  • ESP8266(按照乐鑫的产品供货保证文档,ESP8266 即将在 2026 年停止支持,故排除)
  • STM8、MCS-51 等(8 位单片机该退出历史舞台了,故排除)
  • ESP32、STM32、RP2040
  • TI 等大厂或沁恒等小厂的 MCU(网上资料偏少,不考虑)

最主要的三项选择就是 ESP32、STM32、RP2040。最终决定使用 RP2040 的理由是:

  • 官方文档质量极佳。树莓派的文档是教程式的而非字典式的。从头到尾阅读文档,就能学到很多东西。
  • 工具链比较直观。既然我们要系统性地学习 MCU 开发,则需要弄清楚从编译到执行的每个过程,而 RP2040 在这方面远优于其他二者。例如,编译器是 gcc-arm-none-eabi;调试软件是 openocd;构建系统是 cmake;官方调试器 Pi Debug Probe 符合 CMSIS-DAP 协议:这些都是开源社区的最主流选择。与之相比,STM32 首先就需要用 STM32CubeMX 生成初始代码,而 ESP32 需要用 idf.py 来配置、编译、烧录项目。
  • 独特的 PIO 硬件。这极大地提升了 RP2040 的 IO 能力。

RP2040 相比起其他 MCU,最显著的优势就是 PIO。PIO 是轻量级的状态机,它们独立于主核,可以读写 GPIO、与处理器通讯,从而完成各种各样的 IO 任务(例如,进行 SPI 通讯、操纵 WS2812B LED 等)。开源社区利用 PIO 做出了许多令人惊叹的项目,例如:100MHz 逻辑分析仪(sigrok-pico 项目)、2MS/s 采样率的示波器(scoppy 项目)。我们甚至可以利用 PIO 生成脉冲,对 Switch 游戏机进行时钟毛刺攻击(参考资料:机核B站文章)。

下面开始记录 RP2040 的学习过程。笔者是初学者,文章中不可避免地含有一些十分 trival 的细节,显得冗长,读者见谅。笔者希望本系列文章至少实现以下目标:

  1. (本文)配置开发环境,点亮开发板上的 LED。
  2. 使用常规方法和 PIO 来驱动 WS2812B LED。
  3. 驱动 I2C OLED 屏和 SPI 墨水屏。
  4. 设计 PCB 并打样验证。
  5. 跑通 Pico W 开发板的 WiFi 功能。

0x01 阅读 product brief

树莓派官网给出了 RP2040 这颗 MCU 的 product brief,我们详细阅读一番:

组件 描述
CPU 双核 ARM Cortex-M0+ @ 133 MHz
RAM 264kB SRAM
ROM 无内置 ROM,支持至多 16MB 外部 flash
GPIO 30 个,其中 4 个支持模拟信号输入
外设 2x UART、2x SPI、2x I2C、16x PWM、1x USB1.1、8x PIO状态机
封装 7 × 7 mm QFN-56
制程 TSMC 40nm

RP2040 的双核 CPU 是够用的。虽然赶不上性能溢出的 ESP32,但比起我们常用的 STM32F103 等 MCU 来说,133MHz 的主频很有优势(甚至还能轻易超频)。另一方面,RP2040 的 RAM 很大(在 ST 的产品线中,要 STM32F4 才有这样多的 RAM)。GPIO 数量方面,RP2040 拥有 30 个有效的 GPIO,不算多(而 ST 那边,即使是 STM32F103,也有 122 GPIO 的型号),但已经足够日常使用,比 ESP32-C3 好一些。USB 外设速率是 full speed(12Mbps),支持 host 和 device 模式。

值得注意的是,RP2040 没有板载 ROM。对于树莓派公司来说,这可以节省 die 面积;对开发者而言,他们可以按照自己程序的需求,自由选择 flash(与之相比,STM32 开发者可能出于 ROM 尺寸需求,不得不选择更高价的 MCU)。不过,另一方面,不内置 ROM 也导致代码加密变得十分困难。笔者作为开源 DIY 爱好者,当然是乐见代码不加密的。

RP2040 芯片采用 QFN-56 封装,焊接是个问题。笔者焊个 0603 封装的电阻尚且费劲,去年焊 UFQFPN-20 封装的 STM8S003F3U6 时,失败率更是高达 100%。有两条解决方案:要么购买邮票孔开发板当作模块使用(例如微雪的 RP2040-Tiny、矽递的 XIAO-RP2040),要么苦练焊接技术(加热台和热风枪可以降低焊接难度)。笔者打算先用手上的开发板学一段时间,等到设计 PCB 之后,再练一练焊接技术,争取能够手焊。

0x02 插上面包板

笔者手里有几块树莓派官方开发板 Pi Pico,以及合宙的开发板。pico 的 PCB 是开源的,所以市场上有大量仿制品,与 pico 的接口基本一致。例如合宙的开发板就是把 pico 的 micro usb 接口换成了 type-c 接口,并在另一端多引出了几个焊盘。使用上区别不大。

在写本文时,发生了一点小插曲:笔者的 daplink 坏掉了,win10 系统能认出 usb 转串口,但认不出 DAP 设备。于是下单了一个树莓派官方调试器,还在路上。但等快递的期间也不能啥也不干,于是考虑利用闲置的 Pico 开发板自制一个 daplink。这是可行的,毕竟官方调试器也是利用 RP2040 实现的,而且开源,可以直接在 pico 开发板上烧入固件,让它变成 daplink 调试器。

RP2040 的固件刷写非常简单(很适合 DIY 玩家),无需串口、无需调试器,甚至不用安装额外软件。只需先按住 BOOTSEL 按钮再插 usb 接口上电,RP2040 就会将自己伪装成一个 u 盘,将 .uf2 格式的固件放进虚拟 u 盘中,即可刷入,写入完毕之后 RP2040 会自动重启。现在我们想要把 pico 开发板刷成调试器,则先去 Github 下载 debugprobe_on_pico.uf2 固件,写进 RP2040,即可成功。openocd 能认出设备:

现在把这个调试器与我们要开发的板子连接起来。根据代码,是由 GPIO2 作为 SWCLK,GPIO3 作为 SWDIO,GPIO4 作为 TX ,GPIO5 作为 RX。笔者的目标板是合宙版本,因此 3V3 和 GND 也在尾部,接线比较方便。

现在,我们可以使用 openocd 检查一下环境:

可见目标板的两个 Cortex-M0+ 核心都被识别出来,我们的调试硬件搭建成功了。

0x03 搭建 CLion 开发环境

由于 RP2040 的工具链十分通用,我们可以使用任何 IDE 进行开发。笔者最初想在 win10 上使用 PlatformIO,然而由于一些未知的怪异原因,笔者电脑上的 PlatformIO 无法新建项目。最后选择在 Debian 12 上使用 CLion 开发。

首先,按照文档,安装工具链、拉取 pico sdk 和 pico-examples 代码:

sudo apt install cmake gcc-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib
git clone https://github.com/raspberrypi/pico-sdk.git
git clone https://github.com/raspberrypi/pico-examples.git

然后配置 CLion。我们用 CLion 打开 pico-examples 项目,现在是找不到 pico sdk 的,所以会报个错:

我们去 Settings 里面指定 PICO_SDK_PATH

现在,选择 blink 项目,可以编译成功了:

我们可以在 cmake-build-debug 目录下找到 .uf2 文件(用于烧录)和 .elf 文件(用于调试)。我们先试试利用 openocd 命令行烧录固件,并使用 gdb 手动调试。

💡
合宙开发板的 flash 型号并非官方版采用的 W25Q16JV。openocd 不认识这颗 flash 芯片,导致无法烧写,具体报错为 unknown flash device。其他的非官方开发板也可能存在这样的问题。可以参考 shabaz 的文章,自行修改 openocd 代码,将自己的 flash 信息加入 spi.c
现在,树莓派公司 fork 的 openocd 以及主线 openocd 都加入了特性:若 bank size 已经指定,则不再检测 flash 芯片 ID,详情见这个 commit。但是在 openocd 中,底层的 flash bank 指令才能指定 bank size,而我们常用的 program 指令无法利用这个特性。
所以,现在推荐的解决办法仍然是自行修改 openocd 代码,或者先在 pico 上用 openocd 开发和调试,部署到非 pico 开发板时再通过 usb 烧录固件。

先烧录固件:

openocd -f interface/cmsis-dap.cfg -f target/rp2040.cfg \
                                                      -c "adapter speed 5000" \
                                                      -c "program blink.elf verify reset"
Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
adapter speed: 5000 kHz

Info : Using CMSIS-DAPv2 interface with VID:PID=0x2e8a:0x000c, serial=454B42313130000A
Info : CMSIS-DAP: SWD supported
Info : CMSIS-DAP: Atomic commands supported
Info : CMSIS-DAP: Test domain timer supported
Info : CMSIS-DAP: FW Version = 2.0.0
Info : CMSIS-DAP: Interface Initialised (SWD)
Info : SWCLK/TCK = 0 SWDIO/TMS = 0 TDI = 0 TDO = 0 nTRST = 0 nRESET = 0
Info : CMSIS-DAP: Interface ready
Info : clock speed 5000 kHz
Info : SWD DPIDR 0x0bc12477, DLPIDR 0x00000001
Info : SWD DPIDR 0x0bc12477, DLPIDR 0x10000001
Info : [rp2040.core0] Cortex-M0+ r0p1 processor detected
Info : [rp2040.core0] target has 4 breakpoints, 2 watchpoints
Info : [rp2040.core1] Cortex-M0+ r0p1 processor detected
Info : [rp2040.core1] target has 4 breakpoints, 2 watchpoints
Info : starting gdb server for rp2040.core0 on 3333
Info : Listening on port 3333 for gdb connections
Info : starting gdb server for rp2040.core1 on 3334
Info : Listening on port 3334 for gdb connections
[rp2040.core0] halted due to debug-request, current mode: Thread 
xPSR: 0xf1000000 pc: 0x000000ee msp: 0x20041f00
[rp2040.core1] halted due to debug-request, current mode: Thread 
xPSR: 0xf1000000 pc: 0x000000ee msp: 0x20041f00
** Programming Started **
Info : Found flash device 'win w25q16jv' (ID 0x001540ef)
Info : RP2040 B0 Flash Probe: 2097152 bytes @0x10000000, in 32 sectors

Info : Padding image section 1 at 0x10005188 with 120 bytes (bank write end alignment)
Warn : Adding extra erase range, 0x10005200 .. 0x1000ffff
** Programming Finished **
** Verify Started **
** Verified OK **
** Resetting Target **
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections

解释一下 program blink.elf verify reset  指令。按照 openocd 文档program 指令的作用是一键式烧录,这句话的意思是烧录 blink.elf 并验证,然后退出 openocd。

💡
若希望 openocd 在烧录完成之后直接退出而不等待 gdb 连接,则使用 program blink.elf verify reset exit

接下来,我们可以使用 gdb-multiarch 调试。通过 target remote localhost:3333 指定 gdb server。

现在,我们已经能手动调试了,接下来尝试在 CLion 中调试。CLion 自带了嵌入式调试的支持,按照 Jetbrains 文档的指引即可配置。

💡
2024/07/22 更新:建议使用 clion 的 OpenOCD Download & Run 配置而非下文的 pyocd。这种方式在 windows 下比 pyocd 更稳定(例如,pyocd 有时会直接忽略断点,而 openocd 暂未观察到此情况),且能直接查看外设寄存器,无需手动设置内存可见性。

我们选用 pyocd(建议用 pipx install pyocd 安装),即可使用断点和 gdb 终端:

尽管现在“Threads & Variables”界面可以追踪变量,但并不能查看常用寄存器。参考 atoktoto 写的教程,我们将 pico sdk 提供的 svd 文件导入 CLion:

然而出现了新问题:Peripherals 面板中读取不到这些内存。

在 gdb 面板中使用 p *0x4004c000 指令,显示 Cannot access memory at address 0x4004c000。因此锅不在 CLion 这里。使用 info mem 看一下内存地址空间:

(gdb) info mem
Using memory regions provided by the target.
Num Enb Low Addr   High Addr  Attrs 
0   y  	0x00000000 0x00004000 ro nocache 
1   y  	0x10000000 0x11000000 flash blocksize 0x1000 nocache 
2   y  	0x11000000 0x12000000 ro nocache 
3   y  	0x12000000 0x13000000 ro nocache 
4   y  	0x13000000 0x14000000 ro nocache 
5   y  	0x20000000 0x20040000 rw nocache 
6   y  	0x20040000 0x20042000 rw nocache 
7   y  	0x21000000 0x21040000 rw nocache 
8   y  	0x51000000 0x51001000 rw nocache 

可见 0x4004c000 根本没被 gdb 认为是合法地址。查到 pyocd 相关 issue,发现可以通过下面的 gdb 指令让它忽略内存映射表,直接将所有内存请求转发给 gdb server:

set mem inaccessible-by-default no

现在可以查看寄存器列表了。我们至此成功搭建了 CLion 调试环境。

0x04 点灯

pico 开发板上有一颗黄色 LED,由 GPIO25 控制(输出高电平时灯亮)。我们现在自己写代码,让这颗灯实现呼吸效果。按照 pico sdk 文档指引,首先用 CLion 新建项目,然后将 pico_sdk_import.cmake 文件复制到新项目中,并编写 CMakeLists.txt

cmake_minimum_required(VERSION 3.28)

set(PICO_SDK_PATH "/home/blue/Desktop/dev/pico-sdk/")
include(pico_sdk_import.cmake)

project(my_blink C CXX ASM)

pico_sdk_init()

set(CMAKE_CXX_STANDARD 17)
add_executable(my_blink main.cpp)

target_link_libraries(my_blink pico_stdlib)

要做出呼吸灯效果,我们可以设法控制 LED 亮度(通过 pwm),每隔一小段时间修改亮度。代码如下:

#include "pico/stdlib.h"

static const uint pin = 25;

// 通过 PWM 控制亮度
void set_bright(uint bright) {
    gpio_put(pin, true);
    sleep_us(bright);
    gpio_put(pin, false);
    sleep_us(100 - 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() {
    gpio_init(pin);
    gpio_set_dir(pin, GPIO_OUT);

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

这份代码并不是非常精确。keep_bright_for_10ms 函数可能有 100us 左右的误差,不过我们只需要实现呼吸灯的视觉效果,不必特别关注这些细节。

0:00
/

pico sdk 文档第二章提到,硬件库(例如 gpio 库)是很薄的抽象,一般仅仅是寄存器操作的简单封装,以便让编译器产生足够优的代码。那我们看一眼 gpio_put 这个函数的实现:

static inline void gpio_put(uint gpio, bool value) {
    uint32_t mask = 1ul << gpio;
    if (value)
        gpio_set_mask(mask);    // sio_hw->gpio_set = mask
    else
        gpio_clr_mask(mask);    // sio_hw->gpio_clr = mask
}

可见这种代码确实离寄存器很近。观察 set_bright(uint bright) 函数的汇编码:

10000410 <_Z10set_brightj>:
10000410:    b570          push    {r4, r5, r6, lr}     // 保存寄存器
10000412:    0004          movs    r4, r0
10000414:    25d0          movs    r5, #208    @ 0xd0
10000416:    062d          lsls    r5, r5, #24          // 获得 0xd0000000
10000418:    2680          movs    r6, #128    @ 0x80
1000041a:    04b6          lsls    r6, r6, #18          // 获得 0x02000000
1000041c:    616e          str     r6, [r5, #20]        // 将 0x02000000 写入 0xd0000014
1000041e:    2100          movs    r1, #0
10000420:    f000 ff86     bl      10001330 <sleep_us>  // 第一次 sleep,借用传入的 r0 参数(即 bright)
10000424:    61ae          str     r6, [r5, #24]        // 将 0x02000000 写入 0xd0000018
10000426:    2064          movs    r0, #100    @ 0x64
10000428:    1b00          subs    r0, r0, r4           // 获得 100 - bright
1000042a:    2100          movs    r1, #0
1000042c:    f000 ff80     bl      10001330 <sleep_us>  // 第二次 sleep
10000430:    bd70          pop     {r4, r5, r6, pc}     // 恢复寄存器

代码中的 0x02000000 即为 (1 << 25),是 GPIO25 对应的掩码。可以看到,上述代码几乎是最高效的实现(arm 指令集对立即数大小有限制,所以很大的常量必须通过几次运算来获取)。0xd0000000 是 Single-cycle IO 寄存器的基址,上述代码先写了 GPIO_OUT_SET 寄存器将 GPIO25 置为 1,休眠一段时间后写 GPIO_OUT_CLR 寄存器将 GPIO25 置为 0。

▲ 图源:RP2040 Datasheet

现在,我们完成了 RP2040 的 hello world 项目。本系列的下一篇文章将快速学习 RP2040 片上的各种外设。