多 hart 适配是怎么落地的¶
日期:2026-09-26 标签:smp, multi-hart, async-driver, riscv, starryos, critical-section, ipi, scheduler
范围:StarryOS 的异步 UART(NS16550)和异步网卡(VirtIO-MMIO)两个驱动,QEMU
SMP=16。 前置:问题清单见 为什么异步驱动要做多 hart 适配。 依据:本地mul-hart-k3分支。QEMU 结果不外推到 K3 真板。
整体数据流¶
四件事按这个顺序接起来,后一件依赖前一件:
- 每个 hart 写完自己的运行队列,再把自己登记进就绪位图
- 驱动角色从就绪位图里挑 hart,在第一次入队前把绑核钉死
- 唤醒发生时选目标队列、改状态,目标在远端就发一次 IPI
- waker、ring、快照这类共享状态靠"本地关中断 + 全局 owner 锁"互斥
1. 临界区:本地关中断之外,加一把全局 owner 锁¶
原来的问题¶
kernel 的临界区只关本 hart 中断。Embassy AtomicWaker 的 waker cell 是非原子的读-改-写,它靠临界区提供跨 hart 互斥。单 hart 下每个 hart 独占自己的"临界区",恰好成立;多 hart 下两个 hart 可以同时认为自己独占临界区,同时改同一个 waker cell。
现在的做法¶
kernel/src/critical_section_policy.rs 里的 acquire() 分三步(:87):
let was_enabled = ops.irqs_enabled();
ops.disable_irqs(); // 1. 关本 hart 中断,记下原状态
let prev = depth_mut(cpu).fetch_update(AcqRel, Acquire, checked_increment_depth)?;
if prev == 0 { // 2. 嵌套深度 0 → 1,才抢全局所有权
while GLOBAL_LOCK.compare_exchange_weak(false, true, Acquire, Relaxed).is_err() {
core::hint::spin_loop(); // 3. 抢不到就自旋
}
}
was_enabled
release()(:121)对称:深度减一,1 → 0 时 GLOBAL_LOCK.store(false, Release),然后只有 was_enabled 为真才重新开中断。
| 做法 | 为什么 |
|---|---|
| 全局只有一把锁 + per-hart 嵌套深度 | 临界区要保护的是同一组 waker cell,锁的粒度必须覆盖所有 hart。嵌套深度让同 hart 重入只加计数,不必反复抢 |
| 抢不到锁时自旋 | 临界区里只有几次原子操作,禁止阻塞、让出或拿驱动锁,所以不能用任何会挂起的锁 |
release(false) 不开中断 |
ISR 里 acquire 时中断已经是关的。恢复原状态才能避免在中断还没处理完时提前重入 |
挂在 critical_section crate 的官方 set_impl! 上(kernel/src/lib.rs:66),RawRestoreState 直接用 restore-state-bool。这个文件不依赖 axhal,宿主测试用 #[path] 把同一份代码连同假的 IrqOps 后端编进去,跑的是同一套 acquire/release。
失败路径一律 fail closed。hart id 越界、深度下溢、深度到 u32 上限都直接 panic,不绕回去。
2. 每个 hart 一条运行队列,就绪状态单独用一个位图¶
队列数组¶
RUN_QUEUES 是长度 MAX_CPU_NUM 的 [MaybeUninit<&'static mut AxRunQueue>](crates/axtask/src/run_queue.rs:56)。用 MaybeUninit 是因为不能让整个系统为"还没启动的 hart"付出初始化成本,代价是取槽位前必须先证明它写过了。
就绪位图¶
SCHEDULABLE: AtomicUsize(:72)是全系统判断某个 hart 的队列能不能用的唯一依据:
// 写:先把槽位写好,再 Release 发布
unsafe { RUN_QUEUES[cpu_id].write(RUN_QUEUE.current_ref_mut_raw()); }
mark_schedulable(cpu_id); // fetch_or(1<<cpu, Release)
// 读:Acquire 读到就说明槽位已经写好
let bits = SCHEDULABLE.load(Acquire);
init()(:782,boot hart)和 init_secondary()(:812,副 hart)都走"写槽位 → 置位"这两步。Release/Acquire 这一对决定了顺序:读侧看到某位为真之后再去解引用 RUN_QUEUES[cpu],写侧保证已经写完。
三个容易混的集合¶
| 名字 | 含义 | 用错的后果 |
|---|---|---|
axhal::cpu_num() |
配置的 CPU 数量 | 只说明"硬件有几个",不代表副 hart 的队列已初始化。用它做校验会放行未初始化的槽位 |
cpu_mask_full() |
全 CPU 掩码 | 普通 spawn 的默认亲和性。任务第一次入队就可能被丢进任意一条队列 |
schedulable_cpu_mask() |
已发布就绪的 hart 集合 | 绑核和分配只应该看这个 |
入队前绑核¶
spawn_task_with_affinity()(crates/axtask/src/api.rs:239)的顺序是:先 validate_schedulable() 校验,再 set_cpumask(),最后才 select_run_queue() 选队列入队。校验不过直接返回 None 且不入队,不会退回"随便找个队列"。
set_cpumask_checked()(crates/axtask/src/task.rs:242)是运行期改绑核的安全版本:掩码为空、越界或指向未发布 hart 时返回 false,旧掩码原样保留,并记一次 reject 计数。
3. hart 之间怎么通信:一次远端 IPI¶
通路¶
QEMU virt 上 axhal::irq::send_ipi 走 SBI send_ipi(hart mask)→ OpenSBI 给目标 hart 挂 supervisor software interrupt。guest 侧 IPI_IRQ 注册进 S_SOFT 槽位,handler 跑完由平台清 sip::clear_ssoft()。
单槽位所有权¶
try_register_ipi()(crates/axtask/src/ipi.rs:101)用 IPI_REGISTERED 做 CAS,保证全系统只有一个 S_SOFT 拥有者。注册失败时把标志位回滚,下一次还能再试。init_scheduler()(api.rs:98)在 boot hart 上注册,失败直接 assert——宁可启动失败,也不要出现第二个 IPI 拥有者。
handler 做什么¶
reschedule_ipi_handler()(ipi.rs:86)只做两件事:记一次接收(含 per-hart 计数),给当前任务置抢占标志。不切任务、不碰运行队列。切换交给通用的中断退出路径,和 timer tick 走同一条路。
什么时候发¶
should_notify_remote()(ipi.rs:71)是纯判定:
was_ready 只在 Blocked → Ready 状态迁移成功时为真,所以重复唤醒、不在 Blocked 状态的唤醒都不会发 IPI。本地唤醒不发,只置抢占标志(run_queue.rs:397)。
4. 唤醒目标选择:本地优先¶
踩到的问题¶
UART TX copier 固定在远端 hart,startup 打完一个字节后 copier 最终恢复了,IPI 发送和接收计数一直是 0。原因是 AxWaker 复用了普通 spawn 的全局轮转来选目标,并以 resched=false 入队,远端入队不通知任何人。
直接把唤醒一律改成 resched=true 的尝试被完整回退:全掩码的网络协议栈 runner 会在 16 个 hart 之间被唤醒轮转,每次都发 IPI,形成 IPI 风暴。
最终规则¶
select_wake_cpu()(run_queue.rs:145)是纯函数:
let intersect = task & schedulable;
if intersect.is_empty() { return None; } // 失败关闭
if current < MAX_CPU_NUM && intersect.get(current) { return Some(current); }
intersect.first_index() // 交集里最低的一个
即:当前 hart 同时在任务亲和集和已发布集合内就留在本地;否则从交集里确定性地取一个。两个后果正好对上前面两个问题——固定绑核的 copier 必然投递到它那一个远端 hart(发一次 IPI),全掩码任务留在本地(不发 IPI、不迁移)。
AxWaker(crates/axtask/src/future/mod.rs:41)走这个入口并请求完成 Blocked → Ready 迁移。普通 spawn 仍走 select_schedulable_cpu() 的全局轮转,行为没变。
5. 绑核和分配:角色到 hart 的确定性映射¶
规则¶
kernel/src/drivers/placement.rs:103 的 place_roles() 接收已发布 hart id 的升序去重列表(每项都小于 MAX_CPU_NUM)和一个 anchor:
at(i) = schedulable[(anchor 在列表中的位置 + i) % len]
角色 i:UART RX copier = 0,UART TX copier = 1,网卡 owner = 2,网卡 runner = 3
输入为空、乱序、重复或越界时返回 None,调用方 fail closed。anchor 不在列表里时从索引 0 开始,不按拓扑过滤。
规则是纯函数,不碰 MMIO、IRQ 路由和 hart 数量连续性,所以能在宿主测试里穷举。SMP=16 下四个角色落在四个不同 hart 上;只有单 hart 时才共置。
启动顺序¶
| 角色 | 启动点 | 顺序要求 |
|---|---|---|
| UART RX/TX copier | uart_init::start_copiers()(:463) |
硬件初始化之后、startup benchmark 之后。RX_COPIER_STARTED/TX_COPIER_STARTED 拒绝第二次启动 |
| 网卡 stack runner | axnet::start_stack_runner_affinity()(crates/axnet/src/stack_runner.rs:683) |
Service 先装好。runner 起不来是硬停止,不允许 owner 在没有 runner 的情况下启动 |
| 网卡队列 owner | start_rx_task_affinity()(crates/axnet/src/async_rx.rs:3078) |
IRQ 注册成功之后才起 |
pinned 的 hart 只在 spawn 返回 Some 之后记录(record_runner_pinned 在 Ok 分支里),spawn 被拒时观测里显示 UNKNOWN,不记成已绑核。
失败原子性¶
start_affinity_with_checked()(stack_runner.rs:658)保证失败的 spawn 不消耗掉"只能启动一次"的生命周期:先按已发布集合校验 → CAS 生命周期 → spawn → 只有拿到句柄才提交,None 时把生命周期回滚,下一次还能重试,且不会重复入队。
迁移¶
不改结构,只改掩码:把某个角色的掩码从单 hart 扩到两个 hart,它会在下一次自然阻塞/唤醒时落到另一个 hart,再收回单 hart。全程不新建第二个 copier/owner,UART ring 保持单生产者单消费者,网络保持每队列唯一 owner。目标非法时失败并保留原掩码。
6. 顺带修掉的三个缺陷¶
UART TX copier 丢唤醒¶
copier 在 ring 空时注册 waker 并发布 inactive,然后返回 Pending 停放。如果生产者在这两步之间推入数据,任务状态还是 Running,没有 Blocked → Ready,那次唤醒就丢了,随后停放在一个非空 ring 上。表现是同一个二进制时而 PASS 时而失败。
修法是补上"注册→重查":注册 ring waker 后重查 ring,非空就 self-wake 重试,不停放(crates/uart_16550/src/async_/driver.rs),并加 park_after_register_retry 计数。
release 构建越界读出幽灵 hart¶
绑核代码按硬编码 64 遍历 CpuMask<16>,而 cpumask 0.1.0 的索引 get/set 只有 debug_assert 保护。release 下越界读返回垃圾 true,于是并不存在的 hart 16 被当成可调度。
修法是 crates/axtask/src/cpumask.rs 的 AxCpuMask 新类型:索引访问在 debug 和 release 都做容量检查,越界读报"不在集合",越界写返回 AxCpuMaskError::IndexOutOfRange 且掩码不变。registry 类型不再从公开 API 露出(无 Deref、无 AsRef、拿不到内部值)。所有掩码容量统一取 axconfig::plat::MAX_CPU_NUM,不再各自硬编码。
PLIC 窗口不够 16 个 hart¶
QEMU 平台配置里 PLIC MMIO 写成 0x0c00_0000/0x21_0000,只够 8 个 hart 的 supervisor context。16 hart 实测中 boot hart 11 在 init_percpu 访问超映射地址时 page fault,后面的 current task is uninitialized panic 把原始故障盖掉了。.axconfig.toml:22 改成 0x0c00_0000/0x60_0000(设备树给的就是 6 MB)。
7. 怎么证明它按这个规则跑¶
- UART 快照(QEMU-only 命令
0x55534d31)是一份 152 字节定长帧。帧内有配置/在线掩码、两个 copier 的绑核、实际中断 hart,以及 copier 最近/累计 hart、ring 占用与空位、TX 四阶段完成。另有远端入队/IPI/恢复计数和非法绑核拒绝数。逐字节序列化,不 memcpy 结构体,否则repr(C)填充的未定义字节会带进用户态。旧 TXDBG 接口不变 - 网卡 V5(
0x4e495435)按字节是 V4 的前缀扩展,V1–V4 的命令、布局、语义不动。V5 新增的 owner/runner hart 字段是任务在轮询入口记下的实际值,不是从绑核掩码推断的 - 关 timer 的远端唤醒见证:目标 hart 关掉本地 timer 再停放,另一个 hart 触发唤醒。任务还能被救活、并记录到自己 hart 上的 IPI 接收增量,就排除了"靠 100Hz tick 救活"的可能。同一时刻只允许一次运行,每次开始都重置状态;取消和超时都要求目标先恢复 timer 才允许发布终态
8. 还没做完的¶
- 迁移视图的读写序还有缺口。
MigrationSlot用原子字段加自定义 seqlock,写侧在奇数标记后加了 Release fence,但 Release 只约束它之前的操作,后续字段写仍可能越过;读侧在最终序号检查前也少一道边界。弱内存序下读者可能接受混合视图。修法已定,代码还没改 - SMP=16 上的分驱动运行验证还没做。串口和网卡各自单独跑一遍、独立判定,不以另一方成功替代
- 组合压力与恢复交错还没做。两驱动同时跑,以及网卡 reset/link 翻转时 UART 仍有可判定进度
代码位置¶
| 主题 | 位置 |
|---|---|
| 临界区策略 | kernel/src/critical_section_policy.rs:87(acquire)、:121(release) |
| 临界区接线 | kernel/src/lib.rs:66 |
| 运行队列与就绪位图 | crates/axtask/src/run_queue.rs:56、:72、:782、:812 |
| 唤醒目标选择 | crates/axtask/src/run_queue.rs:145、crates/axtask/src/future/mod.rs:41 |
| 远端 IPI | crates/axtask/src/ipi.rs:71、:86、:101 |
| 入队前绑核 | crates/axtask/src/api.rs:239、crates/axtask/src/task.rs:242 |
| 掩码容量安全 | crates/axtask/src/cpumask.rs |
| 角色分配 | kernel/src/drivers/placement.rs:103 |
| UART copier 启动 | kernel/src/drivers/uart_init.rs:463 |
| 网卡角色启动 | kernel/src/drivers/virtio_net_irq.rs:168、crates/axnet/src/stack_runner.rs:683、crates/axnet/src/async_rx.rs:3078 |
| 观测命令 | kernel/src/syscall/fs/ctl.rs |
边界¶
- 迁移只改变同一任务的执行 hart,不创建第二实例
- QEMU
SMP=16只证明 16 个同构 hart 的软件并发。X100/A100 异构能力、AIA 中断路由和真板实际在线的 hart 集合留给真板验证 - 不包含 CPU 热插拔、IRQ 动态负载均衡,以及多队列网卡和多网卡