项目名称 :中文操作系统(Chinese OS) 项目地址 :中文高级编程助手/x86-64 中文汇编器 版权声明 :本文为原创内容,允许非商业转载,转载请注明出处 上篇 :《我用中文从零写了一个操作系统:53轮真机调试、45个BUG、25000行中文代码》
写在前面
上篇结尾,我的OS已经能在真机上跑通磁盘、文件系统、键盘和抢占式调度。45个BUG修完,53轮真机调试熬过来。
我当时真的以为,最难的阶段已经过去了。
我太天真了。
结果接下来的一个月,BUG编号从45涨到了165 。而且这120个新BUG,比前面45个更难、更毒------它们有一个共同点:表面上看,系统一切正常。
- 文件写成功了,冷启动后数据却不翼而飞
- USB设备握手成功,DMA却始终死在总线上
- OS内编译器"全绿"编译通过,产出的程序却是一堆垃圾
这篇下篇,讲的就是这三场仗:假持久化、USB地狱、和"在OS里编译自己" 。

数字对比:一个月发生了什么
先看看这一个月,我的项目经历了什么。
| 指标 | 上篇(2026-08-12) | 现在(2026-09-06) |
|---|---|---|
| BUG编号 | 45 | 165 |
| 内核模块 | 25个 | 33个 |
| 内核代码行数 | 25091行 | 37573行 |
| 回滚点 | 无此概念 | 43个 |
| kernel.bin | ~340KB | 450KB(880扇区) |
| 真机硬件 | Intel X79 + Xeon E5-2430 | 不变 |
最刺激的不是数字变大了,而是新增的这4个能力,每一个都是从地狱里爬出来的:自研中文文件系统 ZhFS、USB EHCI 全栈驱动、系统安装器(U盘→硬盘启动)、OS内编译器/汇编器自举(M3阶段)。
主线一:假持久化------最毒的BUG类型
1.1 热重启的骗局(D-104)
某天真机测试,文件系统一切正常:创建文件、写入内容、重启、读回------数据都在。
直到一次冷启动。
断电10秒再开机,刚才"保存成功"的文件,内容全部消失。我盯着屏幕愣了很久:写成功的代码路径,明明每一步都返回了成功。
根因出在硬盘的DRAM写缓存 上。AHCI的WRITE DMA EXT命令完成后,数据只躺在盘内缓存里,还没刷到磁介质。而我的写路径缺少一步关键操作------FLUSH CACHE(ATA命令0xE7)------它强制硬盘把缓存刷到介质。
于是:
arduino
热重启(不断电)→ 缓存还在 → 数据"还在" → 看着持久化了(假象)
冷启动(断电≥10秒)→ 缓存蒸发 → 数据打回原形 → 假持久化实锤
修复就是在每次写成功后补发FLUSH CACHE。这个BUG给我的教训后来写进了项目的验收铁律:持久化功能,必须冷启动验收,热重启不算数。
1.2 幽灵空闲块(D-105)
紧接着,更诡异的事来了。同一个文件,我写入三次------前两次成功,第三次报错。
没有规律,没有预警,就像有人在暗处随机按了一个"失败"按钮。
zhfs取证命令揭示了真相:文件系统的"空闲块链表"上,挂着一批物理上根本不存在的幽灵块。
根因:容器的超级块里记录的总块数、空闲块头,与磁盘物理容量脱节。目录项实际占用了4个块,超级块却以为只有7个块;分配块时读链失败又"静默跌穿"继续追加,导致总块数虚增------于是分块分到幽灵块上,一写就错。
修复是给文件系统加了容量自愈:打开容器时用物理容量钳制总块数,清洗空闲链上越界的幽灵块;分配块读链失败时直接作废空闲链,宁可不分配,也不分配错误的块。
教训:元数据不可信,物理容量才是真相。
1.3 超时假失败的连锁反应(D-111)
FLUSH CACHE 引入后又冒出新问题:机械硬盘的 FLUSH 很慢,PxCI(命令挂起位)长时间保持1。而 AHCI_写扇区 在发新命令前要等上一个命令的 PxCI 归零,这个等待超时设置得太紧。
超时后代码"静默继续",提交了无效命令,再等 PxCI 清零又超时,返回失败------但上层已经走了"成功"分支。功能表面显示成功,数据实际没落盘 。这是最毒的假成功类型:QEMU 全绿查不出来,只能靠真机的 WFAIL 标记反推。
修复:三处等待超时上调(20000→200000、200000→1000000),FLUSH 失败时重试最多3次。

1.4 一个差点埋下的死循环(2026-09-06 复查)
写这篇文章时复查代码,又发现一个隐患:在"等上一个命令 PxCI 归零"的循环里,超时上限被调到了200000,但"命令完成就提前退出"的哨兵值还停留在20001。
这个循环的退出机制是:PxCI=0(命令完成)时,把计数器置成"比上限大1"来跳出。而 20001 < 200000 永远成立------一旦 PxCI 归零,循环会永远读、永远跳不出去,也就是每次写盘都会死循环。
对照 git 历史,正确值200001在8/28的提交里本来就是对的,工作区里出现的是未提交的回归改动。修复一行,隐患消除。
这类"循环上限改了、哨兵值没跟上"的对称性错误,在裸机代码里几乎无法通过测试暴露------因为它在最正常的情况下触发(命令完成→死循环),而不是在异常情况下。code review 和 git 历史对照,是最后一道防线。
主线二:USB EHCI 的25轮地狱
2.1 握手成功,DMA却全灭
我给自己定的下一个目标是:让操作系统认出U盘。为此写了完整的 EHCI(USB 2.0主机控制器)驱动:PCI枚举、寄存器初始化、描述符环、异步调度。
第一轮真机测试,握手非常顺利:
csharp
[USB] 控制器握手成功,端口检测到设备
[USB] 发送第一个控制传输...
然后------Master Abort。总线上没有任何设备认领这次DMA,控制器进入 Halt 状态,报 Serious Error(HSE)。系统死机。
更诡异的是:QEMU里跑得好好的。
2.2 排他法清单:25轮的排除过程
写到第15轮的时候,我开始怀疑人生。
不是怀疑某一个假设,是怀疑我自己------是不是我根本不懂USB?是不是这行代码从一开始方向就错了?
但我有一个原则:没有"灵感",只有"清单"。 只要清单上还有没排除的项,就不能放弃。
从第一轮到第25轮,我用"排他法"一个个排除嫌疑:
-
第5轮 :怀疑SMM(系统管理模式)------BIOS 还没放权?检查
USBLEGSUP,实锤 BIOS 持有控制器。按 Linux 标准 handoff 流程接管 → 无效- (我心想:行,BIOS不背锅。)
-
第8轮 :怀疑 PCI 总线主控位(BME)从未使能 → 使能 → 无效
- (也不是这个。)
-
第11轮 :怀疑地址区域------把描述符放到低2MB → 无效
- (地址不是问题。)
-
第13轮 :怀疑 VT-d(IOMMU)在拦截------探针扫遍全部ACPI表,没有DMAR;再用 Windows 注册表交叉验证:这板子固件原生就没有VT-d → 理论证伪
- (VT-d死亡,排除。)
-
第17轮 :怀疑异步调度没被使能 → 使能 → 无效
- (调度开着呢。)
-
第20轮 :怀疑 qTD 缓冲区是垃圾值 → 清零 → 无效
- (数据是干净的。)
-
第22轮:怀疑短包恢复指针 → 修正 → 无效
-
第24轮:怀疑野指针 PERIODICLISTBASE → 证伪
25轮,每一轮都是"逐项都对但没用"。所有低半字段的修复都正确,但 Master Abort 纹丝不动。
2.3 真根因:EHCI 附录B,一行注释顶十遍规范
第25轮。清单上所有嫌疑人都被排除了。
我坐在电脑前,盯着屏幕发呆。然后目光落在串口输出里一个躺了19轮的数字 上:HCCPARAMS 的 bit0=1。
19轮。这个数字从我第一次打印它就在那儿,我看了19轮,从来没有追问过它到底是什么意思。
然后我打开 Linux 的 ehci.h 源码,看到这样一行注释:
ini
struct ehci_qtd {
__hc32 hw_next;
__hc32 hw_alt_next;
__hc32 hw_token;
__hc32 hw_buf[5];
__hc32 hw_buf_hi[5]; /* Appendix B */
};
EHCI 附录B 的64位布局 :所有结构的高半字段(buf_hi)不是插在中间,而是统一追加在结构尾部。我的描述符是纯32位紧凑布局------在64位读法的控制器眼里:
bash
我的布局: qTD0@0x40 qTD1@0x60 qTD2@0x80
64位读法: qTD0 的 buf_hi[0..4] 落在 0x60~0x73 = qTD1 的 Next/AltNext/Token 字段!
→ DMA 缓冲地址 = (qTD1.Next << 32) | 低32位
→ 万亿字节级地址 → 总线无人认领 → Master Abort
一次解释了全部25轮观测 :首笔事务即炸、地址无关、三次一字不差、QEMU通过(QEMU的EHCI是32位模式不读buf_hi)、所有低半修复"逐项都对但没用"。
修复:描述符布局改为与 Linux dma_pool 同构------64字节步长,低半偏移一字未动,buf_hi 区互不覆盖。这样一份代码在32位/64位两种模式下都合法。
第26轮真机:H=0 E=0,描述符完整读出,U-3b 收官。

2.4 教训
- "控制器位域声明"必须当真 ------
HCCPARAMS bit0=1在眼前躺了19轮,直到排他清单清空才被追究。位域声明的含义比寄存器位本身更重要。 - 参考驱动的数据结构定义,是最强文档 ------Linux 里一行
/* Appendix B */,顶得上读十遍规范文字。 - QEMU通过≠真机通过的又一次实证:QEMU(32位)与真机(64位声明)对同一布局解读不同。差分实验的"环境差"既是盲区,也是线索。
主线三:M3 自举------在OS里编译自己
3.1 终极目标:让OS自己编译自己
compiler.py 编译自己,这叫"自举"。听起来很高级,对吧?
但 M3 阶段我要干的,是在裸机上让OS编译自己------没有 Python,没有宿主机,只有一块 Intel X79 主板和一套我自己写的编译器。
结果第一轮全量编译,四连炸。
3.2 D-155~D-158:M3b 四连炸
- 编译 crc32表.zl 死循环:AST子节点移动逻辑是 O(n²),空间溢出后污染了偏移表
- 编译 crc32表.zl 代码生成卡死 :
输出_整数的"减10除法"是 O(数值) 的性能爆炸------打印一个百万级数字要执行百万次循环 - 编译 系统调用.zl page fault :源码里的数字
4294967295触发了符号表查找,访问地址 0xFFFFFFFF - 编译 USB驱动.zl #GP:关键字表添加项时写到了垃圾地址
修完四个,全量编译28个模块通过------M3b 里程碑达成:OS内编译器+汇编器,在裸机上完成了全部内核源码的自我编译。
3.3 D-150:AST节点池溢出------静默覆盖
M3c 前夜,QEMU里出现了幽灵般的现象:词法分析和语法分析全过,代码生成却卡在第4层bug,输出大量 ? 标记。
真根因:AST节点池溢出了 。节点池和子节点池在内存里相邻,节点池写满后继续写,静默覆盖了子节点池------解析器以为自己在读子节点,实际读到的是别的节点的ID。没有报错,没有崩溃,只有错误的结果。
修复:页表从16映射扩到32(覆盖64MB),节点池扩到8MB(131072节点),地址重排留出256KB安全间隙,并在创建节点时加了溢出检查------宁可显式失败,不要静默污染。
3.4 D-160:源码缓冲与token数组重叠8364字节
M3d 扩容 token 池后,QEMU 仍输出37个 ?。用 Python 模拟词法分析器复现,发现 token[100504] 本该是标识符,真机读到的却是字符串类型------源码被污染了。
根源是一处内存重叠 :编译器把 kernel_merged.zl 读入 0x500000(末尾 0x6230AC),而 token 数组基址在 0x621000------重叠了8364字节。词法分析边读源码边写token,token从0x621000起写,直接覆盖了还没读到的源码尾部,尾部字节变成token结构,被词法器误读成引号、括号和数字。
之前"全绿"没暴露,是因为源码没超过1183744字节;M3d 新增函数后源码膨胀到1192115字节,首次越过红线。
修复:源码加载地址上移到0x3000000(48MB),并新增构建期校验------"源码缓冲、token数组、数据链三者互不重叠",漏改任一直接构建FAIL。
3.5 D-164:计算长度≠编码长度
M3d-1,QEMU 启动后 #GP 崩溃。反汇编发现 调用 主 的跳转目标偏移了1112字节,而且落在 movabs rsi 指令中间------非指令边界。
根因:汇编器分两阶段工作,阶段1用"计算指令长度"累加标签地址,阶段2用"实际编码字节数"写代码。两条 MOV 类指令在计算长度时被多算了1字节(把REX前缀算了两次),每条错1字节,累积1112字节。
修复后"调用 主"的目标与主函数地址完全一致,入口以停机收尾。
3.6 D-165:写到这里的最新BUG
写到这里,最新的一个BUG还在修。
A阶段编译器把 取地址 X 解析出来了,但代码生成阶段直接跳过了它。参数加载只有两个分支:"数字"和"变量值",没有"取地址"。
结果就是:调试_标记(取地址 DBG_BOOT) 编译通过了,运行的时候 DBG_BOOT 的地址根本没加载到寄存器里------输出的全是空字符串。
编译器说"全绿",但产出的程序是残的。这大概是我这个月最深的体会:
编译器的正确性,最终要靠编译器自己产出并运行的程序来证明。
数字总结
| 指标 | 上篇 | 下篇累计 |
|---|---|---|
| 开发周期 | 47天 | 47+25=72天 |
| BUG数量 | 45个 | 165个 |
| 内核代码行数 | 25091行 | 37573行 |
| 内核模块 | 25个 | 33个 |
| 回滚点 | - | 43个 |
| 知乎技术文章 | 22篇 | 24篇 |
方法论升级:新的三条铁律
上篇我总结过"VGA诊断标记法、二分法、黄金法则"。这一个月的三个大坑,逼我加了三条新铁律:
1. 持久化必须冷启动验收
热重启通过≠持久化成功。断电10秒再开机,数据还在才算数。这条铁律直接源于D-104的教训。
2. 排查用"排他法清单"
面对"一切看起来都对"的疑难杂症,把嫌疑列成清单,一条条做实验排除(BME→SMM→地址区域→VT-d→布局......),每轮只验证一个假设。25轮USB地狱教会我:不要在一个假设上反复打转,排他清单比灵感可靠。
3. 双信源互证
单一来源的判断会骗你。D-139用"ACPI探针 + Windows注册表"双信源互证 VT-d 死亡;D-102 用"真机输出 + 宿主机离线取证"反推假成功。凡是关键结论,都要有第二个独立信源。
写在最后
上篇我写过:"记录每一个BUG,它们是你最宝贵的财富。"
下篇我想补一句:比BUG更宝贵的,是那些"看起来没有BUG"的时刻。
- 热重启后数据还在------直到冷启动才暴露的假持久化
- QEMU里全绿------直到真机才暴露的附录B布局
- "全绿"编译通过------直到运行才暴露的静默覆盖
裸机开发的残酷之处在于:成功可以造假,但真相会在最意外的时刻找你算账。而每一次算账,都让系统离"可靠"更近一步。
目前系统已经实现:中文编译器自举、ZhFS文件系统、USB全栈驱动、U盘安装器、OS内自我编译。下一步计划:
- M3 收官:D-165"取地址"修复,OS内编译器全功能
- 键盘/鼠标 USB 驱动:Split 事务支持全/低速设备
- 图形界面:从VGA文本模式升级到真正的图形交互
- 网络栈:让操作系统连上局域网
如果你也在写OS、写编译器、或者做任何"从零开始"的事------记住:热重启不算数,排他清单比灵感可靠,永远准备一个Plan B。
我们下篇见。