RP2040裸金属双核应用与核心1启动协议
研究RP2040核心1启动协议。在多核裸金属应用中启动核心1。
树莓派Pico, RP2040, ARM, Cortex M0+, 汇编, 裸金属, 引导程序, 单片机, 双核, 多核处理器
--by Captdam @ Mar 7, 2026Index
这篇文章的针对读者为32位RP2040与ARM Cortex-M0+新手,但是熟悉使用汇编与C语言开发8位单片裸金属应用的机开发者。
因为我们将开发裸金属应用,我们将会直接读写单片的控制寄存器,并不使用任何库。
我们将大量依靠文档。文档中包括了我们所需要知道的所有单片机控制寄存器的信息。
因为RP2040文档更新的缘故,而且他们还决定把老文档的链接直接重定向到新文档,我决定在本地保存当前版本的副本(2025-02-20)。你可以通过原始链接(取样于2026-02-10)获取该文档。
这篇文章基于我的上一篇文章:W25Q闪存与裸金属RP2040 SDK引导程序,你应该先看看那一篇文章。
片上启动程序ROM
RP2040是一款双核单片机。当片上引导程序结束后,CPU 0开始执行闪存中保存或通过USB下载的用户程序,CPU 1则进入睡眠模式。
为了最佳性能,我们当然希望同时使用两个核心。因此,我们需要先将第二个核心唤醒。
首先,让我们来看看第二个核心(核心1)是如何在片上引导程序阶段进入睡眠模式的。
进入睡眠模式
CPU ID
.global _start
.type _start,%function
.thumb_func
_start:
check_core:
ldr r0, =SIO_BASE
ldr r1, [r0, #SIO_CPUID_OFFSET]
cmp r1, #0
bne wait_for_vector
bl _nmi
上电启动后,两个核心都会开始执行相同的片上引导程序。通过CPU ID,片上引导程序可以判断当前运行的核心。如果CPU ID是0(核心0),继续执行片上引导程序;如果CPU ID是1(核心1),则执行核心1等待(睡眠)程序。
这个过程和Linux上面的pid_t pid = fork() 操作类似,我们通过pid来判断主程序或子程序。
注意每个核心都有自己的私有CPU控制寄存器。核心0只能访问核心0的CPU控制寄存器,核心1只能访问核心1的CPU控制寄存器.换句话说,两个核心都使用相同的地址0xD0000000来获取CPU ID,但是返回的值却是该核心私有的那个寄存器的内容,且两个寄存器内容不同。
如果返回的值(CPU ID)为零,也就是说我们在核心0执行,就继续执行程序_nmi。该程序不在本文讨论的内容中。
如果返回的值非零,也就是说我们在核心1执行,就继续执行程序wait_for_vector。
睡眠模式
wait_for_vector:
ldr r4, =SIO_BASE
ldr r7, =M0_BASE
mov r1, #M0PLUS_SCR_SLEEPDEEP_BITS
str r1, [r7, #(M0PLUS_SCR_OFFSET - M0PLUS_CPUID_OFFSET)]
设置系统控制寄存器中深度睡眠值,让核心1在等待事件(event)时进入深度睡眠模式。
r4将作为SIO控制寄存器的基础地址,r7将作为CPU控制寄存器的基础地址。
你的设备可能选配有唤醒中断控制器(WIC),用于在中断时唤醒处理器。WIC只在SCR中深度睡眠值为1时有效。WIC是纯硬件的,不可编程,也没有相应控制寄存器。Your device might include a Wakeup Interrupt Controller (WIC), an optional peripheral that can detect an interrupt and wake the processor from deep sleep mode. The WIC is enabled only when the DEEPSLEEP bit in the SCR is set to 1. The WIC is not programmable, and does not have any registers or user interface. It operates entirely from hardware signals. -- Cortex-M0+ Devices Generic User Guide: 2.5.3 The optional Wakeup Interrupt Controller
WIC在SCR中设置为深度睡眠时用于从深度睡眠中唤醒处理器。WIC接收事件(从另一个核心发出)、32个中断信号与NMI。The Wakeup Interrupt Controller (WIC) is used to wake the processor from a DEEPSLEEP state as controlled by the SCR register. The WIC takes inputs from the receive event signal (from the other processor), 32 interrupts lines, and NMI. -- RP2040 Datasheet: 2.4.2.8.4. Wakeup Interrupt Controller
也就是说,核心0通过信箱向核心1发送数据时可以唤醒深度睡眠模式下的核心1。
清空跨核心信箱FIFO
1:
ldr r1, [r4, #SIO_FIFO_RD_OFFSET]
core_0_handshake_loop:
ldr r1, [r4, #SIO_FIFO_ST_OFFSET]
lsr r1, #SIO_FIFO_ST_VLD_LSB + 1
bcs 1b
使用信箱(FIFO)来从一个核心向另一个核心发送信息。这里有两个单向的信箱,从一个核心写入的数据只能在另一个核心被读取,反之亦然。
大部分情况下,信箱在启动后(上电重置)应该是空的。但是,也有小概率可能有未读的信息。保险起见,我们先清空未读信息。该过程为:
- 检查信箱状态寄存器中
VLD比特。该“有效”比特在信箱中有(核心0发送至核心1的)未读消息时为1。 - 如果有效,从信箱中读取一条信息。
- 重复以上步骤,直到信箱清空。
片上启动程序ROM进行了一些微优化以节约空间,不管有没有信息都先读取一条再说。这不会造成任何错误。读取空的信箱并不会影响FIFO状态,只是会设置ROE标志,只要我们不启用ROE中断就没问题,反正眼不见心不烦。
握手协议
发送0,期望1
adr r5, receive_and_check_zero
mov r0, #0
bl send_and_then
cmp r0, #1
bne core_0_handshake_loop
r5将作为程序指针,写入将子程序receive_and_check_zero的地址,该子程序用于从跨核心信箱FIFO读取一条信息,包括:协议头“1”、向量表地址、初始栈指针值、进入点。
所有的信息都不应该为“0”。如果从核心0接收到的信息为“0”,则跳回到core_0_handshake_loop(见清空跨核心信箱FIFO)并从头开始协议。
该程序指针将在接下来的过程中重复使用。
将信息“0”装入r0,并使用r0作为参数调用send_and_then(见此处)发送该信息到核心0。接下来,send_and_then将跳转程序指针r5,也就是receive_and_check_zero(见此处)来接收一条消息。最终,从核心0接收到的消息将在r0中。
该返回的消息必须为“1”.否则,跳回到core_0_handshake_loop(见清空跨核心信箱FIFO)以重复上面的操作。
复读1,接收向量表地址
bl send_and_then
str r0, [r7, #(M0PLUS_VTOR_OFFSET - M0PLUS_CPUID_OFFSET)]
将r0中的消息,即“1”,调用send_and_then原样返回。接下来跳转receive_and_check_zero从核心0接收新的消息。
返回的消息将作为核心1的向量表地址。
复读向量表地址,接收初始栈指针值
bl send_and_then
msr msp, r0
将r0中的消息,即向量表地址,调用send_and_then原样返回。接下来跳转receive_and_check_zero从核心0接收新的消息。
返回的消息将作为核心1的初始栈指针值。
复读初始栈指针值,接收进入点
bl send_and_then
; Keep r0
将r0中的消息,即初始栈指针值,调用send_and_then原样返回。接下来跳转receive_and_check_zero从核心0接收新的消息。
返回的消息将作为核心1的进入点。
复读进入点,启动核心1主程序
adr r5, core1_launch
bl send_and_then
程序指针r5装入子程序core1_launch。该子程序用于启动核心1的程序(核心1跳转到该进入点)。
将r0中的消息,即初始栈指针值,调用send_and_then原样返回。接下来跳转core1_launch启动核心1的程序(见此处)。
握手协议子程序
下面是握手协议中使用的子程序:
发送 - send_and_then
send_and_then_again:
wfe
send_and_then:
ldr r1, [r4, #SIO_FIFO_ST_OFFSET]
lsr r1, #SIO_FIFO_ST_RDY_LSB + 1
bcc send_and_then_again
str r0, [r4, #SIO_FIFO_WR_OFFSET]
sev
add r6, r5, #1
bx r6
该子程序的进入点为send_and_then!
检测信箱状态寄存器中RDY的值。该“准备好了”比特在信箱中有一个或多个可写入(核心1到核心0)的空间时为1。
使用逻辑右移时,进位符将被放在该寄存器的第0比特的右边。
将寄存器右移N + 1次就可以把该寄存器的第N个比特移入进位符中。
如果没准备好,也就是说信箱满了,跳转到send_and_then_again。接下来,使用wfe指令将核心1停止并进入睡眠模式以省电。当核心0读出消息后,核心1苏醒并重新检查RDY的值。
如果准备好,将信息(r0)写入信箱。接下来,使用sev指令来向核心0发送事件表示有新的信息。
核心1为深度睡眠模式,WIC已启用。新的信息会自动唤醒核心1.
核心0可能为深度睡眠模式,WIC可能已启用。如果核心0不在深度睡眠模式,核心1需要发送事件来唤醒核心0。
跳转到程序指针r5中地址+1的地址。Thumb指令要求指令地址的LSB为1,所以我们要在这里+1。
接收 - receive_and_check_zero
receive_and_check_zero:
wfe
ldr r0, [r4, #SIO_FIFO_ST_OFFSET]
lsr r0, #SIO_FIFO_ST_VLD_LSB + 1
bcc receive_and_check_zero
ldr r0, [r4, #SIO_FIFO_RD_OFFSET]
cmp r0, #0
beq core_0_handshake_loop
bx lr
等待事件,并进入睡眠模式以省电。当有新的消息时,WIC将唤醒核心1.
检查信箱状态寄存器中VLD比特。若为0,则表示没有新的信息,重新等待事件。(核心1可能是被别的事件唤醒的)
如果接收到的信息为“0”,跳回到core_0_handshake_loop(见清空跨核心信箱FIFO)以重复上面的操作。
核心0发送的信息包括协议头“1”、向量表地址、初始栈指针值、进入点,这些信息都没有可能为“0”:
- 协议头必须为“1”。
- 地址0x00000000 - 0x0FFFFFFF为BOOT ROM,用户程序不可能使用这个地址区间,因此向量表地址和进入点也不能使用这些值。
- 初始栈指针值应该为可读写的、通用的内存区间的顶部。0为内存区间0xFFFFFFFF-的顶部,该区间不适用。
若非零,返回该子程序,继续下一步。注意接收到的信息保存在r0。
进入点 - core1_launch
core1_launch:
mov r1, #0
str r1, [r7, #(M0PLUS_SCR_OFFSET - M0PLUS_CPUID_OFFSET)]
blx r0
禁用深度睡眠。
跳转到r0中的进入点。
从核心0启动核心1
要启动核心1,我们需要从核心0发送上面讨论的启动指令到核心1。
协议
| 序号 | 我们应该收到(核心1发送到核心0) | 然后:我们发送(核心0发送到核心1) |
|---|---|---|
| 0 | “0” | “1” |
| 1 | (复读)“1” | 向量表地址 |
| 2 | (复读)向量表地址 | 初始栈指针值 |
| 3 | (复读)初始栈指针值 | 进入点 |
| 4 | (复读)进入点 |
如果我们在任何时候发送“0”就可以重新开始该协议。
SDK提供的方法
根据手册,SDK将适用下面的步骤启动核心1:
const uint32_t cmd_sequence[] = {0, 0, 1, (uintptr_t) vector_table, (uintptr_t) sp, (uintptr_t) entry};
uint seq = 0;
do {
uint cmd = cmd_sequence[seq];
if (!cmd) {
multicore_fifo_drain();
__sev();
}
multicore_fifo_push_blocking(cmd);
uint32_t response = multicore_fifo_pop_blocking();
seq = cmd == response ? seq + 1 : 0;
} while (seq < count_of(cmd_sequence));
SDK将:
- 清空信箱FIFO并发送“0”两次。这等于是将信箱与协议重置为初始状态。
- 发送“1”。
- 发送向量表地址。
- 发送初始栈指针值。
- 发送进入点。
如果核心1的复读和发送的信息不符,重新执行协议。这用于防止核心1与核心0不同步。
最小方法
我们知道,上电重启后核心1就在等待启动协议,信箱FIFO也是空的(除了核心1到核心0的信箱中有一条“0”),并且核心1此时期待第一条信息——协议头“1”。因此,我们可以不管别的直接启动核心1。
L我们用一个例子来展示这个过程:
这篇文章使用的工程文档可以在这里找到。
程序代码
C语言程序代码main.c包含:
#include <stdint.h>
void c0_reset();
void c1_reset();
uint32_t c0_vector[48] __attribute__ ((section (".c0_vector"))) = {
0x20041000,
(uint32_t)c0_reset
};
uint32_t c1_vector[48] __attribute__ ((section (".c1_vector"))) = {
0x20042000,
(uint32_t)c1_reset
};
void c0_reset() {
*(uint32_t volatile * const)(0x4000f000) = (1<<5);
*(uint32_t volatile * const)(0x400140cc) = 5;
*(uint32_t volatile * const)(0xd0000020) = (1<<25);
*(uint32_t volatile * const)(0xd0000054) = 1;
*(uint32_t volatile * const)(0xd0000054) = (uint32_t)c1_vector;
*(uint32_t volatile * const)(0xd0000054) = c1_vector[0];
*(uint32_t volatile * const)(0xd0000054) = c1_vector[1];
asm("sev\n\t");
for(;;);
}
void c1_reset() {
for (;;) {
*(uint32_t volatile * const)(0xd000001c) = (1<<25); // XOR
volatile uint32_t dummy;
for (uint32_t i = 100000; i; i--) {
dummy++;
}
}
}
首先,我们定义两个数组作为每个核心的向量表。如我们在之前的文章中提到,RP2040的向量表包含48个32位向量,所以我们使用uint32_t[48]:
- 第一个向量为核心的初始栈指针值。我们使用SRAM back 4(地址0x20040000 - 0x20040FFF,4k大小)作为核心0的栈,SRAM back 5(地址0x20041000 - 0x20042FFF,4k大小)作为核心1的栈。
- 第一个向量为进入点,也就是主程序的地址。核心0的主程序为
c0_rest,核心1的主程序为c1_reset。
将核心0和核心1的向量表分别取名为c0_vector和c1_vector。这很重要,因为向量表的地址有讲究。
在Thumb指令中,指令的地址LSB必须为1.编译器会自动帮我们设置这一点。我们可以通过反编译的代码确认:
10000100 1<c0_vector1>:
10000100: 1000 2004
10000104: 02c1 1000
...
100002c0 1<c0_reset1>:
100002c0: 2220 movs r2, #32
...
可以看到c0_vector1[1]为0x100002c1(LSB为1),c0_reset1的地址为100002c0(LSB为0)。
接下来,定义各个核心执行的程序:
- 核心0执行的程序为
c0_reset1。该程序将设置GPIO模式为SIO输出,但是不会进行任何实际输出。该程序也将启动核心1。 - 核心1执行的程序为
c1_reset1。该程序将闪烁GPIO 25上的LED,但是不会配置GPIO模式。
为了引入一些循环延迟,我们将在每次XOR输出信号之间给一个没什么用的值+1个100000次。我们需要把这个值定义为volatile来防止编译器优化,不然编译器就会一次性+100000。
两个程序缺一不可,不然就看不到输出。
我们不需要特别给这两个程序命名,默认text就行。
链接脚本
链接脚本flash.ld包含:
MEMORY {
FLASH(rx) : ORIGIN = 0x10000000, LENGTH = 2048k
}
SECTIONS {
.text : {
*(.boot2)
. = ALIGN (256);
KEEP(*(.c0_vector))
. = ALIGN (256);
KEEP(*(.c1_vector))
KEEP(*(.text))
} >FLASH
}
烧录到闪存(起始地址0x10000000,2M大小)。
片上引导程序要求:闪存的头256(地址0x10000000 - 0x100000FF)字节为二级引导程序。这里我们使用SDK提供的二级引导程序。我们可以复用上一篇文章中生成的对象文件boot2.o。
SDK二级引导程序要求:紧接着二级引导程序为核心0的向量表,即c0_vector。SDK二级引导程序将通过该向量表的内容设置核心0的栈指针并启动核心0的主程序。
ARM Cortex M0+要求:向量表需要256字节对齐。我们将核心1的向量表c1_vector放在下一个256字节对齐的地址。我们在核心0上运行的程序会将该向量表的信息通过跨核心信箱发送到核心1以启动核心1。
因为整个闪存空间都是可执行的,指令文本可以放在任何地方,只要2字节对齐且不超出2M闪存容量即可。我们将还没有分配的文本(程序指令)放在c1_vector的后面。
编译
编译这个项目:
arm-none-eabi-gcc -mcpu=cortex-m0plus -c -O3 main.c -o main.o
arm-none-eabi-objdump --disassembler-options=force-thumb -Dxs main.o > main.list
arm-none-eabi-ld -nostdlib -nostartfiles -T flash.ld boot2.o main.o -o flash.elf
arm-none-eabi-objdump --disassembler-options=force-thumb -Dxs flash.elf > flash.list
pico-elf2uf2 flash.elf flash.uf2
下载uf2文件到Pico。
将这个程序烧录到闪存(起始地址0x10000000,2M大小)。
上电后片上引导程序将二级引导程序载入闪存空间地址0x10000000 - 0x100000FF并执行。
二级引导程序将配置SSI以允许从外部闪存设备片内执行(XIP),将闪存设备映射到起始于0x10000000的2MiB闪存空间,然后执行核心0的主程序。
核心0的主程序启动核心1。现在,两个核心同时执行各自的主程序。
注意,两个核心都在XIP的内存空间中执行,核心0的栈使用SRAM bank 4,核心1的栈使用SRAM bank 5。
在我的下一篇文章中,我们将讨论如何切换RP2040的核心时钟,包括ROSC、XOSC与PLL。