模拟磁盘文件系统实战:用Python实现块分配、inode与目录项
简介:面向操作系统课程设计的模拟磁盘文件系统项目资源,适合正在完成存储管理类课设的本科生参考。项目完整覆盖文件分配表、目录管理、磁盘调度、文件读写与错误恢复等核心模块,并通过图形界面直观呈现交互效果,便于理解文件系统在磁盘上的组织方式与操作系统的资源管理思路。压缩包共48个文件,以Java源码和已编译类为主,另有界面布局文件、样式表、截图、Word/PDF文档及演示视频,包体仅3.9MB,下载与迁移都很方便。文档部分涵盖课程设计报告、指导书和选题注意事项,视频展示软件运行效果,已有1123人浏览学习过这份资源。对课设答辩、期末复习或希望深入理解磁盘文件系统内部机制的读者,这套资料能提供可运行的代码框架与完整的设计分析路径,有较高参考价值。
1. 模拟磁盘文件系统:课设为什么都拿它“开刀”
操作系统课设里,模拟磁盘文件系统实现是少数“做完能当场演示、答辩能被反复追问”的题目。它不要求你改 Linux 内核,而是拿一个普通文件当硬盘,把磁盘块怎么分配、目录怎么索引、文件怎么读写的全过程写出来。很多人在这一步卡住,不是因为命令记不住,而是搞不清超级块、位图、inode 和目录文件之间的关系。下面用一套 Python 结构讲清楚从格式化到挂载、建目录、写文件、删文件的做法,适合操作系统慕课版学到一半需要交大作业、或者想拿高分把文件系统原理吃透的同学。
2. 用一块普通文件当硬盘:模拟磁盘文件系统的块布局与数据结构
2.1 为什么要用文件模拟磁盘:不是偷懒,是为了能随时“开盖检查”
如果你做过真实驱动开发就清楚,直接对磁盘控制器发指令的课程设计,在普通笔记本上几乎没法验收。常见做法是抽象出一层“虚拟磁盘”:在一个宿主文件(比如vdisk.img)里定义块号,所有逻辑都通过seek到指定偏移量来读写。这样做的第一个好处是安全,就算代码把位图写飞了,也只是毁掉一个文件,不会把电脑上的重要资料清空;第二个好处是可观测,你随时可以把vdisk.img的某一段用十六进制编辑器查看,玄学问题立刻变成白盒问题。
这一步的设计核心是确定两个数字:块大小和块数。块大小我一般选 1024 字节,也就是 1KB。这个值接近很多简化文件系统的常见选择,目录项、inode 和位图都能整块对齐,演示算法时也好解释。块数选多大要看你的换算关系:虚拟磁盘总容量 = 块大小 × 块数。如果做 4MB 的磁盘,块数就是 4096,位图只需要 4096 位也就是 512 字节,恰好一个块放得下;inode 表如果 32 字节一个,一个块能放 32 个 inode,32 个块就能放 1024 个 inode。这个容量对课设的几十个文件绰绰有余。
创建空磁盘的代码非常直接:
# make_image.py import os BLOCK_SIZE = 1024 BLOCK_NUM = 4096 DISK_SIZE = BLOCK_SIZE * BLOCK_NUM # 4MB def create_image(path: str) -> None: if os.path.exists(path): os.remove(path) # 强制重建,避免旧内容干扰 with open(path, "wb") as fp: fp.write(b"\x00" * DISK_SIZE)这里b"\x00" * DISK_SIZE是一次性写入 4MB 的零字节。没有用循环写单个块,是因为 Python 对大字节串的连续写入会直接交给底层write,效率高很多。后续格式化时,会把这些零字节区域按结构切分;如果哪一段在你格式化后还是全 0,基本可以断定没写到位。
这一段的参数值得记一下:BLOCK_SIZE=1024和BLOCK_NUM=4096是整篇代码的“宪法”,所有偏移量都由它们推导。不要图省事写死偏移量 2000、3000 这种数字,否则后面加一个命令就要重构。常见做法是写一个block_off(block_no)辅助函数,所有读写都通过它换算。
2.2 四类磁盘结构:超级块、位图、inode 表和目录项
有了磁盘文件,接下来要回答“文件系统怎么知道自己有没有空间、某个文件在哪”。我不建议照搬 ext2 的完整结构,课设的目标是能讲清楚,不是和真实系统比细节。最常见的简化布局是四段式:
| 区域 | 块号范围 | 职责 |
|---|---|---|
| 超级块 | block 0 | 魔数、块大小、块总数、inode 数、空闲块数 |
| 位图 | block 1 | 每一位对应一个物理块,0 空闲、1 已用 |
| inode 表 | block 2 ~ block 33 | 每个文件一项,记录类型、大小、块指针 |
| 数据区 | block 34 ~ block 4095 | 存放文件内容、目录文件、间接块 |
超级块是整个文件系统的“黑匣子”入口。它第一个字段必须是魔数。魔数的作用是让挂载程序知道,这个文件确实是本文件系统格式,而不是随便一个二进制文件。格式化时写入MAGIC = b"FSIM",挂载时先读前 4 个字节,不匹配就拒绝。这个习惯很值钱,真实文件系统里同样有 magic,比如 ext 系列的超块有s_magic。
位图的定位是“零号苦力”。它每个位表示一个块是否被占用。由于我们的虚拟磁盘是 4096 块,位图只要 512 字节,block 1 的一半都用不到,剩下部分保持 0。位图怎么更新最容易犯错:块 0 到块 33 是系统区,格式化时必须把这 34 个位全部置 1;数据区从 34 开始才有资格分配。很多初版程序把所有位清 0,结果后来分配数据块时把 inode 表覆盖了,这是第一节课设翻车点。
inode 表里每一项是一个固定大小的结构。为了对齐,inode 大小我固定为 32 字节:4 字节类型、4 字节大小、4 个直接块指针共 16 字节、1 个一级间接块指针占 4 字节,再加 4 字节保留字段。每个 inode 块能放 32 个 inode,inode 区共 32 个块,所以最多 1024 个文件。看到这里你应该明白,inode 号本质是“第几个 inode 结构”,不是文件内容的位置。文件内容位置在 inode 的块指针里。
目录项则是另外一层设计:目录本身也是一个文件,有自己的 inode;目录文件的数据区存放的是一串目录项。每个目录项 32 字节,其中 4 字节是 inode 号,28 字节是文件名(UTF-8 编码后截断,中文名一般不超过 9 个汉字)。目录项的删除不要求复用空间,把 inode 号置 0 就算“空槽”;查找时跳过 inode 号是 0 的项。这样删除和创建都不需要移动后续数据。
2.3 先用 Python 把磁盘结构和常量固定下来
代码不是从功能开始的,而是先定义结构和打包格式。Python 的struct模块是最适合做二进制协议的工具。每个对象提供一个pack和一个unpack方法,写入磁盘前打包,读出来再解包。这里唯一要强制的参数是字节序:统一用<(little-endian),避免 x86 机器上显示“反过来的数字”。
# layout.py import struct BLOCK_SIZE = 1024 BLOCK_NUM = 4096 INODE_NUM = 1024 INODE_TABLE_START = 2 DATA_START = 34 # inode 表共 32 个块,占用 block 2..33 MAGIC = b"FSIM" SUPER_FORMAT = "<4sIIII" # magic, block_size, block_num, inode_num, free_count SUPER_SIZE = struct.calcsize(SUPER_FORMAT) # 20 字节 INODE_FORMAT = "<II6I" # type, size, d0~d3, indirect, reserved INODE_SIZE = struct.calcsize(INODE_FORMAT) # 32 字节 DIRENT_FORMAT = "<I28s" # inode 号 + 文件名 DIRENT_SIZE = struct.calcsize(DIRENT_FORMAT) # 32 字节 def block_off(block_no: int) -> int: return block_no * BLOCK_SIZE def load_inode(fp, ino: int) -> tuple: fp.seek(block_off(INODE_TABLE_START) + ino * INODE_SIZE) return struct.unpack(INODE_FORMAT, fp.read(INODE_SIZE)) def save_inode(fp, ino: int, inode: tuple) -> None: fp.seek(block_off(INODE_TABLE_START) + ino * INODE_SIZE) fp.write(struct.pack(INODE_FORMAT, *inode))这段代码里真正需要注意的不是结构体内容,而是“所有偏移都由block_off推导”。load_inode计算的是 inode 表首块偏移加ino * INODE_SIZE,如果你漏掉INODE_TABLE_START * BLOCK_SIZE,就会把 inode 0 写到 block 0 附近,超级块直接被覆盖。这里的struct.calcsize是运行时计算,不用手算 20 和 32,避免因为格式字符串写错而留下隐患。
另外,类型字段我约定 0 表示空闲 inode,1 表示普通文件,2 表示目录。后续所有命令都按这个约定判断。如果你想把文件系统做得更像真实系统,还可以在这个基础上扩展权限位、时间戳,但课设阶段先把四类结构跑通更重要。
3. 格式化与挂载:让一张空白磁盘变成可用文件系统的第一关
3.1 format 命令:写魔数、初始化位图、创建根目录
格式化要完成四件事:重建超级块、清空位图并把系统区标记为占用、把 inode 0 初始化为根目录、把目录数据清空。注意顺序,如果先写 inode 后写超级块,中途断电可能留下一个“有 inode 却不知道块大小”的半成品。在课设里,format 是可以反复执行的,所以最简单的实现就是“删了重来”:把磁盘文件重新创建,然后从头写。
下面这段是格式化核心:
# ops.py import os, struct from layout import * def init_bitmap(fp): bitmap = bytearray(BLOCK_SIZE) for block_no in range(DATA_START): # 系统区全部占用 byte_no, bit_no = divmod(block_no, 8) bitmap[byte_no] |= (1 << bit_no) fp.seek(block_off(1)) fp.write(bitmap) def format_disk(path): if os.path.exists(path): os.remove(path) with open(path, "wb") as fp: fp.write(b"\x00" * (BLOCK_SIZE * BLOCK_NUM)) # 重建空磁盘 free_blocks = BLOCK_NUM - DATA_START with open(path, "r+b") as fp: fp.seek(block_off(0)) fp.write(struct.pack(SUPER_FORMAT, MAGIC, BLOCK_SIZE, BLOCK_NUM, INODE_NUM, free_blocks)) init_bitmap(fp) root_inode = (2, 0, 0, 0, 0, 0, 0, 0) save_inode(fp, 0, root_inode) fp.flush()init_bitmap里的range(DATA_START)只标记 block 0 到 block 33。很多同学觉得系统区也要算“空闲”的,于是把所有位清零,后面分配数据块时一个不留神就把 inode 表分配出去。其实位图的作用是供“分配器”查询,所以系统区必须从一开始就标记为占用,空闲块数量也只算数据区,即free_blocks = BLOCK_NUM - DATA_START = 4062。
root_inode是 8 个数字,对应<II6I的 8 个字段:类型 2、大小 0、4 个直接块指针都是 0、间接块指针 0、保留字段 0。根目录一开始不预分配数据块,等第一次mkdir或touch时再分配。这样格式化逻辑更干净,也方便你验证“目录也是文件”。
3.2 mount 命令:校验魔数并把位图、inode 表加载到内存
挂载只需要打开文件、读超级块、校验、加载位图。很多课设把位图每次现读现写,这样省内存但很慢,而且读错偏移时难排查。我一般习惯在内存里保留一个bytearray,所有分配收回操作先改内存,需要落盘时再一次性写回位图块。
class FS: def __init__(self, fp): self.fp = fp self.super = None self.bitmap = bytearray(BLOCK_SIZE) def mount(path): fp = open(path, "r+b") fp.seek(block_off(0)) data = fp.read(SUPER_SIZE) magic, block_size, block_num, inode_num, free_count = ( struct.unpack(SUPER_FORMAT, data)) if magic != MAGIC: fp.close() raise ValueError("不是本文件系统格式,请先执行 format 命令") io = FS(fp) io.super = (block_size, block_num, inode_num, free_count) fp.seek(block_off(1)) io.bitmap = bytearray(fp.read(BLOCK_SIZE)) return io挂载打开模式用r+b是关键,很多人用rb读,后面写数据块时报io.UnsupportedOperation: not writable。这里的校验有两层:第一层检查magic == MAGIC,第二层在需要时检查block_size == BLOCK_SIZE。如果块大小不一致,说明磁盘文件被改动过,或者打包时用了不同常量。
位图只读一个块就够了。因为 4096 位正好 512 字节,bytearray(fp.read(BLOCK_SIZE))会把 1024 字节整块都读进来,后半段本来就是 0,不影响统计。挂载后不要关闭这个文件句柄,整个会话共用它;每个命令执行完可以fp.flush(),这样直接复制磁盘文件到另一台机器也能正常挂载。
3.3 挂载失败的三种表现和调试顺序
挂载失败的信息量很大,我总结三条血泪经验。第一种:魔数不对。先不要改代码,用十六进制查看 block 0 前 16 字节,看看是不是有 ASCII 乱码。如果显示全是 0,说明 format 没真正写进去,检查是不是文件句柄打开时被 close 了,或者写入后没 flush。
第二种:魔数对,但空闲块数是 0。这不是满盘,很可能是位图把系统区之外也置 1 了。格式化时bytearray(BLOCK_SIZE)初始化就是 0,只有循环range(DATA_START)置位,照理不会出现这种情况;真出现就打印位图里统计DATA_START之后有多少个 1。
第三种:inode 0 类型不是目录。这是 inode 表偏移算错了,ino * INODE_SIZE前面少了INODE_TABLE_START * BLOCK_SIZE,导致 root inode 写到了 inode 表前面的位置。调试时我习惯在 mount 之后加一条show命令,打印超级块字段、位图统计、根目录 inode 内容。这条命令在答辩时也很好用,能直接证明“文件系统已经挂载成功”。
4. 把读写目录和文件跑通:数据块分配、inode 链和目录项操作
4.1 目录项查找:文件名到 inode 号的“地址翻译”
文件系统的路径解析本质就是把目录项数据一块一块读出来,拿文件名逐项比对。实现时不要用递归,先做单层函数lookup(ino, name),参数 ino 是某个目录文件的 inode 号,返回值是子文件 inode 号。
def read_block(fp, block_no): fp.seek(block_off(block_no)) return fp.read(BLOCK_SIZE) def read_dir_entries(fs, dir_ino): dir_inode = load_inode(fs.fp, dir_ino) entries = [] for direct in dir_inode[2:6]: if direct == 0: continue data = read_block(fs.fp, direct) for off in range(0, BLOCK_SIZE, DIRENT_SIZE): ino, name_raw = struct.unpack( DIRENT_FORMAT, data[off:off + DIRENT_SIZE]) name = name_raw.rstrip(b"\x00").decode("utf-8", "replace") if ino != 0: entries.append((ino, name)) return entries def lookup(fs, dir_ino, name): for ino, ent_name in read_dir_entries(fs, dir_ino): if ent_name == name: return ino return -1注意,load_inode返回的是 tuple,索引[2:6]对应 d0~d3,因为 tuple 顺序是(type, size, d0, d1, d2, d3, indirect, reserved)。如果你觉得下标太隐晦,可以把 inode 解析成一个简单类,用inode.direct_blocks这样的属性,答辩时更好讲。
目录项名字用 28 字节定长保存,读取后必须rstrip(b"\x00"),否则比较"a.txt" == "a.txt\x00\x00..."永远为 False。这个坑几乎人人会踩一次。如果你在 Windows 上开发的,还要注意文件名大小写,简单文件系统默认区分大小写;README和readme会被当成两个文件,答辩前想清楚这个设定。
4.2 创建与写入:分配 inode 和数据块后,如何让目录项落盘
创建文件需要三步:找一个空闲 inode,写入 inode 信息;分配数据块并写入内容;在父目录文件里添加一个目录项。顺序不能反,先分配 inode 再写目录项,否则目录项指向一个空的 inode,挂载扫描时会出现“悬空项”。
分配的核心逻辑:
def alloc_block(fs): for byte_idx, byte_val in enumerate(fs.bitmap): for bit in range(8): if not (byte_val & (1 << bit)): block_no = byte_idx * 8 + bit fs.bitmap[byte_idx] |= (1 << bit) return block_no return -1 def alloc_inode(fs): for ino in range(1, INODE_NUM): tp, size, *_ = load_inode(fs.fp, ino) if tp == 0: return ino return -1alloc_block是最容易写错的函数。字节遍历顺序是低位在前,所以第 0 块对应 byte0 的 bit0。格式化时已经将系统区置 1,这里从数据区之后的块开始找。alloc_inode从 1 开始,因为 0 号是根目录,不能被覆盖。找到空闲 inode 后要把文件类型和大小先写入,再分配数据块。
写文件时还需要处理“追加”和“覆盖”的差别。课设里write最简单的语义是覆盖:先释放旧块,再分配新块。释放旧块意味着把 inode 里所有已用的直接块、间接块都遍历出来,把位图对应位置 0。如果你跳过释放直接复用write,删除文件时可用空间会莫名减少。
创建目录其实和创建文件几乎一样,区别在于 inode 类型写 2,并且要初始化目录文件的数据块。我第一次实现时只把目录作为“特殊标记”,没有给目录分配数据块,结果ls永远遍历不到次级目录项,后来才意识到目录项必须存在某个文件的数据块里。
4.3 读取与删除:读回数据块链,并把位图块收回
读取文件的过程是 inode 索引的逆过程:从 inode 拿到直接块指针,读对应块;如果文件大小超过 4 个直接块能容纳的范围,再去间接块里读块号。简化版代码:
def read_file(fs, ino): tp, size, d0, d1, d2, d3, indirect, _ = load_inode(fs.fp, ino) blocks = [b for b in (d0, d1, d2, d3) if b] if indirect: fp = fs.fp fp.seek(block_off(indirect)) raw = fp.read(BLOCK_SIZE) for i in range(BLOCK_SIZE // 4): b = struct.unpack_from("<I", raw, i * 4)[0] if b: blocks.append(b) data = bytearray() for b in blocks: fp = fs.fp fp.seek(block_off(b)) data += fp.read(BLOCK_SIZE) return bytes(data[:size])这里从间接块读块号时用了unpack_from,而不是先struct.unpack("<256I", raw)再循环,后者也能用,但前者更直观,而且不会因为块数不满导致 unpack 长度不匹配。直接块只有 4 个,单文件最大约 260KB;如果希望文件更大,把 inode 格式改成 12 个直接块的版本,但所有load/save和read_dir_entries的下标都要同步改。
删除文件需要三步:把 inode 的块指针全部清 0;遍历这些块在位图中置 0;在父目录项里把 inode 号写 0。顺序上建议先写目录项,再释放 inode,避免别人通过目录项还能访问到正在释放的文件。释放间接块本身在前,间接块里的数据块在后,顺序无所谓,只要最后位图一致。
删除时最容易被忽略的是“目录项空洞”。如果删掉一个目录项,不能把后面的项往前移动,因为目录文件不是数组,而是定长记录流;往前移动会破坏正在使用的 inode 号索引。正确做法是把这个 32 字节全部写 0,后续read_dir_entries会跳过。
5. 操作系统课设避坑:模拟磁盘文件系统最常见 5 个翻车点
5.1 格式化后重启程序,超级块读出来是 0
现象:执行 format 命令时打印一切正常,关闭程序重开,mount 报“不是本文件系统格式”。用十六进制工具看vdisk.img的前 16 字节,全是 0。
原因:format_disk里把磁盘文件删掉重建后,写入超级块的文件对象没有 flush,进程退出时缓冲未落盘;或者写入后没有把文件指针归位,后续又把其他区域读到 super 位置。
解决:在format_disk的with open(...)块结束前调用fp.flush()确认落盘;调试时在fp.seek(block_off(0))后立刻读回校验。推荐每次写关键区域后调一次flush(),代价很小,换来的是程序在 IDE 里直接中断重启也不会留下坏盘。
5.2 文件超过一个块后,后半段读出来是错位
现象:新建一个 3KB 文件,前 1KB 正确,后面内容和另一个文件的碎片重叠,或者全 0。
原因:写入时只更新了 inode 的第一个直接块指针,第二个和第三个指针还是 0;读文件时遇到 0 就跳过,于是数据对不上。另一个间接原因是位图分配本身是对的,但分配第二个块时从 block 1 开始找,把 inode 表区域当数据块分了出去。
解决:写文件时先调用alloc_block拿到的块号,校验它必须大于等于DATA_START;再把指针写进 inode 的 d1、d2、d3。调试时打印每次分配到的块号和位图统计,连续分配几次,看看块号是否在数据区范围内且没有落在系统区。
5.3 删除文件后,磁盘空闲空间没有增加
现象:删了一个大文件后,再创建同等大小的文件,提示没有空闲块或直接报分配失败;重新格式化后空间才回来。
原因:删除函数只是把父目录项里 inode 号改为 0,没有把 inode 里的块指针清 0,位图中对应位也没有释放。还有可能是删了目录项但没有释放文件 inode 本身,导致 inode 泄漏。
解决:删除时先从 inode 读出全部块号,逐个把位图位置 0;再把 inode 第 0 个字段(类型)置 0;最后写目录项。顺序上先释放块再释放 inode 更稳妥。释放完成后打印一遍位图统计,和文件删除前的统计对比,空闲块数应该正好增加文件所占块数。
5.4 目录嵌套两层后,ls 看不到子目录内容
现象:在根目录建dir1,在dir1里建dir2,回到根目录cd dir1后ls,看到dir2是空的,但再ls dir2又能看到内容。
原因:目录项查找时用的是“目录文件在哪个 inode 号”的索引,但read_dir_entries读目录数据块时把目录文件的直接块指针和根目录的混用了;更常见的是创建子目录时,给子目录分配了数据块,但 inode 的 type 字段写的是文件类型,导致读取时按文件内容跳过目录项。
解决:把“目录也是文件”贯彻到底,读取目录一律通过子目录自己的 inode 号,避免在路径解析里传父目录 inode。创建目录时 type 写成 2,大小写成 0,首次写入子目录项时分配一个数据块并更新子目录 inode 的 d0。ls一个目录时,先load_inode(dir),再读它的直接块,而不是读当前工作目录的块。
5.5 内存里明明正常,重启进程后报“磁盘结构损坏”
现象:mount 后连续创建文件、删除、再创建,同一会话内都正常;进程退出重开,再读某个文件报struct.error或目录项乱码。
原因:你用的是内存位图,修改位图后没有写回磁盘,或者写回时机只在close时才做,而程序异常退出没有触发。真实文件系统把位图视为元数据,不是内存临时变量。
解决:每次alloc_block、free_block之后立即把位图相关字节写回 block 1;inode 表同理,每次save_inode后马上flush()。不要依赖进程正常退出。另外,所有结构统一用<I打包,关闭程序前把激活的目录项打印一遍,能提前发现“半透明”的损坏。
6. 让课设拿得出手:三个加分设计和一组最少验收脚本
6.1 加一个 REPL 命令交互器,演示才好看
课设验收时,比起脚本一次性跑完,老师更喜欢你手动输入命令。一个 20 行的 REPL 就能把磁盘变成 shell:循环input("fs> "),把输入按空格 split,第一个单词当命令,后面当参数。命令表只需要format、mount、ls、mkdir、write、read、rm、exit。每个命令执行后都调一次fp.flush()。这样做还有一个额外好处,每敲一条命令都会触发一次落盘,等于在做耐久性测试,能尽早暴露 5.1 和 5.5 的坑。
6.2 加一条 fsck 命令,答辩追问有话说
加分项建议做fsck或stats命令。fsck做两件事:遍历所有 inode,把每个文件占用的数据块标记收集起来;再和位图比对,如果位图标记为已用但在所有 inode 里都找不到对应的块,就报告孤儿块。反过来,如果 inode 里的块号没有在位图中标记为已用,就是缺失分配。这个设计能在答辩时展示你对一致性的理解,也能把调试时“看起来正常”的磁盘变成可证明的正确。
stats命令就简单些:输出总块数、空闲块数、文件数、目录数、位图前几位的十六进制。老师问“你的空闲空间管理怎么看”时,直接指位图和统计输出。
6.3 准备一条命令回放脚本,跑完即验收
验收脚本不需要写单元测试框架,一个普通文本文件就能完成。把命令按行排列,例如format、mkdir dir1、write dir1/a.txt hello、read dir1/a.txt、rm dir1/a.txt。然后让 REPL 支持从文件读输入:判断当前行有没有>前缀,没有就从脚本读,有的就从键盘读。这样你提前准备一份 20 行的验收脚本,跑完把输出贴到报告里就行。
我自己的习惯是:每写一个子系统,先用十六进制工具把对应 block dump 出来,对照设计图检查字段偏移,再继续写下一个。这套方法帮我避免了很多“在内存里好端端、落盘就乱”的灵异问题。希望帮到你。
本文还有配套的精品资源,点击获取