操作系统实验:手写模拟文件系统与磁盘调度算法避坑指南
发布时间:2026/10/8 20:03:51 锦皓数字建站

计院的操作系统实验课前七次基本上都是进程、线程、同步互斥这些老面孔做到第八次画风突然就变了。这轮实验是文件系统和磁盘调度不写业务逻辑不调API而是让你亲手在内存里搭一个“假硬盘”再把文件系统一层一层地种进去。我拿到题目的时候第一反应是“就这”真动手才发现这玩意儿比前面的实验有意思得多也更能逼着你把课本上那些概念串起来。这篇不是实验报告是给你用来“避坑”的实操总结。我会把实验8里最核心的设计思路、关键数据结构和几个容易翻车的细节全部拆开讲代码也给到可以直接抄作业的程度。不管你是在忙着赶ddl还是想认真把这块搞明白这篇都值得你看完。1. 实验三件套需求、原理与准备工作1.1 实验需求到底在说什么计院的实验8题目一般长这样模拟一个简单的文件系统支持文件的创建、打开、读、写、关闭、删除以及目录的创建和删除同时实现几种常见的磁盘调度算法FCFS、SSTF、SCAN并比较性能。听起来朴素但实际上它要求你自己从零定义一套文件在磁盘上怎么存、目录项怎么组织、空闲空间怎么管理、读写的时候磁头怎么移动的完整方案。拆开看这个实验考察的知识点其实涵盖了操作系统课程里文件管理这一章的大部分核心内容文件控制块FCB、目录结构、文件存储空间管理、文件的物理结构再加上设备管理里的磁盘调度。所以它不是单纯的编码题更是一道综合应用题。很多同学在这里挂掉不是因为代码写不出来而是因为“需求都没审清楚”——比如把目录实现了文件却只能存固定大小或者文件能创建了删除之后空间却回收不了。我建议你拿到需求之后先别急着写代码。花半小时在纸上画一张数据流图从用户输入一条指令开始到最终数据落在“磁盘”上中间要经过哪些模块、哪些数据结构。把这个画明白了写代码只是时间问题。别问我怎么知道的——我当时就是没画结果写到一半推翻重建了两轮。1.2 需要用到的原理一场“纸上谈兵”的文件系统实验8要你模拟的文件系统本质上和Linux的ext系列思路一致只是砍掉了大部分工业级细节。核心就三件事文件是什么、目录是什么、空闲空间怎么管。文件在磁盘上的存放方式有连续分配、链接分配、索引分配三种。连续分配就是把文件的数据块连续放在磁盘上读起来最快但会产生大量外部碎片而且在文件增长时很痛苦链接分配用指针把散落的块串起来解决碎片问题但随机访问性能惨不忍睹索引分配则是为每个文件建立一个索引块里面记录这个文件占用了哪些磁盘块号这也是Linux真正采用的方案。这个实验里你绝对应该选索引分配道理我在后面代码部分细说。空闲空间管理常见的有空闲表和空闲块链但实验里最好用的是空闲位图bitmap。每一位对应一个磁盘块1表示占用0表示空闲。位图的好处是查找连续空闲块非常快而且删除文件时只需要把对应的位清0操作简单不易出错。这和我们在课本上学到的“位示图法”是一回事。磁盘调度这块FCFS就是先来先服务磁头顺着请求的顺序一路走SSTF是优先处理离当前磁头最近的请求SCAN则是电梯算法磁头先朝一个方向走走到头再折返途中按顺序响应请求。实验要求你统计这几种算法的磁头移动总道数说白了就是让你直观感受到不同调度策略对寻道时间的影响。有时候SSTF看着最聪明但会导致距离远的请求长时间饥饿而SCAN虽然平均寻道可能不是最优胜在公平稳定。这些结论在你对比实验数据的时候都会体现出来。1.3 开发环境准备别在工具上浪费时间这个实验对语言没有硬性要求但我强烈推荐C语言。C和操作系统底层概念贴合最紧密而且用数组模拟磁盘再自然不过。你要是用Java写还得额外搞一层对象序列化绕远路。当然除非你们实验要求图形界面那就另说。环境方面有Linux直接用Linux没有的话Windows下的VS或CodeBlocks也能跑因为实验本身就是纯软件模拟不涉及真实磁盘操作所以跨平台没问题。唯一要注意的是中文字符串的处理在Windows的终端下容易乱码建议统一用英文输出省心也方便老师看结果。另外强烈建议给代码加上编译参数-Wall开启所有警告。这种大作业级别的代码量一个不经意的类型转换问题可能藏得很深多一个警告提示能帮你省下大量调试时间。编译命令就是gcc -Wall -o filesys main.c你要是用IDE打开警告提示就行。2. 核心设计与关键数据结构把“硬盘”装进内存2.1 整体架构设计思路这个实验在架构上有一个经典的分层思路严格按照“用户接口层 → 文件管理层 → 磁盘模拟层”三层来设计。用户接口层负责解析你输入的 create、open、write 这些指令文件管理层实现目录和文件操作磁盘模拟层只做一件事——管理那个用来模拟磁盘的数组以及处理磁盘调度的逻辑。分层的最大好处是每一层都可以单独测试。比如磁盘模拟层你可以先不写文件管理层直接模拟几个磁盘请求跑调度算法看寻道道数对不对。层与层的边界清晰之后连复杂度的心理负担都小很多。我见过有同学把所有逻辑全塞进一个 main 函数写到后面自己都绕晕了排查了两天的bug最后发现是变量名重用。磁盘模拟层我采用的做法是定义一个结构体数组 disk[128][8] 或者直接展开成一个一维数组每个元素是一个块。数据块的逻辑大小不用太大64字节足够因为模拟而已。整个“磁盘”的大小用宏常量定义方便调整。文件管理层在这个数组之上维护目录、分配和回收块。用户接口层则是简单的字符串匹配和传参比如create file1就调用my_create(file1)。接下来是三个逃不掉的数据结构超级块、文件控制块FCB和目录项。这三个结构一确定实验就完成了一半。2.2 超级块、FCB 与目录项文件系统的“地基”超级块描述整个文件系统的状态说白了就是文件系统的元信息集合。在建文件系统时初始化一次里面至少要包含磁盘总块数、空闲块数、空闲位图数组、根目录起始块号。代码如下#define BLOCK_SIZE 64 #define BLOCK_NUM 1024 typedef struct super_block { int total_blocks; // 磁盘总块数 int free_blocks; // 空闲块数 char bitmap[BLOCK_NUM]; // 空闲位图1表示占用0表示空闲 int root_dir_block; // 根目录起始块号 } super_block;FCB就是每个文件在磁盘上的“身份证”保存了文件名、大小、创建时间、第一个索引块的位置。这里有一个关键取舍FCB是直接存在目录项里还是单独存放、目录项只存文件号的索引前者就是“一级目录”的简化思路实现最简单后者类似UNIX的 inode 理念把文件名和元数据分离更接近真实系统。考虑到实验的核心是看你对文件系统机制的理解我建议简化处理把FCB直接嵌在目录项里这样目录和文件的逻辑都在一处代码量小且容易讲解清楚。你可以在实验报告的“设计亮点”里提一句“本设计将FCB直接嵌入目录项以简化实现同时保留了进一步扩展为索引节点的空间”既诚实又能体现思考。目录项定义如下typedef struct inode { char filename[20]; // 文件名 int size; // 文件大小字节 int index_block; // 索引块号 } inode; typedef struct dir_item { inode file_inode; int valid; // 该目录项是否有效1有效0已删除 } dir_item;目录本质上就是一个特殊文件内容是目录项的数组。根目录占一个磁盘块一个块64字节每个目录项约30字节意味着一个块最多放两个目录项——这对于模拟来说肯定不够。解决方案有两种一是直接把根目录设计为多个块组成的“目录文件”二是把根目录直接放在一个专门的结构体数组里不进磁盘。考虑到简化我选第二种在内存里开一个dir_item root_dir[MAX_FILES]这样不需要额外设计目录的存储结构专注文件机制的模拟。实验报告里说明这是为了聚焦文件操作而做的合理简化完全可以。2.3 磁盘模拟与位图管理如何安全地分配和回收空间磁盘用一个二维数组模拟最直观char disk[BLOCK_NUM][BLOCK_SIZE]disk[块号][块内偏移]就是我们要读写的字节。分配一个块核心操作就是扫描位图数组找到第一个为0的位置把它置1然后返回块号。回收刚好相反把对应的位置0。这里有个特别容易踩的坑分配出去的块在回收时你必须确保不会重复释放。所谓“重复释放”一般发生在删除文件的时候。删除操作先把文件数据块和索引块释放再把目录项标记为无效。如果你在释放时只检查“块号对应位图位是否为1”一旦代码逻辑出错同一个块号被释放两次位图就乱了后续可能把同一个块分配给两个不同文件——这种数据覆盖问题极其隐蔽跑少量测试根本发现不了。我的建议是写一个bitmap_check函数释放前检查、释放后再次检查并且在整个模拟过程中维护一个allocated_count计数器和free_blocks对账。我后来还写了一段断言在校验失败时直接报错终止程序对调试有奇效。另外索引分配中索引块本身也是一个数据块。这个块不存文件内容存的是文件数据块的块号列表。举个具体例子文件“test.txt”占用了3个数据块块号20、31、58那么它的索引块里存的就是20、31、58这三个数字。用一个二维数组index_table[MAX_FILES][MAX_BLOCKS]来做模拟可以简化但体现不出索引块的存在所以我还是建议真正在磁盘上预留一块作为索引块让数据流对应真实的磁盘布局。3. 核心环节代码实现从建盘到文件操作3.1 文件系统初始化给你一面“空盘”初始化函数要做的事情很清晰把位图全部置0标记超级块和根目录相关的块为占用初始化根目录数组然后预置几个文件比如init和test方便后面测试。还有一点容易被忽视就是要“格式化”整个磁盘数组把所有字节清零。不然新盘里可能有上次运行留下的残留数据这在真实磁盘上就是“格式化”的意义所在。void init_fs() { memset(disk, 0, sizeof(disk)); // 格式化磁盘 memset(sb.bitmap, 0, sizeof(sb.bitmap)); sb.total_blocks BLOCK_NUM; sb.free_blocks BLOCK_NUM; // 超级块占用第0块索引块/根目录从第1块开始分配 set_bitmap(0, 1); sb.free_blocks--; for (int i 0; i MAX_FILES; i) { root_dir[i].valid 0; memset(root_dir[i].file_inode.filename, 0, 20); root_dir[i].file_inode.size 0; root_dir[i].file_inode.index_block -1; } // 预置示例文件 create_file(init); create_file(test); }这里有个细节我要专门提醒初始化预置文件时如果 create_file 需要分配索引块sb.free_blocks的初始值和你位图实际占用情况必须一致。我在第一次运行时就因为只设置位图忘了减 free_blocks导致后续所有统计对不上。3.2 文件的创建、写入与读取一条龙实现创建文件的核心逻辑很简单在根目录里找一个空目录项填上文件名、大小0、分配一个索引块就完事了。注意检查同名文件如果已经存在要返回错误。删除文件时则要做反向操作释放索引块把索引块里记录的每一个数据块都释放然后标记目录项失效。写入操作是最能体现“索引分配”优势的地方。用户给出文件名和内容系统读出这个文件的索引块号然后扫描索引块里的数据块列表已有数据块就直接写写满了就新分配数据块继续写。完全不需要移动块也不怕文件越写越大。如果用连续分配文件增长到磁盘边缘就只能失败搬家或者直接“内存爆掉”代码写起来非常痛苦。int write_file(const char* filename, const char* data) { int idx find_file(filename); if (idx -1) return -1; int index_block root_dir[idx].file_inode.index_block; int* block_list (int*)disk[index_block]; // 索引块里存的就是块号数组 int len strlen(data); int total_written 0; while (total_written len) { int block_idx total_written / BLOCK_SIZE; int offset total_written % BLOCK_SIZE; if (block_list[block_idx] -1) { block_list[block_idx] alloc_block(); if (block_list[block_idx] -1) { printf(磁盘已满写入失败\n); return -1; } } int copy (len - total_written BLOCK_SIZE - offset) ? (len - total_written) : (BLOCK_SIZE - offset); memcpy(disk[block_list[block_idx]] offset, data total_written, copy); total_written copy; } root_dir[idx].file_inode.size len; return 0; }这里有个 C 语言特有的坑必须提(int*)disk[index_block]这种把字符数组强转成 int 数组的做法在本实验没问题但如果你把块大小设成不是4的倍数就会遇到对齐问题。最稳妥的方案是把块大小设为64每个索引块能放16个块号这样不会越界也方便计算。读取文件时反向操作根据索引块定位数据块然后复制出来就行代码和写入是对称的我建议你写完之后用同样的文件做一次“写→读→比对”测试确保内容完整。3.3 磁盘调度算法模拟让数据产生说服力文件系统写完之后剩下的就是磁盘调度。模拟思路是先生成一个包含若干随机磁道号的请求序列然后分别用FCFS、SSTF、SCAN三种算法处理统计每种算法的磁头移动总道数。FCFS最简单按请求顺序依次移动磁头移动距离累加。SSTF稍微需要点技巧每次从剩余未处理的请求里找一个离当前磁头最近的移动过去并标记完成。SCAN则要先记录磁头当前移动方向每次从当前方向找最近的请求处理完继续找直到该方向没有请求才转向。注意SCAN在模拟时无论该方向的请求有没有处理完只要触底就必须折返这是它和LOOK算法的区别。你们实验只要求SCAN就按标准电梯算法来。给出一个FCFS的参考实现其余两个在思路上以此类推void fcfs(int* requests, int n, int start) { int total 0; int current start; for (int i 0; i n; i) { total abs(requests[i] - current); current requests[i]; } printf(FCFS总寻道道数: %d\n, total); }测试数据直接用rand()生成范围设在0到199之间模拟200个磁道的硬盘请求数20个左右初始磁头位置设成100。跑完之后用表格记录算法名称、总寻道道数和平均寻道道数这就是你实验报告里最有说服力的对比数据。我记得我当时跑出的结果是FCFS约1800SSTF约600SCAN约800。SSTF在随机请求下的优势明显而如果请求分布偏向一端SCAN可能会反超。想拿高分的话你可以多跑几组数据找到一组能体现SCAN“均衡”的请求序列在报告里专门分析一下。4. 测试、结果分析与问题排查实录4.1 功能测试用例怎么证明你的文件系统是能用的测试用例设计的原则是覆盖所有操作路径包括正常路径和异常路径。我从我的测试记录里摘一份给你参考创建a.txt、b.txt重复创建a.txt应返回“文件已存在”向a.txt写入内容 “hello world”读取并比对创建一个内容恰好超过一个块大小的文件测试跨块写入删除b.txt查看磁盘空闲块是否恢复删除a.txt重新创建一个同名文件验证无残留数据连续创建大量文件直到磁盘满验证“磁盘已满”错误处理运行磁盘调度模块分别用3种算法处理相同请求序列测试时我建议你写一个简单的print_status()函数输出当前空闲块数、文件数、位图摘要。每完成一个测试步骤就调用一下观察数据变化是否符合预期。我现在做这类实验项目只要功能码写完先不急着上界面而是用几个脚本化的测试序列连续跑用输出断言判断成功失败会比自己手敲命令测试高效得多。4.2 四个高发问题每一坑都是真金白银换来的下面这些坑是我自己写实验8过程中真实踩过的也是我帮同学改代码时最常见的四个问题每个都值得你提前避开。问题一重复释放位图块号。表现是你的程序跑一段时间后两个不同文件的内容互相覆盖文件大小突然变得异常大。原因多半是删除文件时索引块里的某个数据块号被释放了多次或者删目录项时错误地多释放了一次索引块。解决方法是在free_block函数里判断如果位图位已经是0就打印告警并中止当前操作同时用计数器校验空闲块数。问题二索引块和数据块大小边界算错。索引块大小64字节每个块号int占4字节能存16个块号。如果你的文件超过16个数据块写入函数就会数组越界后果不可预测——可能是内存篡改也可能直接崩溃。建议你写个MAX_FILE_BLOCKS宏并在写入前检查超过就直接报错别让它崩在莫名其妙的地方。问题三目录项数量设置过小。比如你把MAX_FILES设成10然后循环创建15个文件第11个文件的目录项无处可放。这种错误通常不容易在早期暴露因为前面几个文件都很正常直到你“感觉差不多该满了”才触发。我的建议是一开始就设成64或128不要抠门模拟文件系统的“容量”又不是争取物理内存。问题四磁盘调度SCAN的方向边界。SCAN实现时最容易做错的是“扫描到最外道后折返但漏掉了反向新来的请求”。标准SCAN的折返条件是当前方向无请求或到达磁道边界但很多同学的代码里在实现时把这个条件和LOOK算法混淆了导致寻道总道数统计结果少了一段往返距离。对比标准答案数据就能发现差异所以写完SCAN建议先用一组手算用例去验证。比如磁头在100方向向内请求序列是{5018040160}标准SCAN的移动路径应该是100→40→50→160→180不对——方向向内时应该先到40再到50这里就暴露了方向向内应该找比当前磁头位置小的请求按从小到大顺序处理全部处理完没更小的请求再折返处理更大的。这个逻辑错一步数据全乱建议你画个磁道数轴来辅助验证。4.3 结果分析实验报告里的“加分项”很多同学程序能跑起来但实验报告只贴代码和截图分数自然不理想。其实结果分析才是报告里最能拿分的部分。拿着你跑出来的数据你可以做出这些有深度的分析FCFS的寻道道数受请求序列影响最大分布均匀时表现尚可一旦请求跨度大磁头来回跑的次数就多SSTF显著减少了寻道距离但存在“饿死”风险如果一直有近距离请求到达远距离请求可能永远得不到服务SCAN在性能上通常介于两者之间但胜在响应时间方差小对长距离请求也更公平你可以从“吞吐量”和“公平性”两个维度做个对比表格。我说的吞吐量就是寻道道数少代表吞吐率高公平性就看最远端请求的等待时间是否可接受。能写出这样的分析说明你不仅写了代码还把算法特性想明白了这正是计院老师想看到的。5. 几个值得琢磨的扩展点做完基础的实验8如果还有余力我强烈建议你挑一两个扩展方向做一下。这些扩展不仅能让实验报告更好看还能帮你把文件系统和磁盘调度这一章真正打通。第一个扩展是支持多级目录。现在的根目录是直接放在内存里的数组你完全可以进一步设计成“目录文件”机制让每个目录也是一个文件它的数据块里存放的是下一级目录项。这样整个文件系统从根目录开始就是一个树结构create dir1/file.txt这样的路径解析也能实现。这个扩展其实不复杂核心就是多写一个路径解析函数parse_path。第二个扩展是引入文件打开描述符表。现在所有文件操作都直接基于文件名真实系统中用户先 open 一个文件获得 fd文件描述符之后所有读写都用 fd。你可以模拟一张打开文件表在 open 时检查文件是否存在并把文件加入打开表close 时从表里移除。这个扩展能让你提前体会到“文件句柄”的语义也方便后续实验衔接。第三个扩展是加入写回策略的概念。你可以让文件在写入时直接更新磁盘数据也可以先把修改记录在内存缓存、定期或显式 sync 时统一刷回磁盘。两者各自有性能和数据安全的取舍模拟跑一遍你会对“缓存一致性”这个操作系统里无处不在的问题有非常直观的体会。我个人实际做实验八时的体会是这个实验最麻烦的地方其实在于“你是设计者也是实现者”。平时上理论课觉得文件系统也就那么回事真正自己定义数据结构、自己控制分配回收才感觉到一个个看似平常的概念背后都是精心的权衡。写好一个文件系统就像在真实世界里搭了一套仓库管理系统——仓库存什么、货架放哪里、入库出库走什么路径全部由你一个人说了算。这种“造物主视角”的体验前面那些进程同步的实验给不了。最后分享一个小技巧在你调试删除和空闲块回收时可以刻意把磁盘总块数调小比如从1024块改成32块。这样位图一眼能看完写大一点的文件也会很快触发磁盘满的异常路径所有边界问题都能快速暴露出来。我实验里的几个隐性bug都是靠这个小技巧逼出来的。等你所有功能都调试稳定了再把块数调回1024跑完整测试基本就能一遍通过。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。