ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

实模式内核开发:从零实现FAT12文件系统与磁盘I/O

实模式内核开发:从零实现FAT12文件系统与磁盘I/O 做 OS 开发的人大概都有一个共同经历引导扇区能打印字符了内核能切换到保护模式了内存分页也能跑了但一提到“文件系统”很多人会直接卡住。原因不复杂文件系统不像中断和分页那样有一堆现成规范可以照抄它需要你同时处理磁盘 I/O、数据结构解析、扇区偏移计算和内核内存布局任何一环出错表现都是“读出来的数据全是乱的”。这次这篇我们就来拆解实模式内核开发中的文件系统实现。实模式意味着 16 位寻址、1MB 地址空间、没有内存保护但依然可以使用 BIOS 的int 13h磁盘中断。在这个前提下做一个可用的文件系统不需要先上 ext2、EXT4 那种复杂设计最适合入门的是FAT12也就是当年 1.44MB 软盘使用的文件系统。文章会按“磁盘 I/O 基础 - FAT12 结构解析 - 内核文件系统接口设计 - 功能测试与调试 - 常见问题排查”的顺序展开。不管你是跟着教学视频或者开源项目在写自己的内核还是单纯想搞明白“文件在磁盘上到底是怎么被找到的”这篇都值得看完。1. 核心能力速览先给一张总表把这次涉及的关键点和最终目标说清楚。能力项说明运行模式x86 实模式16 位代码段1MB 地址空间目标存储介质1.44MB 软盘镜像也可用虚拟磁盘镜像文件系统类型FAT12面向入门与教学的最小完整实现底层磁盘访问BIOS 中断int 13hAH02h读扇区核心功能解析引导扇区 BPB、读取 FAT 表、遍历根目录、按 FAT 链读取文件内容内核接口封装fs_open/fs_read/fs_find_file一类函数供内核上层调用显存占用不涉及实模式下关注的是内存占用和代码体积内存占用典型情况仅需几十 KB具体取决于缓冲区设计需要按实际实现统计支持平台QEMU、BOCHS、真实 x86 实模式环境启动方式软盘镜像引导QEMU-fda参数启动API 支持不涉及网络 API提供内核内部函数接口批量任务不涉及批处理但文件系统本身支持目录项逐项遍历适合场景OS 开发学习、自制内核实验、BIOS 层磁盘读取实验从材料来看这个主题属于 OS 开发入门系列中的文件系统实现部分最大的特征是“实模式”也就是不依赖保护模式、不依赖内存管理单元直接靠 BIOS 和裸数据结构完成文件读取。2. 适用场景与学习边界文件系统实现放在实模式阶段学是很划算的一件事。为什么因为实模式下的文件系统实现可以把问题限制在非常小的范围内不需要处理多进程并发访问不需要考虑块缓存、日志、事务不需要处理权限模型和复杂目录树只需理解“扇区 - FAT 表 - 根目录项 - 数据簇”这条主链路。适合读者有三类第一类是正在写自制内核的开发者。你已经有了一个能从软盘启动并加载内核的 bootloader现在想让内核从磁盘读取文件摆脱“所有数据硬编码在镜像里”的窘境。第二类是学习操作系统原理的学生。你不需要完整跑通 Linux只需要亲手实现一个最小的 FAT12 读取器就能直观理解 VFS虚拟文件系统为什么存在以及文件系统为什么需要“结构化布局”。第三类是对底层存储原理好奇的嵌入式开发者。很多 MCU 项目读 SD 卡时也要解析 FAT 文件系统这里面的思想是一样的。但也要说清楚边界这个主题不是教你实现一个可以商用的文件系统。FAT12 本身已经很老了它的目录项格式、长文件名支持、FAT 表损坏恢复都有限。这不是一个图形界面项目。整个过程以串口/屏幕输出和内存数据验证为主。如果你还没有完成基本的实模式内核引导、中断处理和字符串打印建议先把这些基础补上否则文件系统代码调试时会叠加太多变量。实现过程中如果需要参考 Linux 内核、GRUB、开源 FAT 驱动等源码要注意许可证问题。学习思路没问题直接把 GPL 代码搬进自己的内核项目需要考虑开源合规。另外如果你做的内核后续会加载用户程序或读取外界素材必须保证只处理自己拥有授权或符合测试范围的数据。素材越界、版权不清的文件不要放进实验镜像更不要公开发布到网上。3. 环境准备与前置条件文件系统实现需要准备一整套构建和运行环境下面按层次列出。3.1 编译工具链实模式内核和文件系统代码典型的开发语言组合是NASM 汇编器用来写启动扇区和底层磁盘读取GCC 交叉编译器用来编译 C 语言部分的文件系统解析逻辑GNU ld 链接器用来把目标文件链接成可执行二进制GNU Make用来组织引导扇区、内核、文件系统模块的构建过程。如果只是做最简实验只使用 NASM 也可以完成整个 FAT12 解析流程但代码会非常啰嗦。更推荐的做法是底层扇区读取用汇编FAT12 结构解析用 C。3.2 模拟器推荐 QEMU原因是启动快、调试方便、对 x86 实模式支持好。qemu-system-i386 -fda os.img -boot aBOCHS 也可以内置调试器适合单步跟踪扇区读取过程缺点是启动速度偏慢。真实硬件上测试会更硬核但每次需要写盘和重启排错成本高建议先跑通模拟器。3.3 镜像制作工具需要生成一张软盘镜像并在其中放入 FAT12 引导扇区、内核和测试文件。dd创建空镜像、写入引导扇区mkfs.fat或mformat把镜像格式化为 FAT12mcopy往镜像里写入测试文件xxd或hexdump查看镜像中的二进制内容排查扇区偏移问题。3.4 配置检查清单开始写文件系统代码前建议先确认这几项汇编器是否能正常编译 16 位代码bits 16C 编译器是否按照 16 位代码模式编译工具链是否支持__attribute__((packed))或等价语法QEMU 是否安装-fda参数是否可用你已有的引导扇区能否成功加载第二个扇区到内存是否能从串口或屏幕输出十六进制字符串用于打印调试信息。这些前置条件都具备后再来碰文件系统效率会高很多。4. 实模式下的磁盘 I/O 基础文件系统的基础是磁盘 I/O。在实模式下访问磁盘最直接的方法就是 BIOS 的int 13h中断。4.1 CHS 与 LBA 地址FAT12 标准软盘布局中磁盘数据通过 CHS柱面 Cylinder / 磁头 Head / 扇区 Sector来定位。1.44MB 软盘规格80 个柱面2 个磁头每个磁道 18 个扇区每个扇区 512 字节。CHS 地址在代码中需要拆成三个部分CH寄存器保存柱面低 8 位CL寄存器的高 2 位保存柱面高 2 位低 6 位保存扇区号DH寄存器保存磁头号DL寄存器保存驱动器号0x00 表示软盘 A。扇区号从 1 开始编号柱面和磁头从 0 开始这是初学者最容易踩的坑。如果你的实现中使用了 LBA逻辑块地址还需要把 LBA 转换成 CHS。公式如下柱面号 LBA / (磁头数 * 每磁道扇区数) 磁头号 (LBA / 每磁道扇区数) % 磁头数 扇区号 (LBA % 每磁道扇区数) 14.2 读取扇区的核心汇编代码下面是一段通用模板实际路径和寄存器保留字需要按你的汇编器调整。; 读取一整个扇区 ; 输入 ; DL 驱动器号 ; CH 柱面低 8 位 ; CL 扇区号 (柱面高 2 位 6) ; DH 磁头号 ; ES:BX 缓冲区地址 ; 输出 ; CF 0 表示成功CF 1 表示失败 read_sector: mov ah, 02h ; 功能号读扇区 mov al, 01h ; 读取扇区数 int 13h jc read_sector_failed ret read_sector_failed: ; 先复位磁盘再重试一次 mov ah, 00h int 13h mov ah, 02h mov al, 01h int 13h retint 13h调用失败时返回的AH寄存器会携带错误码比如0x02表示地址标记未找到0x04表示扇区未找到0x20表示控制器故障。调试时这个错误码非常有用建议把AH的值打印出来。4.3 读取多个扇区与缓冲文件系统读取不会一次只读一个扇区。最简单的做法是写一个read_sectors循环逐个扇区读取更好的做法是一次性读取连续扇区减少 BIOS 调用开销。但实模式下可用的缓冲区非常有限尤其内存布局还没完善时缓冲区通常放在低 1MB 内存中。推荐的做法是分配一个 512 字节的扇区缓冲区读引导扇区读 FAT 扇区读目录扇区读数据簇。这样每次只占用 512 字节简单可控。缺点是 FAT 表和根目录可能跨多个扇区需要反复读取。对于入门实现这个是完全可以接受的。5. FAT12 文件系统结构剖析FAT12 文件系统是理解后续一切设计的核心它把磁盘结构化成了几个连续区域。5.1 整体布局一张 FAT12 软盘从上到下依次是引导扇区第 0 扇区512 字节包含 BPB 参数和引导代码FAT 表区通常两个 FAT 表每个 FAT 表占用 9 个扇区根目录区最多 224 个目录项每个目录项 32 字节共 14 个扇区数据区真正存放文件内容的地方。关键公式根目录起始扇区 保留扇区数 FAT 个数 * 每个 FAT 占用的扇区数 根目录占用扇区数 根目录项数 * 32 / 每扇区字节数 数据区起始扇区 根目录起始扇区 根目录占用扇区数 数据区某簇对应的扇区号 数据区起始扇区 (簇号 - 2) * 每簇扇区数这些公式在写代码时几乎每一条都会用到建议在代码注释里写得非常明确。5.2 引导扇区 BPBFAT12 的引导扇区在开头偏移 0x00 处是跳转指令和 OEM 名称从偏移 0x0B 开始是 BPBBIOS Parameter Block。BPB 字段非常多但文件系统实现中最关键的字段如下偏移字段含义0x0B每扇区字节数通常为 5120x0D每簇扇区数软盘通常为 10x0E保留扇区数通常为 10x10FAT 表个数通常为 20x11根目录项数软盘通常为 2240x13总扇区数1.44MB 软盘为 28800x16FAT 表大小扇区数通常为 90x18每磁道扇区数通常为 180x1A磁头数通常为 2用 C 语言定义对应的结构体时一定要使用打包对齐否则编译器插入的填充字节会让字段偏移错误。#include stdint.h typedef struct __attribute__((packed)) { uint8_t jump[3]; char oem[8]; uint16_t bytes_per_sector; uint8_t sectors_per_cluster; uint16_t reserved_sectors; uint8_t num_fats; uint16_t root_entry_count; uint16_t total_sectors_16; uint8_t media_type; uint16_t fat_size_16; uint16_t sectors_per_track; uint16_t num_heads; uint32_t hidden_sectors; uint32_t total_sectors_32; } fat12_bpb_t;实际读取时直接从引导扇区偏移 0 开始线性解析遇到不确定字段先用xxd查看真实镜像内容来对齐你的结构体。5.3 FAT 表与 12 位表项FAT 表的作用是记录簇的分配状态和链接关系。每个文件占用的若干个簇在 FAT 表里形成一个单向链表。FAT12 最特别的地方是每个表项只有 12 位1.5 字节不是 2 字节。两个相邻的 12 位表项会合并成 3 个字节存储。读取第 N 个 FAT 表项的方法如下FAT 偏移 N N / 2 表项所在扇区 保留扇区数 FAT 偏移 / 每扇区字节数 表项在扇区内偏移 FAT 偏移 % 每扇区字节数读出一个 16 位数据后还需要根据 N 的奇偶性来截取如果 N 是偶数表项值 16 位数据 0x0FFF如果 N 是奇数表项值 16 位数据 4。FAT 表项的值有特殊含义0x000表示空闲簇0x002到0xFF6表示数据簇值表示下一个簇号0xFF7表示坏簇0xFF8到0xFFF表示文件的最后一个簇。这也是文件系统实现中最容易出错的地方因为 12 位表项在内存中不是字节对齐的必须用移位和掩码来拼出来。5.4 根目录项FAT12 的根目录是一块连续区域每个目录项固定 32 字节。typedef struct __attribute__((packed)) { char name[11]; // 8.3 文件名 uint8_t attr; // 文件属性 uint8_t reserved; // 保留 uint8_t create_time_tenth; // 创建时间毫秒 uint16_t create_time; uint16_t create_date; uint16_t access_date; uint16_t first_cluster_hi; // 起始簇高 16 位FAT12 中为 0 uint16_t write_time; uint16_t write_date; uint16_t first_cluster_lo; // 起始簇低 16 位 uint32_t file_size; // 文件大小 } fat12_dir_entry_t;文件名是以空格补齐的 8.3 格式例如TEST TXT不是TEST.TXT。比较文件名时要么在读取目录项后把空格去掉要么在查找文件时把输入文件名也补成 8.3 格式。目录项的attr字段如果是0x0F表示长文件名目录项如果是0x10表示子目录。入门实现中可以先忽略长文件名跳过attr 0x0F的目录项避免解析错误。6. 内核文件系统模块设计与接口约定现在进入真正的内核开发部分在内核中设计一个最小的文件系统模块。6.1 分层设计建议把代码拆成三层磁盘层负责int 13h调用、CHS/LBA 转换FAT 解析层负责解析 BPB、FAT 表、目录项文件接口层向上层提供按名字读文件的能力。这样分层的意义在于将来你从 FAT12 换成 FAT16 或只读 ext2 时不需要重写上层代码。6.2 接口设计最简接口可以设计为// 初始化文件系统读取 BPB 到内存 int fs_init(void); // 在根目录中查找文件返回起始簇号找不到返回 -1 int fs_find_file(const char *name); // 按起始簇号读取文件内容到缓冲区 int fs_read_file(uint16_t start_cluster, uint8_t *buffer, uint16_t max_size); // 封装上述两步直接按文件名读取 int fs_load_file(const char *name, uint8_t *buffer, uint16_t max_size);这些接口不需要在后续保护模式下继续兼容它是面向课堂学习的最小接口集重点是把“查找 读取”的流程跑通。6.3 FAT 链遍历核心逻辑读取一个文件的内容本质上就是从一个起始簇出发不断根据 FAT 表项找到下一个簇直到遇到结束标记。int fs_read_file(uint16_t cluster, uint8_t *buffer, uint16_t max_size) { uint16_t written 0; uint16_t entries_per_sector boot_bpb.bytes_per_sector / 2; while (cluster 0x002 cluster 0xFF6 written max_size) { uint32_t lba data_region_start_lba (cluster - 2) * boot_bpb.sectors_per_cluster; if (read_lba_sector(lba, sector_buffer) ! 0) { return -1; } // 将 512 字节拷贝到输出缓冲区 uint16_t copy_len 512; if (written copy_len max_size) { copy_len max_size - written; } memcpy(buffer written, sector_buffer, copy_len); written copy_len; // 获取 FAT 中的下一个簇号 uint16_t fat_offset cluster (cluster / 2); uint16_t fat_sector_lba boot_bpb.reserved_sectors (fat_offset / boot_bpb.bytes_per_sector); uint16_t entry_offset fat_offset % boot_bpb.bytes_per_sector; if (read_lba_sector(fat_sector_lba, sector_buffer) ! 0) { return -1; } uint16_t raw *(uint16_t *)(sector_buffer entry_offset); if (cluster 1) { cluster raw 4; } else { cluster raw 0x0FFF; } } return written; }这段代码是一个通用实现框架实际项目里你还需要处理“文件大小超过缓冲区”“文件大小不是 512 的整数倍”等边界情况。6.4 文件名查找逻辑目录项查找有几个容易被忽略的细节目录项区域最多 224 项不是把整个根目录扇区刷完就算完要按root_entry_count限制遍历次数文件名比较时需要去掉目录项文件名末尾的空格如果目录项首字节是0x00表示后续目录项全部为空可以直接结束查找如果目录项首字节是0xE5表示该项已被删除跳过。查找函数本身就是线性比较代码量不大但边界条件要写清楚否则很容易在读满 224 项之后继续越界。7. 功能测试与效果验证文件系统写完怎么验证最直观的方法是创建一张 FAT12 软盘镜像放入一个测试文件让内核启动后读取文件内容并在屏幕上显示。7.1 准备测试镜像首先创建一个 1.44MB 的空白软盘镜像并格式化为 FAT12。dd if/dev/zero ofos.img bs512 count2880 mkfs.fat -F 12 os.img如果你的构建系统里没有mkfs.fat可以用 mtools 的mformat来代替。之后把测试文件放进镜像mcopy -i os.img test.txt ::TEST.TXT然后写入引导扇区和内核。这里的关键是引导扇区会加载内核到内存内核再识别 FAT12 文件系统并读取文件。nasm -f bin boot.asm -o boot.bin nasm -f bin kernel.asm -o kernel.bin # 引导扇区写入第 0 扇区 dd ifboot.bin ofos.img bs512 count1 convnotrunc # 内核写入第 1 扇区开始的位置 dd ifkernel.bin ofos.img bs512 seek1 convnotrunc7.2 启动与验证qemu-system-i386 -fda os.img -boot a -m 32观察现象屏幕上输出文件系统初始化信息例如FS OK输出文件查找结果例如Found TEST.TXT, cluster3, size23文件内容逐字节显示在串口或屏幕上如果找不到文件输出明确的错误码而不是空跑或死机。7.3 测试用例设计建议至少覆盖下面这组用例测试项测试方法预期结果根目录查找镜像内置TEST.TXT能找到返回正确起始簇短文件读取文件内容小于 512 字节屏幕输出完整内容跨簇文件读取文件内容大于 512 字节FAT 链遍历正常内容完整非 512 倍数字节文件大小 600 字节读取长度等于实际文件大小不足一个簇的末尾不越界文件名不存在查找NOPE.BIN返回明确错误码目录项区域耗尽预先填入大量测试文件遍历到root_entry_count后停止不越界长文件名目录项镜像中存在长文件名文件直接跳过attr 0x0F目录项不影响普通文件查找7.4 判断是否成功一个文件系统实现“基本可用”的判断标准能够从 BPB 中解析出所有关键参数能够从根目录中找到用户指定的文件能够沿 FAT 链读取多个簇正确返回文件长度文件内容能在屏幕上显示或写入指定内存缓冲区。如果以上都通过说明你的文件系统已经具备最核心的功能足够支撑后续加载一个 ELF 文件或用户程序的需求。8. 资源占用与性能观察实模式内核开发与保护模式开发不同这里没有“显存占用”的概念更关心代码体积和内存占用。8.1 内存占用一个最小可用的 FAT12 读取模块汇编代码加 C 代码估算情况如下磁盘读取 FAT 解析代码通常几千字节到十几 KB缓冲区512 字节扇区缓冲区 可能需要的 FAT 表缓存内核本身如果加载到0x8000或类似位置剩余可用内存依然有数百 KB。实模式整体只有 1MB 可寻址空间所以文件系统模块的代码如果超过 64KB必须考虑分段加载问题。这也是入门项目里比较少见的但值得留意不要以为 C 编译器会自动帮你管理段。8.2 读取性能QEMU 模拟的软盘读取速度非常快基本感受不到延迟。但真实软盘或一些嵌入式环境里每次int 13h调用都有几十毫秒的旋转延迟此时就要考虑优化连续扇区一次多读FAT 表只在初始化时读取一次并缓存在内存中根目录区域也尽量一次性读入内存读文件时尽量减少 BIOS 调用次数。实模式内核中做性能优化最大的收益往往来自“减少中断调用次数”而不是一句句抠指令周期。8.3 如何观察运行时状态推荐两种方式使用 QEMU 的-d int,cpu_reset参数观察中断是否异常在关键函数入口和出口添加十六进制打印例如打印读取到的簇号、FAT 表项值、目录项偏移。这些打印在最终版本里可以保留也可以放进编译开关后面。重点是文件系统出现问题往往不会像保护模式那样直接触发异常而是“数据不对但程序还在跑”所以调试输出显得尤为重要。9. 常见问题与排查方法实模式文件系统实现中以下问题出现频率非常高。问题现象可能原因排查方式解决方案读取引导扇区后 BPB 字段全是 0扇区读取地址错误或 ES:BX 设置错误打印 BPB 字节对比镜像内容检查ES:BX是否指向真实缓冲区FAT 表项读取结果异常12 位表项合并时没有按奇偶性移位打印 FAT 表项原始 16 位数据按cluster 1分别处理文件名找不到文件名不是 8.3 补齐格式打印目录项原始 32 字节比较时把空格补齐读取的文件数据错位数据区起始 LBA 计算错误打印数据区起始 LBA手动计算确认“起始簇减 2”的偏移逻辑文件内容最后多出无效字节没有按文件大小截断读取时用file_size做边界控制copy_len与剩余空间取较小值长文件名目录项导致文件找不到跳过逻辑错误把普通文件也跳过了打印attr值仅跳过attr 0x0F越界读取根目录遍历次数超过root_entry_count加循环次数上限按root_entry_count限制QEMU 启动后没有输出引导扇区本身没有加载内核检查是否使用convnotrunc确认镜像写入方式换镜像后解析失败BPB 字段不是固定值代码写死了打印全部 BPB 字段不用常量全部从 BPB 读取屏幕输出乱码段寄存器设置不正确检查打印函数使用的 DS统一设置 DS 后再打印读取扇区时 CF 一直为 1驱动器号不对或 CHS 计算错误打印int 13h返回错误码检查软件盘用DL0x00检查扇区号从 1 开始在这些问题里最值得单独提一下的是 FAT 表项解析。它不常见但很容易被疏忽因为 FAT12 表项是 12 位对齐的读取时不能简单地按两个字节读出来就用必须按照上面那套奇偶判断逻辑去算。10. 最佳实践与调试建议把我的经验浓缩成几条可以直接执行的建议。10.1 先用固定镜像验证再写内核不要一上来就写全部代码。可以先做镜像用xxd查看引导扇区和根目录的原始字节确认 FAT12 布局清晰了再动手写解析代码。这样调试时你能判断“是代码错了还是镜像格式不对”。xxd -l 512 -s 0 os.img xxd -l 512 -s 19 os.img # 根目录起始扇区的十六进制内容10.2 缓冲区集中管理实模式内核里缓冲区建议集中定义避免散落各处导致后续想扩大缓冲区时改了一堆代码。static uint8_t sector_buffer[512]; static uint8_t fat_cache[512];10.3 打印调试信息时带上函数名和状态码最简单的形式[fs_init] bps512 spc1 fat_sectors9 root_entries224 [fs_find] nameTEST TXT cluster3 [fs_read] cluster3 - next0x0FFF size23这种输出在真实环境下排查问题非常好用比一个裸的错误码有用得多。10.4 版本管理文件系统实现涉及镜像、汇编代码、C 代码、构建脚本多个文件。建议每次修改后把镜像也一并纳入版本控制或者至少生成一个固定版本的可复现产物。10.5 关于许可与合规如果你参考了现有开源 FAT 驱动或他人内核项目请保留原始版权声明并注意许可证兼容性。教学项目不打算开源也要保留参考出处避免后续发布时出现合规问题。11. 总结与下一步文件系统是自制内核从“玩具”走向“可运行系统”的关键门槛。在实模式下实现 FAT12最大的收获不是记住 FAT 表项怎么解析而是理解磁盘数据到底是怎么被组织、定位和读取的。这个认知在后续学习保护模式、虚拟文件系统、块设备驱动时都会反复用到。这篇内容建议先按“读扇区 - 读 BPB - 遍历根目录 - 读 FAT 链”的路线跑通一次。不用追求功能齐全先把TEST.TXT的内容打印到屏幕上就已经完成了一次完整的文件系统实现闭环。最容易踩的坑还是三个CHS 扇区号从 1 开始、FAT12 表项 12 位字节序处理、数据区起始 LBA 计算公式。这三个问题一旦解决整个文件系统实现基本就稳了。下一步可以继续扩展的方向包括支持长文件名、支持子目录遍历、增加文件写入功能、把文件系统结果对接到保护模式的 VFS 层或者在 OS 内核中加入简单的 ELF 加载器让内核直接从 FAT12 镜像中加载并执行用户程序。到了那一步你的内核就已经具备一个真正可用的“软件生态入口”了。
返回列表