Rust 开发 RP2350 双核:资源全景与 embassy-rp 核间通信实战
从 RP2350 的双核双架构特性出发,梳理芯片资源与核间通信硬件基础,并用 embassy-rp 0.10 演示 Channel、Signal、SIO FIFO 等跨核通信方式。
Rust 开发 RP2350 双核:资源全景与 embassy-rp 核间通信实战#
RP2350 并不是 RP2040 的简单“超频版”。它真正的关键词有两个:双核双架构和更强的外设与安全能力。对 Rust 开发者来说,RP2350 的双核既有诱人的算力,也带来一个现实问题:两个核上的异步任务怎么可靠地通信?
本文会先客观地梳理 RP2350 的资源,再说明它和 RP2040 在双核模型上的关键差异,最后以 embassy-rp 0.10.0 为基准,讲清楚 embassy-rp 中启动第二个核、运行第二套 executor,以及用 Channel、Signal、SIO FIFO 完成核间通信的方法。
时效性说明:本文写作时间为 2026-08-13。文中代码与 API 以 embassy-rp 0.10.0、embassy-executor 0.10.0、embassy-sync 0.8.0、embassy-time 0.5.1 为准。Embassy 仍处于 0.x 阶段,API 可能继续演进,建议最终以 docs.embassy.dev ↗ 中对应版本为准。
一、RP2350 的资源全景#
1.1 双核、双架构,但不是四核同时跑#
RP2350 在芯片上有两套“处理器槽位”:core 0 和 core 1。每个槽位都同时连接了一个 Arm Cortex-M33 和一个 Hazard3 RISC-V 处理器。同一时刻每个槽位只能激活其中一个架构,所以实际可用的逻辑 CPU 始终是两个,而不是四个。
更准确地说,RP2350 的双架构能力是:
- 默认可以由引导 ROM 自动识别镜像架构,也可以由 OTP 固定启动架构;
- core 0 与 core 1 的架构可以在启动阶段分别选择;
- 常见的量产策略是通过 OTP 永久固定为双 Cortex-M33,或固定为双 Hazard3;
- 在嵌入式 Rust 生态里,
embassy-rp当前主要针对 RP2350 的 Cortex-M33 路径;RISC-V 路径通常需要从rp235x-hal或直接使用rp235x-pac入手,不适合假设embassy-rp开箱即用地覆盖全部 RISC-V 能力。
所以,对本文的 embassy-rp 示例,默认场景是:两个 Cortex-M33 核心,目标三元组为 thumbv8m.main-none-eabihf。
1.2 资源速览#
下表是 RP2350 的主要资源。它和 RP2040 相比,最明显的变化不只是主频,还包括 FPU、TrustZone、更大的 SRAM、HSTX、真随机数发生器、更细的低功耗域,以及对外部 PSRAM 的支持。
| 类别 | RP2350 关键能力 |
|---|---|
| CPU | 双 Cortex-M33 @ 150 MHz,带 FPU/DSP;或双 Hazard3 RISC-V |
| 安全 | Cortex-M33 TrustZone、Secure Boot、可选镜像签名与加密、8 KB OTP |
| SRAM | 520 KB,分布在 10 个可并行访问的存储体 |
| 外部存储 | 无内置 Flash,通过 QSPI XIP 运行代码,支持 PSRAM |
| GPIO | RP2350A:30 个;RP2350B:48 个 |
| 模拟 | 12-bit ADC:RP2350A 4 路,RP2350B 8 路 |
| PWM | 24 通道 |
| PIO | 3 个 PIO 块,共 12 个状态机 |
| 串行接口 | 2 × UART、2 × SPI、2 × I2C |
| 高速接口 | 1 × HSTX、USB 1.1 Host/Device |
| DMA | 12 通道 |
| 定时器 | 两个硬件定时器块、AON Timer、Watchdog |
| 安全随机源 | 硬件 TRNG |
这里有一个容易被忽略的点:RP2350 的 520 KB SRAM 并不是单一连续块,而是多个 bank。对于双核同时访问不同 bank 的场景,这种设计有利于降低总线竞争;但对单核大数组场景,是否连续通常还要结合链接脚本和实际地址映射看,不应直接假设所有 520 KB 都能当一个线性大堆用。
1.3 与双核直接相关的硬件#
除了 CPU 本身,还有几组硬件资源专门服务于核间通信与同步:
- SIO 核间 FIFO:两个单向 32 位 FIFO,一个由 core 0 写、core 1 读,另一个相反。RP2040 每方向深度为 8 项;RP2350 每方向深度为 4 项。
- 硬件自旋锁:32 个,用于跨核互斥。Embassy 的
critical-section-impl和SpinlockRawMutex都会用到它们。 - Doorbell:RP2350 新增 8 个门铃中断,可用于“只唤醒、不传数据”的轻量信号。RP2040 没有这套门铃机制。
- FIFO 中断:RP2350 与 RP2040 的中断号分配不同,RP2350 的 FIFO 中断在两个核上使用同一个中断号,编写底层中断处理时要按当前核区分。
了解这些硬件是有意义的:embassy_rp::multicore 本身就会使用 SIO FIFO 完成 core 1 启动、暂停/恢复和中断唤醒,因此上层应用不要随意直接读写 SIO FIFO,否则可能干扰 Embassy 的控制协议。
二、embassy-rp 的双核启动模型#
2.1 核心思路:一个核一个 executor#
Embassy 的推荐模型并不是“把任务自动分发到两个核”,而是手动启动 core 1,并在 core 1 上运行第二个 Executor。
core 0 和 core 1 各自维护自己的任务队列和调度器:
- core 0 运行 executor 0,负责主流程;
- core 1 运行 executor 1,负责实时、阻塞或高负载子任务;
- 两个 executor 之间通过共享内存、
embassy-sync原语或硬件 FIFO 通信。
这一点非常重要:它意味着你可以在 core 1 上启动多个异步任务,而不是只能塞一个裸函数。
2.2 启动第二个核的最小骨架#
下面代码展示如何启动 core 1,并让两个核各自运行一个异步任务。
#![no_std]
#![no_main]
use core::ptr::addr_of_mut;
use defmt_rtt as _;
use embassy_executor::Executor;
use embassy_rp::multicore::{spawn_core1, Stack};
use panic_probe as _;
use static_cell::StaticCell;
static mut CORE1_STACK: Stack<4096> = Stack::new();
static EXECUTOR0: StaticCell<Executor> = StaticCell::new();
static EXECUTOR1: StaticCell<Executor> = StaticCell::new();
#[embassy_executor::task]
async fn core0_task() {
loop {
defmt::info!("hello from core0");
embassy_time::Timer::after(embassy_time::Duration::from_secs(1)).await;
}
}
#[embassy_executor::task]
async fn core1_task() {
loop {
defmt::info!("hello from core1");
embassy_time::Timer::after(embassy_time::Duration::from_secs(1)).await;
}
}
#[cortex_m_rt::entry]
fn main() -> ! {
let p = embassy_rp::init(Default::default());
spawn_core1(
p.CORE1,
unsafe { &mut *addr_of_mut!(CORE1_STACK) },
move || {
let executor1 = EXECUTOR1.init(Executor::new());
executor1.run(|spawner| {
spawner.spawn(core1_task().unwrap());
});
},
);
let executor0 = EXECUTOR0.init(Executor::new());
executor0.run(|spawner| {
spawner.spawn(core0_task().unwrap());
});
}rust这个骨架中的关键点如下:
Stack<4096>为 core 1 单独分配栈。栈太小会导致任务空间不足,实际项目应根据 core 1 任务深度调整。spawn_core1接收p.CORE1、静态栈和FnOnce() -> !风格的入口闭包。- core 1 的入口闭包会创建并运行第二个
Executor,因此它可以拥有自己的任务集合。 - 两个 executor 都是静态分配,分别由
StaticCell保管。
2.3 常用辅助 API#
embassy-rp 的 multicore 模块还提供了几个直接可用的辅助函数:
current_core():返回当前执行所在核,CoreId::Core0或CoreId::Core1;pause_core1()/resume_core1():暂停和恢复 core 1;Stack<N>:为 core 1 提供按 32 字节对齐的栈内存。
这些 API 在调试、低功耗和状态观测时很实用。例如,你可以让 core 0 在进入某种安全状态前调用 pause_core1(),避免两个核继续争用同一外设。
三、跨核通信的三种层次#
在 embassy-rp 上,跨核通信通常可以分成三个层次:
- 高层异步原语:
embassy-sync的Channel、Signal、Mutex等,最推荐; - 共享内存 + 互斥:借助
CriticalSectionRawMutex或硬件自旋锁保护共享状态; - 底层硬件协议:直接操作 SIO FIFO 或 Doorbell,灵活但需要自行处理与 Embassy 的冲突。
3.1 高层异步原语:推荐路线#
embassy-sync 提供了适合嵌入式异步场景的通信结构。跨核使用时,最重要的是选择正确的 mutex 类型:
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_sync::signal::Signal;rustCriticalSectionRawMutex 依赖 critical-section 实现。跨核安全的前提是在 embassy-rp 中启用 critical-section-impl feature。该 feature 会让 critical-section 使用 RP 的硬件自旋锁,而不是只关闭当前核的中断。
如果关闭当前核中断就能保证互斥,那在单核 Cortex-M 上通常成立;但双核中,core 0 关闭自己的中断并不会阻止 core 1 同时进入临界区。因此缺少 critical-section-impl 时,共享数据的“安全”是脆弱的。
Channel:带背压的有界消息队列#
Channel 适合命令、事件流等“每条消息都需要被处理”的场景。它是 MPMC 有界队列,满了以后发送方会等待,天然形成背压。
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
#[derive(Clone, Copy, defmt::Format)]
enum Command {
ReadAdc { seq: u32 },
Shutdown,
}
static CMD: Channel<CriticalSectionRawMutex, Command, 4> = Channel::new();rustcore 0 发送:
CMD.send(Command::ReadAdc { seq }).await;rustcore 1 接收:
match CMD.receive().await {
Command::ReadAdc { seq } => { /* ... */ }
Command::Shutdown => { /* ... */ }
}rustSignal:只关心最新状态#
Signal 是单槽信号,重复写入会覆盖旧值。它适合“最新传感器读数”“当前状态”这类允许丢失中间值的场景。
#[derive(Clone, Copy, defmt::Format)]
struct Reading {
seq: u32,
raw: u16,
}
static TELEMETRY: Signal<CriticalSectionRawMutex, Reading> = Signal::new();rustcore 1 上报:
TELEMETRY.signal(Reading { seq, raw });rustcore 0 等待:
let reading = TELEMETRY.wait().await;rust一个完整通信示例#
把启动骨架与 Channel、Signal 组合起来,就能形成清晰的“命令/遥测”双核结构:
#![no_std]
#![no_main]
use core::ptr::addr_of_mut;
use defmt_rtt as _;
use embassy_executor::Executor;
use embassy_rp::multicore::{spawn_core1, Stack};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_sync::signal::Signal;
use embassy_time::{Duration, Timer};
use panic_probe as _;
use static_cell::StaticCell;
#[derive(Clone, Copy, defmt::Format)]
enum Command {
ReadAdc { seq: u32 },
}
#[derive(Clone, Copy, defmt::Format)]
struct Reading {
seq: u32,
raw: u16,
}
static CMD: Channel<CriticalSectionRawMutex, Command, 4> = Channel::new();
static TELEMETRY: Signal<CriticalSectionRawMutex, Reading> = Signal::new();
static mut CORE1_STACK: Stack<4096> = Stack::new();
static EXECUTOR0: StaticCell<Executor> = StaticCell::new();
static EXECUTOR1: StaticCell<Executor> = StaticCell::new();
#[embassy_executor::task]
async fn core0_task() {
let mut seq = 0u32;
loop {
CMD.send(Command::ReadAdc { seq }).await;
let reading = TELEMETRY.wait().await;
defmt::info!("seq={}, raw={}", reading.seq, reading.raw);
seq = seq.wrapping_add(1);
Timer::after(Duration::from_millis(500)).await;
}
}
#[embassy_executor::task]
async fn core1_worker() {
let mut fake_adc = 0u16;
loop {
match CMD.receive().await {
Command::ReadAdc { seq } => {
// 这里替换成真实的 ADC/传感器读取。
fake_adc = fake_adc.wrapping_add(17) & 0x0FFF;
TELEMETRY.signal(Reading { seq, raw: fake_adc });
}
}
}
}
#[cortex_m_rt::entry]
fn main() -> ! {
let p = embassy_rp::init(Default::default());
spawn_core1(
p.CORE1,
unsafe { &mut *addr_of_mut!(CORE1_STACK) },
move || {
let executor1 = EXECUTOR1.init(Executor::new());
executor1.run(|spawner| {
spawner.spawn(core1_worker().unwrap());
});
},
);
let executor0 = EXECUTOR0.init(Executor::new());
executor0.run(|spawner| {
spawner.spawn(core0_task().unwrap());
});
}rust这个示例虽然是“假 ADC”,但结构可以直接替换成真实外设。它的通信关系很清楚:
- core 0 通过
CMD发送请求,并携带递增序号; - core 1 接收请求、执行工作,再通过
TELEMETRY返回结果; - core 0 不轮询寄存器,而是在
TELEMETRY.wait().await上等待异步通知。
3.2 共享内存与互斥#
如果需要共享一个较大的状态结构,而不是每次拷贝消息,可以使用异步互斥锁 embassy_sync::mutex::Mutex:
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;
struct SharedState {
mode: u8,
samples: [u16; 16],
}
static SHARED: Mutex<CriticalSectionRawMutex, SharedState> = Mutex::new(SharedState {
mode: 0,
samples: [0; 16],
});
async fn writer() {
let mut guard = SHARED.lock().await;
guard.mode = 1;
}rust如果你只需要保护一小段不跨越 .await 的临界区,也可以使用 embassy_sync::blocking_mutex::Mutex,它的 API 是 SHARED.lock(|data| { ... }) 这种闭包形式,不会产生异步等待。
跨核场景下,同样要开启 critical-section-impl。此外,Embassy 还提供 embassy_rp::spinlock_mutex::SpinlockRawMutex<N>,让用户显式选择某个硬件自旋锁;如果你的系统还同时使用 PAC 直接访问自旋锁,需要提前规划编号,避免冲突。
3.3 底层 SIO FIFO 与 Doorbell#
在某些延迟敏感的场景,开发者可能想直接使用 SIO FIFO。RP 的核间 FIFO 本质上是一个 32 位单向队列,写端阻塞到可写,读端阻塞到可读:
use cortex_m::asm;
use embassy_rp::pac;
fn fifo_write(value: u32) {
let sio = pac::SIO;
while !sio.fifo().st().read().rdy() {}
sio.fifo().wr().write_value(value);
asm::sev();
}
fn fifo_read() -> u32 {
let sio = pac::SIO;
while !sio.fifo().st().read().vld() {}
sio.fifo().rd().read()
}rust这段代码只适合理解硬件原理,不建议在 embassy-rp 多核项目中随意使用。原因是:
spawn_core1、pause_core1、resume_core1以及 interrupt executor 都依赖 FIFO 控制协议;- 如果应用层也写 FIFO,可能把控制令牌和业务数据混在一起,造成 core 1 卡死或状态错乱;
- RP2350 的 FIFO 深度只有 4 项,比 RP2040 更小,直接用裸 FIFO 传连续数据流并不宽裕;
- RP2350 的 SIO 区域还存在已知勘误(如 RP2350-E2 对自旋锁别名的影响),底层寄存器操作应优先依赖经过测试的 HAL/PAC 抽象。
如果确实需要“只唤醒、不传数据”,可以关注 RP2350 的 Doorbell。不过在 embassy-rp 的 multicore 模块中目前没有直接暴露高层 Doorbell API,通常需要借助 PAC 访问;非必要场景下,用 Signal 或 Channel 往往更简单、更可移植。
四、工程配置要点#
要让上面的例子跑起来,除了 Rust 代码,还需要正确配置目标、链接脚本和依赖 feature。
一个 RP2350B/Pico 2 风格的 Cargo.toml 依赖片段如下:
[dependencies]
embassy-rp = { version = "0.10.0", features = [
"rp235xb",
"time-driver",
"critical-section-impl",
"executor-thread",
"unstable-pac",
] }
embassy-executor = { version = "0.10.0", features = [
"platform-cortex-m",
"executor-thread",
] }
embassy-sync = "0.8"
embassy-time = "0.5"
static_cell = "2.1"
cortex-m = "0.7"
cortex-m-rt = "0.7"
panic-probe = { version = "0.3", features = ["print-defmt"] }
defmt = "1"
defmt-rtt = "1"toml其中 unstable-pac 只是为了前面 SIO FIFO 示例能访问 embassy_rp::pac;如果你的项目完全使用 embassy-sync 高层原语,可以省略它。
memory.x 也需要按 RP2350 地址空间配置。以板载 4 MB Flash 为例:
MEMORY
{
FLASH : ORIGIN = 0x10000000, LENGTH = 4096K
RAM : ORIGIN = 0x20000000, LENGTH = 520K
}text.cargo/config.toml 中应指定 Cortex-M33 目标,并链接 defmt 脚本:
[build]
target = "thumbv8m.main-none-eabihf"
[target.thumbv8m.main-none-eabihf]
runner = "probe-rs run --chip RP2350"
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
]toml最后再强调一遍:如果你的板子是 RP2350A 而不是 RP2350B,把 embassy-rp 的 feature 换成 rp235xa;如果板载 Flash 不是 4 MB,memory.x 的 FLASH 长度要按实际芯片调整。
五、什么时候该用双核?#
双核不是免费的午餐,它同时带来共享外设竞争、临界区、调试和功耗等问题。客观地说,以下场景更适合把 core 1 单独拉出来:
- 需要长时间运行的实时采集、编解码、电机控制或软 DSP;
- 不希望慢速外设或复杂协议阻塞 core 0 的 UI/控制流程;
- 需要把安全敏感流程和普通业务放在不同执行域;
- 单核已经无法满足延迟预算,且任务之间有清晰边界。
反过来,如果任务本身只是几个低频异步任务,或者外设竞争比并行收益更大,单核 + Embassy 异步通常已经足够。先测量,再决定是否引入第二个核,比一开始就把系统切成双核更稳健。
六、总结#
RP2350 的双核并不是“四核”,而是两个处理器槽位在 Arm 与 RISC-V 之间二选一。对当前 Rust/Embassy 实践,最成熟的是双 Cortex-M33 路线。embassy-rp 的双核模型可以概括为:
spawn_core1启动第二个核;- 在 core 1 上运行独立的
Executor; - 用
embassy-sync的Channel、Signal、Mutex跨核通信; - 开启
critical-section-impl,让 critical-section 在双核下真正安全; - 底层 SIO FIFO 和 Doorbell 可作为性能手段,但需要理解 Embassy 内部协议,避免冲突。