资讯动态

操作系统课程设计:最小内核实现与可验证报告写作

发布时间:2026/10/29 21:20:08 来源:尧图企业网站定制
简介这份课程设计报告面向计算机专业操作系统课程学习者聚焦Linux环境下的系统级编程与内核实验适合需要完成课程设计、复盘实验流程或查漏补缺的学生参考。内容围绕Linux使用、内核代码结构、添加系统调用、设备驱动程序、/proc文件系统分析等主题展开并涉及Ubuntu 11.10虚拟机搭建、root登录配置、GTK图形界面开发、文件拷贝程序及并发进程展示等实践环节。资源包共1个doc文件约1.22MB以实验报告形式组织包含课程设计目的、环境搭建、各实验内容要求、设计过程及实现结果便于对照理解从环境配置到代码实现的完整思路。目前已有405人学习下载可作为操作系统课程设计的结构模板与实验记录参考帮助读者梳理内核实验步骤、代码组织方式和报告撰写要点。1. 一份操作系统课程设计报告真正被评的是内核里那几百行答辩现场常见的画面报告里写着「实现了基于时间片轮转的进程调度」老师翻到那一页问一句「时间片多长谁定的切换的时候你保存了哪几个寄存器」场面就安静了。操作系统课程设计报告这类文档很多人以为难点在排版、在章节齐不齐、在字数够不够其实评审的人只看一件事——报告里描述的每一个机制在内核代码里能不能找到对应的那几十行参数能不能解释清楚。这篇讲的就是这条路从引导扇区开始把一个能开机、能响应时钟中断、能分页、能跑两三个进程、能挂一个只读文件系统的小内核做出来再把它的设计决策写成一份经得起追问的报告。适合正在做操作系统课程设计、手里有一份半成品内核或者干脆还没起步的人也适合已经写完报告但心里发虚、想知道哪些地方会被戳穿的人。结论先放这儿报告的可信度不来自篇幅来自「参数 现象 证据」三件套。下面每一章都围绕这个来。2. 从第一条指令到第一个进程最小内核的四步跑通路径课程设计的常见做法是基于一个几千行的教学内核改删掉不需要的部分再补上自己的调度和文件系统也有不少题目要求从零写引导。两条路我都走过从零写更痛苦但对报告更有利——每一行你都能解释。这一步的目标很朴素机器起来串口能打印时钟中断能进然后创建第一个进程。2.1 引导与进入保护模式让 CPU 认得你的代码实模式到保护模式的跳转是最容易被糊弄过去的一段也是答辩时最容易被问的一段。核心就三件事装好 GDT、置 CR0 的 PE 位、用远跳转刷新 CS。前两件用汇编十来行就能写完。; boot.S —— 16 位实模式起步装 GDT 后跳到 32 位段 .set PROT_CSEG, 0x08 ; GDT 第 1 项代码段选择子RPL0, TI0, index1 .set PROT_DSEG, 0x10 ; GDT 第 2 项数据段选择子 lgdt gdtdesc ; gdtdesc 里是 GDT 基址(32位) 界限(16位) movl %cr0, %eax orl $0x1, %eax ; CR0.PE 1进入保护模式 movl %eax, %cr0 ljmp $PROT_CSEG, $protcseg ; 远跳转刷新 CS 并跳到 32 位代码逻辑说明lgdt必须在置 PE 位之前执行否则后面的远跳转取到的是垃圾描述符CPU 直接三重故障重启屏幕上什么都看不到这是新手第一个「黑匣子」时刻。ljmp不能省段寄存器 CS 里缓存的还是实模式的段基址只有远跳转能强制重新加载。参数说明GDT 每一项 8 字节字段顺序是 limit[15:0]、base[15:0]、base[23:16]、访问权字节、granularity 与 limit[19:16]、base[31:24]。访问权字节里代码段填0x9A数据段填0x92granularity 填0xCF表示 4KB 粒度和 32 位段。这两个常量写错症状是能跳过去但一访问数据就异常。配套的链接脚本也得看一眼加载地址必须和链接地址一致否则全局变量读写全是错的/* kernel.ld —— 常见做法是把内核链接到 1MB 处引导代码负责搬过去 */ ENTRY(_start) SECTIONS { . 0x100000; .text : { *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(.bss) *(COMMON) } }启动方式用模拟器两个参数很关键# -s 在 1234 端口开 gdb server-S 让 CPU 停在第一条指令等你 qemu-system-i386 -kernel build/kernel.elf -serial stdio -s -S # 另开一个终端 gdb build/kernel.elf -ex target remote :1234 -ex b kinit -ex c-serial stdio把串口输出接到终端比 VGA 打印省事得多早期调试就靠它。-s -S这一对是后悔药内核一启动就重启、串口一个字都不出的时候只有在第一条指令处停下来单步才能知道是卡在 lgdt 还是卡在跳转。2.2 中断与时钟抢占式调度的地基没有时钟中断就没有抢占没有抢占「进程调度」这四个字在报告里就站不住。要做的顺序是建 IDT、初始化 8259A 可编程中断控制器、初始化 PIT 定时器、开中断。// trap.c —— 时钟中断入口做的事越少越好 static volatile uint32_t ticks 0; void timer_irq(struct trapframe *tf) { ticks; if (ticks % TICK_PER_SLICE 0) { sched_tick(tf); // 时间片用完标记需要切换 } pic_send_eoi(0); // 先给主片应答再返回顺序反了会丢后续中断 }逻辑说明中断处理函数里只累加计数和置标志真正的切换放在中断返回路径上做。原因很实在——切换要改栈、要改页表在中断上下文里做一半被打断就是灾难。trapframe是压栈保存的寄存器快照后面sched_tick直接改它的返回地址和栈指针就能完成「切走」。参数说明TICK_PER_SLICE由 PIT 分频决定。PIT 输入频率 1193182 Hz写1193182 / 100 11931到通道 0 就得到 100 Hz即每 10ms 一次时钟中断TICK_PER_SLICE 10对应 100ms 时间片。这个数字必须写进报告并且和后文调度章节保持一致——两处对不上一问就露。IDT 表项的填充有个常被忽略的细节段选择子要填0x08就是 GDT 里的代码段类型属性填0x8E表示 32 位中断门DPL 为 0。系统调用门如果要从用户态进DPL 得改成 3。这个字节写错的表现是「用户态一执行 int 就 triple fault」在模拟器里就是瞬间重启。2.3 分页与地址空间把「内存」变成你能管的资源开启分页那一行mov %eax, %cr0是整份课设里最危险的一行。开启的瞬间CPU 下一条取指就要走页表翻译如果页表里没有当前指令所在页的映射立刻页错误页错误处理程序还没建好就是三重故障。// vm.c —— 先恒等映射前 4MB保证开分页之后还能继续执行 static uint32_t pgdir[1024] __attribute__((aligned(4096))); static uint32_t pgtab[1024] __attribute__((aligned(4096))); void paging_init(void) { for (int i 0; i 1024; i) pgtab[i] (i * 4096) | PTE_P | PTE_W; // 虚拟页 i - 物理页 i pgdir[0] V2P(pgtab) | PTE_P | PTE_W; // 页目录第 0 项指向这张页表 lcr3(V2P(pgdir)); // CR3 必须是物理地址 uint32_t cr0 rcr0(); lcr0(cr0 | CR0_PG | CR0_WP); // 置 PGWP 让内核也遵守只读页 }逻辑说明pgdir[0]覆盖虚拟地址 0 到 4MB因为内核链接在 1MB正好落在里面所以开分页的下一条指令能正常取到。等内核跑稳了再建立高地址映射、把低 4MB 撤掉这一步在报告里最好画一张「地址空间布局」的图比写三段话管用。参数说明aligned(4096)不是可选项页目录和页表的物理基址低 12 位必须为 0否则 CR3 写进去直接被截断症状是「映射建了但访问还是错」。PTE_P是存在位PTE_W是写位CR0_WP置 1 之后内核写只读页也会触发页错误——想演示「缺页异常处理」这个加分项就得靠它。另外V2P宏在开了分页之后要减掉内核虚拟基址这个转换漏掉CR3 里装的就是虚拟地址表现同样是秒重启。3. 进程、调度与同步报告里最容易被追问的三块怎么落地这三个点是操作系统课设的固定考点也是报告里最容易写成「概念复述」的部分。判断一份报告是不是自己做的通常就看这三块有没有具体的结构体字段、具体的汇编指令、具体的测试现象。3.1 进程控制块与上下文切换的保存清单进程控制块不要设计得太花哨够用就行状态、栈指针、页目录物理地址、父进程、打开文件表、退出码。真正被问的是上下文切换保存了什么、为什么是这些。// proc.h enum procstate { UNUSED, RUNNABLE, RUNNING, SLEEPING, ZOMBIE }; struct context { // swtch 保存的最小集合 uint32_t edi, esi, ebx, ebp, eip; }; struct proc { enum procstate state; void *chan; // 睡眠等待的通道 struct context *ctx; // 保存的栈顶指向 context char *kstack; // 内核栈底用于回收 uint32_t pgdir; // CR3 要装的值 struct proc *parent; int pid; int xstate; // 退出码 };; swtch.S —— 只保存 callee-saved 寄存器再换栈 swtch: movl 4(%esp), %eax ; 参数1oldp-ctx movl 8(%esp), %edx ; 参数2newp-ctx pushl %ebp pushl %ebx pushl %esi pushl %edi movl %esp, (%eax) ; 旧进程的栈顶存进 *old movl %edx, %esp ; 换到新进程的栈 popl %edi popl %esi popl %ebx popl %ebp ret ; 返回到新进程上次调用 swtch 的位置逻辑说明只保存 edi/esi/ebx/ebp 四个是因为swtch是被普通 C 函数调用的按调用约定 eax/ecx/edx 属于 caller-saved编译器在调用点已经自己处理了你再保存一遍就是画蛇添足。ret能返回去是因为新进程的栈上压着的正是它上次执行swtch时的返回地址——第一次调度的进程没有这个返回地址所以创建进程时要手工伪造一个栈帧让第一次ret跳到forkret之类的入口。参数说明这个函数必须声明成void swtch(struct context **old, struct context *new)并在头文件里用extern声明让编译器知道它会「返回两次」。如果开优化后出现诡异现象——切换一次之后变量值不对了——十有八九是编译器把它当普通函数做了寄存器分配加__attribute__((noinline))或者干脆整段写在汇编文件里就没事。3.2 调度算法选型时间片轮转够不够用课设要求一般写「实现至少两种调度算法并对比」。老实说真正能对比出东西的只有两种时间片轮转和带优先级的多级反馈队列其余多半是调参。算法关键参数优点什么时候会翻车时间片轮转 RR时间片 10~100ms实现简单公平时间片太小切换开销占比超过 20%优先级抢占静态优先级 0~31响应实时任务低优先级进程饿死多级反馈队列 MLFQ3~4 级每级时间片翻倍兼顾交互与批处理参数没调好长任务被反复降级抖动彩票调度总票数概率公平好演示随机数种子固定结果可复现性差带优先级的版本要加老化每经过 N 个时间片所有就绪进程优先级 1数值变小封顶到 0。这个 N 取多少报告里要写清楚我一般取 50 个时钟中断也就是 500ms。没有老化演示时很容易出现「高优先级进程一直在跑其余全部饿死」评审老师看现象就能判断你没做老化。切换点的选择也要说清楚只在时钟中断返回时切还是系统调用主动让出时也切。常见做法是两处都切但把「是否重新调度」做成一个标志位need_resched中断处理里置位返回路径统一判断。这样代码只有一条切换路径出问题好定位。对比实验的做法跑三个 CPU 密集进程各自累加计数记录每个进程在 5 秒内拿到的计数比例和时间片大小的关系。RR 下应该接近 1:1:1MLFQ 下交互型进程带 sleep应该拿到更多调度次数但总 CPU 时间更少。这些数据放进报告的一张表里比任何文字描述都有说服力。3.3 信号量与管程用生产者消费者验证正确性同步原语的实现有两个绕不过去的细节关中断的范围以及睡眠和唤醒之间的窗口。// sem.c —— 计数信号量count 为负表示等待者数量 void sem_wait(struct sem *s) { pushcli(); // 关中断防止 wakeup 与 sleep 之间被抢占 s-count--; if (s-count 0) sleep(s, s-lock); // 原子地放锁并睡眠 popcli(); } void sem_signal(struct sem *s) { pushcli(); s-count; if (s-count 0) wakeup(s); // 唤醒所有在这个通道上等的进程 popcli(); }逻辑说明s-count为负时绝对值就是等待者数量这样不需要额外维护一个等待队列长度。sleep的第二个参数是「睡眠时要释放的锁」它必须和「进入睡眠」在同一段临界区里完成否则会出现唤醒丢失——信号已经发了进程还没睡下去然后就永远睡着。参数说明pushcli/popcli是计数式关中断不是简单的cli/sti因为可能嵌套调用。计数器回到 0 才真正开中断。这一点如果写成了裸cli/sti嵌套场景下会提前开中断症状是极低概率的随机崩溃跑一百次才复现一次属于典型的玄学问题别问我是怎么知道的。验证用生产者消费者两个生产者、两个消费者、缓冲区大小 8跑 10 万次进出最后检查生产总数 - 消费总数 缓冲区当前占用。这个不变式在报告里必须给出而且要给一次失败的样子——把sem_wait里的pushcli去掉跑同样的用例大概率在一次运行内就能看到计数对不上或者内核卡死。4. 文件系统与系统调用把「能跑」变成「能演示、能验证」到这一步内核已经能调度进程了。但报告里如果没有「文件」和「系统调用」工作量看起来就少了一半。这一章的目标是把磁盘布局、inode 查找、系统调用入口这三条链路打通。4.1 从 inode 到目录项一个可用的只读文件系统最小集课设阶段不建议做实打实的磁盘驱动把镜像文件当成块设备是最省事的做法——模拟器直接挂一个二进制镜像读写就是内存拷贝出问题也好查。布局按经典 Unix 风格分五段区域内容大小说明block 0保留 / 引导1 块不用留空block 1超级块1 块魔数、总块数、inode 数、数据区起始块block 2..kinode 表若干块每块 1024/64 16 个 inodeblock k1空闲位图1 块标记数据块是否被占用其余数据块剩余文件内容// fs.c —— 按路径逐级查找返回 inode 编号失败返回 -1 struct dinode { uint16_t type; // 0 空闲1 普通文件2 目录 uint16_t nlink; uint32_t size; uint32_t addrs[NDIRECT 1]; // 前 12 个直接块第 13 个是一级间接 }; int namei(char *path) { int inum ROOTINO; while (*path) { struct dinode *d iget(inum); if (d-type ! T_DIR) return -1; // 路径中间不是目录直接失败 inum dirlookup(d, path); // 在目录数据块里按 16 字节条目线性找 if (inum 0) return -1; path skip_elem(path); // 跳过 / 和当前分量 } return inum; }逻辑说明dirlookup遍历目录的数据块每个目录项是「2 字节 inode 号 14 字节名字」的定长结构定长是为了实现简单代价是不支持长文件名——报告里要主动说明这个取舍别等着被问。参数说明NDIRECT取 12 时单文件最大 (12 256) × 1024 字节 ≈ 268KB因为一个 1KB 的间接块能放 256 个块号。这个数字写进报告并且给出「超过上限时返回 -1」的处理比直接崩溃要好。ROOTINO固定为 10 号保留表示「空」。4.2 系统调用表与用户态入口系统调用这一层的设计目标只有一个让用户态能通过一条指令进内核并把参数和返回值传对。// syscall.c —— 表驱动索引就是系统调用号 static int (*syscalls[])(void) { [SYS_fork] sys_fork, [SYS_exit] sys_exit, [SYS_wait] sys_wait, [SYS_write] sys_write, [SYS_open] sys_open, [SYS_read] sys_read, }; void syscall_entry(struct trapframe *tf) { int n tf-eax; // 用户态把调用号放在 eax if (n 0 n NELEM(syscalls) syscalls[n]) tf-eax syscalls[n](); // 返回值写回 eax else tf-eax -1; }逻辑说明参数通过 ebx/ecx/edx 传sys_write里用argint(0)、argptr(1, ...)这类辅助函数从 trapframe 里取不要直接解引用用户态指针——用户给一个内核地址你就把内核数据写穿了。argptr要做范围检查这是报告里的一个加分点。参数说明int 0x80的 IDT 表项 DPL 必须是 3否则用户态执行就是保护性异常内核态执行同一指令则正常。断点号用 0x80 还是 0x40 无所谓但报告里要固定写一种代码里别混用。用户态库函数包一层// usys.S .globl write write: movl $SYS_write, %eax int $0x80 ret这个返回值直接就是内核写进 eax 的东西负数表示错误。约定保持一致-1表示失败、0表示字节数用户态代码里就好判断。4.3 报告里必须给的验证用例跑通不算完报告里得给出可复现的现象。下面这几条是我每次都会放的用例操作期望现象失败时的排查方向路径查找打开/a/b/c返回 inode 号内容正确目录项定长解析、skip_elem越界越界写保护用户态向内核地址 write返回 -1进程不被杀死argptr范围检查是否实现非法系统调用传调用号 999返回 -1内核不崩表边界判断n NELEM文件超限写到 300KB第 269 块处返回 -1间接块逻辑并发写同一文件两个进程各写 1000 次无数据错乱加锁后是否给 inode 加了睡眠锁每一行都要配一段串口输出截图或日志片段。只有表格没有输出等于没做。5. 排查与避坑内核调试里 5 个高频翻车现场内核出问题的麻烦在于没有报错信息只有重启、卡死、或者更糟——看起来正常但数据是错的。下面五条是踩的次数最多的。5.1 一启动就重启串口一个字都没有现象模拟器窗口一闪或者-serial stdio里什么都没有QEMU 直接退出或陷入重启循环。原因三重故障。最常见的是 GDT 描述符里的界限写成了字节数而不是「字节数 - 1」或者lgdt里的基址用了虚拟地址而当时还没开分页。另一个高频原因是链接地址和加载地址不一致ljmp跳过去的时候那里根本没有代码。解决用-s -S挂 gdb在_start下断点si单步过lgdt、过mov cr0、过ljmp看哪一步info registers里的 CS/EIP 变得不合理。另外-d int,cpu_reset能把异常和复位原因打到日志里比盯着黑屏猜快得多。5.2 开了分页立刻页错误错误码是 0现象lcr0(cr0 | CR0_PG)之后第一条指令就异常页错误错误码为 0表示页不存在。原因页目录或页表没有 4KB 对齐物理基址低 12 位不为 0写进 CR3 被硬件截断指向了一块随机内存或者pgdir[0]里存的是页表的虚拟地址而不是物理地址。解决在lcr3之前打印V2P(pgdir) 0xFFF必须是 0。再用 gdb 的x/4xw直接看 CR3 指向的物理内存确认第 0 项非零且低位标志正确。5.3 时钟中断里打印跑一会儿就卡死现象想用串口打印调试时钟中断跑几秒后系统完全无响应也没有异常输出。原因串口输出内部拿了锁中断处理程序里再去拿同一把锁就是自己等自己或者在中断上下文里调用了会睡眠的函数。解决中断处理里绝不调用任何可能阻塞的函数要打印就把数据丢进环形缓冲区退出中断后再打。检查方式是给锁加一个「持有者」字段卡死时在 gdb 里bt看栈能直接看到自己等自己的现场。5.4 切换一次之后变量值莫名其妙变了现象swtch之后返回局部变量、循环计数器全乱了但代码逻辑看着没问题。原因swtch声明成了普通函数编译器按调用约定认为 eax/ecx/edx 可以随便用而你手工换栈之后这些寄存器的值是另一个进程的。解决swtch加noinline并且整段用汇编实现如果坚持用 C 内联汇编clobber 列表里要把 caller-saved 全写上。另外第一次调度的进程要伪造栈帧eip填入口函数地址ebp/ebx/esi/edi填 0栈顶 16 字节对齐——对齐这一条违反了SSE 指令一执行就异常。5.5 全局变量初值全是垃圾现象定义了一个static int counter 1;读出来是个随机大数。原因.bss段没清零或者链接脚本把.bss放到了镜像之外加载时根本没被加载。ELF 里.bss不占文件空间靠加载器清零自己写的引导代码不处理就没人处理。解决在kinit最开头手工把_bss_start到_bss_end之间清零这两个符号在链接脚本里用PROVIDE导出。清完之后再调用任何 C 代码包括printf。注意这五条里有四条都能通过「在第一条指令处下断点、单步走到出错点」定位。别急着改代码猜原因先让模拟器停住。6. 报告怎么组织才经得起追问图表、参数表与可复现实验记录写到最后一章前面所有代码都只是素材报告要做的是把这些素材变成一条能被验证的论证链。我的习惯是先写「设计决策表」再写正文——每个机制填一行做了什么、为什么这么做、关键参数、怎么验证。这张表填不满的地方就是答辩时会被问住的地方。报告小节必须附的证据常被追问的点引导与保护模式GDT 表项截图 跳转前后 CS/EIP为什么必须远跳转中断与时钟PIT 分频计算式 中断计数日志时间片多少毫秒怎么算的分页页目录/页表布局图 CR3 值为什么先恒等映射进程与切换context 结构 汇编清单为什么只存 callee-saved调度三种算法的时间占比表老化参数怎么定的同步生产者消费者不变式 失败复现关中断范围为什么是计数式的文件系统磁盘布局表 超限测试输出间接块寻址算得对不对系统调用表驱动代码 非法调用返回用户指针怎么校验两个具体技巧。第一把所有「参数」集中到一张全局配置表里报告正文引用表里的符号名而不是写死数字代码和文档就不会对不上改时间片只改一处报告里的毫秒数也跟着变。第二实验记录用脚本跑而不是手工点# run_exp.sh —— 每个用例跑三次输出重定向到单独文件 for alg in rr mlfq; do for i in 1 2 3; do qemu-system-i386 -kernel build/kernel.elf \ -append sched$alg trace1 -serial file:log/${alg}_${i}.log -display none done done-display none让它在无窗口模式跑完就退出-serial file:把串口输出直接落盘。跑完拿日志里的计数值算比例比手工抄屏幕靠谱。三次是为了看稳定性只有一次的实验数据评审一眼就知道是挑出来的。最后说个血泪教训我第一版报告写了四十多页机制描述得很全但每个机制后面都没有一行具体数字。评审只翻了五分钟问的全是「你这儿写的时间片到底多少」。后来重写砍到二十页每个机制配一张表、一段日志、一个失败案例反而没人再问「这是不是你自己做的」。文档的价值不在于说了多少而在于说过的每一句都能被验证。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑