脚本说 PASS、OS 读全零 —— 到底是谁在撒谎?

缓存谎言、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 本身,也正存在这个缓冲里被遍历。

走一遍案发现场你就懂了:

  1. 先把 MBR(LBA 0)读进 检查缓冲,开始逐个解析分区条目;
  2. 处理到 F 盘条目(07 类型),fallback 探针触发:往同一个 检查缓冲 里,读入了 LBA 127 的内容;
  3. MBR 被覆盖了。 不管探针命中与否,缓冲里现在装的都是 LBA 127 的扇区;
  4. 继续解析 "第二个分区条目"(扩展分区 05 类型)------ 从被覆盖的缓冲里读,读出来全是零;
  5. 扩展分区条目全零 → 认为没有扩展分区 → 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 裸写三定律(全是血换的):

  1. 锁活 = 句柄活:锁跟句柄同生死,句柄关了锁就没了;
  2. 写透 ≠ 落盘 :FILE_FLAG_WRITE_THROUGH 也不是免死金牌,验证必须靠物理回读;
  3. 验证 = 新进程 + 无缓存:同进程回读毫无意义,缓存会配合你演戏。

八、终极方案:不跟 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。

三重算术印证,环环相扣:

  1. SUM 偏差 354 = 入口 8 字节 150 + 拼音表末扇区残留 204------ 拼音表被压进了 LBA2019,正好被错位拷贝带进了 10000 扇区;
  2. ENT 值正确 ✓------ 序号 260 是公式的 "例外项",反而写对了;
  3. 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 现形。修复后症状变化时,先问自己一句:是不是有什么东西一直在替它挡枪? 再怀疑修复方向。


相关推荐
code2cat2 小时前
【随笔】MCP资源更新订阅:通知到达以后,Agent怎样刷新旧资料
java·后端·开发工具·ai agent·mcp
小小小小钰儿3 小时前
2.2-模型微调
人工智能·深度学习·机器学习·计算机·网络安全·操作系统·编程
Harvil_3 小时前
任务跑一半,API 突然报“对话超长“断流:我给 Agent 修的紧急逃生通道
后端
用户EasyAdminBlazor3 小时前
EasyAdminBlazor SignalR 实时消息源码解析:从 NotificationHub 到站内消息
后端
程序猿乐锅3 小时前
【黑马点评 | 第十一篇】关注 Feed 流实现
java·数据库·redis·分布式·后端·缓存·maven
用户EasyAdminBlazor3 小时前
EasyAdminBlazor 定时任务:FreeScheduler 可视化调度与任务管理
后端
Cosolar3 小时前
云端部署阿里 Qwen-Image-2.1 保姆级教程
人工智能·后端·github
yunwei374 小时前
eBPF 开发实践:使用 sockops 加速网络请求转发
linux·后端·性能优化
看浪的路人4 小时前
第7讲:实时告警与自动化响应
开发语言·后端·golang