3730 字
19 分钟
手搓 OS 续集:40 个并行 subagent 把它养成真操作系统,靠一条"不信自述"的纪律

前传里,那个手搓的 RISC-V OS 达成了唯一目标:给 Google 发一次证书链验证过的 HTTPS,把明文 200 OK 打到串口。但它有个尴尬的形态——跑完那一次演示就 sbi_shutdown() 关机了。没有 shell,没有分页,没有进程,没有文件系统。用它自己文档里的话说:一个单线程、轮询、跑完即走的脚本机。

这次要把它养成真操作系统。里程碑清单是现成的:M11 shell → M12 Sv39 分页 → M13 定时器中断 → M14 抢占式调度器 → M15 U-mode + syscall → M16 文件系统。依赖锁死,一步压一步。

但这篇的重点不在”做了什么”——那是六个教科书里都有的里程碑。重点在**“怎么做的”**:这一整轮代码,一行都不是我写的。是 ~40 个并行 subagent 写的。而让这件事没有变成一坨互相撒谎的浆糊的,是一条纪律。

起因:weekly reset 前,token 用不完#

动机很俗:距离 Claude 的 weekly 额度重置不到 24 小时,token 还剩一大把。用不完就浪费了。所以我给的指令很直接——“你负责提需求,让 subagent 写代码,不断循环完善”,后来又追加一句”狠狠给我并行任务,效率最大化”。

这句话把角色定死了:我(主 agent)当 conductor——只提需求、写自包含的 brief、独立验证、排任务顺序;不亲自写代码。subagent 负责实现。

这不是偷懒。这是一个可验证的架构选择。conductor 的长上下文是负债——上下文一长,约束沉进低注意力区,模型开始拿貌似合理的东西填空,那就是幻觉,而且它自己的旧输出会回喂滚大。所以重上下文的探索、实现、审查,全部外包给 fresh context 的 subagent;conductor 只留判断。

纪律:subagent 说”完成了”,一个字都不能信#

这是整轮最要紧的一条,也是这篇想讲的核心。

多 agent 编排最大的陷阱不是工作量,是信任。一个 subagent 跑完回来跟你说”M13 完成,make test-boot exit 0,全绿”——你信吗?

不信。对话里的漂亮话糊弄得了对话,糊弄不了文件和测试结果。 我的研究协议里写死了一条:conductor 结构上看不见代码,禁止拿 subagent 自己的汇报当验证。

所以每一个里程碑 subagent 回报”完成”之后,我做的第一件事永远是同一件:自己 make clean && make && make test-boot,亲眼看串口输出。

这条纪律不是形式主义。它当场抓过 bug。M11 shell 做完,subagent 报告 PASS。我自己跑,串口里蹦出来这个:

sos> dns google.com
dns: query 'google.com' (id=0x845b) -> 10.0.2.3:53
dns: timeout
dns: could not resolve 'google.com'

shell 的 timerand 都好使,但 dns 在开机 demo 之后必然超时。subagent 其实诚实地把这个 bug 挖出来了(它叫 D13),但如果我只信”PASS”那句话,这个”旗舰命令 get www.google.com 交互时直接废掉”的缺口就漏过去了。

根因很典型:udp_listen() 的监听表只有 4 个槽、从不驱逐。开机 demo 正好做 4 次 DNS 解析(google.com、example.com、www.google.com×2),把 4 个槽全填满。之后 shell 里任何走 DNS 的命令,第 5 次 udp_listen 找不到空槽就静默失败。修法是加一个 udp_unlisten(),让一次性的 DNS 查询用完就释放槽位。修完再自己跑:

sos> dns google.com
google.com -> 142.250.72.14

活了。而且——延续前传那个规律——每次跑解析出的 Google IP 都在变(.72.14.217.142……),这恰恰是没硬编码、DNS 真去问了的铁证。

六个里程碑,每个都要一个”内核自称”之外的独立证据#

前传立过一个调性:不自证,找第三方裁判。串口是内核自称,Wireshark 是独立盖章。这套往下每一步都保持。所以每个里程碑,subagent 的 brief 里都写死了”definition of done”必须是串口可观测、最好是独立可核验的证据,而不是”我觉得它对”。

M12 Sv39 分页。 恒等映射(VA==PA)不是图省事——是两个 load-bearing 的理由:virtio 驱动把指针直接当物理地址交给设备做 DMA,恒等映射下 VA==PA,整条网络栈零改动仍正确;而且 csrw satp 后的下一条指令按虚拟地址取指,恒等映射下等于它原来的物理地址,PC/栈/gp 全部有效,不需要跳板,避免了分页启动最经典的黑屏。但”satp 设上了没崩”不算证据。真正的证据是一个 VA≠PA 的翻译自测:

[M12] PASS: MMU translated V=0x40000000 -> PA=0x87ffc000 (wrote via V, read 0xcafebabe back at PA)

把一个物理帧映射到一个本来没映射的虚拟地址 0x40000000,往这个 VA 写 0xcafebabe,再从物理帧的恒等别名读回来——读到了,说明 MMU 真的在翻译,不是”satp 设了个值然后啥也没变”。(0x40000000 是 Sv39 三级页表里第一个全净的 gigabyte——这个细节后面 M15 会再用到。Sv39 是 39 位虚拟地址、三级页表、satp.MODE=8,已核证。)

更强的证据是回归:整条对 Google 的验证 HTTPS 现在跑在翻译过的虚拟地址上,[M10] PASS 照旧。

M13 定时器中断。 内核第一次有了中断源,从纯轮询变成可被打断。trap vector 要把全部整数寄存器存进 trap frame、调 C 处理、再全部恢复 sret——丢一个寄存器,被打断的那段代码(正在跑 TLS 握手)恢复时就是错的,boot 直接死。证据是:定时器每 ~100ms 打断一次,整条验证 HTTPS 抓取在被抢占 ~300 次的情况下完整跑通。

[timer] tick 10 (uptime ~1s)
...
[timer] tick 300 (uptime ~30s)
[M10] PASS: TLS 1.3 tunnel established and certificate VERIFIED

TLS 抓取活着穿过 300 次抢占,就是上下文保存/恢复正确的证明——错一个寄存器它早崩了。

M14 抢占式调度器。 手写 swtch.S(14 个 callee-saved 寄存器)、PCB、round-robin。证据设计得很讲究,分两步:先是协作式(关掉定时器抢占、任务主动 yield())证明 swtch 本身对,再是抢占式(两个任务永不 yield、纯忙循环):

[preempt] A0 B0 A1 B1 A2 B2 A3 B3 A4 B4 [timer] tick 260 A5 B5

两个从不主动让出的忙循环还能交错打印,唯一能切换它们的就是定时器强切——所以交错 = 真抢占。这是”真状态机”而非”硬编码 happy path”的同款证明思路。

M15 U-mode + syscall。 这是”真·操作系统”那一刻——第一个真正的特权边界。一个独立工具链编译的用户程序跑在 ring-3,只能通过 write 这个 ecall 摸到串口(UART 是 U=0 的页,用户态碰不到)。但光有正向不够。真正证明边界在强制执行的,是负向测试:让用户程序去读一个内核地址,看它会不会被拦。

--- entering U-mode isolation test (negative: touch kernel VA) ---
user mode: about to read a kernel address 0x80200000 (should fault)
[M15] user fault: pid=6 load page fault (scause 13) at sepc=0xc0000028 stval=0x80200000 — killing task
[M15] PASS: U-mode blocked from kernel address 0x80200000 (task killed, kernel survives)
[M15] kernel survived the user fault; continuing

用户态读内核地址 → scause 13(load page fault)→ 只 kill 掉这一个任务、内核继续活、没有 FATAL。这个被捕获的 fault,就是特权边界真在执行的证明。GDB 里还确认了跑用户代码时 sstatus.SPP==0、QEMU 的 $priv==0——是真 ring-3,不是内核假装。(SPP 存进入前的特权级、sret 按 SPP 返回、SUM 位控制 S 态能否读 U=1 的页、U 态程序写 sstatus 会吃 illegal-instruction trap——这些全核证过。)

M16 文件系统。 virtio-blk 驱动(device id=2,链式 3 描述符请求:header 只读 + 数据可写 + 1 字节状态,全核证过)+ 一个自制极简只读 FS。这里的验证哲学最能体现前传的调性——除了内核自己的 data_cksum 整盘自检,还有一个格式盲的外部裁判:测试脚本抓 guest cat FILE 打到串口的字节,跟宿主机上原封不动的 fsroot/FILE 逐字节 cmp——用一个完全不懂我磁盘格式的工具证明”编码器∘解码器 = 恒等”。

[M16] PASS: data checksum verified (0xbb82acf6) over 6 data sectors
ok: M16 foreign cmp — guest 'cat MOTD' == host fsroot/MOTD (108 bytes, byte-for-byte)

编排机制:judge-panel 和跨里程碑接口契约#

依赖锁死的只是”实现”,不是”设计”和”审查”。所以我开了两条并行战线——一个 workflow 脚本,fan out 十几个纯读 agent:M14/M15/M16 深度设计、三个横向糖设计、对 M0–M12 的五维度对抗审计、测试/文档规划。全部只写 .claude/state/,零代码冲突。

其中最值的是 judge-panel 多方案对抗规划:对 M15、M16 这种有真设计分叉的里程碑,派 3 个架构师从不同被冻结的约束出发独立出方案,再派一个 judge 评分、合成、把次优方案的亮点嫁接进来。

这不是花架子,它做了真决策。M15 的 judge 否掉了”每任务页表”方案,选了”单全局页表 + U 页 + exec-first”,理由写得很硬:一个里程碑不该同时 front-load ring-3 地址空间隔离两个难概念;per-task 页表要重构 M12 已验证的 vm.c;它最凶的失败模式是静默的跨任务腐败,违反”大声失败、绝不静默”的原则。M16 的 judge 否掉了 FAT32,理由是探测了这台机器——mkfs.fatmtools 都没装,而 FAT 在 rv64imac(无非对齐 load 保证)上那些奇偏移 BPB 字段是雷,教的是 FAT 考古学不是文件系统本质。自制格式 512 字节对齐、构建器和读取器共享同一个 fs.h 结构体、结构上杜绝 drift。

另一条战线是跨里程碑接口契约:M13 的 trap frame 被 M14 上下文切换和 M15 syscall 复用、M15 用户页表建在 M12 vm 上、M16 的 fs_read 喂 M15——一个 agent 端到端把这些接缝冻死,免得后面的里程碑把前面的逼进死角。

两个规划 workflow 烧了 915K + 2.5M 共约 340 万 token。这就是”狠狠并行”的兑现。

诚实的细节:这一轮真实发生的糙事#

延续前传”不掩盖瑕疵”的规矩,这轮也有一堆藏不住的糙。

subagent 经常只发 idle 不发报告。 一个实现 agent 跑完了,只推来一条 idle_notification,正文报告不见了。我得 SendMessage 主动去催:“你 idle 了但没发报告,把文件清单、串口证据、exit code 发过来。“催了才吐。这不优雅,但比”没收到报告就当它没做完”强。

workflow 脚本被一个模板笔误卡住。 第二个规划 workflow 第一次提交直接 parse error——我在一个 judge 的 prompt 里写了 ${p.title)},括号写反了,) 跑进了 ${} 里面。改成 ${p.title}) 重发才过。手搓的东西,连字符串模板都会咬你。

gp 寄存器那个坑,M15 实打实踩了一次。 从 U 态 trap 进内核时,C 处理器跑在用户的 gp(U 态被清零)下,而内核是 gp 相对寻址 small-data 的——第一个 gp 相对的全局访问就 FATAL 了。修法是在 trap_vector 里 la gp,__global_pointer$ 重载。这跟前传里 medcany 那个坑同源:手搓内核里,寄存器约定这种”平时不用管”的东西,会在最意外的地方要你命。

加固批次修了两个 HIGH。 那轮五维对抗审计(18 个候选,独立反驳后剩 6 个确认),揪出两个真的高危:

一是 UDP 收包从不验校验和。ip/icmp/tcp 收包都验,唯独 udp 不验——一个非对称缺口。后果是:DNS 响应里一个 bit 翻转(IP 头仍合法),就被静默交给 dns_parsedns_resolve() 返回一个错误的 IP,HTTPS 连向错误的目标,整个验证 TLS 的信任模型被从底下掏空。RFC 768 明确 UDP 校验和覆盖伪头+数据、0x0000 才表示”没算校验和”——一个从不验证 present 校验和的接收方是实打实的安全缺口(已核证)。修法照 tcp_input 的伪头验证。

二是 tls_connect() 每条失败路径泄漏 mbedTLS 堆get https://<非Google> 证书验证必然失败(只打进了 Google 的 CA),每次失败泄漏 ~32KB+ 进那块 512KB 的静态 arena,十几次失败的 get 后 arena 耗尽,之后连合法的 google 抓取都以一个”假的 alloc 错误”失败——症状和病因完全脱钩,极难 debug。修法照 dns.c 那个 out: 单出口把三个 free 都走一遍。我给这个修复加了个铁证回归:先 get https://example.com(失败),紧接着 get https://www.google.com

tls: handshake failed -0x2700
get: www.google.com -> 142.251.157.119, https
tls: certificate chain VERIFIED against bundled CA

失败握手之后 google 抓取照常 verified,证明 arena 没被泄漏卡死。

M0–M15 全栈跑完对抗审查,出货代码零已知 bug。

最后#

从”跑完一次 HTTPS 就关机”到”能开 shell、能分页、能抢占、能进 U 态、能从磁盘 cat 文件”,这一大坨我一行没写。~40 个并行 subagent 写的,2 个规划 workflow 设计的,5 个对抗审查 agent 挑的刺,一次加固批次堵的洞。累计烧掉约 800 万 subagent token——weekly reset 前,用完了。

但让这堆并行产出没有塌成一坨互相撒谎的浆糊的,从头到尾就一条:subagent 说”完成”,我一个字都不信,每一步亲自 make test-boot 复核。 前传证明”手搓的东西是真跑的”靠的是变化的 IP 和诚实的截断;这一轮证明”40 个 agent 的活是真的”靠的是同一件事——真相只认可验证的产物,不认对话里的漂亮话。

漂亮的汇总表谁都能编。40 个 agent 齐声说”全绿”也能编。编不出来的,是一份处处自洽、连每次都在变的 Google IP、连被 kill 的用户任务、连失败握手后仍然 verified 的 arena,都对得上的串口日志。

它比任何”全部 PASS”的表,都更能证明这东西是活的。


几处外部核证(写之前查过的技术事实):

  • UDP 校验和覆盖伪头(源/目的 IP + 协议 17 + UDP 长度)+ 头 + 数据,0x0000 表示”未计算”;不验证 present 校验和是真实安全缺口 —— RFC 768
  • virtio-blk device id=2,读请求 = 16 字节头(type/reserved/sector)+ 数据 + 1 字节状态,链式描述符、可读在前可写在后 —— VIRTIO Spec v1.2 §5.2
  • Sv39 = 三级页表 / 39 位 VA / 4KB 页 + 2MB/1GB 大页,satp.MODE=8 选中 —— RISC-V 特权手册
  • sstatus.SPP 存进入前特权级、sret 按 SPP 返回、SUM 控制 S 态访问 U 页、U 态写 sstatus 触发 illegal-instruction —— RISC-V 特权手册
  • mbedTLS 3.6 的 TLS 1.3 依赖 MBEDTLS_PSA_CRYPTO_C,握手前须 psa_crypto_init() —— Mbed TLS PSA 文档
手搓 OS 续集:40 个并行 subagent 把它养成真操作系统,靠一条"不信自述"的纪律
https://blog.lishuyu.app/posts/手搓os续-并行subagent养成真操作系统/
作者
猫猫魔女
发布于
2026-07-01
许可协议
CC BY-NC-SA 4.0