
简介这份资源是西安电子科技大学操作系统课程的上机实验报告面向正在学习操作系统、需要完成进程与线程相关实验的高校学生及自学者。报告围绕Linux环境下C语言编程展开完整覆盖进程建立、线程共享进程数据、信号通信、匿名管道与命名管道通信以及用信号量实现进程同步等核心实验内容每部分均包含实验目的、软硬件环境、程序代码、运行分析与心得体会可帮助读者理解fork()、waitpid()、pthread_create()、sem_wait()等关键函数的实际用法。资源包为1个doc文档约377KB结构紧凑便于直接参考与整理。目前已有1346人学习下载适合需要对照实验流程、梳理进程同步与进程间通信思路、积累系统级编程经验的读者使用。1. 操作系统实验报告到底在写什么从一次进程调度翻车说起很多人第一次拿到操作系统实验任务以为就是“写个报告交上去”。真正动手才发现实验报告背后是一整套从内核机制理解到代码验证的闭环。标题里的“操作系统实验报告”不是一份文档模板而是一个信号你需要把进程调度、内存管理、文件系统、并发控制这些抽象概念用可运行的代码和可复现的数据讲清楚。我带过几届学生的实验课最常见的翻车场景是代码跑通了但报告里说不清为什么这么设计参数为什么取这个值换个输入会怎样。这份笔记面向两类人正在做操作系统实验、需要把过程整理成可交付报告的学生以及想通过实验反补理论短板的开发者。接下来我会按“先立住理论、再动手复现、最后避坑”的顺序把一份能站得住脚的操作系统实验报告拆开讲。2. 进程调度实验从FCFS到时间片轮转的代码复现2.1 为什么先做调度实验它把“并发”变成可观测数据操作系统实验里进程调度是最适合入门的模块。原因很直接它不需要你改内核源码也不需要特殊硬件用一门通用语言就能模拟出调度器的核心逻辑。你写一个进程队列给每个进程标注到达时间、服务时间然后按不同策略选下一个执行的进程统计周转时间、带权周转时间、等待时间。这些数字就是报告里最硬的内容。我一般会让学生先实现FCFS先来先服务再实现SJF短作业优先最后做时间片轮转。顺序不能反。FCFS的代码最短但能暴露一个关键问题长作业先到会把短作业堵死带权周转时间急剧恶化。SJF能改善平均周转时间但需要预知服务时间现实中往往只能估算。时间片轮转则引入时钟中断的概念时间片大小的选择直接决定上下文切换开销和响应速度的平衡。实验报告里必须有一张对比表列出三种策略下每个进程的完成时间、周转时间、带权周转时间以及平均值。没有这张表报告就只是代码堆砌。2.2 用Python模拟调度器核心数据结构与调度循环下面这段代码实现了一个通用的调度框架支持FCFS、SJF和时间片轮转三种策略。你可以直接复制运行然后按自己的实验要求调整进程列表。import collections class Process: def __init__(self, pid, arrive, burst): self.pid pid # 进程标识 self.arrive arrive # 到达时间 self.burst burst # 服务时间 self.remain burst # 剩余服务时间用于时间片轮转 self.start None # 首次开始执行时间 self.finish None # 完成时间 def fcfs(processes): 先来先服务按到达时间排序依次执行 procs sorted(processes, keylambda p: p.arrive) current_time 0 for p in procs: if current_time p.arrive: current_time p.arrive # CPU空闲跳到下一个到达时间 p.start current_time current_time p.burst p.finish current_time return procs def sjf(processes): 短作业优先非抢占每次从已到达的进程里选服务时间最短的 procs sorted(processes, keylambda p: p.arrive) current_time 0 completed [] ready [] i 0 while len(completed) len(procs): # 把所有已到达的进程加入就绪队列 while i len(procs) and procs[i].arrive current_time: ready.append(procs[i]) i 1 if not ready: current_time procs[i].arrive continue # 选服务时间最短的 ready.sort(keylambda p: p.burst) p ready.pop(0) p.start current_time current_time p.burst p.finish current_time completed.append(p) return completed def rr(processes, quantum): 时间片轮转每个进程最多执行quantum时间未完成则回到队尾 procs sorted(processes, keylambda p: p.arrive) current_time 0 queue collections.deque() completed [] i 0 # 先把第一个到达的进程入队 while i len(procs) and procs[i].arrive current_time: queue.append(procs[i]) i 1 while queue or i len(procs): if not queue: current_time procs[i].arrive while i len(procs) and procs[i].arrive current_time: queue.append(procs[i]) i 1 p queue.popleft() if p.start is None: p.start current_time run_time min(quantum, p.remain) current_time run_time p.remain - run_time # 在进程执行期间到达的新进程入队 while i len(procs) and procs[i].arrive current_time: queue.append(procs[i]) i 1 if p.remain 0: p.finish current_time completed.append(p) else: queue.append(p) return completed def print_stats(procs, name): print(f--- {name} ---) total_turnaround 0 total_weighted 0 for p in procs: turnaround p.finish - p.arrive weighted turnaround / p.burst total_turnaround turnaround total_weighted weighted print(fP{p.pid}: 到达{p.arrive}, 服务{p.burst}, 完成{p.finish}, f周转{turnaround}, 带权周转{weighted:.2f}) n len(procs) print(f平均周转时间{total_turnaround/n:.2f}, f平均带权周转时间{total_weighted/n:.2f}\n) if __name__ __main__: # 构造一组测试进程到达时间和服务时间 test_procs [ Process(1, 0, 7), Process(2, 2, 4), Process(3, 4, 1), Process(4, 5, 4), ] # 每次实验前重新构造避免状态污染 print_stats(fcfs([Process(p.pid, p.arrive, p.burst) for p in test_procs]), FCFS) print_stats(sjf([Process(p.pid, p.arrive, p.burst) for p in test_procs]), SJF) print_stats(rr([Process(p.pid, p.arrive, p.burst) for p in test_procs], quantum2), RR(q2))这段代码的关键点有三个。第一Process类里用remain字段记录剩余服务时间时间片轮转靠它判断进程是否完成。第二SJF的就绪队列每次重新排序选burst最小的进程这模拟了非抢占式短作业优先。第三时间片轮转里进程执行期间新到达的进程必须及时入队否则统计结果会偏。quantum参数就是时间片大小你可以改成1、2、4观察平均带权周转时间的变化。一般来说时间片越小响应越快但上下文切换次数增加时间片越大退化成FCFS的趋势越明显。2.3 实验报告里必须写清的三个参数第一个参数是进程数量。少于3个进程调度策略的差异不明显多于8个手工分析变得困难。我建议用4到6个进程到达时间错开服务时间有长有短。第二个参数是时间片大小。在时间片轮转实验里时间片取所有进程服务时间的最大公约数附近或者取平均服务时间的一半。比如服务时间是7、4、1、4时间片取2比较合适。报告里要写清楚为什么取这个值以及取1和取4时结果怎么变。第三个参数是到达时间的分布。如果所有进程同时到达FCFS和SJF的差异只体现在服务时间排序上如果到达时间分散才能观察到CPU空闲和就绪队列动态变化。报告里至少给一组“到达时间递增”的输入再给一组“短作业后到达”的输入对比调度策略的适应性。3. 内存管理实验页面置换算法的命中率对比与实现3.1 为什么页面置换是内存管理实验的核心内存管理实验通常围绕分页机制展开而页面置换算法是其中最能量化评估的部分。你给一个页面访问序列比如7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1然后模拟OPT、FIFO、LRU三种算法统计缺页次数和缺页率。这个实验的价值在于它让你直观看到“理想算法”和“实际可实现算法”之间的差距。OPT最优置换需要预知未来访问序列现实中无法实现但它是理论下界。FIFO先进先出实现最简单但会出现Belady异常——增加物理块数缺页率反而上升。LRU最近最少使用用栈或链表维护访问顺序性能接近OPT但硬件支持成本高。实验报告里必须包含这三者的对比并解释为什么LRU在实际系统中更常用。3.2 用Python实现三种置换算法并统计缺页率def opt(pages, frames): 最优置换淘汰未来最长时间不被访问的页面 memory [] faults 0 for i, page in enumerate(pages): if page in memory: continue faults 1 if len(memory) frames: memory.append(page) else: # 找未来最远才被访问的页面 farthest -1 victim None for m in memory: try: next_use pages[i1:].index(m) except ValueError: next_use float(inf) # 之后不再使用 if next_use farthest: farthest next_use victim m memory.remove(victim) memory.append(page) return faults def fifo(pages, frames): 先进先出淘汰最早进入内存的页面 memory [] faults 0 for page in pages: if page in memory: continue faults 1 if len(memory) frames: memory.append(page) else: memory.pop(0) # 淘汰队首 memory.append(page) return faults def lru(pages, frames): 最近最少使用淘汰最久未被访问的页面 memory [] faults 0 for page in pages: if page in memory: memory.remove(page) # 命中时移到末尾表示最近使用 memory.append(page) continue faults 1 if len(memory) frames: memory.append(page) else: memory.pop(0) # 淘汰最久未使用的 memory.append(page) return faults if __name__ __main__: access_sequence [7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1] for frames in [3, 4]: print(f物理块数{frames}) print(fOPT 缺页次数{opt(access_sequence, frames)}) print(fFIFO 缺页次数{fifo(access_sequence, frames)}) print(fLRU 缺页次数{lru(access_sequence, frames)}) print()代码逻辑很直白。OPT在需要淘汰时遍历当前内存中的每个页面计算它在未来第一次出现的位置选位置最远的淘汰。FIFO用列表模拟队列pop(0)淘汰最早进入的。LRU在命中时把页面移到列表末尾淘汰时取列表头部。注意LRU的memory.remove(page)和append(page)操作这保证了列表顺序始终是“从最久未使用到最近使用”。运行结果会显示物理块数为3时OPT缺页9次FIFO缺页15次LRU缺页12次。物理块数增加到4时FIFO缺页次数可能反而增加这就是Belady异常。报告里要专门用一段解释这个现象FIFO的淘汰策略与访问序列的局部性无关增加块数可能打乱原有的淘汰节奏。3.3 缺页率对比表怎么写才有说服力表格不能只列缺页次数。我建议列五列算法名称、物理块数、缺页次数、缺页率、命中率。缺页率等于缺页次数除以总访问次数。命中率等于1减去缺页率。然后加一行“相对OPT的差距”用百分比表示。比如LRU在3块时缺页12次OPT缺页9次差距是33.3%。这个数字能说明LRU离理论最优有多远。另外报告里要给出至少两组不同的访问序列。一组是“局部性明显”的序列比如反复访问少数几个页面另一组是“循环扫描”的序列比如0,1,2,3,0,1,2,3,...。局部性强的序列LRU表现接近OPT循环扫描序列FIFO和LRU都可能表现很差。这种对比能体现你对算法适用场景的理解。4. 文件系统实验用系统调用模拟目录树与文件读写4.1 文件系统实验的两种做法模拟实现与真实调用文件系统实验通常有两种路径。一种是纯模拟用结构体或类定义目录项、索引节点、超级块在内存里构建一棵目录树实现创建、删除、读写、列目录等操作。另一种是调用真实操作系统的API比如Linux下的open、read、write、mkdir、opendir观察文件在磁盘上的组织方式。两种做法各有价值模拟实现帮你理解文件系统的内部数据结构真实调用帮你掌握系统编程接口。我一般建议先做模拟实现因为可控性强不需要特殊权限。模拟实现的核心是设计一个目录项结构包含文件名、文件类型普通文件或目录、指向数据块的指针或索引节点编号。然后实现路径解析函数把/home/user/test.txt这样的路径拆成各级目录名逐级查找。4.2 用Python构建内存目录树核心类与路径解析class INode: 索引节点存储文件元数据和数据 def __init__(self, name, is_dirFalse): self.name name self.is_dir is_dir self.data if not is_dir else None self.children {} if is_dir else None # 目录项名字到INode的映射 class FileSystem: def __init__(self): self.root INode(/, is_dirTrue) self.current self.root def _resolve(self, path): 解析路径返回目标INode。支持绝对路径和相对路径 if path.startswith(/): node self.root parts path.strip(/).split(/) if path.strip(/) else [] else: node self.current parts path.split(/) if path else [] for part in parts: if part or part .: continue if part ..: # 简化处理不实现父目录指针仅示意 continue if not node.is_dir or part not in node.children: return None node node.children[part] return node def mkdir(self, path): 创建目录 parent_path /.join(path.strip(/).split(/)[:-1]) dir_name path.strip(/).split(/)[-1] parent self._resolve(parent_path) if parent_path else self.current if parent is None or not parent.is_dir: print(fmkdir: 无法创建 {path}父目录不存在) return if dir_name in parent.children: print(fmkdir: {path} 已存在) return parent.children[dir_name] INode(dir_name, is_dirTrue) print(f目录 {path} 创建成功) def create(self, path): 创建空文件 parent_path /.join(path.strip(/).split(/)[:-1]) file_name path.strip(/).split(/)[-1] parent self._resolve(parent_path) if parent_path else self.current if parent is None or not parent.is_dir: print(fcreate: 无法创建 {path}父目录不存在) return if file_name in parent.children: print(fcreate: {path} 已存在) return parent.children[file_name] INode(file_name, is_dirFalse) print(f文件 {path} 创建成功) def write(self, path, content): 写入文件内容覆盖 node self._resolve(path) if node is None or node.is_dir: print(fwrite: {path} 不是有效文件) return node.data content print(f写入 {path}: {len(content)} 字节) def read(self, path): 读取文件内容 node self._resolve(path) if node is None or node.is_dir: print(fread: {path} 不是有效文件) return None return node.data def ls(self, path.): 列出目录内容 node self._resolve(path) if node is None or not node.is_dir: print(fls: {path} 不是目录) return for name, child in node.children.items(): suffix / if child.is_dir else print(f {name}{suffix}) if __name__ __main__: fs FileSystem() fs.mkdir(/home) fs.mkdir(/home/user) fs.create(/home/user/test.txt) fs.write(/home/user/test.txt, hello os experiment) print(读取内容:, fs.read(/home/user/test.txt)) print(列出 /home/user:) fs.ls(/home/user)这段代码模拟了一个极简的树形文件系统。INode类区分目录和文件目录的children是字典文件的data是字符串。_resolve方法负责路径解析支持绝对路径和相对路径遇到不存在的路径返回None。mkdir和create先解析父目录再在父目录的children里添加新节点。write和read直接操作data字段。ls遍历children并打印。报告里要写清楚这个模拟系统与真实文件系统的差距没有磁盘块分配、没有索引节点编号、没有权限控制、没有硬链接和软链接。但核心的“目录树路径解析文件数据存储”已经体现出来了。你可以在此基础上增加删除操作、重命名操作或者把data改成按块存储模拟磁盘空间管理。4.3 真实系统调用版用os模块操作实际文件如果你需要在Linux环境下做真实调用实验下面这段代码展示了如何用Python的os模块创建目录、写入文件、读取文件、遍历目录。import os base /tmp/os_exp_demo # 实验用临时目录避免污染用户目录 # 创建多级目录 os.makedirs(os.path.join(base, home, user), exist_okTrue) # 写入文件 file_path os.path.join(base, home, user, data.txt) with open(file_path, w) as f: f.write(operating system experiment\n) # 读取文件 with open(file_path, r) as f: content f.read() print(文件内容:, content.strip()) # 遍历目录树 for root, dirs, files in os.walk(base): level root.replace(base, ).count(os.sep) indent * level print(f{indent}{os.path.basename(root)}/) for file in files: print(f{indent} {file}) # 获取文件状态 stat_info os.stat(file_path) print(f文件大小: {stat_info.st_size} 字节) print(f索引节点号: {stat_info.st_ino})这段代码的关键是os.makedirs的exist_okTrue参数避免目录已存在时报错。os.walk递归遍历目录树root是当前目录路径dirs是子目录列表files是文件列表。os.stat返回文件的元数据其中st_ino是索引节点号st_size是文件大小。报告里可以把st_ino和模拟实现里的“索引节点”概念对应起来说明真实文件系统确实用索引节点管理文件。5. 并发与同步实验生产者消费者问题的信号量实现与死锁排查5.1 为什么生产者消费者是并发实验的必做项并发与同步是操作系统实验里最容易出玄学问题的部分。生产者消费者问题把“共享缓冲区、互斥访问、条件等待”三个要素集中在一起用信号量或管程实现。你写一个固定大小的缓冲区生产者线程往里放数据消费者线程从里取数据用信号量控制空槽位和满槽位数量再用一个互斥信号量保护缓冲区的访问。这个实验的翻车点非常多信号量初值设错、P操作和V操作顺序颠倒、忘记释放互斥锁、缓冲区索引越界。每一个都能导致死锁、数据竞争或程序挂起。报告里必须记录你遇到的至少一个并发bug以及怎么定位和修复的。5.2 用Python线程和信号量实现生产者消费者import threading import time import random BUFFER_SIZE 5 buffer [] mutex threading.Semaphore(1) # 互斥访问缓冲区 empty threading.Semaphore(BUFFER_SIZE) # 空槽位数量 full threading.Semaphore(0) # 满槽位数量 def producer(pid): for i in range(5): item f产品-{pid}-{i} time.sleep(random.uniform(0.1, 0.3)) # 模拟生产耗时 empty.acquire() # 等待空槽位 mutex.acquire() # 进入临界区 buffer.append(item) print(f生产者{pid} 放入 {item}缓冲区: {buffer}) mutex.release() # 离开临界区 full.release() # 增加满槽位 def consumer(cid): for i in range(5): time.sleep(random.uniform(0.1, 0.3)) # 模拟消费耗时 full.acquire() # 等待满槽位 mutex.acquire() # 进入临界区 item buffer.pop(0) print(f消费者{cid} 取出 {item}缓冲区: {buffer}) mutex.release() # 离开临界区 empty.release() # 增加空槽位 if __name__ __main__: threads [] for i in range(2): t threading.Thread(targetproducer, args(i,)) threads.append(t) for i in range(2): t threading.Thread(targetconsumer, args(i,)) threads.append(t) for t in threads: t.start() for t in threads: t.join() print(所有线程结束)这段代码里empty信号量初值是缓冲区大小表示初始有5个空槽位。full初值是0表示没有产品。生产者先empty.acquire()等待空槽位再mutex.acquire()进入临界区放入产品后mutex.release()最后full.release()通知消费者。消费者顺序相反先full.acquire()等待产品再mutex.acquire()取出后mutex.release()最后empty.release()。如果交换empty.acquire()和mutex.acquire()的顺序比如先拿互斥锁再等空槽位当缓冲区满时生产者持有互斥锁等待空槽位消费者无法进入临界区取产品也无法释放空槽位直接死锁。这就是经典的“锁顺序错误导致死锁”。报告里要画出这个死锁的等待关系生产者持有mutex等待empty消费者等待mutex无法释放empty。5.3 死锁排查用线程转储和日志定位阻塞点Python线程死锁不会自动报错程序只是挂起。排查方法是在关键位置加日志打印每个信号量acquire前后的状态。或者用threading.enumerate()打印所有活跃线程再用sys._current_frames()获取每个线程的调用栈。import threading import sys import traceback def dump_threads(): 打印所有线程的调用栈用于死锁排查 frames sys._current_frames() for thread in threading.enumerate(): print(f线程: {thread.name}, 状态: {thread.is_alive()}) if thread.ident in frames: traceback.print_stack(frames[thread.ident])在程序挂起后从另一个终端发送信号触发dump_threads或者直接在代码里加一个定时器每隔几秒打印一次。调用栈会显示线程阻塞在哪个acquire调用上。如果两个线程互相等待对方持有的信号量就是死锁。报告里要写清楚你用了什么方法复现死锁日志里看到了什么修改了哪一行代码修改后程序是否正常结束。这种“现象→定位→修复→验证”的闭环是操作系统实验报告最有价值的部分。6. 实验报告避坑从数据造假到环境差异的五个血泪教训6.1 坑一进程调度结果对不上因为忽略了CPU空闲时间现象FCFS模拟结果里第一个进程完成时间比预期早后续进程的周转时间全部偏小。原因代码在第一个进程到达前就把current_time从0开始累加没有判断CPU是否空闲。如果第一个进程到达时间是3CPU在前3个时间单位是空闲的current_time应该跳到3再开始执行。解决在调度循环里加一个判断if current_time p.arrive: current_time p.arrive。这个坑在SJF和时间片轮转里同样存在每次从就绪队列取进程前都要检查。6.2 坑二页面置换缺页率算错因为没区分“命中但未更新顺序”现象LRU的缺页次数比预期高明明页面在内存里却算成了缺页。原因LRU在命中时也需要更新访问顺序把命中的页面移到“最近使用”的位置。如果只在缺页时才操作内存列表命中时什么都不做LRU就退化成了FIFO。解决在if page in memory分支里先memory.remove(page)再memory.append(page)确保列表末尾始终是最近使用的页面。6.3 坑三文件系统路径解析失败因为没处理尾部斜杠和空路径现象mkdir(/home/user/)报错“父目录不存在”。原因路径按/分割后末尾多了一个空字符串导致父目录解析时多了一层不存在的目录。解决在分割后过滤掉空字符串和.或者用path.strip(/)去掉首尾斜杠再分割。另外空路径应该解析为当前目录而不是根目录。报告里要列出你测试过的边界路径/、/home、/home/、home/user、../home。6.4 坑四生产者消费者死锁因为信号量P操作顺序反了现象程序运行几秒后挂起缓冲区满时生产者和消费者都不动。原因生产者先mutex.acquire()再empty.acquire()消费者先mutex.acquire()再full.acquire()。当缓冲区满时生产者持有mutex等待empty消费者等待mutex无法释放full循环等待。解决资源信号量的P操作必须在互斥信号量之前。生产者先等空槽位再拿互斥锁消费者先等满槽位再拿互斥锁。这个顺序不能颠倒。6.5 坑五实验数据在不同机器上不一致因为线程调度和时钟精度不同现象同一份生产者消费者代码在A机器上输出顺序是“生产-消费-生产-消费”在B机器上变成“生产-生产-消费-消费”。原因线程调度由操作系统决定time.sleep的精度也受系统负载影响。解决报告里不要写“输出顺序必须如何”而是写“在多次运行中观察到两种顺序说明线程调度的不确定性”。如果要可复现的结果用信号量或事件强制同步比如生产者放入产品后full.release()消费者取出后empty.release()但不要依赖print的顺序。另外统计平均周转时间时多跑几次取平均值减少单次波动的影响。7. 把实验报告写成可复现的技术笔记我的三个习惯第一个习惯每个实验先写“最小可运行版本”再逐步加功能。比如进程调度先写FCFS只统计完成时间跑通后再加周转时间和带权周转时间再加SJF和时间片轮转。这样每一步都有可验证的输出不会一次性堆几百行代码然后面对一堆报错。报告里可以按这个递进过程组织读者能跟着你的思路走。第二个习惯所有参数都记录在代码开头的配置区不散落在各处。比如进程列表、时间片大小、物理块数、缓冲区大小全部用变量定义在文件顶部。报告里附上配置区的截图或代码块读者改一个数字就能复现你的实验。我见过太多报告参数藏在循环里别人根本没法复现。第三个习惯每次实验至少跑三组输入一组正常、一组边界、一组异常。正常输入验证基本功能边界输入测试空队列、满缓冲区、单进程异常输入测试路径不存在、信号量初值为0。报告里用表格列出三组输入下的输出差异这比只给一组“完美结果”有说服力得多。最后一个技巧用time.perf_counter()替代time.time()做性能统计。perf_counter返回高精度计时器适合测量短时间间隔。在调度实验里如果你要比较不同策略的实际执行时间用perf_counter能减少误差。但注意模拟实验的“执行时间”主要是CPU计算时间和真实操作系统的调度开销不是一回事报告里要区分清楚。我在整理这些实验时最大的教训是不要为了报告好看而修改数据。有一次我为了让LRU的缺页率看起来更接近OPT手动调整了访问序列结果答辩时被问“为什么序列和题目要求的不一样”当场翻车。后来我养成习惯所有数据从代码输出直接复制原始日志和最终报告一起保存。希望帮到你。本文还有配套的精品资源点击获取