简介:这是一份面向计算机、软件工程等专业学生的操作系统课程设计参考资料,聚焦 Linux 环境下二级文件系统的模拟实现,适合正在完成操作系统实验、课程设计或准备相关答辩的学习者。文档围绕课程设计目的、内容要求、数据结构设计、实现原理与关键算法流程展开,重点讲解 Login、Dir、Create、Delete、Open、Close、Read、Write 等命令的模拟实现,并给出主目录、子目录、活动文件等结构组织思路,以及用户登录校验、文件创建删除、目录遍历等核心代码片段,读者可据此理清二级目录磁盘文件系统的整体框架,对照完成编码与调试。资源包共 1 个文件,为 1.35MB 的 PDF 文档,内容完整、便于打印与查阅。目前已有 114 人学习,适合需要快速上手二级文件系统设计、补充实现细节与排错思路的同学参考。
1. 二级文件系统到底是什么:从磁盘块到用户目录树
Linux 下的“二级文件系统”课程设计,本质是在用户态把一块固定大小的文件当作“磁盘”,自己实现超级块、空闲块管理、inode 和两级目录,再对外暴露 create/open/read/write/delete 这组接口。它跟内核里的 ext4、XFS 没有代码关系,但你在这套作业里踩过的坑——位图错乱、目录项越界、inode 与数据块不一致——会以另一种形式在内核文件系统里重现。“二级”说的是目录结构只分两层:主文件目录(MFD)记录每个用户名和它的用户文件目录(UFD)所在块号,UFD 中放该用户的文件目录项。适合刚学完操作系统文件管理、需要把“空闲块、inode、目录检索”这些抽象概念落到真实字节偏移上的人。
2. 二级文件系统的磁盘布局:超级块、位图、inode 怎么排
2.1 先定磁盘布局,再写 create 和 open
如果先动手写 create,写到一半会发现找不到“下一个空闲块在哪”的记录点,回头改结构体,所有偏移常量都得重算。常见做法是把磁盘分成固定几段,每段的起始块和用途写进超级块,后续读写都通过一个disk_read(blk, buf)统一入口。
教学项目里我一般用这套参数:每块 512 字节,映像文件总共 1024 块,也就是 512 KB。布局如下表:
| 区域 | 起始块 | 块数 | 用途 |
|---|---|---|---|
| 超级块 | 0 | 1 | 全局元数据 |
| inode 位图 | 1 | 1 | 512 个 inode 的占用位 |
| 数据块位图 | 2 | 2 | 1024 个数据块的占用位 |
| inode 区 | 4 | 64 | 每个 inode 64 字节,共 512 个 |
| 数据区 | 68 | 956 | 文件数据与目录内容 |
提示:inode 位图和数据块位图不要合成一个结构。位图错乱时分开看,能快速分辨是“目录项挂了”还是“数据块泄漏”。
2.2 inode 与目录项的结构体定义
结构体一旦发布就尽量别改,课程设计后期加字段会连累所有持久化数据的偏移。下面这组字段能覆盖绝大多数验收要求:
/* fs_struct.h */ #define BLOCK_SIZE 512 #define TOTAL_BLOCKS 1024 #define INODE_NUM 512 #define MAX_BLOCKS 12 /* 直接块指针数量 */ typedef struct { int block_size; int total_blocks; int inode_count; int free_blocks; int free_inodes; int inode_bitmap_start; /* 1 */ int data_bitmap_start; /* 2 */ int inode_start; /* 4 */ int data_start; /* 68 */ } superblock_t; typedef struct { int inode_id; /* 0 号保留,1 号为 MFD */ int type; /* 0 空闲 / 1 普通文件 / 2 目录 */ int size; /* 文件字节数 */ int nlink; /* 目录项引用计数 */ int blocks[MAX_BLOCKS]; /* 直接块号,-1 表示未分配 */ long ctime; long mtime; } inode_t; typedef struct { int inode_id; /* 0 表示空目录项 */ char name[28]; /* 文件名,'\0' 结尾 */ } dir_entry_t; /* 恰好 32 字节,每块 16 项 */dir_entry_t 特意做成 32 字节,是为了每块正好放 16 个目录项,遍历时不用做除法带偏移,算术边界少一半。
2.3 MFD 怎么找 UFD,UFD 怎么找文件
二级结构的关键在于把“用户”和“文件”分成两步查。MFD 独占 inode 1,内容是一组 dir_entry,每条记录一个用户名和它的 UFD inode 号。UFD 是 type=2 的目录 inode,内容是该用户的文件目录项。
查找/alice/report.txt的流程:
- 读 inode 1 的数据块,逐个 dir_entry 匹配
name == "alice",拿到 UFD 的 inode 号。 - 用该 inode 号载入 UFD,遍历它的数据块,匹配
name == "report.txt"。 - 返回最终 inode 号,后续 read/write 都基于这个 inode。
因为只有两级,路径解析不需要递归栈。如果老师要求“支持三级目录加分”,把 UFD 里的 dir_entry 再指向 type=2 的 inode 并递归查找即可,但很多学校只验收两级,先用一级 UFD 跑通再扩展。
3. 在 Linux 用户态模拟磁盘:块读写与空闲块分配的最小实现
3.1 用 fseek + fread/fwrite 模拟扇区读写
不建议一上来就 mmap,因为 mmap 在“磁盘映像持久化”里不好控制刷盘时机。fseek + fread/fwrite 更直观:
/* disk.c */ #include <stdio.h> #include <string.h> static FILE *disk_fp = NULL; int disk_open(const char *path) { disk_fp = fopen(path, "r+b"); if (!disk_fp) { /* 首次运行,创建映像并初始化 */ disk_fp = fopen(path, "w+b"); if (!disk_fp) return -1; return 1; /* 1 表示新建,调用方负责格式化 */ } return 0; } int disk_read(int blk, void *buf) { if (blk < 0 || blk >= TOTAL_BLOCKS) return -1; if (fseek(disk_fp, (long)blk * BLOCK_SIZE, SEEK_SET) != 0) return -1; size_t n = fread(buf, 1, BLOCK_SIZE, disk_fp); if (n == 0 && ferror(disk_fp)) return -1; /* 真错误 */ if (n < BLOCK_SIZE) memset((char*)buf + n, 0, BLOCK_SIZE - n); return 0; /* 允许短读,用 0 补齐 */ }disk_read里对短读做零填充,是关键一步:映像刚创建时后面是空洞,fread 可能返回 0;如果直接当错误处理,格式化流程就永远走不下去。disk_open返回 1 是约定,告诉上层“这次是新盘,先调 format”。
3.2 位图法:分配与回收一个块的完整函数
数据块位图占 2 块共 1024 位,每个数据块对应一位。位操作按字节做,比先读整块再拆位简单:
| 参数 | 值 | 说明 |
|---|---|---|
| 映像总大小 | 512 KB | 1024 × 512 字节 |
| 位图每字节覆盖 | 8 位 | 对应 8 个数据块 |
| 数据区起始块 | 68 | 与 superblock.data_start 一致 |
| 空闲判定 | 位=0 | 1 表示已占用 |
/* bitmap.c */ static unsigned char bm_buf[2 * BLOCK_SIZE]; /* 缓存数据块位图 */ int bitmap_init(void) { memset(bm_buf, 0, sizeof(bm_buf)); int reserved = 68; /* 0~67 已被占用 */ for (int i = 0; i < reserved; i++) bm_buf[i / 8] |= (1 << (i % 8)); return disk_write(2, bm_buf) || disk_write(3, bm_buf + BLOCK_SIZE); } int bitmap_alloc_block(void) { for (int i = 68; i < TOTAL_BLOCKS; i++) { if (!(bm_buf[i / 8] & (1 << (i % 8)))) { bm_buf[i / 8] |= (1 << (i % 8)); int bm_blk = 2 + (i / (BLOCK_SIZE * 8)); disk_write(bm_blk, bm_buf + (bm_blk - 2) * BLOCK_SIZE); return i; } } return -1; }位图出错时最常见的表现是“明明删了文件,再创建却报磁盘满”,多半是 alloc 和 free 没成对,或回写位图时块下标算错。调试时用hexdump -C fs.img -s 1024 -n 1024直接看第 2、3 块。
3.3 超级块初始化与一致性检查
格式化时把超级块写进块 0,同时把 inode 位图置零、数据块位图预置保留区:
int fs_format(const char *path) { superblock_t sb = { .block_size = BLOCK_SIZE, .total_blocks = TOTAL_BLOCKS, .inode_count = INODE_NUM, .free_blocks = TOTAL_BLOCKS - 68, .free_inodes = INODE_NUM, .inode_bitmap_start = 1, .data_bitmap_start = 2, .inode_start = 4, .data_start = 68 }; if (disk_write(0, &sb) != 0) return -1; if (bitmap_init() != 0) return -1; inode_t mfd = { .inode_id = 1, .type = 2, .nlink = 1 }; for (int i = 0; i < MAX_BLOCKS; i++) mfd.blocks[i] = -1; return inode_write(1, &mfd); }注意:
free_blocks只减不增是新手常见 bug。删除文件时别忘在回收块的分支里同步sb.free_blocks++,否则磁盘统计会一路飘。
4. 二级文件系统的文件操作全链路:create 到 delete 的实现与参数
4.1 create 与 open:目录项插入与 inode 分配
create 的实质是两步:在目标 UFD 里插入一条 dir_entry,再分配一个 inode 并初始化。顺序不能反——先分配 inode 再插目录项,中间失败会留下悬挂 inode。
int fs_create(const char *user, const char *name, int type) { int ufd_ino = mfd_lookup(user); if (ufd_ino < 0) return -1; /* 用户不存在 */ inode_t ufd; if (inode_read(ufd_ino, &ufd) != 0) return -1; int slot_blk, slot_off; if (ufd_find_slot(&ufd, &slot_blk, &slot_off) != 0) return -1; int new_ino = inode_alloc(); if (new_ino < 0) return -1; inode_t ni = {0}; ni.inode_id = new_ino; ni.type = type; ni.nlink = 1; ni.ctime = ni.mtime = time(NULL); for (int i = 0; i < MAX_BLOCKS; i++) ni.blocks[i] = -1; if (inode_write(new_ino, &ni) != 0) return -1; dir_entry_t de = { .inode_id = new_ino }; strncpy(de.name, name, sizeof(de.name) - 1); if (dir_entry_write(slot_blk, slot_off, &de) != 0) { inode_free(new_ino); /* 失败回滚 */ return -1; } ufd.size += sizeof(dir_entry_t); inode_write(ufd_ino, &ufd); return new_ino; }参数里type决定普通文件还是目录;目录文件的数据块由 create 按需分配,普通文件的数据块由 write 触发。open 与 create 的区别是只查找不分配 inode,返回一个打开文件表下标,后续 read/write 用这个下标索引 offset。open 系统调用背后通常维护一张内存表:
| 字段 | 含义 |
|---|---|
| fd | 返回给用户的文件描述符 |
| inode_id | 指向磁盘上的 inode |
| offset | 当前读写位置 |
| mode | 只读 / 只写 / 读写 |
| ref | 引用计数 |
两个用户同时 open 同一个文件,各自拿到 fd、彼此独立 offset,但共享同一个 inode_id。
4.2 read 与 write:直接块寻址与块边界
read 需要把逻辑偏移映射到具体数据块。因为有 12 个直接块,映射逻辑非常直接:
int fs_read(int ino, char *buf, int len, int offset) { inode_t node; if (inode_read(ino, &node) != 0) return -1; if (offset >= node.size) return 0; if (offset + len > node.size) len = node.size - offset; int done = 0; char blk_buf[BLOCK_SIZE]; while (done < len) { int logic = (offset + done) / BLOCK_SIZE; int inner = (offset + done) % BLOCK_SIZE; if (logic >= MAX_BLOCKS) return done; /* 超出直接块范围 */ int blk = node.blocks[logic]; if (blk < 0) break; /* 读到空洞 */ if (disk_read(blk, blk_buf) != 0) return -1; int chunk = BLOCK_SIZE - inner; if (chunk > len - done) chunk = len - done; memcpy(buf + done, blk_buf + inner, chunk); done += chunk; } return done; }write 的流程分三种情况:逻辑块已分配则读旧块、改区间、回写;未分配则先bitmap_alloc_block,再把块号登记到inode.blocks[logic];跨多个逻辑块时循环跑前面两步。教程里常犯的错是 offset 超过当前 size 时先手动填零再写,其实空洞块不必立即分配,读时返回 0 即可,这样一段“先读文件末尾、再在很后面写一个字节”的场景不会瞬间浪费掉几个块。
提示:write 结束前统一更新
inode.mtime和size = max(size, offset+len),不要在每个循环里都改,一是慢,二是中途回滚麻烦。
4.3 delete 与一致性:回收顺序不能颠倒
delete 要做三件事:删除 UFD 里的 dir_entry、把 inode 标记为空闲、把 inode 占用的数据块归还位图。三者的顺序直接决定崩溃后能否恢复:
- 先清目录项。这样即使后续步骤失败,文件也早已不可见。
- 再遍历
inode.blocks,逐个bitmap_free_block。 - 最后
inode_free,更新 inode 位图和superblock.free_inodes。
如果倒过来先inode_free,一个崩溃点就会同时留下“占用中的数据块”和“空目录项”,需要写额外的一致性修复逻辑才能对齐。把“先摘目录项”写进 delete 函数顶部的注释,下学期重新捡代码时能省一小时。
删除目录时还要检查目录内是否还有非空目录项,有则返回 ENOTEMPTY,不能直接递归清空——这是很多验收必问的点。
5. Linux 课程设计验收:用 hexdump 和 gdb 定位位图错乱与目录项越界
5.1 用 hexdump 看超级块与位图的实际内容
格式化后先验证布局,别完全相信代码里的偏移常量:
hexdump -C fs.img -s 0 -n 512 # 看超级块 hexdump -C fs.img -s 512 -n 512 # 看 inode 位图 hexdump -C fs.img -s 1024 -n 1024 # 看数据块位图超级块前 8 个 int 应该依次是 512、1024、512、956、512、1、2、4。如果 block_size 的位置出现 0,说明结构体对齐在编译时用了两套规则,字段 padding 不一致;解决办法是对四个 .c 文件统一加-fpack-struct,或直接在结构体前加__attribute__((packed))。
数据块位图前 68 位应为 1,其余为 0。hexdump 看到第 0、1 字节是 0xFF、第 2 字节是 0x0F 后归零,说明保留区正好 68 位、处理正确;如果第 4 字节还有非零位,多半是数据区起点算错,把 inode 区末尾多算进了数据区。
5.2 在 create 之后用 gdb 检查 inode 与目录项一致性
比满屏 printf 更高效的做法是在 create 返回前下断点,直接看内存和磁盘:
gcc -g -O0 -o fs fs.c disk.c bitmap.c inode.c dir.c gdb ./fs (gdb) break fs_create (gdb) run (gdb) finish # 让 create 执行到底 (gdb) print new_ino (gdb) call inode_read(new_ino, &ni) (gdb) print ni.type检查三件事:新 inode 的 type 是否为 1、blocks[0]是否 -1(文件还没写数据)、dir_entry 是否真的写进了 UFD 的某个数据块。如果ni.blocks[0]被意外填了值,通常是 inode_alloc 复用了之前没清空的 inode 槽,别忘了在 inode_alloc 里 memset 整个 inode 结构。
5.3 反复出现的三个边界条件
| 现象 | 常见原因 | 快速定位 |
|---|---|---|
| 创建第 17 个文件失败 | UFD 只分了一个数据块,16 项已满 | 在 ufd_find_slot 里加“满则分配新块”分支 |
| read 返回 0 但文件非空 | 读到了blocks[logic]<0的空洞 | 打印 logic 和 inner,确认是否越过了已分配块 |
| 删除后 free_blocks 不减 | delete 忘了superblock.free_blocks++ | grep -n "free_blocks" *.c对照 write 路径 |
最后提一个容易被忽略的细节:目录项里的 name 必须以\0结尾。用 strncpy 后补一行de.name[sizeof(de.name)-1] = '\0',否则 name 长度恰好是 28 时,字符串比较会读进紧邻的 inode_id 字段,表现出来就是“两个不同文件偶尔被判定为同一个”。
本文还有配套的精品资源,点击获取