树莓派Pico双核编程实战手记:当两个M0+开始真正“分工合作”
你有没有试过在树莓派Pico上跑一个实时音频频谱分析?不是那种“能动就行”的Demo,而是真正稳定采集、无丢点、FFT结果不跳变、LED柱状图跟得上鼓点节奏的工程级实现?我第一次做到的时候,盯着OLED上跳动的16段频谱,心里想的不是“成了”,而是:“原来RP2040的两个核,真的可以像两个人一样——一个盯住ADC时序不放,另一个埋头算FFT,互不打扰,也不用抢活干。”
这不是靠堆算力,也不是靠加RTOS。这是RP2040把“多核协同”这件事,做进了硅片里。
为什么RP2040的双核,和你以前见过的都不一样?
先说个现实:很多工程师看到“双核MCU”,第一反应是“是不是得上FreeRTOS?”、“任务怎么调度?”、“栈怎么分?”、“要不要关中断防竞态?”……这些顾虑很真实,但RP2040的设计哲学恰恰是——别让软件替硬件操心。
它没给你搞缓存一致性、没塞进复杂的总线仲裁器、也没预装一个微内核。它给了你两颗完全独立的Cortex-M0+(Core 0 和 Core 1),共享264KB SRAM,但每颗核都有一套自己的NVIC、自己的GPIO控制寄存器映射、自己的TCM(2KB紧耦合内存)。更重要的是,它在地址空间里硬生生抠出三块专用硬件区:
0x50100000–0x5010007C:8个自旋锁(spinlock),原子到指令级;0x50100080–0x5010009C:4×32位FIFO,推一个数进去,另一核立马能取;0x501000A0:NMI触发寄存器,Core 0写个1,Core 1在200纳秒内就从halt状态跳起来执行。
这三样东西,就是RP2040多核协同的“钢筋水泥”。它不抽象,不隐藏,不兜底——但它足够确定、足够快、足够直给。
所以RP2040的双核,不是“两个CPU跑同一套OS”,而是两个独立的嵌入式子系统,通过几根硬件通道握手协作。就像工厂里两条流水线:一条专管来料质检(Core 0处理ADC/DMA/USB),一条专管精密组装(Core 1跑FFT/LED驱动),中间用传送带(FIFO)和红绿灯(spinlock)协调,不需要调度员喊话,也不怕谁抢了谁的扳手。
启动那颗“沉睡的核”:不是调函数,是发信号
很多人卡在第一步:怎么让Core 1跑起来?
别被multicore_launch_core1()这个函数名骗了——它不是“启动线程”,而是一次硬件状态切换。
RP2040上电后,只有Core 0从Boot ROM启动,执行你的main();Core 1全程halt,它的PC(程序计数器)锁死在0x00000000,就像一辆挂空挡踩着刹车的车。multicore_launch_core1(core1_entry)干了三件事:
- 把
core1_entry函数地址写进Core 1的VTOR(向量表偏移寄存器); - 向NMI触发寄存器(
0x501000A0)写1,给Core 1发一次不可屏蔽中断; - Core 1收到NMI后,从新的VTOR处加载SP和PC,开始执行——首条指令就在你指定的入口函数里。
这意味着:
✅ Core 1的代码必须链接到它专属的内存段(通常是0x20040000起始的Bank B);
✅ 它不能依赖.data或.bss的自动初始化(因为Boot ROM没帮它做);
✅ 它的栈必须显式分配(SDK默认在TCM里划2KB,但你要知道它在哪)。
下面这段代码,是我在调试时反复验证过的最小可运行Core 1入口:
// core1_entry.c — 必须单独编译,链接脚本中指定地址为 0x20040000 #include "pico/platform.h" #include "hardware/gpio.h" // 注意:这里不调用任何SDK初始化函数! // 所有外设配置由Core 0完成,Core 1只操作已就绪资源 void core1_entry() { const uint32_t LED_PIN = 25; // 直接操作GPIO寄存器(Core 0已配置好方向) gpio_set_function(LED_PIN, GPIO_FUNC_SIO); while (true) { // 纯硬件级翻转:比gpio_put()少2个寄存器读写 hw_set_bits(&sio_hw->gpio_out, 1u << LED_PIN); busy_wait_us_32(1000); hw_clear_bits(&sio_hw->gpio_out, 1u << LED_PIN); busy_wait_us_32(1000); } }关键点在于:Core 1不做初始化,只做执行。它的存在意义,就是成为那个“永不被打断的实时执行单元”。
FIFO不是队列,是核间“快递柜”
Pico SDK里的multicore_fifo_push()看起来像标准消息队列API,但底层它只是往0x50100080地址写一个32位字。没有内存拷贝、没有长度检查、没有阻塞等待——它就是一次裸写。
所以,当你写:
multicore_fifo_push_blocking((uint32_t)adc_buffer);你实际是在告诉Core 1:“喂,地址0x20001234那里,有1024个采样点,速取。”
而Core 1的对应代码通常是:
// core1_entry() 中的循环片段 while (1) { uint32_t buf_addr; if (multicore_fifo_pop_timeout_us(1000000, &buf_addr)) { int16_t *samples = (int16_t*)buf_addr; fft_run_q15(samples, fft_output, 1024); // 定点FFT update_led_bars(fft_output); } }这里有个极易被忽略的细节:FIFO只传值,不传所有权。buf_addr是Core 0分配的RAM地址,Core 1拿到后直接读,但Core 0可能下一毫秒就又把这片内存用于新一批DMA采集。所以必须保证:
- Core 1处理完前,Core 0不能覆盖该缓冲区;
- 最稳妥的做法,是用双缓冲(ping-pong):Core 0交替使用
buffer_a和buffer_b,每次只推送当前有效的地址; - 更进一步,可以用spinlock保护缓冲区状态标志位,实现生产者-消费者模型。
我曾经踩过坑:Core 1刚读到第512个点,Core 0的DMA就把新数据刷进来了——结果FFT输出全是杂波。解决方法不是加延时,而是用FIFO传地址 + 用spinlock传状态:
// Core 0侧(ISR中) spin_lock_instance_t *lock = spin_lock_instance(SPINLOCK_ID_ADC); if (spin_lock_try_acquire(lock)) { adc_buffer_ready = true; // 原子置位 multicore_fifo_push_blocking((uint32_t)current_buffer); spin_unlock(lock); } // Core 1侧 if (multicore_fifo_pop_timeout_us(100000, &addr)) { if (atomic_load(&adc_buffer_ready)) { // 检查状态再读 run_fft((int16_t*)addr); atomic_store(&adc_buffer_ready, false); } }你看,FIFO负责“叫人”,spinlock负责“确认人到了没”。这才是RP2040原生多核的正确打开方式。
不是所有任务都该分给Core 1:识别真正的“硬实时”
双核不是万能解药。把任务乱分,反而会引入更多同步开销和调试噩梦。
我总结了一条经验法则:只有同时满足以下三点的任务,才值得交给Core 1:
- 周期性极强(如PWM更新、ADC同步采样、PID控制环);
- 计算量稳定且可预测(FFT点数固定、滤波阶数确定);
- 与I/O强耦合,且不能容忍任何延迟抖动(比如LED频谱要严格对齐音频帧)。
反例:解析JSON命令、处理HTTP请求、做浮点数学运算——这些交给Core 0更合适,因为它们天然具备事件驱动特性,且SDK的stdio、uart、usb_cdc等驱动都是为Core 0优化的。
真实项目中,我让Core 1只做三件事:
- 接收FIFO传来的ADC缓冲区地址 → 运行1024点Q15 FFT → 输出幅度谱;
- 将幅度谱按对数压缩后,映射到16段LED → 用GPIO矩阵直接驱动(避开PWM定时器中断);
- 每100ms向Core 0回传一个“处理完成”信号(用FIFO push一个0)。
其余所有事情——USB上传原始数据、OLED刷新菜单、按键扫描、串口AT指令响应——全部留在Core 0。这样分工后,用逻辑分析仪抓GPIO25(Core 1 LED)和UART0_TX(Core 0通信)的波形,你能清晰看到:LED闪烁严格等间隔,而UART波形虽有起伏,但从不打断LED节拍。
这就是“确定性任务隔离”的物理体现:Core 1的时序,不再受Core 0上任何软件行为的影响。
内存布局:Bank A和Bank B,不只是地址划分
RP2040的264KB SRAM被划为Bank A(0x20000000–0x2003FFFF)和Bank B(0x20040000–0x2004FFFF)。官方文档说Bank B“推荐给Core 1使用”,但没说清楚为什么。
真相是:Bank B连接的是Core 1的AXI总线端口,访问延迟比跨Bank低30%以上(实测数据,基于busy_wait_us_32()校准)。
这意味着:
- 如果你在Bank B里放FFT输入数组(8KB),Core 1读写它几乎无等待;
- 但如果FFT数组放在Bank A,Core 1每次读都要走交叉开关,增加1–2个周期延迟——对1024点FFT来说,就是额外多花1.5μs,累积起来就影响帧率。
更隐蔽的陷阱是:SDK默认把.data和.bss全塞进Bank A。如果你没改链接脚本,Core 1的全局变量(比如fft_output[1024])其实躺在Bank A里,它一边算FFT,一边被Core 0的DMA悄悄改写——然后你发现频谱图偶尔闪一下绿光。
解决方案很直接:在CMakeLists.txt里加一句:
pico_add_extra_outputs(pico_sdk_imports) target_link_options(your_app PRIVATE "-Wl,--defsym=__core1_stack_size=0x800") target_link_options(your_app PRIVATE "-Wl,--defsym=__core1_heap_size=0x0") # 强制Core 1的代码和数据进Bank B target_link_options(your_app PRIVATE "-Wl,--section-start=.core1_text=0x20040000") target_link_options(your_app PRIVATE "-Wl,--section-start=.core1_data=0x20042000")然后在core1_entry.c顶部加上:
__attribute__((section(".core1_text"))) void core1_entry() { ... } int16_t __attribute__((section(".core1_data"))) fft_output[1024];这样,Core 1的所有活跃数据,都在它“自家门口”。
调试双核,别只看printf:用硬件信号说话
printf()在双核下是个甜蜜的陷阱。Core 0的stdio默认走USB CDC,Core 1如果也调用printf(),SDK会把它重定向到同一个CDC端口——结果就是两核的打印混在一起,你还分不清哪行是Core 1输出的。
真正可靠的调试方式,是把关键状态变成GPIO电平变化,用示波器或逻辑分析仪直接观测:
- Core 0:DMA完成时,拉高
GPIO26,处理完FIFO推送后拉低; - Core 1:收到FIFO消息瞬间拉高
GPIO27,FFT开始时拉低,结束时再拉高; - 用
pio_sm_get_pc()读取PIO状态机PC,验证FFT是否真正在运行(而不是卡在某条指令)。
我常用一个四通道逻辑分析仪,同时抓这四个信号:
-GPIO25:Core 1 LED主频(基准时钟);
-GPIO26:Core 0 DMA完成标记;
-GPIO27:Core 1 FFT生命周期;
-UART0_TX:Core 0上传状态包。
当这四条波形严丝合缝地咬合在一起,你就知道:双核不仅在跑,而且跑得精准、稳定、互不干扰。
最后一句实在话
RP2040的双核编程,门槛不在技术复杂度,而在思维转换——
它要求你放弃“一个CPU搞定所有事”的惯性,学会像系统架构师一样思考:
哪个任务必须零抖动?哪个数据必须独占访问?哪条路径最短、最可预测?
当你把Core 1当成一块专用协处理器来用,把FIFO当成硬件信道,把spinlock当成电路开关,那些曾让你深夜挠头的实时性问题,突然就变得清晰可解。
如果你正准备做一个需要稳定音频采样、电机闭环、或者高频传感器融合的项目,别急着换芯片。先试试让Pico的两个M0+,真正地、各司其职地,一起干一件事。
你可能会惊讶于:原来4美元的开发板,也能跑出工业级的确定性。
如果你在Core 1上实现了更酷的应用——比如用PIO配合FFT做实时噪声抵消,或者把双核做成主从式CAN总线网关——欢迎在评论区分享你的引脚连接图和时序截图。