FAT12文件系统实战:实模式读取根目录并显示文件名

发布时间:2026/9/4 5:59:45
FAT12文件系统实战:实模式读取根目录并显示文件名 操作系统开发入门里实模式下的 kernel 开发最容易卡住的地方除了中断和内存分段就是文件系统。这次要拆的是 FAT12 文件遍历实验 1加载引导扇区之后用裸机代码读取软盘镜像里的根目录并把文件名打印到屏幕上。做完这个实验你能理解扇区、簇、目录项和 FAT 表之间是怎么配合的也能明白为什么很多操作系统课会要求先从 FAT12 这种简单结构练手。这篇文章适合的人正在学 OS 开发、写过简单 bootloader、想继续往 kernel 里加文件系统功能的人。如果你还没写过引导扇区建议先跑通一个输出字符串的引导程序再来做这个实验。本文不会直接给一套能原样抄走的大型内核而是把“读取 FAT12 根目录并显示文件列表”这条主线拆开按实际落地顺序讲清楚。1. 为什么实模式开发要先做 FAT12 文件遍历1.1 实模式下的“文件”不是现成的很多人第一次写实模式程序时会下意识地以为“读文件”是理所当然的事情。但在实模式裸机环境下CPU 上只有 BIOS 提供的最基础能力读磁盘扇区、写屏幕、读键盘没有fopen没有readdir更没有“当前目录”的概念。你面对的是一个连续的线性磁盘BIOS 只认识“第几磁道、第几磁头、第几扇区”而不认识文件名。所以要在实模式 kernel 里访问文件系统第一步不是调用某个现成 API而是自己把磁盘上的二进制结构解析出来。FAT12 是特别适合拿来入门的文件系统原因很直接结构简单整个文件系统只有引导扇区、FAT 表、根目录区、数据区四块。镜像小标准的 1.44MB 软盘镜像在 QEMU 里直接就能跑。目录项固定 32 字节字段含义清楚不像 NTFS 那样有复杂的 MFT。遍历根目录只需要读少量扇区一个几百字节的引导程序就能完成。这个实验会让你真正意识到“文件系统遍历”这几个字背后是大量偏移和扇区计算而不是在高级语言里遍历一个数组。1.2 实验 1 做到哪一步不做到哪一步实验 1 的范围很明确从 FAT12 软盘镜像启动后把根目录下的 8.3 格式文件名打印到屏幕。具体来说读取引导扇区。BIOS 会把引导扇区加载到0x7C00然后跳转到那里执行。从磁盘读取 FAT1 到内存。这次实验暂时不需要用 FAT 表找文件内容但先把它加载进来为实验 2 做准备。从磁盘读取根目录区。根目录区记录了文件名、属性、起始簇、文件大小。扫描根目录项跳过卷标、删除项和目录结束符输出文件名。不做什么也很重要不读取文件内容不跟踪 FAT 链不处理子目录不做写入操作。这么做不是为了偷懒而是为了把“磁盘结构解析”和“文件读写”分开。第一次跑通遍历时如果你同时处理了文件内容读取出问题后很难判断是目录项算错了还是 FAT 链算错了。这个实验的验收标准只有一个QEMU 启动后能在屏幕上看到镜像里已有的文件列表且没有乱码。2. 动手前先把 FAT12 磁盘布局读明白2.1 从引导扇区到数据区标准 1.44MB 软盘的整体结构是这样排列的区域起始扇区扇区数说明引导扇区01BPB 参数和启动代码FAT119文件分配表保存簇链FAT2109FAT1 的备份根目录区1914固定大小的根目录最多 224 个目录项数据区332848文件内容实际存放位置这里每个扇区按 512 字节计算。FAT1 占 9 个扇区FAT2 也占 9 个扇区两者内容通常一样。根目录区一共 14 个扇区因为每个目录项占 32 字节224 个目录项正好需要224 * 32 / 512 14个扇区。实验 1 的核心就是把这 19 到 32 扇区读进内存然后逐个扫描目录项。只读 FAT1 就够了FAT2 是给数据恢复和容错用的遍历根目录时用不上。2.2 BPB 参数是后面的数学基础FAT12 的根目录位置不是靠猜的而是由引导扇区中的 BPB 参数算出来的。标准 1.44MB 软盘的 BPB 关键字段如下字段偏移典型值含义BPB_BytsPerSec0x0B512每扇区字节数BPB_SecPerClus0x0D1每簇扇区数BPB_RsvdSecCnt0x0E1保留扇区数通常是引导扇区本身BPB_NumFATs0x102FAT 表个数BPB_RootEntCnt0x11224根目录项数量BPB_FATSz160x169每个 FAT 表占用的扇区数BPB_TotSec160x132880总扇区数BPB_SecPerTrk0x1818每磁道扇区数BPB_NumHeads0x1A2磁头数根据这些字段可以依次算出FAT1 起始扇区 保留扇区数 1FAT2 起始扇区 保留扇区数 FATSz16 1 9 10根目录起始扇区 保留扇区数 FATSz16 * NumFATs 1 9 * 2 19根目录占用扇区数 RootEntCnt * 32 / BytsPerSec 224 * 32 / 512 14数据区起始扇区 根目录起始扇区 根目录占用扇区数 19 14 33这些计算在代码里最好直接从引导扇区偏移处读取而不是硬编码数字。实验 1 为了快速验证可以先写常量但心里要记住这些常量的来源。2.3 目录项到底怎么记录文件根目录区里不是文件名连续排列而是由 32 字节的目录项组成。每个目录项包含一个文件的元数据排布如下偏移长度含义08文件名不足部分用 0x20 空格填充83扩展名111属性字节1210保留字段222最后修改时间242最后修改日期262文件起始簇号284文件大小遍历时最常用的是这几种特殊值文件名首字节为0x00表示根目录到此结束后面不用再扫描。文件名首字节为0xE5表示该项已经被删除跳过。文件名首字节为0x2E表示.或..目录项通常在子目录中出现。属性字节第 3 位为 10x08表示卷标不是普通文件跳过。实验 1 只需要扫描根目录遇到0x00就停止遇到0xE5和卷标就跳过其余项可以打印出来。3. 实模式环境准备和最小工程结构3.1 工具链和最小验证链路推荐环境是 Linux 或 WSL工具链只需要四个东西NASM把汇编源码编译成纯二进制。QEMU模拟 x86 机器方便启动软盘镜像。dd把编译出的引导扇区写入镜像。mtools创建 FAT12 镜像并放入测试文件。如果是 Windows也可以安装 NASM 和 QEMU 的 Windows 版本命令基本一致。macOS 的 brew 同样能装这些工具但要注意路径和权限。最小验证链路如下nasm -f bin boot.asm -o boot.bin dd if/dev/zero offloppy.img bs512 count2880 dd ifboot.bin offloppy.img bs512 count1 convnotrunc qemu-system-i386 -fda floppy.img第一步把汇编源码编译成二进制第二步创建空软盘镜像第三步把引导代码写到镜像第一个扇区第四步用 QEMU 启动。这套流程里convnotrunc很关键它告诉dd不要截断文件只覆盖前 512 字节否则会把后续文件系统数据清掉。3.2 内存布局代码、栈、FAT、根目录各放哪里BIOS 加载引导扇区时会自动把代码放到0x7C00并从那里开始执行。为了不把正在运行的代码覆盖掉其他数据必须放到安全的内存区域。我习惯这样安排0x7C00引导代码也就是当前正在执行的程序。0x7E00到0x7FFF栈空间。这个位置在引导代码上方往下增长只要代码没有占据这里就不会冲突。0x8000FAT1 表缓冲区。0x9000根目录缓冲区。0xA000附近预留后面实验 2 读取文件内容时用。选择0x8000和0x9000的原因是它们离0x7C00比较远不会和引导代码重叠又还在实模式 1MB 地址空间内直接用段寄存器就能访问。注意实模式访问内存用的是段基址和偏移0x9000:0x0000对应的物理地址是0x90000。代码里只要统一用段:偏移就不容易混。为什么栈要单独设置因为启动时 BIOS 不一定把栈设置成可用状态如果直接用当前段寄存器调用子程序时压栈可能把代码或数据覆盖掉。所以进入主逻辑前要先设置SS:SP比如mov ax, 0x7E00; mov ss, ax; mov sp, 0x2000。这个细节在简单例子里可能不报错但一旦调用函数多了就会出现莫名其妙的问题。3.3 用 Makefile 固定构建流程实验 1 的操作步骤不多但每次改代码后都要重新编译、写镜像、启动手工敲命令容易漏。用一个简单的 Makefile 把这些步骤固定下来ASnasm ASFLAGS-f bin IMGfloppy.img BOOTboot.bin all: $(IMG) $(BOOT): boot.asm $(AS) $(ASFLAGS) boot.asm -o $(BOOT) $(IMG): $(BOOT) dd if/dev/zero of$(IMG) bs512 count2880 dd if$(BOOT) of$(IMG) bs512 count1 convnotrunc run: $(IMG) qemu-system-i386 -fda $(IMG) clean: rm -f $(BOOT) $(IMG)之后只需要执行make或make run。这套流程里有一个隐含问题boot.bin 必须小于等于 512 字节。如果编译后超过 512 字节引导扇区也装不下。这也是实验 1 保持范围小的原因之一代码量控制在几百字节以内比较舒服。4. 写代码把根目录从磁盘读进内存4.1 启动主流程引导代码的固定开头是这样的[org 0x7C00] cli xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7E00 sti[org 0x7C00]告诉 NASM所有标签的地址都从0x7C00开始算。cli和sti是关开中断在启动阶段先把中断关掉防止时钟中断打断初始化。段寄存器全部设置成 0这样ds:si访问的就是从0x00000开始的物理地址。设置完环境后调用主流程然后死循环call main hang: jmp hang在实验 1 里main做三件事读取 FAT1 到0x8000。读取根目录区到0x9000。遍历0x9000的内存打印文件名。4.2 用 INT 13h 读扇区实模式下读磁盘用的是INT 13h中断功能号AH0x02参数如下寄存器含义AH0x02表示读扇区AL要读取的扇区数CH磁道号cylinder从 0 开始CL扇区号从 1 开始DH磁头号从 0 开始DL驱动器号0 表示第一个软盘ES:BX数据缓冲区地址注意这里不是 LBA而是 CHS 地址。BIOS 不认识“第 19 个扇区”这种线性编号它需要的是“第几磁道、第几磁头、第几扇区”。LBA 转 CHS 的公式很固定每磁道扇区数SecPerTrk 18磁头数NumHeads 2C LBA / (SecPerTrk * NumHeads)H (LBA / SecPerTrk) % NumHeadsS (LBA % SecPerTrk) 1例如根目录从 LBA 19 开始C 19 / (18 * 2) 0 H (19 / 18) % 2 1 S (19 % 18) 1 2所以根目录的第一个扇区位于第 0 磁道、第 1 磁头、第 2 扇区。这个计算结果在排查问题时很重要手算一遍能提前发现很多错误。读扇区函数可以写成这样; 输入: ; al 扇区数 ; ch 磁道 ; cl 扇区 ; dh 磁头 ; es:bx 缓冲区 read_sectors: mov ah, 0x02 int 0x13 jc read_error ret read_error: mov si, msg_read_error call print_string jmp hangINT 13h返回时如果进位标志CF1表示出错出错后直接显示错误信息后停机。这样方便快速定位问题而不是让程序乱跳。4.3 为什么先读 FAT1再读根目录读 FAT1 和读根目录是两个独立操作。实验 1 只遍历根目录理论上可以不读 FAT1但我建议还是读一下原因有两个FAT1 是实验 2 读取文件内容的前提。提前加载好只需要判断缓冲区是否有效。如果在同一个函数里把 FAT1 和根目录的读取流程都调通后面加 FAT 链追踪时排错范围更小。读 FAT1 需要读取 9 个扇区缓冲区放在0x8000。根目录需要读取 14 个扇区缓冲区放在0x9000。计算好 CHS 后用循环调用read_sectors或者一次读取连续扇区都可以。这里容易踩的坑是读取多个扇区时ES:BX地址不会自动递增。如果一次读多个扇区BIOS 确实会往同一块缓冲区连续写入但如果想分成多次读就必须在每次读取后把BX增加对应字节数。5. 写代码遍历目录项并打印文件名5.1 扫描根目录的逻辑根目录缓冲区在0x9000从那里开始每 32 字节是一个目录项。遍历逻辑如下mov si, 0x9000 mov cx, 224 ; 根目录项总数 .loop: cmp byte [si], 0x00 je .done ; 遇到 0x00目录结束 cmp byte [si], 0xE5 je .next ; 遇到 0xE5已删除项 test byte [si 11], 0x08 jnz .next ; 跳过卷标 call print_entry .next: add si, 32 loop .loop .done: ret这段代码有几个关键点0x00是目录结束标志。遍历时遇到它就可以直接退出不需要继续扫描后面的空目录项。0xE5是删除标记但删除后的目录项后面可能还有有效文件所以只跳过这一项继续往后找。属性字节在偏移 110x08表示卷标。如果不处理卷标会像普通文件名一样打印出来看起来像多了一个奇怪文件。打印print_entry时我建议把 8 字节主文件名和 3 字节扩展名分开处理。先打印主文件名跳过空格然后打印一个点再打印扩展名最后换行。标准 8.3 目录项里文件名不足部分用0x20填充直接一次性打印会把一堆空格也带出来。5.2 打印 8.3 文件名实模式打印字符最方便的是INT 10h功能号AH0x0E把字符放在AL中调用即可print_string: lodsb or al, al jz .done mov ah, 0x0E int 0x10 jmp print_string .done: ret这个函数使用DS:SI作为字符串地址遇到0x00停止。但在打印目录项时文件名不是以0x00结尾的字符串而是固定 8 字节或 3 字节的填充区域。所以不能直接复用print_string需要自己控制打印长度从目录项偏移 0 开始打印 8 个字节遇到空格0x20就跳过。打印一个点。从偏移 8 开始打印 3 个字节遇到空格0x20就跳过。打印回车换行。主文件名和扩展名可能没有内容例如目录项名只有 8 个字节全是空格时就要避免打印出..这种奇怪输出。最简单的做法是先检查主文件名是否全是空格如果是直接跳过该目录项。实验 1 里不强制处理大小写。FAT12 目录项通常保存大写文件名但有些 mtools 创建的文件名在目录项里可能是小写。打印时原样输出即可不影响实验目的。5.3 怎么判断实验成功把代码编译、写入镜像、启动 QEMU 后成功结果应该是这样的屏幕上先出现启动阶段的自定义提示信息。接着列出根目录下的所有文件名。文件名顺序和镜像创建时写入的顺序一致。没有乱码没有多余符号。如果只显示一半文件可能是根目录扇区没有读完或者遍历中跳过了某些项目。如果文件名后面带着一堆空格说明没有做空格过滤。如果屏幕上完全看不到文件列表优先怀疑读取根目录的 CHS 地址算错了。6. 验证、排查和下一步扩展6.1 制作标准 FAT12 镜像并放入测试文件要让实验有意义得先准备一个包含已知文件的 FAT12 镜像。可以用 mtools 完成mkfs.fat -F 12 -C floppy.img 1440 mcopy -i floppy.img hello.txt ::HELLO.TXT mcopy -i floppy.img readme.txt ::README.TXTmkfs.fat创建 1440KB 的 FAT12 镜像mcopy把本地的hello.txt拷贝进镜像根目录。文件名用大写::HELLO.TXT是为了确保在目录项里以 8.3 格式保存。启动镜像后预期输出HELLO .TXT README .TXT有一点要注意mkfs.fat在不同发行版上可能生成略有差异的 BPB。如果引导代码里硬编码了“根目录从第 19 扇区开始”但实际镜像的 BPB 不是标准值那就会读错位置。建议在验证阶段用十六进制编辑器查看镜像偏移0x0B到0x16处的 BPB 字段确认和代码里写的常量一致。如果你看到这样的一条输出BOOT READ ERROR说明INT 13h读扇区失败了通常是 CHS 转换错误或者驱动器号设置不对。QEMU 默认第一个软驱是DL0x00如果设置成0x80就会去读硬盘。6.2 常见问题和排查顺序从我的经验看这个实验最容易出的问题不是代码逻辑而是下面这几个方向编译后引导扇区超过 512 字节。NASM 不会自动报错只有 QEMU 启动时表现异常。检查boot.bin文件大小再检查内存布局是否覆盖了代码。CHS 转换算错。扇区号从 1 开始磁道和磁头从 0 开始很多人会把扇区号直接当成 LBA 用读到的数据自然不对。缓冲区地址重叠。如果 FAT1 缓冲区放在0x7E00读取 9 个扇区时会覆盖引导代码导致程序跑飞。根目录大小算错。1.44MB 软盘根目录是 14 扇区如果你只读了 1 扇区只能看到少数文件。目录项偏移写错。属性字节在偏移 11起始簇在偏移 26文件大小在偏移 28这些偏移错一位都会导致判断错误。排查顺序我建议这样先确认boot.bin大小在 512 字节以内。再确认 QEMU 里能看到启动提示如果没有说明引导扇区本身没执行。看到提示但读不到根目录先手算几个扇区的 CHS。读到数据但输出乱码检查缓冲区地址和目录项结构。最后再怀疑页面、字符显示等小问题。注意出现“读不到文件”时不要一上来就怀疑 FAT 表结构。先确认根目录是不是读到了内存最简单的方法是先用调试器或直接打印0x9000前 32 字节的十六进制值。6.3 实验 2 可以怎么扩展实验 1 跑通后你已经有了一个能读取根目录的实模式程序。下一步可以沿着这些方向继续读取文件内容目录项偏移 26 是文件起始簇。根据起始簇找到数据区扇区然后通过 FAT 表读取下一个簇。FAT12 表项是 12 位需要跨字节拼接这是实验 2 比较核心的部分。动态读取 BPB把引导代码里硬编码的“FAT 大小”“根目录项数”改成从偏移0x0B开始读取这样可以兼容不同镜像。显示文件大小和属性目录项偏移 28 是 4 字节大小偏移 11 是属性。打印文件名时一并输出能更直观体会目录项结构。处理子目录属性字节低四位是0x10表示目录项是子目录。遍历子目录时起始簇字段不是文件数据而是一个新的目录簇。加入简易命令交互读取键盘并输入文件名然后按文件名读取内容已经可以有迷你 shell 的雏形。这些扩展里动态读取 BPB 和跟踪 FAT 链是最推荐先做的因为它们会把实验 1 里“固定扇区号”的写法替换成真正可用的文件系统访问方式。这个实验真正让人印象深刻的不是某一段代码而是“文件系统遍历”竟然要同时考虑磁盘布局、内存地址、中断调用和结构体偏移这么几层东西。第一次跑通时你会发现所有问题都集中在扇区计算和地址选择上而不是文件系统本身有什么神秘之处。先把最小范围跑稳再往里面加功能比一开始就想着写完整文件系统要靠谱得多。