第 19 篇的程序仍然来自内核中的 ELF 字节数组。即使磁盘镜像已经装着启动扇区、第二阶段和内核,保护模式内核也不会读盘;它只能使用启动阶段提前搬进内存的内容。下一篇要解析 FAT16,本篇必须先建立一个更窄的基础:给定一个合法 LBA,可靠地取回恰好 512 字节。

本篇增加 primary master 的 ATA PIO 只读驱动。它用 IDENTIFY 取得设备容量,用 READ SECTOR(S) 一次读取一个扇区,区分参数错误、设备错误和等待超时。命令已经发出后若状态序列失效,通道会进入失败状态;在实现复位协议以前,驱动不会假装设备已经恢复。

实验实际读取 MBR、LBA 2047 和最后一个扇区 LBA 4095,另用三个独立镜像验证 BSY 不结束、DRQ 不出现,以及 QEMU 对容量外 LBA 的真实 ERR/ABRT 响应。所有镜像都从 BIOS 完整启动,编译成功不算设备实验通过。

BIOS 读盘不能直接变成内核块设备

第二阶段仍在实模式时,可以调用 BIOS int 0x13 把内核搬到内存。进入保护模式后,当前内核没有虚拟 8086 模式,也没有切回实模式的桥;继续依赖 BIOS 会把文件系统建立在一条尚不存在的执行路径上。

更重要的区别是接口所有权。引导器只需按固定布局读一段连续载荷,失败便停止启动。运行中的内核会接到不同模块的读取请求,还要面对无效 LBA、设备不响应、任务切换和退出后的继续运行。块设备层不能只是一段复制过来的端口循环。

本篇把接口收窄为:

1
2
int block_read_sector(uint32_t lba, void *buffer);
uint32_t block_sector_count(void);

一次调用只读一个 512 字节扇区。固定单扇区不是 ATA 的能力限制,而是当前设计选择。Sector Count 寄存器中的 0 表示 256 个扇区;多扇区 PIO 还要处理每个数据块的 DRQ、部分完成和中途错误。第 21 篇可以逐扇区遍历 FAT 簇链,不需要先引入这些语义。

这里可迁移的规则是:先把底层完成单位做成有明确成功边界的原子操作,再让上层组合。否则一次“读 N 个块”的失败很快会变成“前几个块是否已经可见、设备现在停在哪个阶段”的所有权问题。

固定平台决定了端口,但不能替代探测

实验机器固定为 QEMU pc-i440fx-7.2,磁盘作为 legacy IDE primary master 接入。QEMU 8.2.2 的 PIIX3 实现把 primary command block 放在 0x1f0–0x1f7,alternate status/device control 放在 0x3f6,中断线为 IRQ14。

本篇使用这些端口:

端口
0x1f0 16 位数据 16 位数据
0x1f1 Error Features
0x1f2 Sector Count Sector Count
0x1f3–0x1f5 LBA 低 24 位 LBA 低 24 位
0x1f6 Device/Head Device/Head
0x1f7 Status Command
0x3f6 Alternate Status Device Control

端口固定不等于容量固定。初始化仍发送 IDENTIFY DEVICE 0xec,读取 256 个 16 位 word,检查 word 49 的 LBA 支持位,并从 words 60–61 组合 28 位 LBA 扇区数:

1
2
3
4
block_capacity = (uint32_t)identify_words[60] |
((uint32_t)identify_words[61] << 16);
if (!block_capacity || block_capacity > 0x10000000u)
return BLOCK_ENOTSUP;

这次实验返回 4096,恰好对应 2 MiB 镜像。不能从 FAT BPB 推导整个设备容量:BPB 描述卷,块驱动管理设备;下一篇把文件系统放进分区后,两者更不能混为一谈。

IDENTIFY 还负责区分设备不存在和协议不支持。命令后的状态若为 0x000xff,按当前固定平台记为没有设备;LBA mid/high 非零则拒绝非 ATA 签名;容量无效或没有 LBA 能力返回不支持。这不是通用 PCI/ATAPI 枚举,只是固定 primary master 的教学探测。

LBA28 的四段地址必须同时成立

READ SECTOR(S) 的命令码是 0x20。28 位 LBA 被拆到四处:低、中、高各 8 位写入 0x1f3–0x1f5,最高 4 位写入 Device/Head 的低四位;bit 6 选择 LBA,bit 4 选择 master/slave。

1
2
3
4
5
6
outb(ATA_DEVICE, 0xe0 | ((lba >> 24) & 0x0f));
outb(ATA_SECTOR_COUNT, 1);
outb(ATA_LBA_LOW, lba);
outb(ATA_LBA_MID, lba >> 8);
outb(ATA_LBA_HIGH, lba >> 16);
outb(ATA_COMMAND, ATA_CMD_READ_SECTORS);

公共入口先验证 buffer != NULLlba < block_capacitylba < 2^28。检查放在发命令以前,因此这些拒绝不会改变通道状态。将来若增加 count,边界应写成 count <= capacity - lba,不能先算可能溢出的 lba + count

设备给出数据时,驱动执行恰好 256 次 inw(0x1f0),再拆成 512 个字节。不能在 DRQ 出现前读取,也不能用 512 次 inb 随意替代协议规定的数据传输宽度。

QEMU 8.2.2 的 ide_get_sector 按相同四段组合 LBA;cmd_read_pio 为普通 READ 准备一个扇区;ide_data_readw 只在 DRQ 有效时推进缓冲,读到末尾后清理本次 transfer。固定版本源码解释了实验模拟器的行为,ATA 协议本身仍以 ATA/ATAPI-5 data-in 时序为依据。

状态机先看 BSY,再看错误和 DRQ

PIO data-in 不是“发命令后立刻读 512 字节”。每个等待阶段都读取 Alternate Status,并按同一顺序解释:

1
2
3
4
5
6
7
读取状态
├─ BSY=1 ───────────────> 继续等待
└─ BSY=0
├─ ERR=1 或 DF=1 ──> 读取 Error,设备失败
├─ 需要 DRQ 且 DRQ=0 -> 继续等待
├─ 不应有 DRQ 且 DRQ=1 -> 继续等待
└─ 状态符合阶段 ────> 前进

BSY 有效时,其他位不能被当成本次命令的稳定结论。只有 BSY 清零后才判断 ERR、DF 和 DRQ。驱动为现场记录 stage/status/error/polls/lba,所以超时能定位在发命令前空闲、IDENTIFY 数据、READ 数据还是传输结束,而不是只返回一个 -1

普通 Status 和 Alternate Status 也不能随意互换。ATA 规范区分两种读取的中断影响;QEMU 8.2.2 的普通状态路径会降低 IDE IRQ,备用状态路径只返回状态。本篇设置 Device Control 的 nIEN,用轮询完成请求,但仍以 Alternate Status 观察等待过程,只在结束或错误路径读取普通 Status 确认可能的 pending IRQ。

选择设备和发命令后各做四次备用状态读取,作为保守的 I/O 延迟序列。文章不把它写成在真实机器上精确测得的 400 ns:本轮只在固定 QEMU 上实验,没有示波器或真实 ATA 总线证据。

超时必须在时钟不工作时也能退出

只写 while (status & BSY) {} 会让坏设备冻结整个系统;只写 tick deadline 又有另一处循环依赖:若调用时 IF=0 或 PIT 停止,tick 本身不会前进。

状态等待因此有两重上限:3 个 PIT tick,以及 2,000,000 次轮询。IF=1 时,一次失败采样后执行 hlt,让 CPU 等待下一次中断,PIT 可以推进 deadline;IF=0 时执行 pause,最终由轮询预算退出。

1
2
3
4
5
6
if ((uint32_t)(timer_ticks - start) >= ATA_TIMEOUT_TICKS)
return BLOCK_ETIMEDOUT;
if (interrupts_enabled)
__asm__ volatile("hlt" ::: "memory");
else
__asm__ volatile("pause" ::: "memory");

tick 是客体内可解释的时间边界,轮询次数只是执行预算,不能换算成固定毫秒。QEMU、宿主负载和 CPU 速度都会改变相同轮询次数对应的墙钟时间。

本篇没有在驱动里调用 task_yield。同步读取通常在第一轮采样就取得 QEMU 数据;失败注入以 hlt 等真实 tick。以后接入磁盘文件和多个调用者时,可以在块层之上增加请求队列和专用服务任务,但不能在没有取消和退出协议时先宣称“异步 I/O 已完成”。

busy 只保护所有权,FAILED 才描述恢复边界

ATA command block 是一组共享寄存器。一个调用者写完 LBA low 后,另一个调用者若改写 device/head,本次命令地址便被拼坏。驱动用 IF=0 的短临界区 claim 一个 busy 位;冲突者立即得到 BLOCK_EBUSY。真正的端口轮询不一直关闭中断。

返回码按失败层次分开:

返回值 含义 是否发出命令
BLOCK_EINVAL 空缓冲区
BLOCK_ERANGE 超容量或超 LBA28
BLOCK_ENODEV/ENOTSUP 探测失败 可能
BLOCK_EBUSY 另一调用者拥有通道
BLOCK_ETIMEDOUT 状态在预算内未前进 可能
BLOCK_EIO ERR/DF
BLOCK_ECHANNEL 先前命令后通道已失败

命令发出前的参数拒绝不会污染通道。命令发出后的超时或设备错误则把通道设为 FAILED;release busy 以后,后续请求仍只返回 BLOCK_ECHANNEL

这是故意收窄的恢复语义。ERR 返回或软件函数退出,不证明设备已经排空数据、清除 BSY 并重新接受命令。完整恢复至少需要 software reset、重新选择设备、等待签名和再次 IDENTIFY,还要有相应故障实验。本篇没有这些代码,因此永久失败比未经验证地继续读盘更可信。

这条规则可迁移到其他有状态设备:释放软件锁只表示其他调用者可以检查状态,不表示硬件事务已经恢复。互斥状态和设备健康状态应分开表示。

正常实验读取的不是三份相同数据

构建工具在 LBA 2047 和 4095 写入不同的 512 字节模式。模式同时混入 LBA 的高位和字节偏移,避免两个相差 2048 的 LBA 在 8 位截断后意外得到相同扇区。检查脚本在启动前从最终 mbr.img 读回两处并逐字节比较;客体启动后再计算 FNV-1a,与内核独立生成的期望哈希比较。

普通镜像实际输出:

1
2
3
D20 ATA primary master LBA28 sectors=4096 reads=3
D20 hashes mbr=17b4a429 lba2047=c748f45 lba4095=ce0d7bc5 range=3
BLOCK OK mode=0 result=0 followup=0 failed=0 ticks=0

三次成功读取分别是 LBA 0、2047 和 4095。MBR 还检查末尾 0x55aa;最后一扇区成功后,公共接口依次拒绝 LBA 4096、LBA 0x10000000 和空缓冲区。GDB 检查真实容量、读取计数、拒绝计数、两个哈希和通道健康状态,随后继续到原来的阻塞键盘任务。

ATA IDENTIFY、扇区哈希与边界检查

完整 正常串口记录正常 GDB 记录 位于同名素材目录。

两种协议卡死和一次真实设备错误

第一个失败镜像在 READ 命令发出后,让驱动观察到持续 BSY。第二个让 BSY 清零并保留 DRDY,却永远不出现 DRQ。它们都只改测试状态读取分支,目的是验证驱动不会越过状态机读取数据:

1
2
3
4
5
D20 FAIL busy-timeout stage=3 status=80 error=0 ticks=3 polls=4
BLOCK OK mode=1 result=-5 followup=-7 failed=1 ticks=3

D20 FAIL missing-drq stage=3 status=40 error=0 ticks=3 polls=4
BLOCK OK mode=3 result=-5 followup=-7 failed=1 ticks=3

两条路径中的 stage 3 都是 READ 的 DRQ 等待。3 个 tick 实际来自客体 PIT;超时后第二次合法读取返回 -7,没有继续碰端口。

第三个失败镜像不伪造 status。它绕过公共范围检查,向 QEMU 实际发送 LBA 4096。QEMU 8.2.2 返回:

1
2
D20 FAIL device-error lba=4096 status=41 error=4
BLOCK OK mode=2 result=-6 followup=-7 failed=1 ticks=0

0x41 是 DRDY|ERR,error 0x04 是 ABRT。这个数值是固定 QEMU 版本对本场景的实验观察,不宣称所有 ATA 硬盘对越界 LBA 都返回完全相同的寄存器组合。诊断的可靠部分是驱动在 BSY 清零后识别 ERR、保存 Error,并停止复用失败通道。

BSY 超时串口GDB 记录DRQ 缺失串口GDB 记录真实设备错误串口GDB 记录 均随附件保存。

容量上限只剩 560 字节,但本篇没有顺手扩容

本篇最终 kernel.bin 为 64,976 字节,距离 65,536 字节文件上限只剩 560 字节。运行内存仍低于第 18 篇验证过的 128 KiB 上限。

文件上限不能只在链接脚本里改一个数字。当前 stage2 使用实模式段地址分块暂存载荷,镜像头参与大小与校验和验证,磁盘只为内核保留 128 个扇区。若下一篇 FAT16 代码实际超限,需要同时修改并验证生产端、镜像布局、实模式读取、搬运、链接边界和失败镜像;在真正撞到上限以前,本篇保留原契约。

累计回归执行了 buildcheck-blockcheck-processcheck-syscallcheck-elfcheck-memorycheck-runtime-limit。新增代码改变二进制布局后,第 19 篇一个中间观察断点暴露出调度时序敏感:COUNT 尚未首次递增时也可能停住,GDB 却断言进度大于零。累计源码把观察条件收紧到进度已经产生后再停;进程结果和资源回收语义没有改变。

当前边界

本篇只覆盖 QEMU 8.2.2、pc-i440fx-7.2、primary master、LBA28、PIO data-in 和单扇区同步读取。没有验证 secondary/slave、IRQ14 完成路径、LBA48、DMA、ATAPI、写盘、热插拔、真实硬盘或其他 machine type。

busy 冲突立即失败,没有睡眠请求队列;也没有验证持有请求的任务中途退出。BSY/DRQ 注入证明驱动超时和 fail-closed 分支,不证明物理设备恰好以同样方式故障。-snapshot 保护宿主镜像,但客体驱动本身仍没有写命令。

下一篇只读 FAT16 会把“扇区号”提升为“分区、簇链和文件范围”。块层仍只承诺合法 LBA 对应的 512 字节;BPB 校验、坏簇、循环簇链、非连续文件和文件长度截断都属于文件系统层,不能塞进 ATA 驱动掩盖。

下载与复现

下载第 20 篇完整源码。附件 SHA-256:

1
40b03a28429b5a638aee47d494d703a37313551b1c0f5e8cdb1db101dd11bd94

解压后执行:

1
2
3
4
5
6
7
8
9
unzip os-day-20.zip
cd os-day-20
make build
make check-block
make check-process
make check-syscall
make check-elf
make check-memory
make check-runtime-limit

附件包含从第 01 篇累计至本篇的完整工程、检查脚本和 docs/day20-evidence.md。它已在空目录独立解压,从不存在 build/ 的状态重新执行上述七个目标,全部退出 0;重建的 kernel.binmbr.imgday19-user.elf 哈希与主工作区一致。源码版本使用真实提交与附件哈希,不使用虚构标签。

练习

  1. 把公共容量检查改成先计算 lba + count,构造 32 位回绕,说明为什么 count <= capacity - lba 更适合范围验证。
  2. 删除命令后等待 DRQ 的步骤,直接读取 256 个 word;使用 missing-DRQ 镜像检查为何不能把返回的零或旧数据当作成功。
  3. 设计 software reset 状态机,列出只有哪些状态和重新探测结果成立后,FAILED 才能安全清除。先写故障矩阵,不直接把 block_channel_failed=0 当作恢复。

上一篇:19 - 创建与回收进程

下一篇:21 - 只读 FAT16:从扇区追到文件字节

参考资料

  • ATA/ATAPI-5,T13/1321D revision 3,2000-02-29:§7.3、§7.15、§8.12、§8.27、§9.5、Figure 25 与 Annex C.2.5;命令寄存器、Device Control、READ SECTOR(S)、IDENTIFY DEVICE 和 PIO data-in 协议。运行环境无法抽取 PDF 文本,章节定位来自已保存材料,并用固定 QEMU 源码和实验交叉核对。
  • QEMU 8.2.2 hw/ide/core.c:固定提交 11aa0b1ff115b86160c4d37e7c37e6a6b13b77ea;LBA28 组合、PIO read、数据 word 读取、status/alternate-status 和越界失败实现。
  • QEMU 8.2.2 hw/ide/piix.c:固定提交中的 legacy primary 端口与 IRQ14 映射。
  • QEMU 8.2.2 hw/i386/pc_piix.c:i440fx PC 与 PIIX3 设备组合;源码版本用于解释模拟器,不替代 ATA 规范。