从零编写操作系统 21 - 只读 FAT16:从扇区追到文件字节
第 20 篇已经能从 ATA 设备读出指定 LBA 的 512 字节,但用户程序仍然来自内核里的 ELF 字节数组。块设备只回答“某个扇区里是什么”;要回答“README.TXT 在哪里、有多长、怎样读出来”,还需要文件系统层。
本篇在已有 ATA PIO 接口上增加只读 FAT16。内核从 MBR 取第一分区,校验 BPB(BIOS Parameter Block)并计算卷布局,在固定根目录中查找 8.3 短名,再沿 FAT 簇链读取文件。测试文件故意存在 2 -> 9 -> 4 三个不连续簇中,因此“刚好读到一段连续数据”不能冒充 FAT 实现。
本篇还真实撞到了旧的 64 KiB 内核文件上限。扩容同时修改了镜像保留区、头部生产器、stage2 读取与校验、链接边界和失败镜像,并用精确 128 KiB/128 KiB+1 的实验区分成功边界和拒绝边界。
FAT16 分区需要真实的数据簇
原镜像只有 4096 个扇区,FAT 分区预定从 LBA 2048 开始。后半段总共只有 2048 个扇区,即使每簇只用一个扇区,扣除引导扇区、FAT 和根目录后也不可能得到 FAT16 的最小簇数。把 MBR type 改成 0x04,或在 BPB 里写上 FAT16 ,都不会改变这个事实。
Microsoft FAT 格式规范用数据区簇数判定 FAT 类型。根目录占用的扇区数必须向上取整:
1 | |
CountOfClusters < 4085 是 FAT12,从 4085 到 65524 才是 FAT16。所以本篇把磁盘扩为 8 MiB,保留原有启动区和 LBA 2048 分区起点:
| 字段 | 值 |
|---|---|
| 镜像总扇区 | 16384 |
| 分区起点 / 长度 | 2048 / 14336 |
| 每扇区字节 / 每簇扇区 | 512 / 1 |
| 保留扇区 / FAT 副本 | 1 / 2 |
| 每份 FAT 扇区 | 56 |
| 根目录项 / 扇区 | 512 / 32 |
| 数据区首扇区 / 簇数 | 145 / 14191 |
表里的数据区首扇区是卷内相对值。转成设备 LBA 时只加一次分区起点,因此 VBR、FAT1、FAT2、根目录和数据区分别从 LBA 2048、2049、2105、2161 和 2193 开始。BPB_HiddSec 只与 MBR 起点做一致性检查,不再参与一次地址相加。
这类布局的关键是分开“卷内偏移”与“设备 LBA”。两套坐标混用时,最容易出现的错误不是立即越界,而是读到另一个仍然可读的扇区,然后在更高层被误判为格式损坏。
挂载是建立信任边界
BPB 来自磁盘,不能把扇区指针直接强转成 C 结构体后相信其对齐、大小端和数值。fat_mount 用定长小端 helper 逐字段取值,先验证基本条件,再做派生计算:
- 每扇区必须是 512 字节,每簇扇区数必须是非零的 2 的幂;
- reserved、根目录项数和 FAT16 大小不得为零;本项目还固定要求两份 FAT,而不是把它写成 FAT16 的通用有效性条件;
- metadata 结束必须早于卷结束,卷不得超出 MBR 分区,分区不得超出 ATA IDENTIFY 容量;
- 计算得到的簇数必须落在 FAT16 范围,一份 FAT 还必须装得下所有数据簇加两个保留项。
只有这些条件全部成立,内核才保存 fat_start/root_start/data_start/cluster_count 等派生几何。后续代码使用这个已校验对象,但每次读取仍检查实际 LBA;挂载成功不是撤掉边界检查的理由。
可迁移的做法是:先把不可信原始字段收敛为经过验证的几何对象,后续地址计算只依赖该对象。解析 ELF program header、网络包长度和块设备分区表时,同样适用。
根目录不是一条簇链
FAT16 的根目录是 FAT 之后的固定区域,不能套用 FAT32 的根簇设计。每个目录项 32 字节,其中 11 字节短名由 8 字节主名和 3 字节扩展名组成,空位用空格填充。
fat_open 把 readme.txt 规范化为大写的 README TXT,只接受当前教学子集里的字符。扫描时跳过首字节为 0xe5 的删除项、LFN 项、卷标和目录;首字节 0x00 终止扫描。规范中 0x05 是实际首字符 0xe5 的转义,不是删除标记;本篇的 ASCII 子集不支持该名称,因此明确跳过。代码同时用 BPB_RootEntCnt 限制全局项数,不会把根目录最后一个扇区的填充字节解释成新目录项。
命中普通文件后还要校验 FAT16 的高簇字为零。本项目另外要求零长文件使用首簇 0;它的读取返回 0,不解引用 cluster 0。这是只读实现的严格损坏检查,不是在尝试修复不一致目录。
文件长度和 FAT 一起决定读取
数据簇 N 的卷内首扇区是:
1 | |
公式只能接收已验证的数据簇。FAT16 中 0x0000 是空闲项,0x0001 是保留项,0xfff7 表示坏簇,0xfff8–0xffff 是链尾。本项目还防御性地拒绝 0xfff0–0xfff6;普通 next 值必须小于当前卷的 cluster_count + 2。
本篇在复制数据前验证整条必需链。所需簇数由文件长度做除法和余数计算,避免 file_size + cluster_bytes - 1 在恶意 32 位长度上回绕。visited 位图记录已经经过的簇,并且只允许不超过卷簇数的步数。过早 EOC、空闲/保留/坏簇、越界 next 或循环都会在读数据前失败。
fat_read_at(file, offset, buffer, length) 为第 22 篇保留独立文件偏移接口。当前没有 FAT 缓存,每次读取都从链头走到目标簇,复杂度为 O(n)。实验除了完整读取,还检查了扇区内非零偏移、跨 512 字节边界、跨簇以及超过 EOF 时的截短。
文件长度负责“向调用者暴露多少字节”,簇链负责“这些字节从哪些簇取得”。只相信其中一个都不够:只看长度会把连续布局当成链,只看 EOC 则会把最后簇尾部的无效字节交给读者。
两份 FAT 不一致时拒绝猜测
卷中有两份 FAT。本实现每次读取簇链项时,分别从 FAT1 和 FAT2 取出同一个 16 位值;两者不同就返回 FAT_EMIRROR。它不会自动选“第一份”,也不会根据哪条链更像真的来补写另一份。
这是项目的 fail-closed 策略,不是 FAT 规范要求的唯一处理方式。如果将来需要修复卷,必须额外定义主副选择、修复写入和断电一致性;一个只读教学内核没有这些证据,因此不作自动决定。
正常镜像和损坏镜像
普通镜像中的 README.TXT 是 1479 字节。GDB 在 fat_demo_done 核对分区几何、文件长度、三簇链、偏移读取和客体计算的 FNV-1a,再把内核缓冲区导出为文件,与宿主原文逐字节 cmp。
1 | |
完整 正常串口记录 和 GDB 记录 位于同名素材目录。检查在 FAT 完成后继续到原有 keyboard_ready,没有用中途断点代替完整启动链。
失败实验使用独立镜像,每个镜像都先通过 Day 20 的 MBR、LBA 2047 和最后扇区哈希回归,再触发指定 FAT 故障。实测覆盖:
| 故障 | 镜像变化 | 实际诊断 |
|---|---|---|
| 循环 | 2 -> 9 -> 2 |
FAT_ECHAIN,bad=2 |
| 过早结束 | 2 -> 9 -> EOC,长度仍需三簇 |
FAT_ECHAIN,bad=ffff |
| 坏 BPB | sectors-per-cluster 为 0 | FAT_ENOTSUP |
| FAT 副本不一致 | FAT2 的 cluster 9 改为 5 | FAT_EMIRROR,bad=9 |
| 根目录越界诱饵 | RootEntCnt=1,匹配项放在第 5 项 |
FAT_ENOENT |
| FAT16 高簇字非零 | DIR_FstClusHI=1 |
FAT_ECORRUPT |
| 零长文件带簇 | size=0, first_cluster=3 |
FAT_ECHAIN |
| 坏簇 / 保留簇 | next 为 fff7 / fff0 |
FAT_ECHAIN |
| 越界簇 | next 为 4000 |
FAT_ECHAIN |
| 恶意文件长度 | size 为 ffffffff |
在读数据前 FAT_ECHAIN |
这些故障有两个目的:证明坏数据会在有界时间内返回,也证明错误可以定位到挂载、目录或簇链层次。它们不会把短前缀冒充成功 EOF。
跨过 64 KiB 需要修改整条装载链
加入 FAT 后,使用旧上限链接内核真实返回:
1 | |
最终配置把文件上限改为 131072 字节,运行内存上限改为 262144 字节,载荷保留区改为 256 个扇区。stage2 把精确 128 KiB 文件拆成八次各 32 扇区/16 KiB 的 BIOS DAP 请求,单次请求不跨物理 64 KiB 边界;文件整体每读 64 KiB 才增加缓冲段。校验和也在每个 64 KiB 后切换 DS。保护模式内的 rep movsb 使用平坦段和 32 位寄存器复制实际 file_size。
精确 131072 字节的补零测试载荷从 BIOS 启动,通过完整校验和,并在 _start 与宿主载荷逐字节相同。131073 字节声明被生产器拒绝;独立超限头镜像还实际启动到 D03 HEADER FAIL,且没有进入 payload 复制。另一组镜像证明精确 262144 字节的链接运行区能进入 C 并完成旧回归,该镜像的文件只有 69088 字节;262145 字节运行区在链接时拒绝。
容量调整不能靠批量替换 65536。PAYLOAD_LOAD=0x10000 仍然是低端暂存地址,16 位 checksum 仍然对 65536 取模,单次 BIOS 读取仍不越 64 KiB;这些数字与“文件最多 64 KiB”不是同一个契约。
当前边界
本篇的 FAT 层只面向固定 512 字节扇区、MBR 第一分区和两份 FAT 的 8 MiB 镜像。没有处理 GPT、扩展分区、FAT12/FAT32、子目录、LFN 名称重建、写入、日志或缓存。两份 FAT 只在访问的簇项上比较,没有全卷扫描。
正常与损坏卷实验都运行在 QEMU 8.2.2/SeaBIOS 的固定环境中,没有验证真实磁盘或其他 BIOS。精确 128 KiB 测试证明的是当前 stage2 和当前模拟环境的组合,不是所有 BIOS 服务对转移长度的保证。
第 22 篇会把 fat_file 放到有所有权的内核对象中,为每个进程提供文件描述符表。届时才引入独立偏移、句柄复用和引用计数;FAT 层仍然只负责根据文件位置读出字节。
下载与复现
下载第 21 篇完整源码。附件 SHA-256:
1 | |
解压后执行:
1 | |
附件包含从第 01 篇累计至本篇的完整工程、调研对应的实验记录和故障镜像生成器。附件已在无 build/ 的空目录独立解压,上述八个目标全部重建并退出 0;重建的 kernel.bin、mbr.img 和 day19-user.elf 与主工作区逐字节相同。详见独立附件复验记录。
练习
- 把根目录扫描的上限从
RootEntCnt错改为完整扇区数,再用RootEntCnt=1的镜像证明填充区为什么不是目录项。 - 把
README.TXT的第二个 next 分别改成0xfff0、0xfff7和超出卷簇数的普通值,对照三条诊断路径。 - 为
fat_read_at加入每文件簇位置缓存,先定义 seek、多打开句柄和卷重新挂载时的失效条件,不要只保存一个无所有者的“上次簇号”。
上一篇:20 - ATA PIO 块设备。
下一篇:22 - 文件描述符。
参考资料
- Microsoft FAT 磁盘格式规范 1.03 官方 DOC:重点对应
FAT Type Determination、FAT Data Structure、FAT Directory Structure和Name Limits and Character Sets。官方发布页在当前环境返回 403;MIT 机构 PDF 镜像 HEAD 可访问,本文没有把镜像站写成官方托管。 - UEFI FAT 文件系统格式:UEFI 2.10 第 13.3 节,用于交叉核对 FAT 布局、簇数判定和目录项。
- QEMU 8.2.2
hw/ide/core.c:固定提交11aa0b1ff115b86160c4d37e7c37e6a6b13b77ea,用于对照实验中的 IDE 设备容量与 PIO 读路径,不代替 FAT 规范。 - Phoenix Enhanced Disk Drive Specification 1.1:§3.1.1、§3.2.2,用于核对 DAP、AH=42 和单次块数边界。
- SeaBIOS 1.16.3
src/disk.c与src/block.c:固定提交a6ed6b701f0a57db0569ab98b0661c12a6ec3ff8,用于核对实验中 DAP 转换和 ATA PIO 处理。精确 128 KiB 结论仍只限定于本文的 QEMU/GDB 环境。

