拓冰建站拓冰建站
首页 / 资讯中心 / 正文

x86实模式内核中的FAT12文件系统实现详解

这次我们来看一个 OS 开发入门方向里绕不开的里程碑在 x86 实模式下写 kernel并在这个内核里把文件系统实现出来。对这个方向感兴趣的人一般已经能跑通 bootloader、能往屏幕输出字符串但内核一旦要加载配置、读取用户程序、管理磁盘数据就得有文件系统。没有文件系统的内核所有数据都只能裸写扇区想找某个文件只能按固定扇区位置硬编码这在实际项目里根本不可维护。先给结论实模式下的文件系统实现重点不是一上来就做 VFS、做日志、做权限而是先把“块设备读写 - FAT 表解析 - 目录项遍历 - 文件内容读取 - 给用户程序提供接口”这条链路打通。用 QEMU 加一个 1.44MB 的软盘镜像把 kernel 跑在实模式下再通过int 13h读盘解析 FAT12 文件系统最后在 shell 里执行类似read file.txt的命令整个过程清晰、可控、调试方便。本文会从环境准备、编译启动、FAT 文件系统结构、接口设计、批量测试、常见问题排查几个方面完整展开。适合有一定汇编和 C 基础、想从零写小内核的读者收藏。1. 核心能力速览能力项说明项目类型操作系统内核学习项目偏教学实践运行环境x86 实模式建议 QEMU/Bochs 虚拟机调试推荐语言汇编NASM CGCC 交叉编译或 i386 编译启动方式生成软盘/磁盘镜像QEMU 指定镜像启动核心功能内核入口、VGA 文本输出、磁盘扇区读写、FAT12 文件系统解析、文件读取、命令交互文件系统方向FAT12 起步后续可扩展 FAT16/FAT32、简易 VFS、根文件系统调试方式Bochs 内建调试器、QEMU monitor、串口日志、xxd检查镜像接口能力内核内文件接口open/read/opendir可扩展为软中断系统调用批量任务可做目录遍历、批量读取文件并做校验的自动化测试硬件要求普通 x86 机器即可无特殊显卡/显存要求适合人群学过汇编/C、理解段寄存器与中断、想深入内核底层的开发者这个表里的功能范围是按教学型内核的常见组织方式列出的。具体到某个仓库或课程代码组织可能不同但核心技术链路是一样的实模式访问磁盘、解析 FAT、提供文件读取接口。2. 适用场景与使用边界实模式内核适合三类人。第一类是正在学 OS 理论、想验证书上说的“文件系统到底怎么存数据”的人FAT12 简单到可以直接读完源码第二类是做嵌入式引导或裸机开发的工程师需要理解上电后如何从磁盘加载配置、加载应用第三类是准备操作系统课程设计的学生用 QEMU 做实验环境成本低、可反复重来。它能解决的问题也很明确让内核不再依赖固定扇区号而是按文件名定位数据。内核启动后可以读取根目录下的配置文件可以加载一个用户程序到内存执行可以列目录看磁盘上有哪些文件。这些都是后续做多任务、做 shell、做用户态程序的必要前置。但使用边界要划清楚。这个教学内核不是生产级文件系统不要拿它做数据存储不要直接放到真实硬盘上测试不要在没有虚拟层保护的机器上反复写磁盘。尤其是实验代码里如果实现了“写文件”或“格式化”逻辑一旦在真实环境跑错驱动器号可能直接破坏本机分区。稳妥的做法是只在 QEMU 镜像里操作通过xxd和编译脚本随时重建镜像。还有一层边界是版权和合规。如果后续把文件系统扩展到读取真实光盘/U 盘里的内容要注意这些存储介质里的数据可能存在版权或隐私问题不要未经授权批量导出他人设备上的数据。实验用的测试文件自己生成最安全。3. 环境准备与前置条件实模式内核开发不需要大体积工具链依赖很轻。推荐环境是 LinuxWSL2 也可以Windows 下用 MSYS2 或 WSL 会省去不少交叉编译坑。核心依赖有四个汇编器NASM用来编译 boot sector 和早期汇编代码。C 编译器GCC编译内核 C 部分。如果目标格式是 ELF 再用objcopy转成 flat binary或者直接用ld -Ttext链接成指定加载地址的二进制。构建工具Make把汇编、C、链接、生成镜像、启动 QEMU 串成一条命令。虚拟机QEMU 或 Bochs。QEMU 启动快、命令行参数简单Bochs 内建调试器适合单步跟踪内核。Debian/Ubuntu 系的安装命令sudo apt update sudo apt install -y nasm gcc make binutils qemu-system-x86macOS 上可以用 Homebrewbrew install nasm gcc make qemu还要准备一个镜像生成工具。Linux 下dd自带不需要额外装Windows 下可以用 PowerShell 或 ImageWriter 类工具但 WSL 直接用dd更方便。环境检查nasm -v gcc --version qemu-system-i386 --version建议工程目录按功能拆开os_learn/ ├── boot/ │ └── boot.asm ├── kernel/ │ ├── kernel.c │ ├── io.c │ ├── disk.c │ └── fat.c ├── user/ │ └── test_prog.c ├── img/ │ └── test.txt ├── Makefile └── build/把 boot、kernel、用户程序、素材镜像分开构建时统一输出到build/最后生成一个os.img。这样每次测试失败删掉build/重新 make 就能回到干净状态。动手之前还应该把几个基础概念理清实模式下的 20 位地址如何由段寄存器加偏移组成磁盘读写的int 13h调用约定FAT12 的 BPB 结构、FAT 表项、目录项格式。特别是 FAT12 的 12 位表项跨字节问题这是第一个大坑。4. 编译链接与启动流程先给出一套可用的 Makefile 模板。这个模板假设 boot sector 由boot/boot.asm编译内核由kernel.c编译后链接成 flat binary最后通过dd写入镜像。实际路径和内存布局要按你自己的工程调整。# Makefile 模板按实际工程修改 NASM nasm GCC gcc LD ld QEMU qemu-system-i386 # kernel 加载到内存 0x10000实模式下 64KB 段内寻址 KERNEL_LOAD_ADDR 0x10000 all: os.img build/boot.bin: boot/boot.asm mkdir -p build $(NASM) -f bin boot/boot.asm -o build/boot.bin build/kernel.bin: kernel/kernel.c kernel/disk.c kernel/fat.c kernel/io.c mkdir -p build $(GCC) -m32 -fno-pic -ffreestanding -nostdlib -c kernel/kernel.c -o build/kernel.o $(GCC) -m32 -fno-pic -ffreestanding -nostdlib -c kernel/disk.c -o build/disk.o $(GCC) -m32 -fno-pic -ffreestanding -nostdlib -c kernel/fat.c -o build/fat.o $(GCC) -m32 -fno-pic -ffreestanding -nostdlib -c kernel/io.c -o build/io.o $(LD) -m elf_i386 -Ttext $(KERNEL_LOAD_ADDR) --oformat binary \ build/kernel.o build/disk.o build/fat.o build/io.o -o build/kernel.bin os.img: build/boot.bin build/kernel.bin dd if/dev/zero ofbuild/os.img bs1024 count1440 dd ifbuild/boot.bin ofbuild/os.img bs512 convnotrunc dd ifbuild/kernel.bin ofbuild/os.img bs512 seek1 convnotrunc # 这里可以继续用 mtools 或脚本把测试文件写入镜像 run: os.img $(QEMU) -drive formatraw,filebuild/os.img,iffloppy clean: rm -rf build启动命令make clean make make run如果 QEMU 窗口正常出现并且内核打印了启动日志说明 boot sector 和 kernel 加载已经打通。这里有一个非常重要的细节kernel 编译时要使用-ffreestanding和-fno-pic。前者告诉 GCC 不要依赖宿主库后者关闭位置无关代码生成避免产出需要动态重定位的指令。链接时用--oformat binary直接生成裸二进制配合-Ttext指定加载地址boot sector 才能准确跳转到 kernel 代码执行。5. 文件系统实现路径内核能启动、能输出字符之后就可以开始文件系统了。从零实现文件系统建议按下面的层次走不要在第一步就想着做 VFS 和页缓存。5.1 为什么先选 FAT12FAT12 是学习成本最低的磁盘文件系统结构公开、资料多、镜像文件可以手工生成1.44MB 软盘的大小限制反而简化了实现。FAT12 的磁盘布局只有四块区域引导扇区BPB、FAT 表区、根目录区、数据区。理解这一张图文件系统的基础就有了。区域说明引导扇区第 0 扇区含跳转指令、BPB、引导代码FAT 表记录每个簇的分配状态和下一簇指针FAT12 下每个表项 12 位根目录区固定大小FAT12 软盘通常 14 个扇区存目录项数据区按簇划分存放文件内容5.2 FAT12 的关键数据结构BPB 存在于引导扇区偏移 0x0B 之后里面记录了每扇区字节数、每簇扇区数、FAT 表个数、根目录项数、总扇区数等信息。内核读盘前必须先把这些字段解析出来因为根目录和 FAT 表的位置需要计算得到。根目录项是 32 字节定长结构。目录项里的关键字段如下。// FAT12 目录项结构32 字节 struct dir_entry { uint8_t name[8]; // 文件名空格补齐 uint8_t ext[3]; // 扩展名 uint8_t attr; // 属性位0x10 表示子目录 uint8_t reserved[10]; // 保留 uint16_t time; // 修改时间 uint16_t date; // 修改日期 uint16_t cluster_hi; // FAT12 中通常为 0 uint16_t cluster_lo; // 起始簇号 uint32_t size; // 文件大小字节 } __attribute__((packed));注意这里一定要用__attribute__((packed))否则编译器会按 4 字节对齐字段偏移就错了。5.3 读取文件的核心流程读取一个普通文件路径可以拆成四步根据 BPB 计算根目录起始扇区号。遍历根目录项找到文件名匹配的目录项取出起始簇号。从起始簇开始按 FAT 表项追踪簇链逐个读取簇内容。直到 FAT 表项值为 0xFFF文件结束或读取的字节数达到文件大小。用 C 伪代码表示// 简化版读取文件不考虑多 FAT 表和脏状态 int fat_read_file(struct file *f, uint8_t *buf, uint16_t start_cluster, uint32_t size) { uint16_t cluster start_cluster; uint32_t bytes_read 0; while (cluster 0xFFF bytes_read size) { uint32_t sector data_sector (cluster - 2) * sectors_per_cluster; read_sectors(sector, sectors_per_cluster, buf[bytes_read]); bytes_read sectors_per_cluster * 512; cluster fat_get_next_cluster(cluster); // 读 FAT 表项得到下一簇 } return bytes_read; }fat_get_next_cluster是 FAT12 里最容易出错的地方。因为表项是 12 位两个表项拼在 3 个字节里处理时一定要先算偏移再根据奇偶位置取高 4 位还是低 4 位。uint16_t fat_get_next_cluster(uint16_t cluster) { uint32_t offset cluster (cluster / 2); // FAT12 表项字节偏移 uint16_t val *(uint16_t *)(fat_buffer offset); if (cluster 0x0001) { return (val 4) 0x0FFF; } else { return val 0x0FFF; } }这个“奇偶处理”就是 FAT12 的经典面试题。第一次写错大概率是根目录能读到文件名但文件内容读出来只有前几个字节或者读取中途跳到错误的簇。5.4 文件系统分层设计即使是一个教学内核文件系统代码也不要全塞在一个文件里。建议至少分成三层设备层只负责按扇区号读写磁盘向上提供read_sectors(sector, count, buf)。文件系统层解析 BPB、FAT、目录项向上提供fat_read_file、fat_list_dir。内核服务层把文件能力封装成用户能用接口例如命令行里的cat、ls命令。分层之后后续把 FAT12 换成 FAT16或者加一个 ramdisk 作为根文件系统都只需要替换中间层设备层和命令层不用大改。这个思想就是从简单文件系统向 VFS 演进的雏形。6. 内核文件系统接口与批量测试文件系统做完内部读取逻辑后不能让测试代码直接调用fat_read_file因为内核最终要给用户程序或 shell 提供服务。这一步要设计接口。6.1 系统调用接口最简单的做法是定义一组软中断。比如用int 0x21作为文件系统服务入口AX寄存器放功能号BX放文件路径指针CX放缓冲区指针DX放读取长度。内核中断处理函数根据功能号分发到具体函数。; 用户在 shell 中读取文件触发 int 0x21 mov ax, 0x0001 ; 功能号 1read_file mov bx, filename ; 文件名指针 mov cx, buffer ; 读取缓冲区 mov dx, 512 ; 最大读取字节数 int 0x21内核侧的中断处理函数拿到参数后调用文件系统层完成读取返回实际读取字节数到 AX。这个思路虽然简陋但能体现系统调用的本质用户态通过中断陷入内核内核按功能号分发完成后返回。后面学保护模式、学任务切换这个框架还可以继续用。6.2 目录列举接口文件系统不止要读文件还要能列目录。可以定义int 0x21功能号 2 为list_dir遍历根目录区打印每个文件的文件名、扩展名、大小。这其实就是ls的最小实现。列目录时要注意目录项第一个字节为 0x00 表示后面没有更多目录项0xE5 表示该项已被删除。两个都要跳过。6.3 批量测试设计文件系统实现完建议做三个层面的测试。第一层测试文件准备。用脚本生成一批不同大小、不同内容的测试文件比如hello.txt、data.bin、longfile.txt然后写入镜像。# 使用 mtools 向镜像写入测试文件 mcopy -i build/os.img img/hello.txt ::hello.txt mcopy -i build/os.img img/data.bin ::data.bin mcopy -i build/os.img img/longfile.txt ::longfile.txt第二层内核内测试。启动后进入 shell分别执行ls cat hello.txt cat data.bin cat longfile.txt如果ls能列出所有文件cat能输出正确内容说明目录解析和簇链追踪都通了。第三层自动化校验。在主机侧写一个脚本启动 QEMU 后通过串口或 monitor 抓取内核输出和主机上的原始文件做哈希比对。# 主机侧提取测试文件预期哈希 sha256sum img/hello.txt img/data.bin img/longfile.txtQEMU 中内核如果能把读到的文件内容打印出来再把 QEMU 输出重定向到日志文件主机侧对日志里的十六进制内容做比对。这样内核每次改动后可以一键回归确认 FAT 解析没有被破坏。Windows 下没有 mtools 的话可以用dd先把文件写到镜像预留扇区再手动构造目录项但这样比较麻烦建议直接装 mtools。7. 资源占用与性能观察实模式内核的资源占用和现代应用完全不是同一个量级但它有自己的“硬件天花板”。整个内核运行在实模式可寻址空间最多 1MB其中低端 640KB 是常规内存所以内核代码、栈、缓冲区都要精打细算。磁盘缓冲区是最明显的内存开销。FAT12 读取需要先把 FAT 表读到内存再在内存里追踪簇链。1.44MB 软盘的 FAT12 表大小不大单个 FAT 表不超过 10KB但如果有多个缓冲区叠加很容易让一个简单内核的内存占用膨胀到几百 KB。建议一开始就规划好内存布局比如0x7C00boot sector 临时加载区。0x10000kernel 加载区。0x30000FAT 表缓冲区。0x40000用户程序加载区或文件内容缓冲区。用一张内存布局图放在代码注释里每次改内存相关代码都要回来更新这张图能避免大量“不知道为什么崩了”的问题。性能方面实模式下int 13h读盘是按扇区操作的每次中断都有固定开销。如果文件读取是按字节调用int 13h速度会非常慢。正确做法是每次至少读一个扇区512 字节甚至按簇批量读入内存再在内存里做文件内容裁剪。测试时可以在关键路径加计数器打印读取了多少扇区、走了多少簇通过 QEMU 输出观察磁盘访问次数。QEMU 的-d参数也可以用来观察部分指令流比如qemu-system-i386 -drive formatraw,filebuild/os.img,iffloppy -d int,cpu_reset这个输出量很大适合定位中断触发异常不适合日常全开。日常调试建议用串口日志qemu-system-i386 -drive formatraw,filebuild/os.img,iffloppy -serial file:serial.log内核里通过写串口端口0x3F8输出调试信息然后把输出重定向到serial.log日志就能脱离屏幕独立保存。8. 常见问题与排查方法问题现象可能原因排查方式解决方案QEMU 启动后黑屏无输出boot sector 缺少 0x55AA 引导签名或 CPU 没有进入 kernel用xxd build/os.img | head查看第 510 字节在第 510 字节写入 0x55、第 511 字节写入 0xAAboot 能跑但 kernel 没打印kernel 加载地址和链接地址不一致检查链接-Ttext和 boot 跳转地址统一为同一地址例如0x10000int 13h读盘失败驱动器号错误、ES:BX 指向错误、扇区数超限在调用前后打印驱动号和返回值软盘用DL0x00硬盘用DL0x80确保缓冲区段地址和偏移正确文件列表能出来内容读不全FAT 表项读取错误或簇链没追完打印当前簇号和下一次簇号检查 FAT12 奇偶偏移逻辑用固定镜像对照FAT 表读出来全是乱码BPB 解析偏移错误或扇区读错位置用xxd检查镜像手工核对根目录起始扇区按 BPB 字段逐个打印确认每扇区字节数、保留扇区数、FAT 个数文件内容只有前几字节正确缓冲区跨越段边界或按 8 位读取导致簇指针错位检查内存布局打印bytes_read保证缓冲区不跨 64KB 边界必要时用远指针或分段拷贝make run后镜像无法启动镜像只写了 boot没写 kernel用xxd检查 kernel 是否在 1 号扇区检查dd seek参数是否正确QEMU 启动很慢或不断重启内核进入异常、栈溢出或跳转地址错误用-d int,cpu_reset查看异常类型检查栈指针初始化实模式至少给 4KB 栈空间在真实机器上启动失败兼容性问题或 BIOS 差异不建议真机测试继续用 QEMU/Bochs 开发最后再考虑真实硬件验证排查里最重要的是先做“最小验证”。比如文件系统读不出来先不要直接调试 FAT 解析先用int 13h读一个固定扇区打印扇区内容确认磁盘驱动层是通的。磁盘驱动都通再解析 BPB。每一层都验证完问题就只剩当前层。9. 最佳实践与实用建议第一个建议是镜像构建要可重现。Makefile 里把 boot、kernel、文件写入、镜像生成全部串起来保证任何一次make clean make都能生成完全一致的镜像。这样出现诡异 bug 时可以先重建镜像排除脏文件影响。第二个建议是内存布局图一定要维护。实模式内核的 bug 有一半来自内存覆盖某个缓冲区写越界把 kernel 代码覆盖了表现为随机死机。用注释维护内存地图测试时通过打印缓冲区地址和边界快速定位。第三个建议是日志要分级。屏幕输出留给用户命令结果调试信息输出到串口。内核里封装一个debug_print只在调试宏开启时编译。这样屏幕不会刷满调试信息用户命令的输出不会被干扰。第四个建议是加入自动化校验流程。文件系统改一次就批量重跑ls和cat测试用哈希确认输出没有回退。没有自动化校验时看起来“好像行了”的改动很容易在下一个版本里悄悄回归。第五个建议是合规安全边界。文件系统代码里涉及磁盘写操作时一律只在虚拟镜像上执行。做一个write_sector函数之前先问一句如果实参是真实硬盘这个函数会做什么真实机器的实验要留到整个内核足够稳定、且有独立实验盘时再考虑。代码版本管理也很重要。这个阶段代码量虽然不大但 boot、kernel、脚本、文档会快速膨胀。每完成一个功能点提交一次比如“添加 FAT 表解析”“添加根目录遍历”“添加 cat 命令”回滚时就能精确定位。10. 总结与下一步实模式下的文件系统实现是内核开发从“玩具输出”走向“能自主加载资源”的关键一步。整个过程里最有价值的不是某一个函数写得有多漂亮而是把“磁盘扇区 - BPB - FAT 表 - 目录项 - 文件内容 - 用户接口”这条链路完整打通。一旦通了后续很多东西都会变得顺理成章。拿到一个教学内核最先应该验证的就是两件事能不能列出根目录能不能完整读取一个超过一个簇大小的文件。能跑通这两条说明块设备层和 FAT 层的基本设计是对的。最容易踩的坑也集中在这里FAT12 的 12 位表项跨字节处理以及根目录起始扇区的 BPB 计算。建议把这两处单独写好测试用例改动其他代码时重点回归。如果这个方向想继续深入下一步可以考虑这几个扩展点第一把文件系统层抽象成统一接口做一个极简 VFS让 FAT12 和内存文件系统共存第二加入写文件和删除文件支持注意 FAT 表更新与数据写入的顺序模拟一下“掉电一致性”问题第三从软盘切到 IDE 硬盘或 ramdisk理解不同块设备的差异第四进入保护模式后再保留文件系统服务观察实模式中断调用和保护模式代码之间的切换成本。如果还想再往上走就去做根文件系统的挂载逻辑把一个最小 initramfs 或基于内存的文件系统作为内核启动后的第一个根文件系统。那时的“文件系统”就不再只是读软盘上的几个文件而是整个内核资源管理的一部分了。建议先把 FAT12 这条线吃透后面每一步都能复用今天这套“分层 最小验证 自动化回归”的开发习惯。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门