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 的细节,显得冗长,读者见谅。笔者希望本系列文章至少实现以下目标:
- (本文)配置开发环境,点亮开发板上的 LED。
- 使用常规方法和 PIO 来驱动 WS2812B LED。
- 驱动 I2C OLED 屏和 SPI 墨水屏。
- 设计 PCB 并打样验证。
- 跑通 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 手动调试。
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。
program blink.elf verify reset exit
。接下来,我们可以使用 gdb-multiarch
调试。通过 target remote localhost:3333
指定 gdb server。
现在,我们已经能手动调试了,接下来尝试在 CLion 中调试。CLion 自带了嵌入式调试的支持,按照 Jetbrains 文档的指引即可配置。
我们选用 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 左右的误差,不过我们只需要实现呼吸灯的视觉效果,不必特别关注这些细节。
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 的 hello world 项目。本系列的下一篇文章将快速学习 RP2040 片上的各种外设。