简介面向计算机科学专业学生的东北大学操作系统课程设计资料围绕进程管理、内存管理、文件系统与设备管理等核心主题提供可运行、可对照的课程实践样例。压缩包共24个文件约2.11MB以21个C源文件为主体另有头文件、可执行程序及1份docx课程设计报告便于从代码实现与书面总结两个层面理解课设要求。已有281人学习说明其具有一定的参考与复用价值。源码重点呈现文件系统的完整实现链路涵盖文件与目录的创建、打开、读写、复制、重命名和删除以及数据块分配与回收、格式化、登录注销等模块能够帮助读者把抽象的操作系统概念落到具体代码和数据结构上。配套报告可用于梳理设计思路、模块划分与实验结论适合课程设计参考、期末复习、实验答辩准备及二次开发。1. 答辩前夜跑三遍出三个结果的进程调度问题出在哪儿A 同学在答辩前一晚把操作系统课程设计跑了一遍进程调度的输出顺序和白天对不上同一份输入跑三次出来三种甘特图。翻车点不在算法而在他用头插法维护就绪队列、又拿系统时间做了随机种子。这套计科方向的操作系统课设包把进程调度、内存管理、文件系统、同步互斥四块内容塞进一个菜单驱动的程序里make一条命令编译运行后按数字选功能。它适合两类人一类是刚学完操作系统原理、要把作业交上去的另一类是想借课设摸清 PCB、页表、目录项这些数据结构到底怎么在内存里排布的。真正值得拆的不是那几个经典算法而是这些结构体之间怎么互相引用、状态怎么流转。2. 整体骨架四模块菜单驱动的目录结构与编译链路拿到一个课设包我第一件事不是看算法是看它怎么组织文件、怎么编译。因为算法写错了还能改工程结构乱了就没法往上叠功能。这套课设的骨架很朴素每个功能模块一对.c/.h一个main.c负责菜单分发一个公共头文件放全局常量Makefile把所有目标文件链起来。朴素归朴素对课程设计来说这个结构是最不容易出错的。2.1 目录结构和每个文件的职责边界os-course-design/ ├── Makefile ├── include/ │ ├── common.h # 全局常量、状态枚举、通用宏 │ ├── pcb.h # 进程控制块与队列操作声明 │ ├── memory.h # 页表、帧表、置换算法声明 │ ├── fs.h # 目录项、FAT、打开文件表声明 │ └── sync.h # 信号量、银行家算法声明 ├── src/ │ ├── main.c # 菜单循环负责输入解析与模块调度 │ ├── pcb.c # PCB 创建/销毁、就绪队列/阻塞队列维护 │ ├── scheduler.c # FCFS、SJF、RR、优先级四种调度 │ ├── memory.c # 地址转换、FIFO/LRU/OPT 三种置换 │ ├── fs.c # 创建/删除/读写文件、目录遍历 │ └── sync.c # PV 操作模拟、银行家算法安全性检查 └── data/ ├── processes.txt # 进程初始配置 └── access_seq.txt # 页面访问序列这个划分的关键在于include/和src/分离common.h里只放常量和跨模块的类型。很多同学把PAGE_SIZE、MAX_PROC分散写在各自的.c里改一个值要翻五个文件最后内存模块按 1024 算、文件模块按 512 算页内偏移全错。常见做法是这类全局量统一在common.h里用#define定义并在注释里写清单位。data/目录放输入用例这点容易被忽略。把进程配置和页面访问序列外置成文本改用例不用重新编译答辩时现场换一组数据演示也方便。读取时用fscanf按行解析格式固定成进程名 到达时间 服务时间 优先级四列中间用空格分隔。2.2 公共数据结构和编译链路common.h承担的另一件事是把所有枚举和常量集中避免各模块对状态值理解不一致。比如进程状态调度模块认为是 0/1/2同步模块又自定义一套最后switch永远进不了正确分支。集中定义之后所有模块引用同一套符号。编译链路用最基础的Makefile把src/*.c编译成obj/*.o再链接而不是直接gcc *.c -o main。区别在于增量编译改一个文件只重编一个目标文件几百行的项目感受不明显等到文件系统模块写到两千行全量编译的等待就很折磨人了。CC gcc CFLAGS -stdc99 -Wall -Wextra -g -Iinclude SRCS $(wildcard src/*.c) OBJS $(patsubst src/%.c, obj/%.o, $(SRCS)) TARGET osdemo all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ obj/%.o: src/%.c mkdir -p obj $(CC) $(CFLAGS) -c -o $ $ clean: rm -rf obj $(TARGET) .PHONY: all clean这段的关键是-Iinclude把头文件目录加进搜索路径源码里就能写#include pcb.h而不用写相对路径-stdc99是为了让for (int i 0; ...)这种写法合法老版本的默认标准会报错-Wall -Wextra建议一直开着指针类型不匹配、未使用变量这类问题在课设里非常常见早报出来早改。obj/%.o规则里那句mkdir -p obj前面加是为了不把建目录的命令回显到终端输出干净一些。-g保留调试符号gdb ./osdemo之后能直接list到源码行。2.3 菜单驱动的主循环与输入容错main.c的结构是while (1)套switch打印菜单、读一个整数、分发到对应模块输入0退出。这种写法没什么技术含量但输入容错是所有课设演示翻车的重灾区用户输入一个字母scanf(%d)读不到数字缓冲区里留着脏字符后续每次读都失败程序进入死循环疯狂刷菜单。稳妥的写法是用fgets读一整行进缓冲区再用sscanf解析。读失败就清空提示重来读成功但值超范围也提示重来。多写十行代码答辩现场能少一次尴尬。/* 读取一个菜单选项非法输入返回 -1 由调用方处理 */ int read_choice(void) { char line[32]; int val; if (fgets(line, sizeof(line), stdin) NULL) /* EOF 视为退出 */ return 0; if (sscanf(line, %d, val) ! 1) { /* 解析失败 */ printf(输入不是数字请重新选择); return -1; } return val; /* 由调用方判断范围 */ }fgets最多读sizeof(line) - 1个字符保证不会越界sscanf的返回值是成功匹配的项数等于 1 才算拿到了整数。注意fgets会把换行符也读进来这对%d没影响但如果后面要读字符串记得把末尾的\n去掉否则文件名会多一个换行符fopen直接返回NULL——这个坑我在文件系统那章还会提一次。3. 进程管理拆解PCB 状态机、时间片轮转与银行家算法进程管理是这类课设里代码量最大、也最能体现设计水平的一块。很多人写出来的是一个for循环遍历数组算周转时间那不叫模拟叫计算器。真正的模拟得有 PCB、有队列、有状态迁移还得能随时打印当前系统快照。3.1 PCB 结构体与进程状态机PCB 是所有进程相关操作的锚点字段设计得好不好直接决定后面写不写得动。核心字段分三类标识类pid、name、调度类到达时间、服务时间、剩余时间、优先级、统计类开始时间、完成时间、等待时间。再加一个next指针把 PCB 串成链表队列操作就不用挪数据。typedef enum { ST_NEW 0, ST_READY, ST_RUNNING, ST_BLOCKED, ST_TERMINATED } proc_state_t; typedef struct pcb { int pid; /* 进程唯一标识从 1 递增 */ char name[16]; /* 显示名最长 15 字符 */ proc_state_t state; /* 当前状态 */ int priority; /* 数值越小优先级越高 */ int arrive_time; /* 到达时刻 */ int service_time; /* 需要 CPU 的总时长 */ int remain_time; /* 剩余时长RR 每次减 quantum */ int start_time; /* 首次获得 CPU 的时刻 */ int finish_time; /* 完成时刻用于算周转时间 */ struct pcb *next; /* 就绪/阻塞队列的后继指针 */ } pcb_t;remain_time和service_time分开存是为了让同一份 PCB 既能算总周转时间又能在时间片轮转里判断这一轮跑完之后还有没有剩。start_time只在第一次被调度时赋值用-1初始化判一下就能知道是不是首次运行。next指针让 PCB 同时可以挂在就绪队列、阻塞队列上注意不要在多个队列间共享同一个节点而忘了清指针否则打印队列时会顺着野指针跑到天上去段错误的位置跟真正的错误点能差好几百行。状态迁移遵循标准五态模型创建后进就绪队列调度器选中变成运行态时间片用完回到就绪请求 I/O 或等待信号量进阻塞阻塞事件完成回就绪运行结束转终止。代码里每次改state都应该紧跟一次队列的摘除和插入两者写在一起不要分开放在不同函数里不然迟早出现状态是 READY 但人不在就绪队列的幽灵进程。3.2 时间片轮转调度的实现RR 的核心是剩余时间减时间片没跑完就回队尾。用链表实现的话取队头、算一把、插队尾三步搞定。这里最容易翻车的是时间片参数的选取和边界处理时间片大于所有进程的剩余时间时RR 会退化成 FCFS时间片取 1 时上下文切换开销太大模拟里表现为打印的片段极多。/* 时间片轮转ready 为就绪队列头指针quantum 为时间片 */ void rr_schedule(pcb_t *ready, int quantum, int *clock) { pcb_t *cur; int slice; while (ready ! NULL) { cur ready; /* 取队头 */ ready ready-next; /* 出队 */ if (cur-start_time 0) cur-start_time *clock; /* 首次运行记录开始时间 */ slice (cur-remain_time quantum) ? cur-remain_time : quantum; /* 最后一次可能不足一片 */ cur-state ST_RUNNING; printf([%3d - %3d] %s 运行 %d 个单位\n, *clock, *clock slice, cur-name, slice); *clock slice; cur-remain_time - slice; if (cur-remain_time 0) { /* 跑完了转终止 */ cur-state ST_TERMINATED; cur-finish_time *clock; } else { /* 没跑完回队尾 */ cur-state ST_READY; enqueue(ready, cur); } } }quantum从命令行参数或菜单里传进来常用值 1、2、4答辩演示建议用 2输出长度适中。clock用指针传是因为它要在整个调度过程中累加每个进程结束时都得读到最新的全局时刻。slice单独算一步是为了处理最后一个时间片不足的情况——如果直接减quantumremain_time会变成负数后续统计等待时间时算出离谱的值。enqueue是尾插函数把节点追加到链表末尾如果写成头插队列顺序就反了同一组输入每次跑出来的甘特图都不一样这就是开头那个场景的成因。周转时间、带权周转时间、平均等待时间这些指标统一在调度结束后遍历所有 PCB 算一遍不要在调度循环里边跑边打印。分开之后逻辑清晰也方便把结果重定向到文件里做对比。3.3 同步互斥与银行家算法同步这块的课设要求通常是两个用 PV 操作模拟生产者—消费者用银行家算法避免死锁。前者考察信号量的wait/signal语义后者考察安全性检查的搜索过程。银行家算法的核心是试分配—检查安全性—回滚或确认。安全性检查的逻辑是拿当前可用资源available找一个need available的进程假设它跑完并归还资源更新available标记完成循环往复。能全部标记完就是安全状态否则不安全。/* 安全性检查n 进程数m 资源种类数 need[i][j] max[i][j] - alloc[i][j] safe_seq 输出安全序列返回 1 表示安全 */ int is_safe(int n, int m, int avail[], int need[][8], int alloc[][8], int safe_seq[]) { int work[8], finish[8] {0}; int count 0, found, i, j; for (j 0; j m; j) work[j] avail[j]; /* work 是当前可用量副本 */ while (count n) { found 0; for (i 0; i n; i) { if (finish[i]) continue; for (j 0; j m; j) if (need[i][j] work[j]) break; /* 资源不够换下一个 */ if (j m) { /* 这个进程能跑完 */ for (j 0; j m; j) work[j] alloc[i][j]; /* 归还它占用的资源 */ safe_seq[count] i; finish[i] 1; found 1; } } if (!found) return 0; /* 一轮下来没人能跑不安全 */ } return 1; }work数组必须是avail的副本直接改avail会让后续的请求判断全部错乱。finish数组初始化为全 0每轮扫描都从头开始找未完成的进程找到一个就跳出内层j循环并更新资源。found标记这一轮有没有进展没有就说明陷入循环等待判定不安全。这段代码的坑在于只找一条安全序列就返回——有些实现找到第一个可行进程就递归下去回溯写错会导致明明安全的状态被判成不安全正确的做法是用上面这种迭代扫描每轮把所有能跑的进程都标记掉正确性容易保证。生产者—消费者的模拟则要注意empty、full、mutex三个信号量的初始值empty 缓冲区大小full 0mutex 1。PV 的顺序不能反生产者必须先wait(empty)再wait(mutex)如果反过来缓冲区满时生产者占着互斥锁等空位消费者进不来直接死锁——这个坑几乎每届都有人踩。4. 内存与文件系统页面置换、地址转换和 FAT 目录怎么落地内存和文件系统这两块在课设里经常被写成算几个数打印出来代码量和进程调度差一大截。原因多半是没把模拟和计算分清楚。模拟要有真实的页表、帧表、目录项结构操作的是这些结构而不是直接用公式套结果。4.1 页表、帧表与地址转换分页式内存管理需要两张表页表逻辑页号到物理帧号的映射和帧表物理帧的占用情况与访问信息。页表项里的有效位决定这次访问是不是缺页访问位和修改位则给置换算法提供依据。#define PAGE_SIZE 1024 /* 页大小单位字节 */ #define PAGE_NUM 64 /* 逻辑页数 */ #define FRAME_NUM 16 /* 物理帧数 */ typedef struct { int frame_no; /* 映射到的物理帧号-1 表示未装入 */ int valid; /* 有效位1 在内存0 缺页 */ int access_time; /* 最近一次访问时刻LRU 用 */ int dirty; /* 修改位 */ } pte_t; typedef struct { int occupied; /* 是否被占用 */ int page_no; /* 该帧当前装的是哪个逻辑页 */ int pid; /* 所属进程 */ } frame_t;frame_no用-1表示未装入比用 0 更安全因为 0 是合法的帧号。地址转换的流程是逻辑地址除以PAGE_SIZE得页号取模得页内偏移查页表有效则拼出物理地址无效则触发缺页处理调用置换算法选一个帧淘汰更新两张表。这套结构在单进程场景下看不出价值一旦扩展成多进程frame_t里的pid字段就派上用场了——淘汰时要判断这帧属于谁进程结束时要把自己的帧全部释放。很多实现偷懒不写pid最后没法做进程退出时的资源回收内存泄漏的表现是跑几十次之后帧全被占满新进程一律缺页。4.2 FIFO、LRU 与 OPT 三种置换算法三种算法的区别只在选谁淘汰这一步其他流程共用。把它们写成同一个框架下的三个策略函数比复制三遍代码干净得多。/* 返回被淘汰的帧号policy: 0FIFO 1LRU 2OPT */ int pick_victim(pte_t page[], frame_t frame[], int t, int policy, int access_seq[], int seq_len) { int victim 0, i, best; switch (policy) { case 0: /* FIFO先装入的先淘汰 */ for (i 1; i FRAME_NUM; i) if (frame[i].page_no frame[victim].page_no) victim i; break; case 1: /* LRU最久未访问的淘汰 */ best page[frame[0].page_no].access_time; for (i 1; i FRAME_NUM; i) { int at page[frame[i].page_no].access_time; if (at best) { best at; victim i; } } break; case 2: /* OPT往后看最晚被访问的淘汰 */ best -1; for (i 0; i FRAME_NUM; i) { int next seq_len; /* 默认之后不再被访问 */ int j; for (j t 1; j seq_len; j) if (access_seq[j] frame[i].page_no) { next j; break; } if (next best) { best next; victim i; } } break; } return victim; }FIFO 这里用page_no近似装入顺序更严谨的做法是在frame_t里加一个load_time字段避免页号大小和装入顺序无关导致的错误。LRU 依赖每次访问时更新access_time漏更新一次后面全错。OPT 需要完整访问序列做前瞻next初始化为seq_len表示以后不再访问这是它被淘汰的最高优先级内层循环找第一个匹配的位置就break因为只需要最近的将来。三种算法的缺页率对比表可以直接用这套代码跑出来算法特点缺页率表现实现难度FIFO按装入顺序淘汰实现最简单中等可能出现 Belady 异常低LRU按最近访问时间淘汰局部性好通常最低中需维护访问时间OPT淘汰未来最晚访问的页理论最优最低但不可实现高需完整访问序列Belady 异常指 FIFO 在帧数增加时缺页反而变多这个现象用课设里的访问序列跑几组不同FRAME_NUM就能复现答辩时是个不错的加分点。4.3 文件系统的目录项与 FAT 表文件系统模拟通常要求实现创建、删除、读写、列目录。最省事的模型是 FAT目录项记录文件名、起始块、大小FAT 表记录每个块的下一块指针用-1表示链尾用-2表示空闲块。#define BLOCK_SIZE 512 #define MAX_BLOCKS 1024 #define NAME_LEN 12 typedef struct { char name[NAME_LEN]; /* 文件名最长 11 字符 结束符 */ int start_block; /* 起始块号-1 表示空目录项 */ int size; /* 文件大小单位字节 */ int attr; /* 0 普通文件1 目录 */ } dir_entry_t; int fat[MAX_BLOCKS]; /* -2 空闲-1 链尾其他值为下一块号 */ dir_entry_t dir_table[64];写入流程是从 FAT 里找空闲块串成链把数据按BLOCK_SIZE切分写入对应块最后更新目录项的start_block和size。删除时沿链遍历把每块标记回-2。读取时顺着 FAT 走到-1为止每块读BLOCK_SIZE字节。这里有个隐藏的崩溃点start_block和size必须同步更新。如果先改了size而块链没串好读文件时会读到-2标记的空闲块程序要么读出一堆垃圾要么在 FAT 表里绕圈死循环。稳妥的做法是先把整条链建好再一次性更新目录项中间任何一步失败就回滚已分配的块。NAME_LEN设 12 是为了装下 8.3 格式的文件名加结束符。如果只给 8文件名超过 7 个字符就会截断strcmp比较时永远找不到刚创建的文件现象是创建成功但列目录里没有。这个坑用strncpy的时候还会再犯一次——strncpy不保证补\0目标缓冲区必须先memset清零。5. 跑不通先看这里五个高频翻车点排查手册课设代码跑不起来八成不是算法不会是环境、指针、缓冲区这些工程细节没处理好。下面五条是我自己踩过也在别人的代码里见过无数次的按现象→原因→解决写遇到问题照着对。5.1 编译过了链接报 undefined reference现象每个.c单独gcc -c都能过一链接就报undefined reference to enqueue而且报错的那个函数明明写了。原因有两种。一是Makefile的SRCS用手写列表新增了scheduler.c忘了加进去目标文件根本没生成。二是头文件里声明了函数但忘了写#define保护宏pcb.h被两个.c同时包含时声明重复倒不致命但如果头文件里定义了变量比如int clock;就会变成多个定义的符号冲突。解决SRCS改成$(wildcard src/*.c)自动收集所有头文件加#ifndef PCB_H / #define PCB_H / #endif三段式保护全局变量在头文件里用extern int clock;声明在某个.c里定义一次。改完make clean make重新走一遍。5.2 同一组输入每次跑出来结果都不一样现象进程调度的执行顺序、页面置换的缺页次数重复执行三次出现三种结果。原因就绪队列用了头插法入队顺序和到达顺序相反而进程的到达时间又是按数组顺序隐式假设的或者代码里调用了srand(time(NULL))生成随机到达时间但打印的顺序依赖链表遍历顺序链表顺序本身又受插入顺序影响。还有一种隐蔽情况是遍历链表时在循环体内改动了next指针导致部分节点被跳过。解决所有队列统一用尾插写一个enqueue函数所有入队走它不要在业务代码里手写node-next head。随机数据改成从data/processes.txt读保证可复现。遍历链表时用两个指针一个cur走一个prev记删节点只动prev-nextcur在循环末尾统一后移。5.3 进程结束后段错误gdb 显示在 free 之后现象调度跑到最后一个进程打印完统计信息就 Segmentation faultgdb定位在free(pcb)那一行或者之后几行。原因进程终止时只从就绪队列摘了节点但阻塞队列、等待队列里还挂着同一个 PCB 指针。free之后这些队列成了悬空指针后续任何一次遍历都会访问已释放内存。多队列共享节点是链表实现的经典难题。解决写一个remove_from_all_queues(pcb_t *p)在free之前把所有可能挂载的队列都扫一遍找到就摘。更彻底的做法是 PCB 不直接free统一放到一个已终止数组里程序结束时一次性释放模拟程序内存占用不大这种延迟释放完全可接受还能保留统计信息。5.4 缺页率算出来大于 1现象内存模块输出缺页率 1.35明显不合理。原因分子分母搞混了。缺页率是缺页次数 / 总访问次数有人把总访问次数写成页面数去重后的或者把缺页次数里把置换次数和缺页次数重复计数。更常见的是访问序列读取时多读了一行while (!feof(fp))配合fscanf会导致最后一行被读两次访问次数虚高缺页次数也跟着虚高。解决feof的正确用法是在每次读取之后判断而不是用它做循环条件。改成while (fscanf(fp, %d, x) 1)返回值为 1 才说明读到了有效数据。计数变量在初始化时清零每次访问只自增一次缺页分支里再单独自增缺页计数两个变量职责分开。5.5 Windows 下conio.h相关功能编译不过现象在 Linux 或者较新的编译器上编译报conio.h: No such file or directory或者getch、kbhit未定义。原因conio.h是某些老式编译环境特有的控制台头文件标准 C 里没有跨平台编译必然失败。课设代码里用它多半是为了按任意键继续。解决把按任意键继续改成读一行输入用fgets或者getchar()配一个循环清空缓冲区。如果一定要做无回显的单键读取Linux 下需要改终端属性termios代码量大概二十行比引一个非标准头文件划算。更简单的办法是直接删掉暂停功能菜单循环本来就靠用户输入驱动不需要额外的等待。6. 二次开发把课设改成能演示的小系统课设交完就扔是最可惜的。这套代码的结构已经具备扩展基础稍微改改就能变成一个能截图、能讲故事的小项目。我一般从三个方向动手按投入产出比排序。第一个方向是把输出结构化。现在所有模块都是printf打表格把同样的信息改成输出 JSON 行前端或者 Python 脚本读进去画甘特图、画内存帧的热力分布演示效果立刻不一样。改造成本极低只需要在打印函数旁边加一个print_json分支用宏或者全局标志切换输出格式。进程调度的每一条[start, end]记录就是甘特图的一根柱子页面置换的每次命中/缺页就是一行色块数据都是现成的。第二个方向是补测试。课设代码几乎没有测试改一行就不知道会不会崩。给每个模块写一个极简的assert用例银行家算法给一组已知安全、一组已知不安全的数据断言返回值页面置换用固定访问序列断言缺页次数FIFO 队列做入队出队再入队断言顺序。这些用例放进tests/目录Makefile加一个test目标改完代码跑一遍比肉眼检查靠谱得多。第三个方向是把输入彻底外置。现在进程配置和访问序列已经是文件了但调度算法、时间片、页大小这些还写死在代码里。加一个config.ini或者命令行参数解析让./osdemo -a rr -q 2这种形式直接跑指定算法。这样同一份程序可以在演示时切换不同算法做对比不用反复改代码重新编译。改造点改动位置工作量展示价值JSON 输出scheduler.c、memory.c打印函数半天高可配图表单元测试新增tests/Makefile加目标一天中提升可信度命令行参数main.c入口解析半天中演示更灵活多进程内存memory.c加 pid 隔离两天高贴近真实系统这些改造做完代码量大概多三成但项目性质从作业变成了能演示的系统。面试时讲清楚 PCB 怎么在多个队列间流转、页表项的有效位和访问位分别解决什么问题、FAT 链断了怎么回滚比背概念有说服力得多。那次答辩前一晚的三种输出最后查出来就是头插法加随机种子两处改完只有十行。从那以后我每次写队列相关的课设代码第一件事就是先把enqueue写成尾插并单独测一遍再往下写业务逻辑——这个习惯帮我省掉的调试时间比任何算法技巧都多。希望帮到你。本文还有配套的精品资源点击获取