第 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
2
3
4
RootDirSectors  = ceil(RootEntCnt * 32 / BytsPerSec)
FirstDataSector = RsvdSecCnt + NumFATs * FATSz + RootDirSectors
DataSec = TotSec - FirstDataSector
CountOfClusters = floor(DataSec / SecPerClus)

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_openreadme.txt 规范化为大写的 README TXT,只接受当前教学子集里的字符。扫描时跳过首字节为 0xe5 的删除项、LFN 项、卷标和目录;首字节 0x00 终止扫描。规范中 0x05 是实际首字符 0xe5 的转义,不是删除标记;本篇的 ASCII 子集不支持该名称,因此明确跳过。代码同时用 BPB_RootEntCnt 限制全局项数,不会把根目录最后一个扇区的填充字节解释成新目录项。

命中普通文件后还要校验 FAT16 的高簇字为零。本项目另外要求零长文件使用首簇 0;它的读取返回 0,不解引用 cluster 0。这是只读实现的严格损坏检查,不是在尝试修复不一致目录。

文件长度和 FAT 一起决定读取

数据簇 N 的卷内首扇区是:

1
FirstSectorOfCluster = FirstDataSector + (N - 2) * SecPerClus

公式只能接收已验证的数据簇。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
2
3
4
D20 ATA primary master LBA28 sectors=16384 reads=3
D21 FAT mode=0 result=1479 part=2048+14336 clusters=14191
file=1479 read=1479 hash=f34dc752 steps=3 slices=3 bad=0 lba=2195
FAT OK mode=0

FAT16 非连续簇链读取结果

完整 正常串口记录GDB 记录 位于同名素材目录。检查在 FAT 完成后继续到原有 keyboard_ready,没有用中途断点代替完整启动链。

失败实验使用独立镜像,每个镜像都先通过 Day 20 的 MBR、LBA 2047 和最后扇区哈希回归,再触发指定 FAT 故障。实测覆盖:

故障 镜像变化 实际诊断
循环 2 -> 9 -> 2 FAT_ECHAINbad=2
过早结束 2 -> 9 -> EOC,长度仍需三簇 FAT_ECHAINbad=ffff
坏 BPB sectors-per-cluster 为 0 FAT_ENOTSUP
FAT 副本不一致 FAT2 的 cluster 9 改为 5 FAT_EMIRRORbad=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
i686-elf-ld: kernel file exceeds loader bound

最终配置把文件上限改为 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 字节运行区在链接时拒绝。

容量调整不能靠批量替换 65536PAYLOAD_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
3d93e5bc633267b9d8b2d9c1d71c639e9710319711460208194fb3c414efc7f4

解压后执行:

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

附件包含从第 01 篇累计至本篇的完整工程、调研对应的实验记录和故障镜像生成器。附件已在无 build/ 的空目录独立解压,上述八个目标全部重建并退出 0;重建的 kernel.binmbr.imgday19-user.elf 与主工作区逐字节相同。详见独立附件复验记录

练习

  1. 把根目录扫描的上限从 RootEntCnt 错改为完整扇区数,再用 RootEntCnt=1 的镜像证明填充区为什么不是目录项。
  2. README.TXT 的第二个 next 分别改成 0xfff00xfff7 和超出卷簇数的普通值,对照三条诊断路径。
  3. fat_read_at 加入每文件簇位置缓存,先定义 seek、多打开句柄和卷重新挂载时的失效条件,不要只保存一个无所有者的“上次簇号”。

上一篇:20 - ATA PIO 块设备

下一篇:22 - 文件描述符

参考资料