尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

操作系统课程设计:进程调度、动态分区与分页文件系统模拟实现

操作系统课程设计:进程调度、动态分区与分页文件系统模拟实现 简介基于C和C语言实现的操作系统核心机制模拟资源包面向计算机专业操作系统课程设计、实验及复习备考场景覆盖处理机调度、动态分区分配、分页存储地址转换和文件系统四大模拟模块。压缩包共22个文件大小约1.69MB内容以源码文件为主体配合Word格式设计文档、图片格式流程图和文本格式提示信息目录按模块划分清晰便于按需查阅源码与文档快速定位对应功能。源码中包含进程控制块结构体等关键实现可直观了解进程控制块管理、存储空间分配、页表地址映射及文件系统的组织过程配套文档与流程图则有助于快速梳理实验思路适合用来完善课程设计报告、开展二次功能扩展也可作为操作系统实验的参考模板。目前已有1205人学习下载对正在完成操作系统课设或准备实验答辩的读者具有较强参考价值。1. 从.zip到可运行程序这个课程设计在讲什么拿到一个带着.zip后缀的 C/C 课程设计源码包通常意味着里面装着三件事处理机调度的模拟、存储管理的两个子题动态分区分配与分页地址转换以及一个简化版文件系统。这类工程在大学操作系统课里出现频率极高交上去能跑通界面、能打印日志、能随机生成作业就可以拿到一个不错的分数。但很多同学卡在“代码看得懂拼起来跑不对”更有不少人下载后打开就报错连编译都过不了卡在了 vscode 配置 c/c 环境之类的最前面。这篇文章会按“进程模型 - 调度器 - 分区算法 - 分页转换 - 文件系统”的顺序理顺整个工程的骨架。我给的代码不是某个完整作业的粘贴而是我平时做这类课程设计时习惯使用的最小可编译结构用数组模拟PCB用一个全局clock驱动调度用位图模拟内存用 FAT 思想做文件系统。读完你能自己把这套东西拼起来跑出“动态分区分配 分页地址转换 文件系统”的完整日志而不是只在报告里画几张看不清的流程图。2. 先立骨架进程模型、CPU 与时钟以及调度器的最小实现2.1 用数组和结构体手工搭建操作系统的最小舞台在真实的操作系统里进程管理要处理中断、上下文切换、多核同步。课程设计不需要这些它只需要你在用户态模拟出“若干个作业排队 - 一个 CPU 按策略选中某个作业 - 运行 - 阻塞或退出”的循环。我一般用一个PCB结构体再声明一个足够大的数组例如 32 个槽位每个槽位代表一个进程控制块。代码可以这样开头#define MAX_PCB 32 #define MAX_MEM 1024 // 分区管理用 1024 字节模拟物理内存 typedef enum { NEW, READY, RUNNING, BLOCKED, DONE } pstate; typedef struct { int pid; pstate state; int need_time; // 还需要运行多少时间片 int mem_size; // 需要的动态分区大小 int mem_addr; // 实际分配到的分区起始地址 int page_table[16]; // 分页模式下作业的页表 int page_count; } PCB; PCB pcb[MAX_PCB]; int clock 0; int cpu -1; // 当前占用 CPU 的 pid-1 表示空闲这里有个很关键的选型为什么不用链表而是用固定数组因为课程设计要打印“进程控制块列表”数组遍历写起来最方便for循环扫一遍就能输出所有槽位。链表当然更接近真实系统但调试链表时指针画错、内存漏掉很容易让人把时间耗在找 bug 上而不是在验证调度算法本身。数组虽然浪费一点空间但MAX_PCB只有 32 个槽位哪个仿真平台都放得下。时钟是整个模拟的核心。全局变量clock每循环一次自增一代表一个时间片走过调度函数在每个时间片初检查cpu是否有进程在跑没有的话就从就绪队列里挑一个。打印日志时把clock打出来报告里“第 X 时间片进程 Y 运行”这种输出就有了。2.2 最容易乱的三状态流转用一张状态表定住很多版本的程序跑着跑着出现“两个进程同时在 RUNNING”的怪象根子在于没有在切换时清理上一个进程的状态。我的做法是每次状态改变都通过一个小函数完成不让状态字段在 main 循环里被到处赋值。void set_state(int pid, pstate s) { pcb[pid].state s; }这个函数本身没有技术含量但它把“改变进程状态”这个动作收敛到了一个入口。后续调试时只要你怀疑状态错乱在set_state里加一行printf所有变更都看得到。每个时间片的流程如下for (int t 0; t 50; t) { clock t; printf(\n[clock %d] , t); // 当前空转则选一个新进程否则让当前进程继续 if (cpu 0) { cpu schedule(); if (cpu 0) { set_state(cpu, RUNNING); printf(schedule - pid %d, cpu); } } if (cpu 0) { pcb[cpu].need_time--; printf( | run pid %d, rem %d, cpu, pcb[cpu].need_time); if (pcb[cpu].need_time 0) { set_state(cpu, DONE); printf( | done); cpu -1; } } printf(\n); }这个流程模拟的是最简单的非抢占式调度当前作业不主动让出 CPU就一直运行到结束。状态流转只有READY - RUNNING - DONE三条边没有 IO 阻塞、没有等待队列非常适合作为第一步跑通打印的模型。跑通之后再往里面加“时间片到就轮转”或“优先数抢占”。当前状态触发条件迁移后状态NEW创建完成入队READYREADYschedule() 选中RUNNINGRUNNINGneed_time 减到 0DONERUNNING用户按阻塞键BLOCKEDBLOCKED等待时间结束READY这张表建议直接写进报告的数据结构说明里。评阅老师扫一眼就明白你清楚进程状态之间的关系。2.3 先写一个交付最快的最短作业优先后续再替换调度器的接口最好先统一起来方便后面换算法。我习惯定义一个int schedule();函数返回被选中进程的 pid没有可调度进程时返回 -1。第一次实现用最短作业优先代码量最小输出也最好看int schedule() { int min_time 9999, pick -1; for (int i 0; i MAX_PCB; i) { if (pcb[i].state READY pcb[i].need_time min_time) { min_time pcb[i].need_time; pick i; } } return pick; }提示need_time我选择在进程创建时直接指定而不是读用户输入。原因很简单课程设计要跑 50 个时间片手动输入 32 个作业太痛苦。用随机数生成加一个固定种子让结果可复现是最常见的做法。这段代码背后的逻辑是每次扫描整个pcb数组找出剩余时间最短的就绪进程。注意它没有考虑进程到达时间这在课程设计的演示里没人在意但在报告里一定要写明假设“所有作业同时到达或到达时间不影响就绪判断”。写清楚假设比辩解算法先进更重要。3. 动态分区分配三种放置算法一套骨架3.1 用链式空闲分区表还是用循环首次适应动态分区分配要解决的核心问题只有一个当一个新作业带着mem_size来申请内存时在哪块空闲区域里安放它。常见的实现有两种一种是维护一个空闲分区链表节点记录start和len另一种是固定数组分区表。我建议数据结构课程设计阶段直接用数组#define MAX_PART 64 typedef struct { int start; int len; int pid; // -1 表示空闲 } part_node; part_node parts[MAX_PART]; int part_count 0;为什么用数组因为课程设计规模很小分区表最多几十条数组实现没有性能压力。更重要的是动态分区要演示“分配、释放、合并”三个动作数组的遍历和打印比链表直观得多。每次分配后把空闲区从表里拆出已分配区域占一条记录释放时把记录标记为pid -1再检查相邻节点是否也为空闲能合并就合并。合并是动态分配最容易写错的地方。判断合并不该简单看parts[i].pid -1还要确认它是不是紧挨着刚释放的区域。例如释放的分区起始地址是addr长度是len那么合并条件有两个前驱节点的start len addr或者当前节点addr len等于后继节点的start。两个方向都要判断只做前向合并的代码会在连续释放多次后把空闲区越切越碎。3.2 首次适应、最佳适应、最坏适应的一次性实现三种算法的区别只在于扫描空闲区时选择的标准不同。首适应从上往下找第一块够大的最佳适应在所有够大的里挑最小的最坏适应挑最大的。于是可以写成同一个函数加模式参数static int free_cmp(int i, int j, int mode) { int len_i parts[i].len, len_j parts[j].len; if (mode 2) return len_i len_j; // best fit 要更小的 if (mode 3) return len_i len_j; // worst fit 要更大的 return parts[i].start parts[j].start; // first fit 按地址 } int alloc_fit(int size, int mode, int pid) { int pick -1; for (int i 0; i part_count; i) { if (parts[i].pid ! -1 || parts[i].len size) continue; if (pick 0 || free_cmp(i, pick, mode)) pick i; } if (pick 0) return -1; // 把 pick 区域拆成“已分配 剩余空闲” int old_start parts[pick].start, old_len parts[pick].len; parts[pick].start old_start; parts[pick].len size; parts[pick].pid pid; if (old_len size) { // 加一个新的空闲节点 parts[part_count].start old_start size; parts[part_count].len old_len - size; parts[part_count].pid -1; part_count; } return parts[pick].start; }参数mode的含义在调用侧写清楚1对应首次适应2对应最佳适应3对应最坏适应。实现最佳适应时用选出最小可用区最坏适应用选出最大分区首次适应则比较起始地址。这样三个算法共用一套数据结构打印日志时把模式打出来报告里对比起来非常直观。提示最佳适应并不总是最优。它的名字有迷惑性实际上它容易产生大量无法利用的外部碎片。所以报告里如果选了最佳适应不要写“因为它最好”而应该写“它的剩余区最小便于观察碎片产生”。这句话老师爱看。3.3 动态分区分配的可视化输出一次看明白碎片写课程设计的人都知道partition 算法跑起来的输出如果只是“成功”“失败”那报告毫无说服力。加一个打印分区表的函数每行显示一个分区用星号填充已用的部分void dump_parts() { for (int i 0; i part_count; i) { printf([%2d] start%3d len%3d |, i, parts[i].start, parts[i].len); if (parts[i].pid 0) { printf( free\n); } else { printf( pid%2d, parts[i].pid); for (int j 0; j parts[i].len; j) printf(#); printf(\n); } } }这段代码把分区表直接画出来#越多代表占用越长空闲分区一眼可辨。在这个输出上作业申请、释放外部碎片的产生过程就完全可视。内存总容量最好固定为 1024这样列宽更容易对齐打印效果比 4096 好得多。4. 分页存储地址转换与文件系统逻辑地址如何变成物理地址4.1 分页地址转换页号、偏移量和页表的三角关系动态分区解决的是连续分配问题而分页存储管理要解决的是“不连续也合法”。它的核心公式只有两个int page_no logical_addr 12; // 假设页面大小 4KB int offset logical_addr 0xFFF; int phys_addr (page_table[page_no] 12) | offset;这里的12来自页面大小取以 2 为底的对数4KB 4096 2^12。如果页面大小改成 1KB移位位数就改成 10。我在工程里用一个宏#define PAGE_SHIFT 12 #define PAGE_SIZE (1 PAGE_SHIFT) #define PAGE_MASK (PAGE_SIZE - 1)地址转换不在内存里改数据它只是“算”出一个新地址。真正的内存数据用一个小数组模拟int ram[64][PAGE_SIZE]; // 64 个页框每页 4KB但大部分课程设计其实不需要真的往ram里写入数据只要打印出“逻辑地址 0x2A3F 转换为物理地址 0x503F”就行。所以ram可以只声明不用报表里贴转换日志即可。地址转换示例数据代入假设作业的页表为page_table[0]3, [1]7, [2]5, [3]1逻辑地址 0x2A3F计算步骤值逻辑地址0x2A3F二进制 0010 1010 0011 1111页号0x2右移 12 位偏移0x3F查页表得页框号5物理地址(5 12) | 0x3F 0x503F如果页号超过了页表长度就不能直接越界访问page_table[page_no]应该在转换函数里先加判断if (page_no pcb[pid].page_count) { printf(page fault: logical addr 0x%X\n, logical_addr); return -1; }4.2 把文件系统做成 FAT 风格目录项、索引与坏道隔离课程设计里的文件系统大多不需要真正写磁盘二进制。我们用一段内存数组模拟磁盘块一个FAT[128]数组作为文件分配表FAT[i]存放文件下一个簇号。文件的第一个簇号放在目录项里后续通过 FAT 索引串起来。模拟磁盘块数组无需真实存储文件内容只需要按“分配、读取”两个操作演示指针链。#define BLK_NUM 128 #define FAT_END 0xFFFF int fat[BLK_NUM]; typedef struct { char name[8]; int first_block; int size; int dir; // 0 文件1 目录 } dir_entry; dir_entry root[32];创建文件时要在 FAT 里找到一串连续或不连续的块号把它们从0改成文件占用的值FAT[last] FAT_END。删除文件时反向操作把这条链上的块全部清0表示空闲。这套模拟和真实 FAT 的区别只在于没有读写磁盘数据但分配回收逻辑完全一致。FAT 链的遍历必须用一个受限循环防止环链死循环int blk dir.first_block; int cnt 0; while (blk ! FAT_END blk ! 0 cnt BLK_NUM) { printf(blk %d - , blk); blk fat[blk]; cnt; } printf(END\n);cnt BLK_NUM这个上限很重要。如果程序 bug 把 FAT 表改环了没有这个保护就会无限循环下去。这是 FAT 相关代码最值得演示的防御性写法。4.3 文件系统与分页地址转换的整合作业的“文件”如何映射到“内存页”课程设计往往要求把两个模块连起来玩进程在运行到某一步时读取一个文件内容然后把它映射到自己的地址空间某页。这个操作最自然的实现是先解析文件名得到目录项再读取其分配到的数据块号把块号当作逻辑块再经过页表转换为物理页框。很多同学把这两步分开写导致打印日志时文件系统和分页模块各说各话评阅老师看不懂联动关系。我在工程里会设置一个do_read_file(pid, filename, logical_addr)函数它内部先查目录、再取 FAT 链、最后做分页转换分三步打印printf(step1: find [%s] - dir entry first_block%d\n, name, first_block); printf(step2: fat chain - ); // 打印 fat 链 printf(step3: logical addr%X - phys addr%X\n, logical_addr, phys_addr);这样一个函数同时展示了文件系统目录管理、FAT 索引管理和分页地址转换报告里把这个输出贴出来三个模块的联动就全有了。设备资源这块不少模板要求“作业可能请求打印机或磁盘”。做法很简单准备两个设备表数组dev_status[4]存设备忙闲dev_owner[4]存占用作业号。进程运行到某条“申请设备”指令时找到空闲设备置忙打印一条日志即可。它不涉及真实 IO只是把一些位图字段置位不用做得更复杂。5. 四个值得试的验证方法和一个“把模式切换做成配置文件”的小技巧5.1 随机种子固定后一切结果可复现模拟程序如果不固定随机种子调度器每次运行结果不同报告里就很难稳定描述现象。我建议在main开头显式调用srand(2026);并把种子打印出来。后续若想看不同案例只需改动这个数字。随机种子是调试动态分区和调度算法的第一步也是我几乎所有课程设计工程的标准配置。5.2 用一个配置文件切换调度算法与分配算法与其改代码重编译不如让程序启动时从命令行读算法代号。常见做法是./os_sim -s 1 -m 2 -t 20其中-s表示调度算法1为 SJF、2为轮转、3为优先数-m表示分区算法-t指定运行的时钟周期数。参数解析可以自己写一个简单getopt循环不用引入额外库。代码中把算法代号存到全局变量sch_mode和mem_mode调度器和分配器只需要做switch分支即可。这样写还有个优点在报告里可以贴三组不同参数下的输出做对比而不需要贴三份代码。5.3 把 bell 日志级别做出来出错时不用重新编译打印日志可以分级正常调度信息打一行分区分配失败打一行页表越界打一行。我习惯用一个宏来控制#define LOG_LEVEL 2 #define LOG(fmt, ...) do { if (LOG_LEVEL 2) printf(fmt, ##__VA_ARGS__); } while (0)这样在排错阶段把LOG_LEVEL调到 3每个细微步骤都可见交作业时调回 1只输出结果。这种分级方法同样可以扩展到调度和文件系统的log输出避免了一堆printf堆在代码里删不干净的问题。5.4 用内存校验器检查数组越界不要只靠“运行正常”下结论课程设计最隐蔽的问题发生在“看起来跑通但数组已经越界”的场景。pcb[32]这种数组一旦越界写崩溃不一定立刻出现。我的习惯是在交付前用内存校验器跑一遍开-fsanitizeaddress重新编译再运行gcc -g -fsanitizeaddress os_sim.c -o os_sim_san ./os_sim_san -s 1 -m 2 -t 50如果输出里有ERROR: AddressSanitizer说明存在越界访问需要修掉再跑一轮。分析输出内容时先看顶部的“堆栈访问”比如READ of size 4 at 0x... thread T0后面的调用栈能直接定位到第几行函数出问题远比对着printf猜测位置快得多。用 ASan 跑一遍再交基本能避开“老师一运行就崩”的尴尬局面。5.5 自己实现的常见落坑检查法死亡进程的清理调度器最容易漏的环节是进程DONE之后内存却没有释放。于是每轮都有新作业申请分区内存越占越满运行到第 20 个时间片后动态分区分配全部失败。解决方式是在进程退出时调用一次释放函数最好和内存释放合并到同一步防止写了两套清理逻辑导致漏掉一个。我工程里的习惯是在set_state(pid, DONE)这句下面直接补if (pcb[pid].mem_addr 0) free_part_by_pid(pid, mem_mode);这个习惯也被我带到其他仿真类课程设计中凡是进程退出必须同时清理它的内存、设备、文件句柄三类资源。检视代码时只要搜DONE就能排查所有清理动作是否齐全。本文还有配套的精品资源点击获取
返回列表