队列级 NAPI 运行时设计
1. 状态与结论
本文既是网络运行时破坏性重构的设计基线,也是当前实现契约。实现必须同时满足这里定义的所有权、CPU 亲和性、IRQ、DMA、停止和回滚不变量;不满足能力要求的物理网卡不能被发布为可用设备。
设计结论如下:
- 每个共享 IRQ mask/rearm 域对应一个
NetPollGroup。 - 每个物理 IRQ source 与其覆盖的 poll group 形成
NetAffinityDomain,一个 domain 只有一个owner_cpu。 - hard IRQ callback、mask/ack、queue poll、DMA reclaim/refill 和 rearm 必须在同一
owner_cpu上连续推进。 - smoltcp 的
Interface、SocketSet、路由、DHCP 与 socket side table 仍由唯一ProtocolExecutor串行拥有。 - queue executor 与 protocol executor 之间只转移 frame/buffer 的唯一所有权,不把 IRQ continuation 转移到另一 CPU。
- 物理网卡只支持完整 IRQ 模式。缺少 IRQ、固定 affinity、mask/rearm 或 worker pin 能力时初始化失败。
- 本次不保留旧 polling、OOB wake、动态 queue 创建、设备级 IRQ 控制或同步 protocol poll 的兼容入口。
这项改动属于高风险架构变更:它同时改变公开驱动接口、IRQ 生命周期、DMA 所有权、SMP 调度和 StarryOS socket 唤醒路径。实现和验证应按本文的阶段边界独立审查。
2. 问题、用户与成功标准
2.1 具体问题
重构前的网络路径在 hard IRQ 后丢失了 source 和 queue 身份,只向一个全局通知发布事件。共享 net-poll task 随后唤醒全部设备,设备各自的 RX/TX task 再搬运数据,最终竞争单一 smoltcp poll owner。该结构产生四类问题:
- 单个设备 IRQ 会唤醒所有设备,SMP 下形成无关 worker 扇出。
- 永久 net-poll worker 与同步 flush 调用者都可能取得 protocol poll ownership,运行时不具备单一任务所有权。
- virtio task-side transport gate 竞争时不能在 hard IRQ 中等待;当前延迟 ACK 依赖设备周期 poll 才能保证后续探测。
- 迁移前的 AIC8800/SDIO 通过带外回调、独立 RX/TX task 和 10ms kicker 推进,绕过网卡 IRQ 注册与 CPU affinity 契约。
PR #1775 曾实际暴露生产初始化与 split-route helper 各启动一个永久协议 worker,二者竞争同一 IRQ waiter,最终触发 net IRQ waiter was registered concurrently。该失败与当时 dev 仅用原子 owner 串行化 poll 的结构共同构成这次破坏性重构的输入;当前实现以本文其余章节的不变量为准。
2.2 直接用户
rdif-eth和rd-net的可移植网卡驱动与适配层。- ArceOS/StarryOS/Axvisor 的网络运行时。
- virtio-net、E1000、RTL8125、FXMAC、Loongson GMAC 与 AIC8800/SDIO 后端。
- 依赖 Linux socket readiness、errno、信号中断与 restart 语义的 StarryOS 用户态程序。
2.3 成功标准
last_irq_cpu == last_poll_cpu == owner_cpu对每个 group 恒成立。- 相同物理
IrqId覆盖的所有 endpoint 只能注册到同一 CPU。 - 网络注册路径中不存在
IrqAffinity::Any,IRQ 到 queue poll 的 remote wake/IPI 计数恒为零。 - 一个 group 的 IRQ 只激活该 group;空闲 group worker 不被周期唤醒。
- burst 在 IRQ 关闭期间按预算合并处理,drain 与
rearm_and_check()完成后才重开 IRQ。 - 任一步初始化失败都不会发布部分网络 service,也不会留下已使能 IRQ、运行 worker 或失去所有权的 DMA buffer。
- 只有 protocol executor 能调用 smoltcp poll;同步 flush 只等待 generation completion。
- 现有有线和 AIC8800 后端一次性迁移,旧入口在源码、测试与文档中全部删除。
3. 精确研究基线
本设计于 2026-08-24 对以下版本进行了源码核验:
| 来源 | 精确版本 | 采用的事实 |
|---|---|---|
| TGOSKits dev | f96452ce892e2916a0c5bfe5aa9e7908b8085c06 | 当前 per-device worker、global wake-all、poll fallback、driver queue/IRQ contract |
| PR #1775 | 1572814000fc9b27d740cb5ce56dde68c42a14d0 | 单 protocol owner、requested/completed generation、重复 runtime 初始化回归;不直接移植其 ax-task 大重构 |
| Linux | v7.1,8cd9520d35a6c38db6567e97dd93b1f11f185dc6 | NAPI_STATE_SCHED/MISSED/DISABLE、budget repoll、complete/rearm 窗口、per-NAPI threaded owner |
| TGOSKits block runtime | 同 dev 版本 | hctx 固定 CPU worker、NonReentrant + AutoEnable::No + Fixed(cpu)、disabled-before-publish 和同步 teardown |
Linux v7.1 的关键语义不是 API 形状,而是以下所有权规则:
- 原子设置
SCHED的实体取得 poll-list ownership;已在运行时再次 schedule 只设置MISSED。 - poll 消耗满 budget 时保留 ownership 并 repoll,不完成/重开 IRQ。
- complete 清除
SCHED/MISSED时如果观察到MISSED,立即重新 schedule,避免 completion 窗口丢事件。 - disable 阻止新 schedule,并等待已有 poll ownership 退出。
TGOSKits 不复制 Linux softirq、GRO、RPS 或 busy-poll;这里只复用 queue-local ownership、budget、missed-event 与 complete/rearm 不变量。
4. 被否决方案
4.1 保留全局 IRQ 通知并在 worker 中扫描设备
否决原因:source 身份已经丢失,无法证明只唤醒目标 group,也无法让 IRQ callback CPU 与 queue poll CPU 保持一致。增加 bitmap 过滤只能减少扫描,不能建立注册 affinity 与 queue ownership 的同一原子事务。
4.2 每个 NIC 保留 RX/TX task,再增加固定 CPU NAPI task
否决原因:同一 queue 会出现多个 task-context owner,DMA reclaim/refill、IRQ rearm 和 TX submission 的顺序仍需共享锁协调。额外任务也会保留 PR #1775 已观察到的 wake/park 放大。
4.3 hard IRQ 竞争时自旋等待 transport gate
否决原因:IRQ 可能抢占持有 gate 的同 CPU task;等待被抢占者释放 gate 没有前进保证。IRQ 只能发布 ProbeDeferred,由同 CPU queue executor 完成 transport probe。
4.4 fixed affinity 失败后使用 Any、IPI 或周期 poll
否决原因:三者都会破坏 IRQ source -> poll group -> owner_cpu 不变量,并掩盖平台路由、worker pin 或 shared IRQ action 的不兼容。初始化必须原子失败。
4.5 多个 smoltcp Interface 共享或拆分 SocketSet
否决原因:smoltcp poll 要求同时独占 &mut Interface、&mut Device 和 &mut SocketSet。拆分会破坏全局 socket handle、wildcard bind/listen、reuseport、raw socket、orphan、route 与 DHCP 语义;共享锁则仍完全串行。协议分片是另一个高风险项目。
4.6 为 AIC8800 保留 kicker 作为暂时保险
否决原因:kicker 会把丢 IRQ、错误 clear/retrigger 或变体差异伪装为可用网络,无法证明空闲 worker 零唤醒。证据不足的变体必须拒绝发布,而不是静默轮询。
5. 总体结构
唯一允许跨 CPU 的路径是预分配 ring 上的 frame/token 所有权转移与 protocol request generation。hard IRQ 到 queue executor 的 continuation 不跨 CPU。
5.1 核心对象
NetworkRuntimeBuilder:消费全部设备和唯一PinnedNetIrqRegistrar,完成 topology、分配、pin、注册、enable 和 service publication。NetAffinityDomain:一组因共享物理 IRQ source 而必须同 CPU 的 poll group,持有不可变owner_cpu。NetQueueExecutor:固定到一个 CPU,轮询该 CPU 上的所有 group,每轮总预算 256。NetPollGroup:一个 IRQ mask/rearm 域以及对应 RX/TX queue、endpoint、SPSC rings、状态和统计。ProtocolPollRuntime:唯一 protocol task 的 generation 请求/完成状态,不保存第二种同步 poll 入口。ProtocolExecutor:独占Service、smoltcpInterface、SocketSet、route/DHCP/listener/orphan 状态。
6. Affinity domain 构造
6.1 输入
每个 NetPollGroupParts 声明一个或多个 NetIrqSourceId。平台在构造
NetworkDeviceInput 之前完成解析,把每个 source 转换为
ResolvedNetIrqSource { source_id, irq }:driver 的 BindingIrq(含 controller 拥有
的嵌套来源,例如 AIC 的 SDHCI controller IRQ)在这一步统一解析为可注册的物理
IrqId。
source ID 是 topology identity,不等同于 queue ID。多个 endpoint 引用相同 source ID 时表示共享物理 affinity/rearm 约束;builder 内 resolve_endpoint_irq() 要求每个 endpoint source ID 唯一映射到一个物理 IrqId,重复映射或未被引用的 source 都会使初始化失败。
6.2 算法
- 为每个 poll group 建立一个并查集节点。
- 对引用相同
NetIrqSourceId的 group 做 union。 - 每个连通分量构造一个
NetAffinityDomain。 - 按
(minimum source id, minimum group id)排序 domain,保证启动顺序稳定。 - domain 按稳定创建顺序轮转分配在线 CPU(
domain_index % cpu_count),保证不同 domain 均匀分布。 - protocol executor 选择 domain 负载最小的 CPU(先按 group 数,再按 CPU ID 打破平局);没有物理网卡时使用 bootstrap CPU 并只发布 loopback。
同一个 source ID 如果解析出不同物理 IrqId,或同一个物理 IrqId 被不同 source identity 隐式共享,builder 必须拒绝初始化。平台必须在注册前提供完整映射,不能在 callback 内动态发现。
6.3 固定 CPU 不变量
endpoint.registered_cpu
== domain.owner_cpu
== group.owner_cpu
== current_cpu(hard_irq_callback)
== current_cpu(group.poll)
== current_cpu(mask/ack/rearm)
独立 MSI-X/per-queue vector 可形成不同 domain。INTx、共享 FDT IRQ 和 AIC SDIO controller IRQ 只能形成一个 domain。
7. Poll group 状态机
实现使用一个 AtomicU8 编码基础状态和 MISSED 位:
base state: IDLE | SCHEDULED | POLLING | DISABLED
flag: MISSED
文档中的 POLLING|MISSED 等组合对应同一个原子字。不存在需要独立锁协调的第二份运行状态。
7.1 事件发布
IDLE -> SCHEDULED:CAS 使用AcqRel。成功者把 group ID 发布到本 CPU executor 的 pending set,然后IrqNotify::notify_irq()。- 已是
SCHEDULED/POLLING:CAS/fetch_or(MISSED, Release),不重复入队。 DISABLED:拒绝调度并增加 stop-rejected 统计;不能把事件留给未来 enable。
pending set 必须预分配且按 group ID 定位。相同 group 同时只允许一个逻辑 pending entry;通知只是 doorbell,不承载事件计数。
7.2 取得 poll ownership
owner CPU 从 pending set 取 group 后,以 CAS 把 SCHEDULED 变为 POLLING,同时清本轮已消费的 MISSED。Acquire 观察 hard IRQ 在 mask/ack 后发布的 snapshot。非 owner CPU 调用 poll 是不可恢复的 contract violation;测试构建返回 typed error,kernel 构建记录 fatal initialization/runtime invariant failure并禁用 domain。
7.3 预算
每个 group 的一次 poll cycle 依次执行:
- TX completion,最多 64 项;
- TX submission,最多 64 项,批次结束后
flush(); - RX recycle,最多 64 个 token;
- RX reclaim/refill 与完成项发布,最多 64 项。
每个 CPU executor 一轮最多处理 256 项。group 任一子预算耗尽即保持 IRQ 关闭并重新排队;executor 总预算耗尽时 yield 给调度器,然后立即继续,不等待 IRQ 或 timer。
7.4 complete 与 rearm 窗口
poll 结束时按以下顺序判断:
- 如果预算耗尽、ring backpressure、driver 报告仍有硬件工作、
ProbeDeferred未完成或观察到MISSED,把状态恢复为SCHEDULED并重新排队,IRQ 保持关闭。 - 否则 CAS
POLLING -> IDLE,使用 Release 发布所有 queue/DMA 更新。 - 在 owner CPU 调用原子的
rearm_and_check():驱动完成必要 sync、清源、打开 queue IRQ,并立即读取 pending/used ring。 - 若返回
WorkPending,CASIDLE -> SCHEDULED并重新排队;若 IRQ 同时先发布了SCHEDULED,不重复入队。
驱动不能把 enable_irq() 与 pending check 暴露为两个可被 runtime 任意组合的入口;rearm_and_check() 是一个语义原子操作,即使硬件需要多条指令完成。
8. Hard IRQ contract
NetHardIrqEndpoint::handle_irq() 只能返回:
pub enum NetHardIrqResult {
Spurious,
Schedule(NetIrqSnapshot),
ProbeDeferred,
}
允许的工作:
- 读取 bounded 状态寄存器或 transport interrupt status。
- mask queue/controller source。
- ACK 能在 hard IRQ 安全确认的状态。
- 发布固定大小 snapshot、
MISSED与本地 doorbell。
禁止的工作:
- 分配、释放、复制 packet payload。
- 访问 RX/TX descriptor payload 或执行 DMA sync。
- 阻塞、自旋等待 task-side gate、获取 sleeping lock。
- 调用 smoltcp、socket waker、任意用户回调或全局设备扫描。
注册固定为:
execution = NonReentrant
auto_enable = No
affinity = Fixed(owner_cpu)
share_mode = Shared only when all actions have identical fixed affinity
callback 首行在测试构建记录 current_cpu。如果与 registration lease 或 group 的 CPU 不同,callback 只 mask source 并将 domain 标为 failed,不能 remote-wake 正确 CPU 继续运行。
9. Driver queue 与 DMA 所有权
9.1 Move-only token
DmaBuffer 不实现 Clone 或 Copy。它表示 runtime 对一段映射和 DMA ownership 的唯一权利,而不是可复制描述符。
free pool
-> RX posted to device
-> RX completed by group
-> RX ring to protocol owner
-> recycle ring to group
-> RX posted to device
free/TX pool
-> protocol writes frame
-> TX ring to group
-> submitted to device
-> TX completion reclaim
-> free/TX pool
任何 submit 失败必须把原 token 放回 typed error:
pub struct SubmitError {
pub buffer: DmaBuffer,
pub reason: SubmitErrorKind,
}
reclaim 返回原 token,而不是只返回 bus_addr。runtime 不通过地址 side table 猜测所有权。
NetError::Retry 与 NetError::LinkDown 表示只有未来硬件或 task 事件才能改变
提交条件。runtime 保留 typed error 归还的 token;本轮没有 RX reclaim 等可观察进展时,
group 完成本轮 poll 并执行 rearm_and_check(),等待未来事件,而不是在 IRQ 保持关闭时
立即自调度形成 busy loop。只有同一轮 reclaim 已释放 descriptor 时,RX refill 才可以立即
重试。
9.2 DMA sync
跨 CPU ring 只转移 CPU ownership,不替代非一致 DMA 同步:
- RX device completion 后,group owner 先
sync_for_cpu,再 Release 发布到 RX ring。 - protocol owner Acquire 取得 token 后才读 payload。
- recycle 返回 group 后,group owner 完成必要清理与
sync_for_device,再提交 RX descriptor。 - protocol owner 写完 TX payload后 Release 发布;group owner Acquire 后
sync_for_device,再写 TX descriptor/doorbell。 - TX completion ACK 后才能回收 token;设备仍拥有时禁止 CPU 访问或重复提交。
每个 driver parts 必须声明 queue/token 是否 Send。无法证明跨 CPU 唯一所有权和 DMA mapping 生命周期的 driver 不能实现新 trait。
9.3 有界 SPSC ring
每个 group 与 protocol owner 之间预分配四条 ring:
rx_ready: group producer,protocol consumer,载荷RxCompletion;rx_recycle: protocol producer,group consumer,载荷DmaBuffer;tx_ready: protocol producer,group consumer,载荷TxRequest(token 与提交选项);tx_free: group producer,protocol consumer,载荷DmaBuffer。
TX completion 由 queue owner 自行 reclaim 后直接把 token 放回 tx_free,不需要独立的
TX-complete ring。ring 满不分配、不覆盖、不丢失 token:
- RX ring 满:group 进入 backpressure,IRQ 保持关闭,保留尚未移交的 completion ownership。
- protocol 消费 RX 后向 recycle ring 发布 token,并精准调度该 group。
- TX ring 满:socket/Router 观察 backpressure,保留 protocol-owned frame 并等待该 group 的空间 generation;不能同步调用 queue 或 protocol poll。
10. 破坏性驱动接口
10.1 顶层设备
旧 Interface 被删除,由消费式边界替代:
pub trait NetDevice: Send {
fn into_parts(self: Box<Self>) -> Result<NetDeviceParts, NetError>;
}
pub struct NetDeviceParts {
pub info: NetDeviceInfo,
pub control: Box<dyn NetControlEndpoint>,
pub wifi_control: Option<Box<dyn WifiControlEndpoint>>,
pub poll_groups: Vec<NetPollGroupParts>,
}
into_parts 只能调用一次。成功后不再存在能同时访问全部 queues、IRQ 和 control 的完整设备对象。
10.2 Poll group parts
pub struct NetPollGroupParts {
pub id: NetPollGroupId,
pub queues: NetQueuePairParts,
pub irq_control: Box<dyn NetPollIrqControl>,
pub owner_startup: Option<Box<dyn NetOwnerStartup>>,
pub irq_endpoints: Vec<NetHardIrqEndpoint>,
}
- queue ID 与 group ID 使用 typed newtype。
- 当前生产后端全部提供一个 queue-0 group;接口允许多个 group,但本次不启用 virtio/fxmac 硬件多队列。
- 只有拥有独立 IRQ source 和独立
rearm_and_check()域的硬件队列才能拆成多个 group。 NetOwnerStartup是 move-only one-shot endpoint,只能由已固定 CPU 的 group worker 在 IRQ 注册并 enable 之后、initial refill 与队列发布之前执行。它允许驱动在同一 owner 上完成传输层初始化、身份确认和固件启动;runtime 只处理进度与等待,不接管设备协议。若它返回NetError::DeviceNotPresent,runtime 只在cancel()成功并 disable+synchronize 对应 IRQ callback 后剔除该 group;取消失败不发布 absent 状态,其他错误仍使 builder 回滚。NetPollIrqControl暴露quiesce()、shutdown()和rearm_and_check();shutdown()只有在硬件已不能访问 descriptor/token backing 时才能成功,否则 runtime 必须隔离整个 group。- hard endpoint 是 move-only owned callback,不保存 queue 或 control 的反向引用。
10.3 平台获取与 IRQ registrar
平台一次性返回:
pub struct TakenNetDevice {
pub name: &'static str,
pub prepared_device: Box<dyn NetDevice>,
pub dma: DeviceDma,
pub irq_sources: Vec<BindingIrqBinding>,
}
NetworkRuntimeBuilder 显式消费所有 NetworkDeviceInput(含平台解析好的
ResolvedNetIrqSource 列表)和一个 PinnedNetIrqRegistrar。registrar API 必须携带
owner_cpu,并在 lease 中记录实际 CPU;它没有 Any variant。
controller 拥有的嵌套 IRQ 来源(如 AIC SDHCI controller IRQ)在平台解析
BindingIrq 阶段统一展开为物理 IrqId,不能在 driver probe 中提前注册到任意 CPU。
11. Protocol executor
11.1 单一 owner
ProtocolPollRuntime 使用两个 wrapping AtomicU64 generation:
requested:socket、RX batch、TX completion、timer deadline 或 flush 请求发布新 generation。completed:唯一 protocol executor 完成该 generation 可见的全部 poll 工作后发布。
另有一个 scheduled bit 保证只有永久 protocol task 能取得 owner。外部调用者没有 poll_until_idle() 或 required ownership API。
11.2 请求与完成
publisher:
generation = requested.fetch_add(1, Release) + 1
notify protocol executor
protocol executor:
acquire scheduled ownership
target = requested.load(Acquire)
drain RX rings / smoltcp poll / enqueue TX
completed.store(target, Release)
clear scheduled with AcqRel
recheck requested and external ring readiness
if pending: reacquire/schedule before sleeping
同步 flush 记录自己的 generation,并等待 completed 到达该 generation。它不能调用 smoltcp poll、不能取得 queue lock,也不能成为临时 protocol owner。
11.3 Timer
协议 timer 只用于 smoltcp 的真实 poll_at deadline,不用于设备探测或丢 IRQ兜底。timer 到期发布 protocol generation;它不会唤醒空闲 queue executor。