缓存谎言、LBA127 劫持与最后的 off-by-one
BUG 编号 :D-170(涉及 op160~op163,跨 2026-09-17 ~ 09-21)发现阶段 :阶段 A 收官(D-169 bypass 之后紧接着的连环案)结案时间 :2026-09-21(与 D-168/D-169 同日三案闭环)修复状态:✅ 已修复(y 全链路端到端跑通,全部基准命中)
一、引子:一场三方对质的魔幻现场
干底层裸机开发的,大概率都懂这种离谱时刻:
- 宿主机上的 Python 脚本拍胸脯:写入成功!双路回读逐字节比对全过,PASS;
- 我自己写的内核(OS)一读:全是 0;
- 没过多久内核又 "改口":不对,读回来全是 0xFF。
三方各说各话,个个都拿着 "铁证"。最后查完才发现 ------ 没一个在纯撒谎,但也没一个说的是全真相。大家看到的,都只是整条链路里某一层的投影。
这个 bug 从 9 月 17 号查到 21 号,整整四天,最后揪出来 4 个独立的 bug,每一个单拎出来都够单独立案复盘。故事得从上一个 bug D-169 的收尾说起。
二、背景:为了绕开键盘死锁,编译器 "搬家" 去硬盘
上个案子 D-169 查的是键盘死锁,根因是 EHCI 所有权交接之后,SMM 停止了键盘仿真。当时的止血方案叫 bypass :干脆让 y 命令的编译器加载、源码读取,全从 U 盘(EHCI)改成走硬盘(AHCI)------ 彻底不碰 EHCI,键盘自然就活了。
搬家就得搬 "行李":编译器二进制、入口扇区、源码,都得从 U 盘拷到硬盘固定位置,从相对 LBA 10000 开始放。数据怎么上硬盘?最初的想法很朴素:在 Windows 宿主机写个 Python 脚本,直接往物理盘的绝对 LBA 地址写 patch。
9 月 17 号,bypass 版首次真机测试。y 一跑,第一行校验和直接出来:
plaintext
ini
SUM=0
ENT=2818048
全零。对照基准 ENT=2943607,读回来的就是个空地址 ------ 入口扇区一个字节的有效载荷都没有。可宿主机那边明明显示 PASS 啊?第一重 "谎言",就这么上场了。
三、第一重假象:脚本的 "双路回读 PASS",是缓存演的戏
当时写盘脚本 v3.2 的逻辑做得很严谨:往物理盘绝对 LBA 125849360(= 分区偏移 + 10000)写 patch,写完双路回读验证------ 一路走卷句柄读,一路走物理盘句柄读,两边逐字节比对。结果:全部一致,PASS。
但内核读回来就是全零。当时猜了两个方向:要么写入没真正落盘,要么内核读错了位置。
先揭晓写入这边的结论:v3.2 的写入,走的是一个已经卸载卷的卷句柄------ 数据其实只进了 Windows 的写缓存,脚本里那两路回读,全被缓存给满足了。所以所谓的 "双路 PASS",根本就是海市蜃楼。进程一退出,脏页直接被丢弃,硬盘盘面从头到尾,一个字节都没收到。
铁证也很简单:PASS 之后,同一会话里新开一个进程再去读,全是 0。同一个系统里,跳出缓存的新进程,读到的才是真相。
不过当时还没查到这一层。第一反应是往内核那边找:会不会是内核读的地址不对?这一找,挖出了第二重假象 ------ 一个潜伏了 17 天的 "幽灵扇区"。
四、第二重假象:内核读错了位置 ------ 被残留扇区劫持了
4.1 三块拼图,拼出一个幽灵扇区
这段代码考古挺费功夫,最后三块拼图凑到一起,真相才浮出水面。
**拼图一:op86 的拷贝残留。**早在 9 月 2 号,我用 i1 命令做真机硬盘拷贝,把镜像 LBA 0251 写到了 F 盘 LBA 63 314。镜像里的 LBA 64 正好是 FAT32 的 VBR(卷引导扇区)。算一下:63 + 64 = 127------F 盘绝对 LBA 127 这个位置,躺着一块实打实的 VBR。
**拼图二:op89 的 fallback 探针。**AHCI 驱动里有个兜底逻辑:遍历 MBR 分区条目时,遇到既不是 FAT32 也不是扩展分区的类型(比如 NTFS 的 07 类型),就去 LBA(分区起始 + 64) 探一下有没有 FAT32 VBR------ 这是当初为 "F 盘启动" 留的逃生通道,探到了就设置分区偏移,立刻返回。巧了,F 盘条目类型就是 07,起始 LBA 是 63------63 + 64 = 127。探针一头撞上当初那块残留 VBR,直接命中。
**拼图三:分区扫描顺序。**启动扫描 Port1 时,F 盘条目排在扩展分区条目前面。fallback 命中就直接返回,后面的 EBR 链遍历根本走不到。
三条线索一合:内核里 AHCI_分区偏移 = 127,而正确值应该是 125839360 。也就是说,y 想读相对 LBA 10000,实际读的是绝对 LBA 10127------ 那片区域从来没拷过数据,自然全是零。
4.2 为什么潜伏 17 天没人发现
这个幽灵从 9 月 3 号 op89 落地,一直潜伏到 9 月 19 号。为什么这么久没暴露?因为那段时间所有校验读的内容,"本来就该是零":之前的内容级验证在 op89 之前;之后 AHCI 读出来的零,全被当成了 "patch 还没写完",而不是 "读错了地方"。
di 命令读空目录不死机、'2' 命令读零不报错 ------没有崩溃、没有报错,它只是安安静静地读错了地方。唯一的异常是启动时 "思考引擎_恢复失败静默重来",当时没人把这事儿和分区选择联系起来。无症状的 bug,才活得最久。
4.3 清除方案:带 "判决门" 的手术
修复选择在宿主机侧动手,不动内核:写了个 清除LBA127.py 脚本。流程很稳:全盘普查(只读,复刻内核的 fallback 逻辑扫所有盘的所有分区,确认唯一劫持源就是目标盘 LBA 127)→ 备份原扇区到文件 → 锁定 F 盘卷 → 清零 1 个扇区 → 物理回读 + 复查普查双重验收。
脚本里还特意加了个判决门 simulate_selection():清零前先模拟现状,必须复现 "LBA127 劫持" 才允许动手;清零后再模拟,必须指向正确的逻辑分区 125839360,否则拒绝执行。就是怕清错了地方。
结果执行时出了个小岔子:我跑的是旧版脚本,输出里根本没有那三行判决日志,判决门压根没生效。好在清零本身是对的,普查、回读、复查全过了。
然后复测 y,结果让所有人瞳孔地震:
plaintext
ini
SUM=33945600
ENT=2818047
不再是零了 ------ 劫持确实解除了。但读出来的,是整整一片 0xFF。260 个扇区,精准地、完整地全是 0xFF。第三重假象,上桌了。
五、第三重假象:全是 0xFF?其实是总线在 "装死"
5.1 0xFF 是硬件浮空的 "电学噪声"
先给不搞底层的朋友科普个冷知识:从空的总线上读数据,读回来的就是 0xFF。 IDE 总线没有上拉的时候,每次 IN 指令回来的都是高电平,也就是 0xFF。
更坑人的是,0xFF 还有个阴险的副作用:状态寄存器的 DRQ 位是 bit3,0xFF & 0x08 = 0x08------ 总线浮空的状态,看起来刚好就是 "设备就绪、数据请求"。所以程序以为自己在成功读盘,实际上读的全是电学噪声。
证据很硬:两块不同的内存缓冲区(0x2A0000 和 0x3000000),同时、精准地全是 0xFF,还都显示 "读成功"。哪有这么巧的事?只有一种解释:数据根本没到达内存,每次 "读" 都是浮空总线给的回音。
5.2 AHCI 挂了,才会降级到 PIO
什么时候会走 PIO?内核的 AHCI 启动扫描全盘,找不到可用的 FAT32 分区,就会返回失败,AHCI_可用 = 0,所有 ATA 读操作降级到 legacy PIO 模式。而我的真机主 IDE 通道上根本没接盘,于是全程浮空,读出来全是 0xFF。
这也顺便解释了一堆伴随症状:di 显示目录为空、zc a 卡死、启动时反复重试 ------ 全是 PIO 读垃圾数据的连锁反应。
但问题来了:宿主机上模拟内核判定逻辑的诊断脚本显示,盘面一切正常 ------EBR 没问题、J 盘 VBR 没问题、fallback 残留已经清了、模拟选中的也是正确的 125839360。宿主机说 "内核肯定能找到 FAT32",内核说 "我找不到"。这一次,嫌疑真正锁定到了内核侧。而且有个时间线索很不对劲:这条 EBR 链的代码路径,从 9 月 2 号之后,这还是第一次真正被执行。
5.3 加个 '4' 命令,让内核自己说话
要定罪,就得让内核运行时自己 "开口"。我新增了 '4' 命令(op161),一条命令打印全部关键判据:
- A = AHCI 是否可用
- P = 端口号
- O = 分区偏移
- R = AHCI 读扇区返回码
- B = 探针首字节(不经 PIO 兜底)
提前做好了判读矩阵:
表格
| 输出结果 | 结论 |
|---|---|
| O=125839360 + R=0 + B=72 | 链路完好,数据在盘上 |
| A=0 | 启动扫描失败,内核侧 bug 实锤 |
| O = 别的值 | 还存在别的劫持 |
| O 对 + B=0 | 数据没写进盘里,回到宿主机问题 |
9 月 20 号真机测试,'4' 输出:
plaintext
css
A=0 P=1 O=0 R=1 B=0
A=0,一锤定音:AHCI 启动扫描失败,凶手就在内核内部。
六、真相大白:我的探针,把自己正在解析的 MBR 毁了
根因定位在 AHCI_解析MBR 函数里,就是 op89 留下的那行 fallback 探针:
zl
ini
调用 AHCI_读扇区(fallback分区LBA, 检查缓冲) ;; 探针读
问题就扎在 检查缓冲 这四个字上 ------MBR 本身,也正存在这个缓冲里被遍历。
走一遍案发现场你就懂了:
- 先把 MBR(LBA 0)读进
检查缓冲,开始逐个解析分区条目; - 处理到 F 盘条目(07 类型),fallback 探针触发:往同一个
检查缓冲里,读入了 LBA 127 的内容; - MBR 被覆盖了。 不管探针命中与否,缓冲里现在装的都是 LBA 127 的扇区;
- 继续解析 "第二个分区条目"(扩展分区 05 类型)------ 从被覆盖的缓冲里读,读出来全是零;
- 扩展分区条目全零 → 认为没有扩展分区 → EBR 链遍历直接不执行 → 这个端口宣告失败 → AHCI 启动返回 3 → AHCI 可用 = 0 → 全线降级 PIO,浮空读 0xFF。
6.2 为什么偏偏现在炸?三个时代的三种命运
这个 bug 不是新写的,它一直就在那儿,只是前两个阶段都有 "免死金牌":
表格
| 时代 | fallback 探针行为 | 缓冲覆盖的后果 |
|---|---|---|
| op89 之前 | 没有探针 | 无覆盖,一切正常 |
| 劫持期(LBA127 有 VBR) | 探针命中即返回 | 覆盖了,但马上返回,不影响后续解析 |
| 清零后(LBA127 全零) | 探针未命中,继续往下走 | 覆盖的 MBR 被当真,继续解析 → 直接全灭 |
同一个 bug,在三个时代分别是:无影响、潜伏、爆发。说白了,清除 LBA127 劫持那一下,相当于撤掉了一直替它挡枪的盾牌,这个潜伏的 bug 才彻底现形。这也是为什么清零之后,症状反而从 "读零" 恶化成 "读 0xFF"------ 不是手术做错了,是手术让真正的病灶露出来了。
6.3 修复:一句话的事儿
修复简单得不像话:每个分区条目处理完,把 MBR 重新读一遍回来。
zl
ini
调用 AHCI_读扇区(0, 检查缓冲) ;; ★op162:探针读/VBR读都会覆盖MBR,每条目处理后恢复
}
分区号 加 1
每端口最多多 3 次扇区读取,性能影响可以忽略。而且完全符合启动序列的改动约束,零新变量、零新嵌套、零新分支,就加一行调用。
顺带说一句,代码审查时还发现了另一个同类缓冲覆盖 bug------ 解析扩展分区链时,VBR 读取会覆盖缓冲里的 EBR,VBR 校验失败后,从被覆盖的缓冲读下一个 EBR 链接,读到的就是垃圾。不过那个只有在 "逻辑分区 VBR 不合格" 时才触发,不是本案凶手,先登记在案,后面再修。
七、绕回起点:盘,真的是空的
内核修好了,'4' 输出变成 A=1 P=1 O=125839360 R=0------ 链路活了。但 B=0:patch 扇区首字节还是 0。数据,根本不在盘上。
绕了一大圈,又回到了最初的矛盾:脚本说 PASS,盘面却是空的。这次彻底跟 Windows 存储栈死磕,从 v3.2 到 v3.4,三代脚本,三代 "phantom PASS",三种死法:
**v3.2・坑一:缓存海市蜃楼。**往已卸载卷的卷句柄写,数据只进缓存;脚本内两路回读都被缓存满足,造出 PASS 假象;进程退出脏页丢弃,盘面从未收到数据。
v3.3・坑二:锁跟着句柄一起没了。吸取坑一的教训,改成物理盘直写。但写之前把卷句柄 close() 了 ------FSCTL_LOCK_VOLUME 的锁,是跟着句柄同生共死 的,句柄一关,锁就失效,J 盘重新变成可挂载状态,物理写入直接被系统拒绝。真机实锤:WriteFile失败 ret=0 written=0/65536。更气人的是,这个失败是静默的,0 字节写入,连盘面都没弄脏,等于什么都没发生。
**v3.3b・附带伤害:32 位指针截断。**绝对偏移 64434872320 字节(= 125849360 × 512),超出了 32 位有符号整数范围。Python 调用 SetFilePointer 得拆成高低 32 位传参,差点就写到一个天文数字的偏移上去了。
**v3.4・合并修复,还是被吞。**锁全程持有到写入 + 回读完成、物理句柄加 FILE_FLAG_WRITE_THROUGH、WriteFile 带错误码检查。按理说写透应该直通盘片了 ------结果还是没落盘。
这时候我提出最后一个假说:会不会是缓存谎言?Python 普通的读命中了系统级跨进程缓存 ------ 首轮盘面真的是零,零进了缓存,之后每轮读到的都是缓存里的旧零,而某一轮的写入其实早就落盘了?
为此写了个裁决工具 真相读取_无缓存.py:同轮 A/B 对照 ------ 普通缓存读 vs FILE_FLAG_NO_BUFFERING + 对齐缓冲直读盘面。不一致就说明缓存撒谎实锤。顺带还抓到个隐蔽的坑:ctypes 调用 CreateFileW/VirtualAlloc,默认返回值是 c_int,64 位句柄会被截断------ 连真相工具自己都得先把自己的坑踩平。
最终裁决:无缓存直读,盘面全零。没有缓存谎言,没有时空悖论 ------ 三个 PASS,三个海市蜃楼,数据从来就没写上去过。
Windows 裸写三定律(全是血换的):
- 锁活 = 句柄活:锁跟句柄同生死,句柄关了锁就没了;
- 写透 ≠ 落盘 :
FILE_FLAG_WRITE_THROUGH也不是免死金牌,验证必须靠物理回读;- 验证 = 新进程 + 无缓存:同进程回读毫无意义,缓存会配合你演戏。
八、终极方案:不跟 Windows 斗了,OS 自己给自己写盘
和 Windows 存储栈斗了三天三代脚本,我决定换个思路:绕开它。OS 自己的 AHCI 写盘路径,早就被 i1 命令真机验证过 ------ 内核直接操作磁盘控制器,根本不经过 Windows。
于是新增 '5' 命令:OS 侧 patch 拷贝,U 盘 EHCI 读 → 硬盘 AHCI 写。拷贝映射表:
表格
| 序号 | 内容 | 源(U 盘 LBA) | 目标(硬盘相对 LBA) |
|---|---|---|---|
| 0~259 | 编译器 260 扇区 | 2020 + 序号 | 10000 + 序号 |
| 260 | 入口扇区 | 2019 | 10260 |
| 261 | 源码字节数 | 2280 | 10261 |
| 262+ | 源码本体 | 2281 + | 10262 + |
这里有个很有意思的自指细节:'5' 命令自身的代码就在源码里,源码每长一点,要拷的扇区数就变。所以扇区数不硬编码,运行时从 U 盘 LBA2280 动态读取计算 ------ 命令自己算自己要搬多少砖。(顺便踩了个编译器的小坑:条件表达式不支持 或,(A 或 B) 报语法错,只能拆成两条平铺的 如果。记下来,编译器团队(也就是我)欠一笔债。)
9 月 20 号真机测试:'4' → AHCI 可用、分区偏移正确 ✓'5' → DONE 2790 ✓------Windows 三种写法全吞掉的数据,OS 自己一出手就写进去了。
正当我以为要收官的时候,'4' 复测打出了 B=119。
九、最后一个 bug:'5' 自己的 off-by-one
预期 B=72(patch 首字节),实际 B=119 = 0x77------ 这是入口偏移 125559(0x1EA77)的第一个字节 。翻译成人话:硬盘 LBA 10000 里装的不是编译器首扇区(U 盘 2020),而是入口扇区(U 盘 2019)------整体往前错了一个扇区。
查 '5' 的拷贝公式:源LBA = 2019 + 序号。对序号 260 来说刚好对(2019 就是入口扇区),但对序号 0~259,编译器区实际是从 2020 开始的 ------ 一个统一公式想覆盖两段不同布局的地址,必然有一段是错的。教科书级的 off-by-one。
三重算术印证,环环相扣:
- SUM 偏差 354 = 入口 8 字节 150 + 拼音表末扇区残留 204------ 拼音表被压进了 LBA2019,正好被错位拷贝带进了 10000 扇区;
- ENT 值正确 ✓------ 序号 260 是公式的 "例外项",反而写对了;
- y 在 ENT 之后挂死------ 编译器首扇区是入口数据的垃圾,初始化 CALL 直接踩进垃圾代码。
修复也很简单,两条平铺的 如果,连 否则 都不用:
zl
yaml
源LBA 为 2019
源LBA 加 拷贝序号
如果 (拷贝序号 小于 260) { 源LBA 加 1 } ;; ★op163:0~259 是编译器区,实际在 2020 起
如果 (拷贝序号 等于 260) { 源LBA 为 2019 } ;; 序号260=入口,公式天然对
十、收官:9・21,三案同日全闭环
9 月 21 号真机验收,一条链路全跑通:
plaintext
ini
'5' → DONE 2791 ✓(拷贝总数动态算出,随源码自适应)
'4' → A=1 P=1 O=125839360 ✓
y → SUM=12476728 ✓ ENT=2943607 ✓ SUM2=156859823 ✓
→ S=S2=2090280 ✓ GA=38613000 GC=0 GS=1 ✓
→ A/A1/A2/A3 ✓ 回 命令>
按 h → 键盘回显 ✓(D-169 的成果还在)
f → fork 正常 ✓
D-168 / D-169 / D-170,三个案子同一天闭环,阶段 A 正式收官。
(一个小花絮:y 输出里 N= 始终没出现在屏幕上,一度以为丢了 ------ 查代码才发现它只走串口不走 VGA,真机没接串口监视器而已。和之前 "失踪的 S2=656" 同病相怜。另外 SUM 不随源码变化是设计如此:它校验的是编译器二进制窗口;判 "读到当前版源码" 必须 SUM2 + N 双核对。)
十一、血泪教训
教训 1:探针读会摧毁正在解析的数据结构 ------ 缓冲复用类 bug 第二次作案
D-152 是 "见 55AA 即认"(身份校验缺失),这次是探针读覆盖 MBR。同一家族的坑第二次踩:凡是一个缓冲身兼 "解析对象" 和 "临时读入区" 两职,就是在给未来的自己埋雷。排查口令: "这次读操作,会不会覆盖我接下来还要用的东西?"
教训 2:无症状的 bug,活得最久
LBA127 劫持潜伏 17 天,因为读错的地方恰好全是零,和 "数据还没写" 无法区分。设计验证时,要让 "错误的正确" 尽可能显眼 ------ 内容级校验要在代码变更后重跑基准,而不是只在新路径上验证。
教训 3:同进程回读不是验证,是自我安慰
Windows 三代脚本的 PASS,全死在缓存上。"写入" 的验证标准只有一个:另一个进程、无缓存、直读盘面。 任何 "写完后顺手读回来比对",本质上都是对着镜子比肌肉。
教训 4:off-by-one 喜欢藏在 "统一公式" 里
源LBA = 2019 + 序号 想用一个公式覆盖两段不同布局,结果就是一段必然错一位。地址映射表一旦混排不同性质的区域(编译器区 / 入口 / 元数据 / 源码),要么分段写明,要么运行时从元数据推导 ------'5' 的扇区数动态读取就是正确示范,可惜源 LBA 当时没一起动态化。
教训 5:症状恶化,不一定是修错了
清零 LBA127 后从 "读零" 恶化成 "读 0xFF",一度让人怀疑手术本身。实际上是撤掉了免死金牌,让潜伏 bug 现形。修复后症状变化时,先问自己一句:是不是有什么东西一直在替它挡枪? 再怀疑修复方向。