简介:面向操作系统课程文件系统专题,这是一份完整的二级文件系统实验资源,包含基于QT的图形界面工程与原始控制台源码,解决多用户文件系统设计中的目录结构、文件读写及权限保护等核心问题。资源共25个文件,以cpp、h、ui源码和pro工程文件为主,附带doc/docx实验报告文档,压缩包仅1.64MB,结构清晰便于直接打开工程运行或对照源码学习。已有663人学习下载。代码实现了login、dir、create、delete、open、close、read、write等命令,列目录时输出文件名、物理地址、保护码和文件长度,主目录与子目录均以文件形式存放磁盘,并通过编号映射物理地址。实验报告与源码注释有助于理解文件系统内部机制,适合高校学生、课程设计者及Linux文件系统初学者参考和二次开发。
1. 二级文件系统到底在解决什么问题
如果你只在 Linux 上敲过 ls、cat、mkdir,很难想象这些命令背后的目录树是怎么组织起来的。这个资源给出的答案是:用 C++ 手工搭一个二级文件系统——主目录 MFD 下挂若干用户子目录 UFD,每个用户目录里的目录项记录文件名、物理地址、保护码和长度;文件内容不直接塞进内存,而是按编号落到“磁盘”上的 file0、file1……。实践者会以 login 开头,敲完 dir、create、write 再 logout,在控制台里完整走一遍文件生命周期。
对正在做操作系统课程设计的人、想深入 Linux 文件系统但没有时间读内核源码的工程师、以及准备面试时被问“文件系统的工作原理”的人来说,这份资源比只看 PPT 强在两点:一是逻辑全部落在 C++ 源码里,二是 Qt 界面让过程可视化。拿到它之后,你不需要再补教材里那些空泛的概念,直接拆代码就行。
2. 数据结构与磁盘布局:MFD、UFD 与 FCB 怎么落盘
2.1 主目录 MFD 和用户子目录 UFD 的分工
二级文件系统的“二级”指目录树只有两层:顶层是主目录 MFD,底层是每个用户各自的用户文件目录 UFD。登录时,login 命令先查 MFD,确认用户名和密码后,把当前用户切换到对应的 UFD;之后 dir、create、delete、read、write 操作都只能在这张 UFD 里进行。这样设计的好处是用户之间的文件相互隔离,权限判断点集中在两处:登录时看 MFD,访问文件时看 UFD 的目录项。
注意实验提示里的这句话:“主目录和子目录都以文件的形式存放于磁盘”。也就是说 MFD 和 UFD 不能只在内存里维护一份,必须落盘。常见做法是在模拟磁盘目录下放一个 mfd.dat 保存用户表,每个用户再对应一个 ufd_0.dat、ufd_1.dat……登录时加载当前用户的 UFD,登出或每次修改后写回。要验证是否真的落盘,退出程序再启动,执行 dir,如果文件还在,就说明目录项写回来了。
2.2 目录项字段设计:文件名、物理地址、保护码、长度
源码里文件系统相关的结构体通常集中在 Mainfun.h 中,由主程序 include 后使用。目录项可以理解成简化版 FCB,至少包含用户名、文件标识、文件名、物理地址、保护码、长度和有效标记:
// Mainfun.h 中常见的设计,实际字段以源码为准 #define MAX_NAME 16 #define MAX_FILE 32 struct DirEntry { // UFD 中的一个目录项 char username[MAX_NAME]; // 文件所有者 int fileid; // 文件标识号,从 1 开始递增 char filename[MAX_NAME]; // 文件名,不含路径 int physblock; // 物理地址:存放 file0~fileN 的编号 int protect; // 保护码:0=读写 1=只读 2=只写 3=禁止访问 int length; // 文件长度(字节) bool used; // 是否已分配 };这里有几个设计取舍值得关注。fileid用于标识同一个用户的不同文件,即使两个用户都创建了同名文件,fileid 也不同;physblock不是内存地址,而是模拟磁盘的逻辑块号;protect是权限位,write/delete 时先看它,open 时还要再看打开模式。把目录项写成这种扁平结构,dir 命令执行时只需要遍历一张 UFD 表。
| 字段 | 类型 | 含义 | create 时怎么赋值 |
|---|---|---|---|
| username | char[16] | 属于哪个用户 | 从当前登录用户拷贝 |
| fileid | int | 文件标识号 | 取当前 UFD 最大 fileid + 1 |
| filename | char[16] | 文件名 | 由 create 命令参数写入 |
| physblock | int | 物理地址 | 分配一个空闲块号,对应磁盘文件 fileN |
| protect | int | 保护码 | 默认 0(可读可写),可随后修改 |
| length | int | 文件长度 | 初始 0,write 后累加 |
| used | bool | 槽位是否占用 | create 分配,delete 置 false |
实验要求“列目录时列出文件名、物理地址、保护码和文件长度”,说明这套目录项已经覆盖了你要展示的全部信息。UI 上哪怕不画图形化列表,直接把这些字段按列打印,也是一个合格的实验交付。
2.3 用 file0、file1 模拟磁盘块:物理地址登记策略
“物理地址”在这个实验里不是扇区号,而是逻辑块编号。目录项里的 physblock=2,表示文件内容写到磁盘目录下的 file2 中。空闲块管理可以简单用一个 bool 数组记录哪些编号被占,create 时顺序找最小空闲编号,delete 时把对应编号置为空闲。这样不需要真的实现磁盘驱动,也能让“物理地址”字段有实际意义。
需要考虑的一点是:file0 不要分配给普通用户文件,留作 MFD 或超级块镜像;UFD 的镜像从 file1 开始排。否则登录时盘上还没有统一格式,逻辑会很乱。实际操作中我会单独建一个 disk 子目录,所有模拟磁盘文件都放进去,理论上 MFD、UFD 和数据文件都在这一个目录下,方便查看,也方便一次性清理重来。文件长度也不要等于物理块大小,write 只追加在 length 处,所以磁盘上的物理文件会越写越大,这是正常现象。
3. Qt 界面与原始控制台源码:双实现如何共用一套核心逻辑
3.1 控制台程序的命令解析流程
原始控制台源码并不复杂。main.cpp 里通常是一个 while 循环,每次从 stdin 读一行,用 sscanf 切出命令和参数,再通过一组 strcmp 找到对应处理函数。下面这段是这种分发模型的简化版:
// 控制台版入口:读一行命令,切分出 cmd 和 arg char line[128]; while (fgets(line, sizeof(line), stdin) != nullptr) { line[strcspn(line, "\n")] = 0; // 去掉结尾换行 char cmd[16] = {0}, arg[64] = {0}; sscanf(line, "%15s %63s", cmd, arg); // 限制宽度,防止溢出 if (strcmp(cmd, "login") == 0) doLogin(arg); else if (strcmp(cmd, "dir") == 0) doDir(); else if (strcmp(cmd, "create") == 0) doCreate(arg); else if (strcmp(cmd, "delete") == 0) doDelete(arg); else if (strcmp(cmd, "open") == 0) doOpen(arg); else if (strcmp(cmd, "close") == 0) doClose(arg); else if (strcmp(cmd, "read") == 0) doRead(arg); else if (strcmp(cmd, "write") == 0) doWrite(arg); else if (strcmp(cmd, "logout") == 0) break; else fprintf(stderr, "unknown command: %s\n", cmd); }这套代码的价值在于把命令语义和界面彻底分开。doCreate 只关心文件名是否存在、目录项是否还有空位,不关心输入来自键盘还是文本框。sscanf 的%15s和%63s限制了最大宽度,防止超长输入写坏缓冲区;如果你把源码里的 char 数组换成 std::string,解析部分还需要进一步处理空格和中文文件名。
3.2 Qt 界面的事件驱动与对话框
Qt 工程没有把命令逻辑重写一遍,而是让按钮槽函数直接调用控制台源码里的 doCreate、doDelete 等函数。整个工程的 mainwindow.ui、logindialog.ui、writedialog.ui、readdialog.ui、createfile.ui 分别对应登录、写文件、读文件、创建文件这几个交互场景。
// MainWindow.cpp:创建按钮槽函数,复用控制台的 doCreate void MainWindow::onCreateAction() { QString name = ui->nameEdit->text().trimmed(); if (name.isEmpty()) { ui->consoleEdit->append(tr("create: 文件名不能为空")); return; } // 控制台函数接收 const char*,这里把 QString 转成本地编码 int ret = doCreate(name.toLocal8Bit().constData()); if (ret == 0) ui->consoleEdit->append(tr("create %1 ok").arg(name)); else ui->consoleEdit->append(tr("create failed, ret=%1").arg(ret)); reloadDirTable(); // 刷新界面上的目录表格 }这里的核心是把返回值用起来。doCreate 返回 0 表示成功,负数表示失败,界面只负责根据返回值提示用户。toLocal8Bit 在 Linux 下通常是 UTF-8,在 Windows 下是 GBK,如果你的源码里对中文文件名有编码要求,这里需要统一;否则同一份源码编译出的界面版本可能出现“文件创建成功但 dir 显示乱码”的怪问题。
3.3 在 Qt 工程里保留原始控制台源码的价值
文件包里 main.cpp、Mainfun.cpp 和 Qt 的 main window 代码同时存在,filesystem.pro 把它们编进同一个工程。这不是代码冗余,而是调试成本最低的路径。Qt 界面一旦卡在某条命令上,不需要猜界面状态,直接切到终端跑同样的命令序列,看返回码就能定位问题是命令实现本身,还是界面参数传递错误。
我在自己拆这类工程时习惯先跑控制台版本:login、create、open、write、close、delete 各敲一遍,确认命令层没问题,再打开 Qt 界面复现同样步骤。课程设计里最容易被坑的是 write 命令:Qt 的 QTextEdit 会把换行符保存为 \n,但某些版本的 QPlainTextEdit 在 Windows 上会带回 \r\n,如果 doWrite 里没有对长度做特殊处理,文件长度和 read 出来的内容就会对不上。保留控制台源码后,这个问题 5 分钟内就能定位。
4. 读写保护与文件操作:从 open 到 write 的权限校验
4.1 保护码怎么定义才能兼容控制台和 Qt 两套入口
保护码的取值没有统一标准,课程设计里最常见的是 0~3 四个档位。为避免魔法数字散落在代码里,建议在 Mainfun.h 中定义成宏:
#define PROT_RDWR 0 // 可读可写 #define PROT_READ 1 // 只读 #define PROT_WRITE 2 // 只写 #define PROT_NONE 3 // 禁止访问| 保护码 | 含义 | read | write | delete |
|---|---|---|---|---|
| 0 | 可读可写 | 允许 | 允许 | 允许 |
| 1 | 只读 | 允许 | 拒绝 | 拒绝 |
| 2 | 只写 | 拒绝 | 允许 | 拒绝 |
| 3 | 禁止访问 | 拒绝 | 拒绝 | 拒绝 |
注意 delete 是否受保护码约束,实验题目没有明确要求。我倾向于让 delete 也受保护,避免用户用一个只读文件被自己误删;但如果你希望 delete 只受登录用户身份限制,可以把表格里的 delete 列全部改成允许,两种做法都不会影响课程验收,只要在实验报告里写明即可。
4.2 create、open、write 的调用链
一个文件的完整生命周期是 create -> open -> write/read -> close -> delete。create 只负责分配目录项并创建模拟磁盘文件,此时文件还没有进入活动状态;open 把目录项的信息登记到活动文件表,并记录打开方式;write 再次校验保护码和打开方式,确认后才写入数据:
// Mainfun.cpp 中 doWrite 的核心逻辑 int doWrite(const char* fname, const char* buf, int len) { int idx = findFile(fname); if (idx < 0) return -1; // 文件不存在 // 第一道检查:保护码允许写 if (ufd.dir[idx].protect != PROT_RDWR && ufd.dir[idx].protect != PROT_WRITE) { fprintf(stderr, "write denied: protect=%d\n", ufd.dir[idx].protect); return -2; } // 第二道检查:打开方式允许写 if (oft.items[currentFD].mode != OPEN_WRITE && oft.items[currentFD].mode != OPEN_RDWR) { fprintf(stderr, "write denied: file not opened for write\n"); return -3; } // 从当前 length 处开始追加写 char path[32]; snprintf(path, sizeof(path), "disk/file%d", ufd.dir[idx].physblock); FILE* fp = fopen(path, "r+b"); fseek(fp, ufd.dir[idx].length, SEEK_SET); fwrite(buf, 1, len, fp); ufd.dir[idx].length += len; // 更新元数据 fclose(fp); saveUFD(); // 写回目录项 return len; }这里有两道权限门槛。第一道是保护码,表示这个文件天然允许哪些操作;第二道是打开模式,表示这次会话里进程申请了哪种访问权。即使保护码允许写,如果 open 时只传入 OPEN_READ,write 也要拒绝。真实文件系统里同样是这个逻辑:打开时确认访问权限,读写时检查文件状态。physblock 在目录项里存的是编号,真正读写时拼出disk/fileN路径,这样就把虚拟块号和物理文件对应起来了。
saveUFD 这一步容易被忽略。如果不把更新后的 length 写回磁盘,进程退出后目录项里长度还是旧值,read 要么读不全,要么多读出一段脏数据。我在写这类程序时会专门在 doClose 和 doDelete 的返回值里带上元数据回写状态,方便确认。
4.3 一条可复现的完整操作序列
建议按下面的顺序跑一遍,每一步关注预期的输出:
- login user1:系统加载 user1 的 UFD,提示登录成功。
- create demo.txt:UFD 中新增一个目录项,磁盘上出现 fileN。
- open demo.txt w:活动文件表登记一条记录,模式为写。
- write demo.txt hello:写入 5 个字节,length 变为 5。
- close demo.txt:释放活动文件表条目。
- open demo.txt r:以只读方式打开。
- read demo.txt:读到 hello。
- delete demo.txt:目录项 used 置 false,并把对应物理块回收。
这个序列覆盖了文件系统的核心闭环:create 建元数据,open 建立访问通道,write 改数据,close 释放通道,delete 回收空间。每次改动后都执行 dir,观察文件名、物理地址、保护码和长度四列的变化,能很快定位是目录项还是数据文件出了问题。
5. 实用技巧:用命令函数表把 Qt 槽函数瘦成一层壳
5.1 命令函数表
控制台源码里那串 strcmp 链在界面里很容易膨胀:每个按钮一个槽,槽里再做 if 判断。更好的做法是建一张命令函数表,让 Qt 输入框和按钮都走同一个 dispatch:
// 统一命令函数签名,目录项和活动文件表作为全局状态 typedef int (*CommandHandler)(int argc, char* argv[]); struct CommandEntry { const char* name; CommandHandler handler; }; CommandEntry cmdTable[] = { {"login", cmdLogin}, {"dir", cmdDir}, {"create", cmdCreate}, {"delete", cmdDelete}, {"open", cmdOpen}, {"close", cmdClose}, {"read", cmdRead}, {"write", cmdWrite}, };// MainWindow.cpp:把输入行直接交给命令表执行 int dispatch(const QString& raw) { QStringList parts = raw.split(' ', QString::SkipEmptyParts); if (parts.isEmpty()) return -1; QByteArray bufs[8]; char* argv[8] = { nullptr }; int argc = qMin(parts.size(), 8); for (int i = 0; i < argc; ++i) { bufs[i] = parts[i].toLocal8Bit(); argv[i] = bufs[i].data(); } for (const CommandEntry& e : cmdTable) { if (parts[0].compare(e.name, Qt::CaseInsensitive) == 0) { return e.handler(argc, argv); } } return -2; // unknown command }QByteArray 数组持有每个参数的内存,argv 里的指针指向这些数据,函数执行期间有效。这样做的收益是:界面上加一个命令输入框,回车调用 dispatch;下面的按钮也调用 dispatch,两套入口共享同一份命令解析和错误处理,不会再出现“按钮传了文件名、控制台传了路径”的分叉。
5.2 UI 线程同步调用 vs 工作线程
这个实验的文件操作都是小文件,读写几千字节耗费不到一毫秒,直接在 UI 线程里同步调用没问题。唯一需要留意的场景是 dir 命令,如果磁盘目录下积累了成百上千个 fileN,界面可能卡顿。想稳妥一点,就把 dispatch 放到 QtConcurrent::run 里,结果用信号传回,但课程设计阶段同步调用完全够用,还能避免一批 QObject 生命周期问题。
5.3 验证文件系统是否真的落盘的检查点
最后给你几个自查项:退出程序后重启,login 再 dir 能看到之前的文件;protect 为 1 的文件 write 必须返回失败;read 读出的字节数和 length 一致;delete 后再 create 一个文件,能复用之前的物理块编号。把这些场景写成一个脚本化清单,比对着验收表逐条勾选快得多。我的习惯是在 logout 的槽函数里加一行 qDebug,打印本次会话共回写了几块 UFD 元数据,这样每次退出时就能直观确认目录项有没有真正写到磁盘文件里。
本文还有配套的精品资源,点击获取