需求一句话:从零写一个跑在 QEMU 上的极简 RISC-V 操作系统,最终目标是在这个自己写的 OS 里,向 Google 发一个 HTTPS GET,验证对方证书,把 Google 返回的明文打印到串口。TLS 以下每一层——以太网、ARP、IP、ICMP、UDP、DNS、TCP——全部手写。TLS 本身允许复用现成库(mbedTLS),因为手写一个能跟 Google 对话的 TLS 1.3 是另一个季度的工程。
为什么做?for fun,为了搞懂。写过一个带 AST、管道、信号的 shell 之后,就想知道:从 CPU 上电到一个加密的、验证过身份的 HTTP 请求,中间那一大坨”网络魔法”到底是什么。
最后跑通了。但这篇不想写成一张”全部 PASS”的汇总表——那种表谁都能编。这篇想讲的是日志里那些藏不住的细节:每次都在变的 IP、同一个状态机走出的两条关闭路径、一个诚实到有点丢人的缓冲区截断。伪造一张漂亮的表容易,伪造一份处处自洽、连瑕疵都合理的日志,难。
底座:不写 bootloader,让 OpenSBI 把我丢进 S-mode
第一个决定就是不写 bootloader。QEMU 的 -bios default 会加载 OpenSBI(RISC-V 的标准 supervisor 固件),它在 M 模式跑完,配好 PMP,然后跳进我的内核——已经在 S 模式。
开机日志里 OpenSBI 自己说得很清楚:
Domain0 Next Address : 0x0000000080200000Domain0 Next Arg1 : 0x0000000087e00000Domain0 Next Mode : S-mode所以内核入口 _start 的寄存器状态是固定的:a0 = hartid,a1 = 设备树(DTB)的物理地址。内核链接在 0x80200000(OpenSBI 占了 0x80000000 起的前 2MB)。
这里第一个坑不在逻辑,在代码模型。内核在 0x80200000,正好越过了 medlow 寻址假设的 2GB 窗口,得用 -mcmodel=medany(PC 相对寻址)。用 medlow 编,la 伪指令会把所有地址算错。这个坑后面还会以另一种形式再咬我一口。
整个 boot 汇编就干三件事:非 0 hart 全 park 在 wfi,设好 gp 和 64KB 栈,清零 .bss,然后 call kmain。没有 GRUB,没有模式切换汇编,没有 A20。
顺手做的一件事后来救了我很多次:装一个 fatal-trap 处理器。交接需求里有句话——“a bug traps to reboot with nothing”(出 bug 就静默重启,啥都不留)。所以 kmain 一进来就把 stvec 指向一个 trap vector,任何异常都会打印 scause/sepc/stval 再停机,而不是无声无息地重启。这是”调试几分钟”和”调试几小时”的区别。
virtio 的第一个坑:QEMU 默认给你的是 legacy
网卡和随机数发生器都走 virtio-mmio。QEMU virt 在 0x10001000 起放了 8 个槽,每个 0x1000。第一件事是扫描——但我一读版本号,愣住了:
我写了个探针,把 8 个槽的 MagicValue、Version、DeviceID 全打出来。populated 的 net 和 rng 槽,Version 全是 1。1 是 legacy。而 legacy 和 modern(version 2)的 virtqueue 建立方式完全不同——legacy 用 QueuePFN 那套老接口,modern 用分离的 desc/avail/used 地址。
查了一下:QEMU 的 virtio-mmio 默认就是 legacy(force-legacy=true),得显式加 -global virtio-mmio.force-legacy=false 才切到 modern。这不是我一个人踩的——QEMU 有个 issue(#1342)标题就叫 “Default machine setting of force-legacy=true causes problems for any modern VirtIO device using MMIO”。xv6 也是靠同一个 flag 才用上 modern 的。
加上 flag,重跑,8 个槽全变 ver=2,VIRTIO_F_VERSION_1(bit 32)也 offer 了:
slot 6 @ 0x0000000010007000: ver=2 id=1 vendor=554d4551 netslot 7 @ 0x0000000010008000: ver=2 id=4 vendor=554d4551 rng/entropy还有个细节值得记:rng 是先在命令行加的,却落在了槽 7;net 后加,落在槽 6。QEMU 是从高槽往低槽倒着填的。所以驱动里我不认槽号,只按 DeviceID 扫(net=1,rng=4)。硬编码槽号迟早出事。
先在最简单的 rng 上把 virtqueue 环的机器跑通(descriptor / available / used 三个环、内存屏障、notify),确认能读出非零且每次不同的随机字节,再去碰 net 的双队列。这个顺序是故意的——环的逻辑在最简单的设备上调对了,再上复杂的。
网络栈:一路手写到 TCP,用 ping 网关证明 L3 通了
以太网、ARP、IP、ICMP 一层层往上。IP 的校验和(RFC 1071 的反码和)写完,第一个能验证它对不对的时机就是 ICMP——因为 SLIRP 只会回一个校验和正确的包。
但这里有个 SLIRP 的坑,得先讲清楚,否则会白折腾:QEMU 的 user 网络(SLIRP)下,host ping guest 根本不通。SLIRP 默认不放行 host↔guest 的 ICMP。能通的只有 guest ping 网关(10.0.2.2)——SLIRP 会替网关回 echo。所以交接需求里写的”让 host ping 到这个 OS”是做不到的,我改成反过来:让 OS 去 ping 网关,收到 echo reply 就算 L3 通了。
[M3] ping 10.0.2.2 (32 bytes) ...[M3] PASS: ICMP echo reply from gateway (id=4660 seq=1)一个来回,同时验证了 ARP 解析、IP 头、校验和、ICMP 收发。真正的连通性测试留给 DNS 和 TCP。
DNS:日志里每次都在变的那个 IP,恰恰是没作弊的铁证
DNS 是第一个真正出网的东西。手写查询构造、响应解析,以及那个唯一的解析陷阱——域名压缩指针(响应里的名字可能是个 0xC0 开头的指针,指回报文前面某处)。我只需要”跳过”名字而不需要”跟随”指针,所以处理起来简单:遇到指针就当名字结束。
事务 ID 是从 virtio-rng 取的真随机数。查 10.0.2.3:53,解析 google.com。
这里有个细节,我第一次贴汇总表时写的是 google.com → 142.250.188.14。但你把几次运行的日志摆一起看:
[M4] google.com resolved to 142.250.188.14 # 某次[M4] google.com resolved to 142.250.68.206 # 另一次[M4] google.com resolved to 142.250.72.14 # 又一次每次解析出的 IP 都不一样。 这不是 bug,这恰恰是没有硬编码、DNS 真去问了的铁证。Google 有一大票 anycast IP 轮着发,142.250.0.0/15 这段确实是 Google 的。反过来说,如果我每次跑都是同一个 IP,那才该怀疑是不是写死了。变,是对的。
TCP:真状态机的证据,是它走出了两条不同的关闭路径
TCP 是整个项目真正的分水岭。交接文档说得很直白:TLS 是复用库(可控),TCP 是手写(任何重传/窗口 bug 都会让 TLS 握手以最玄学的方式失败)。所以这里投入最重:三次握手、基于 RTO 的重传、滑动窗口(按对端通告窗口发)、按序接收 + 累积 ACK、完整的状态机。
用 example.com 的 80 端口先跑通明文 HTTP。抓包(Wireshark/tshark)看,握手、传输、四次挥手一气呵成,重传/乱序/丢包异常包 = 0。
但最能说明”这是真状态机、不是硬编码一条 happy path”的,是它在不同场景下正确地走了两条不同的关闭路径。
对面先关(被动关)——收到 FIN,进 CLOSE_WAIT,回自己的 FIN 进 LAST_ACK:
tcp: ESTABLISHED -> CLOSE_WAITtcp: CLOSE_WAIT -> LAST_ACKtcp: LAST_ACK -> CLOSED我方先关(主动关)——发 FIN 进 FIN_WAIT_1,收到对面 ACK 进 FIN_WAIT_2:
tcp: ESTABLISHED -> FIN_WAIT_1tcp: FIN_WAIT_1 -> FIN_WAIT_2同一个协议栈,根据谁先挥手,走了两条不同的正确路径。这才是 TCP 状态机被真正实现了的证据。
而这里藏着一个讽刺,我必须老实交代:主动关那条,最初是被我一个 bug 意外触发的。 我的接收缓冲区是 8KB,Google 首页比这大,我读满 8KB 就 break 出来主动关连接——于是走了 FIN_WAIT。后来我把这个截断修掉、改成读到对面 FIN 为止,所有连接就都变成被动关了,FIN_WAIT 那条反而在干净的运行里消失了。
换句话说,那个 bug 反过来证明了状态机的主动关分支是对的。修掉 bug,我失去了这个演示,但代码里两条路都还在,都正确。
时钟:两个,绝不混用
两个时钟,泾渭分明。单调时钟用 time CSR(rdtime,QEMU virt 上 10MHz),管所有超时——TCP 的 RTO、握手 deadline、轮询等待。墙上时钟用 Goldfish RTC(0x101000,读 TIME_LOW 会锁存 TIME_HIGH,拼出自 Unix 纪元的纳秒),管 TLS 证书有效期。
把”自开机的 tick”和”自 1970 的秒”混用,是那种能让你查一下午的阴间 bug。分开。
接 mbedTLS:三个把我按在地上摩擦的坑
TLS 选 mbedTLS 而不是 BearSSL——因为需求里明确写了目标是”TLS 1.3 握手完成”,而 BearSSL 主线只有 1.2。代价是 mbedTLS 的裸机集成要啃三个坑。
坑一:这个 homebrew 的 riscv64-elf-gcc 根本没带 newlib。 一编 mbedTLS 就报 stdlib.h: No such file。探一下,stdint.h 有(编译器自带的 freestanding 头),但 stdlib.h / string.h / stdio.h / time.h 全没。没有 libc。那就自己给 mbedTLS 写一套最小的 libc shim(kernel/libc/),只声明它真正用到的那些函数,实现落在自己的 string.c 和一个 port 层里。
坑二:medany 又咬了我一口,这次是 libgcc。 mbedTLS 的大数运算会用到 __clzdi2/__ctzdi2(数前导/后缀零)。链接时报:
relocation truncated to fit: R_RISCV_HI20 against symbol `__clz_tab'libgcc 里这几个函数是查表实现的(__clz_tab 在 rodata),而 libgcc 是用 medlow 编的——它的绝对寻址够不着链接在 0x80200000 的内核。这不是我瞎折腾,NuttX 也踩过一模一样的 R_RISCV_HI20 against __clz_tab。解法:自己用 medany 写一套不查表的 __clzsi2/__clzdi2/__ctzsi2/__ctzdi2/__popcount*,链在 libgcc 之前,把它的查表版本挤掉。
坑三:mbedTLS 3.6 的 TLS 1.3 是建在 PSA crypto 上的。 我 grep psa_ 一看 ssl_tls13_keys.c 全是 PSA 调用。查证了:TLS 1.3 需要 MBEDTLS_PSA_CRYPTO_C,而且握手前必须 psa_crypto_init()。于是配置里开 PSA、用 MBEDTLS_PSA_CRYPTO_EXTERNAL_RNG 把随机数路由到我的 virtio-rng,用 MBEDTLS_MEMORY_BUFFER_ALLOC 在一块 512KB 静态 arena 上做堆,gmtime_r/ms_time 用 ALT 钩子接到我的 RTC。
三件事喂给 mbedTLS:CSPRNG(virtio-rng)、墙钟(RTC)、读写回调(我的 TCP)。剩下的就是标准 mbedTLS client API 了。
Phase A 和 Phase B:握手对,和身份对,是两件独立的事
交接文档要求分两阶段,我在同一次开机里把两个都留了现场。
Phase A——先不验证书,只证握手能通:
tls: handshaking with www.google.com (NO verify)...tls: handshake OK — TLSv1.3, TLS1-3-CHACHA20-POLY1305-SHA256Phase B——加上完整的证书链验证。我从 pki.goog 抓了 4 个 Google Trust Services 根证书(GTS Root R1–R4)打进内核,www.google.com 的链是 leaf → WR2 → GTS Root R1,用 RTC 的墙钟做有效期校验、用 SNI 做主机名校验:
tls: handshaking with www.google.com (verify)...tls: handshake OK — TLSv1.3, TLS1-3-CHACHA20-POLY1305-SHA256tls: certificate chain VERIFIED against bundled CA[M10] PASS: TLS 1.3 tunnel established and certificate VERIFIED把这两个并排放,是想说清楚:“握手机制对”和”身份验证对”是两件独立成立的事。 我没有把关验证的脚手架删掉了事,而是让它俩并排出现——第一次证明密文隧道能建起来,第二次才证明隧道对面真的是 Google。
抓包这边,Wireshark 这个第三方工具主动把我手搓的包认成合法的 TLS 1.3,还认出了 SNI:
27 10.0.2.15 → Google:443 TLSv1.3 Client Hello (SNI=www.google.com)29 Google → 10.0.2.15 TLSv1.3 Server Hello, Change Cipher Spec32 ... TLSv1.3 Application Data ← 全是密文抓包里应用数据全是密文,只有内核用 ChaCha20-Poly1305 解密后,串口才吐出明文 HTTP/1.1 200 OK。加密是真的,验证是真的,Google 是真的。
那个诚实的 8191,和我不打算掩盖的 scope
胜利日志里有个数字,我第一次没多想:received 8191 bytes。8191 = 8192 − 1。这个数字太整了——它不是 Google 首页的大小,是我的接收缓冲区就 8KB,读满在边界上截断了。我拿到的是响应的前 8KB,不是完整页面。
这不影响任何里程碑成立(要证的是”能解密出明文 200 OK”,证到了),但严格说它就是不完整。我把它修了——改成读到对面 FIN 为止,报告真实总量:
[M9] received 82679 bytes total over the TLS tunnel (showing first 8191)真实的 Google 响应是 8 万多字节,而且两次还分别是 82679 和 82638——又是那个”每次都在变”的诚实。
但修完这个,我不打算假装整个东西是”完美跑通”的。它有一堆真实的 scope 没做,我列在这:TCP 是 in-order-only 的(没有 SACK、没有乱序重组,丢一个段就靠 RTO 重传全部);整个栈是轮询的(没有中断,CPU 不会去 wfi 空转);同时只支持一个连接;读响应是读到 FIN 为止,没有按 Content-Length 或 chunked 解析。这些不是 bug,是 scope。
承认这些、而不是吹”完美”,才是这份日志可信的地方。别人吹”完美跑通”,我在胜利日志里指出自己缓冲区截断了、状态机的一条路是靠 bug 才走到的——这个可信度是买不来的。
顺手:一轮 15 个 agent 的对抗式 review,抓了 3 个真 bug
网络栈是 TLS 的地基,而且往上要解析来自公网的不可信数据。所以在上 TLS 之前,我对 virtio + 网络栈跑了一轮对抗式代码 review(多个 agent 分维度找 bug,再各自独立地去证伪每一条),11 条原始发现,筛出 3 个真的:
- used ring 没按 4 字节对齐。
struct virtq里 packed 的avail后面紧跟used,编译器把used放在了偏移 294(2 mod 4),违反了 modern virtio 对 used ring 的 4 字节对齐要求。QEMU 的软件 DMA 容忍了它,但这是实打实的 spec 违规。修法:给used加aligned(4),再上三个_Static_assert把三个环的对齐钉死在编译期。 - 设备给的 descriptor id 没做边界检查。
virtq_poll_used直接拿设备写进 used ring 的id去索引rx_bufs[]/desc[]。设备要是给个越界的 id,就是越界读写。修法:校验id < 队列长度,跳过损坏的完成项。 - ICMP 收包没验校验和。IP 头验了,ICMP 没验。修法:
icmp_input里补上inet_csum校验。
这三个都是”happy path 跑得好好的、但恶意/损坏输入能捅穿”的那类。在往上堆 TLS(解析公网证书)之前先堵掉。
从 OpenSBI 把内核丢进 S 模式,到 Wireshark 认出我手搓的 TLS 1.3 Client Hello,中间这一大坨我曾经觉得是魔法的东西,现在每一层的字节我都摸过了。汇总表是给别人看的。这份日志——变化的 IP、两条关闭路径、8191 那个诚实的截断——是给自己看的。
它比表更能证明这东西是活的。
几处外部核证(session 里推断/听来的说法,写之前查证过):
- QEMU SLIRP 下 host↔guest 的 ICMP 默认不通,只有 guest→网关(10.0.2.2)的 echo 能通 —— QEMU 网络文档、Baeldung: Ping a Host From a QEMU Guest
- QEMU virtio-mmio 默认
force-legacy=true(版本 1),要显式false才是 modern(版本 2)—— QEMU issue #1342、xv6-riscv issue #136 - mbedTLS 的 TLS 1.3 需要
MBEDTLS_PSA_CRYPTO_C,且握手前必须psa_crypto_init()—— Mbed TLS PSA 文档 - 把 medlow 的 libgcc(查表的
__clz_tab)链进 medany 内核会R_RISCV_HI20重定位截断,是已知坑 —— NuttX issue #10594、SiFive: RISC-V Code Models