ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:这份课程设计报告面向计算机专业操作系统课程学习者,聚焦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 项:代码段选择子(RPL=0, TI=0, index=1) .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,数据段填0x92;granularity 填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); // 置 PG;WP 让内核也遵守只读页 }

逻辑说明: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 ; 参数1:old,&p->ctx movl 8(%esp), %edx ; 参数2:new,p->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:1;MLFQ 下交互型进程(带 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 个 inode
block k+1空闲位图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固定为 1,0 号保留表示「空」。

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 trace=1" -serial file:log/${alg}_${i}.log -display none done done

-display none让它在无窗口模式跑完就退出,-serial file:把串口输出直接落盘。跑完拿日志里的计数值算比例,比手工抄屏幕靠谱。三次是为了看稳定性,只有一次的实验数据,评审一眼就知道是挑出来的。

最后说个血泪教训:我第一版报告写了四十多页,机制描述得很全,但每个机制后面都没有一行具体数字。评审只翻了五分钟,问的全是「你这儿写的时间片到底多少」。后来重写,砍到二十页,每个机制配一张表、一段日志、一个失败案例,反而没人再问「这是不是你自己做的」。文档的价值不在于说了多少,而在于说过的每一句都能被验证。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进