简介:一套面向高校操作系统课程的 PintOS 项目完整代码包,覆盖四个经典实验阶段:线程与等待队列的基本优先级调度、用户程序加载与系统调用实现、虚拟内存(堆栈增长、分页、内存映射文件),以及文件系统(缓冲区缓存、可扩展文件和子目录),适合正在学习进程管理、内存管理和文件系统的学生对照理解 OS 内核设计思路。压缩包共624个文件,大小仅1.19MB,以251个C源文件、79个头文件、184个ck校验文件为主体,另有makefile构建脚本、tests测试文件、patch补丁、PDF说明和GDB调试宏,构成一套可本地运行的实验与验证体系。目前已有501人浏览学习。整套实现可作为自学参考,用于理解上下文切换、系统调用参数传递、页表映射与磁盘缓存等重难点,但需注意其课程私有属性,勿直接提交作业,整体目录结构也便于按实验模块检索代码。
1. PintOS 是什么:一门课设、四个阶段和一个真实的操作系统骨架
操作系统的课设最让人头痛的地方,往往不是代码量大,而是看了一周书,打开项目却不知道从哪个函数开始改。PintOS(Pint OS)就是为打破这层窗户纸而生的教学操作系统:它跑在 x86 模拟器上,把线程调度、同步、用户程序加载、系统调用、虚拟内存和文件系统拆成四个递进的实验阶段,每一阶段都给定一个能启动、能跑测试的半成品,让你只聚焦在核心逻辑上。适合两类人:一类是在做操作系统课程设计、需要独立完成阶段性功能的学生,另一类是工作后想补系统编程底子、想亲手改一改 schedule 和 page fault 的开发者。它是黑匣子最少的一类教学 OS,源码目录清楚,编译和测试工具都给你配好了。
2. 环境与项目结构:先跑通第一个测试再动手改代码
很多人拿到 PintOS 源码包的第一反应是直接打开 threads/thread.c 开始改,结果连make check都跑不起来。我建议的顺序是:先把环境装好,把项目结构看清楚,让一个测试跑通,然后再谈改代码。这一章不讲算法,只讲怎么把地基打牢。
2.1 环境准备:QEMU、32 位交叉编译和 GDB
PintOS 的内核是 32 位 x86 格式,所以编译环境必须能产出 i386 的二进制。在 64 位 Linux 机器上,光装 gcc 是不够的,还需要 multilib 支持。我一般用下面的命令一次性装齐:
sudo apt update sudo apt install -y gcc g++ make qemu-system-x86 gdb-multiarch \ libc6-dev-i386 gcc-multilib装完之后,进入源码的 threads 目录编译看看能不能过:
cd src/threads make clean && make这里解释一下为什么需要这几样:qemu-system-x86是 PintOS 的运行后端,gdb-multiarch用来连接 QEMU 的远程调试端口;libc6-dev-i386和gcc-multilib是为了让内核里的用户程序也能以 32 位格式编译。如果你机器上 apt 源版本比较旧,出现缺包报错,优先补这些名字,不要试图直接用本机 64 位 gcc 去编内核。这一步是最容易翻车的地方,尤其在高版本 Ubuntu 上,pintos脚本默认调用的工具链名字和发行版自带的可能不一致,报No such file or directory时,先看脚本里 `GDB=`` 这类变量指向的是哪个可执行文件,改成实际安装的即可。
2.2 项目目录结构:六个目录各管什么事
PintOS 的源码包按实验阶段划分,组织方式非常接近真实内核。我把关键目录和各自职责列成表:
| 目录 | 职责 | 你需要重点关注的文件 |
|---|---|---|
| threads/ | 内核核心:线程、调度、同步原语 | thread.c、thread.h、synch.c、synch.h、init.c |
| userprog/ | 用户程序加载、系统调用、进程管理 | process.c、syscall.c、exception.c |
| vm/ | 虚拟内存:页表、缺页处理、mmap | page.c、frame.c、swap.c |
| filesys/ | 文件系统:目录、inode、缓冲 | file.c、directory.c、fsutil.c |
| devices/ | 定时器、键盘、串口等设备驱动 | timer.c、input.c、vga.c |
| lib/ | 内核和用户态共用的基础库 | lib/kernel/list.c、lib/user/string.c |
对新手来说,threads 是整个项目的入口,init.c里启动第一个线程,然后进入main();userprog 是在 threads 的基础上加用户态支持,你改系统调用时主要在 syscall.c 里动手。注意,这六个目录不是完全独立的,userprog 会依赖 threads 的调度器,vm 又依赖 userprog 的页表建立逻辑,所以做实验时要按顺序来,不要跳阶段。
2.3 构建与运行:make 和 pintos 脚本的常用姿势
PintOS 的构建系统是每层目录一个 Makefile,所有测试通过make check统一跑。第一次跑的时候,建议先跑 threads 目录自带的基础测试:
cd src/threads make check如果想单独跑某个测试,可以用 utils 下的 pintos 脚本手动加载:
cd src/threads/build ../../utils/pintos --qemu -- run alarm-multiple这里几个参数说明一下:--qemu表示用 QEMU 作为模拟器而不是默认的 Bochs,--后面的是传给内核的命令行参数,run表示要运行一个用户程序,alarm-multiple是测试名字。再加-v能看到更详细的运行输出,-t 30可以设置超时时间防止测试卡死。如果你像我一样在远程服务器上跑,记得加--gdb配合 GDB 使用,后面第 6 章会专门讲怎么用 GDB 观察线程切换。
3. Threads 实验:调度器与同步原语,三个必须改的地方
Threads 阶段是整个 PintOS 的地基,后面的 userprog、vm 都要依赖这里提供的线程模型。它要求你实现优先级调度和优先级捐赠,但在这之前,你得先理解默认的时间片轮转是怎么工作的,以及信号量和锁的内部结构。
3.1 默认调度器:从定时器中断到线程切换
PintOS 的调度由时钟中断驱动。每个线程有一个时间片,timer_interrupt会调用thread_tick把当前线程的时间片减一,减到 0 就触发thread_yield,让出 CPU。thread_yield把当前线程放回 ready_list,然后调用schedule(),而schedule()的第一步就是选出下一个要运行的线程:
/* threads/thread.c * 选择下一个要运行的线程 * 默认实现是取就绪队列队头,因为是先进先出的轮转调度 */ static struct thread * next_thread_to_run (void) { if (list_empty (&ready_list)) return idle_thread; /* 注意:默认实现直接返回 list_front 对应的线程 */ return list_entry (list_pop_front (&ready_list), struct thread, elem); }这段代码里有几个容易忽略的点。第一,ready_list保存的是所有可运行状态的线程,不包含当前正在运行的线程和阻塞在信号量上的线程。第二,list_pop_front会把线程从就绪队列移除,等它下次被调度时再重新入队。第三,idle_thread是系统空闲线程,当没有其他线程可跑时调度它,它不停地让出 CPU。理解这个流程后,你再看优先级调度的需求就清楚了:默认实现只看队头,不管优先级。
3.2 信号量与锁:waiters 队列是核心
同步原语里,信号量是最基础的。sema_down把值减一,如果变成负数就把当前线程塞进 waiters 队列并阻塞;sema_up把值加一,如果 waiters 不为空就唤醒一个线程。synch.c里信号量和锁的骨架长这样:
/* threads/synch.c * 信号量 down 操作:获取资源或阻塞 */ void sema_down (struct semaphore *sema) { enum intr_level old_level; old_level = intr_disable (); if (--sema->value < 0) { list_push_back (&sema->waiters, &thread_current ()->elem); thread_block (); } intr_set_level (old_level); } /* 信号量 up 操作:释放资源并唤醒一个等待者 */ void sema_up (struct semaphore *sema) { enum intr_level old_level; old_level = intr_disable (); if (!list_empty (&sema->waiters)) { thread_unblock (list_entry (list_pop_front (&sema->waiters), struct thread, elem)); } sema->value++; intr_set_level (old_level); }这个实现的关键在于intr_disable和intr_set_level:信号量的操作必须是原子的,否则两个中断可能在 waiters 队列上发生竞争。锁是在信号量之上实现的,lock_acquire等价于sema_down(&lock->semaphore),lock_release等价于sema_up。条件变量则更特殊,它基于信号量但没有“记忆”——cond_signal只唤醒当前正在等待的线程,如果没人等,信号就丢弃,这跟锁的用法要区分开。
3.3 实现优先级调度:三个改动点一个都不能少
原版 PintOS 的第一个任务是实现优先级调度。这里有三个改动点:就绪线程的选择、信号量等待队列的排序、以及线程创建/唤醒时的主动让出。先看第一个改动,也是最直观的:
/* 修改后的 next_thread_to_run: * 遍历就绪队列,选择优先级最高的线程 * 这里不能用 pop_front,必须遍历 */ static struct thread * next_thread_to_run (void) { struct thread *t = NULL; struct thread *p; if (list_empty (&ready_list)) return idle_thread; for (p = list_begin (&ready_list); p != list_end (&ready_list); p = list_next (p)) { struct thread *th = list_entry (p, struct thread, elem); if (t == NULL || th->priority > t->priority) t = th; } list_remove (&t->elem); return t; }这里有个细节:同一优先级的线程要保持先来先服务,所以遍历时只在严格大于时替换,这样多个同优先级线程会保持原来的就绪顺序。第二个改动点在sema_up,它不能只唤醒队首线程,而应该唤醒等待队列里优先级最高的那个;同时thread_unblock之后,如果被唤醒的线程优先级比当前线程高,当前线程应该立刻让出 CPU。第三个改动点在thread_create:创建完新线程后,如果它的优先级比当前线程高,直接thread_yield()。这三处配合,才能保证优先级调度在任何时刻都生效。
优先级捐赠是更深一层的要求。场景是:低优先级线程持有锁,高优先级线程等这把锁,如果不处理,高优先级会被低优先级阻塞很久。核心思路是让持有锁的线程临时继承等待者的优先级:
/* lock_acquire 中,在真正阻塞前先处理捐赠 */ void lock_acquire (struct lock *lock) { struct thread *holder; if (lock->holder != NULL && !thread_mlfqs) { holder = lock->holder; /* 把当前线程的优先级捐赠给持有者 */ thread_donate_priority (holder, thread_current ()->priority); } sema_down (&lock->semaphore); lock->holder = thread_current (); }这段代码背后的陷阱在于:捐赠是有链的,A 等锁 L1(被 B 持有),B 等锁 L2(被 C 持有),那么 A 的优先级要一路传到 C 身上。所以 thread 结构体里要维护一个捐赠列表,记录“谁给我捐了优先级”,当锁释放时要恢复成捐赠列表里的最大值,而不是直接恢复原始优先级。我见过不少人只做了单层捐赠,结果priority-donate-chain测试挂掉,就是因为没处理多层传递。
4. Userprog 实验:系统调用从栈上接参数,指针验证别偷懒
到了 Userprog 阶段,内核要支持加载一个 ELF 用户程序,并且在用户态和内核态之间切换。这个阶段最常见的误解是“系统调用像普通函数调用一样直接传参数”,实际上 PintOS 里用户程序的参数是压在自己栈上的,内核通过中断帧里的 esp 去取,而且取之前必须验证地址合法性。
4.1 进程加载:把 ELF 文件段映射到用户虚拟地址
process_execute负责创建一个新线程并加载可执行文件,最终调load解析 ELF。load_segment是核心函数,把 ELF 的一个段从文件读到内存,并且把未初始化的部分清零:
/* userprog/process.c * 把 ELF 的某个段放到用户虚拟地址 upage 上 * read_bytes 是从文件读的长度,zero_bytes 是 bss 清零长度 */ static bool load_segment (struct file *file, off_t ofs, uint8_t *upage, uint32_t read_bytes, uint32_t zero_bytes, bool writable) { if (read_bytes > 0) { /* 用 file_read 把段内容读进内存 */ if (file_read (file, upage, read_bytes) != (int) read_bytes) return false; } /* bss 段全部清零 */ memset (upage + read_bytes, 0, zero_bytes); /* 建立用户页表映射 */ return pagedir_set_page (thread_current ()->pagedir, upage, upage, writable); }注意三个参数的实际含义:upage是目标用户虚拟地址,read_bytes是 ELF 段里实际占文件长度的部分,zero_bytes是为 BSS 段清出的未初始化数据区。两者的总和才等于段在内存中的实际大小。pagedir_set_page把用户虚拟地址映射到同一个物理页,这里没有做换页,所以映射时直接指定物理地址为当前加载缓冲区即可。很多人在memset的长度上写错,把zero_bytes和整个段大小混淆,导致 BSS 初始化不完全,用户程序会出现垃圾变量。
4.2 系统调用:中断帧与用户栈上的参数
PintOS 的用户程序通过int 0x30发起系统调用,CPU 自动把返回地址、eflags 等压入内核栈,中断处理函数最终把struct intr_frame传给syscall_handler。用户程序的参数是在自己栈上排列的,所以从f->esp这个指针开始,先读系统调用号,再按顺序读参数:
/* userprog/syscall.c * 系统调用分发入口,f->esp 指向用户栈顶 */ static void syscall_handler (struct intr_frame *f UNUSED) { int syscall_no = * (int *) f->esp; switch (syscall_no) { case SYS_HALT: halt (); break; case SYS_WRITE: { int fd = * (int *) (f->esp + 4); const void *buffer = * (const void **) (f->esp + 8); unsigned size = * (unsigned *) (f->esp + 12); /* 关键:buffer 是用户传进来的指针,必须先验证再使用 */ if (!validate_user_buffer (buffer, size)) exit (-1); f->eax = write (fd, buffer, size); } break; case SYS_EXIT: exit (* (int *) (f->esp + 4)); break; default: f->eax = -1; break; } }这里最危险的是buffer可能指向一个完全没有映射的用户地址。内核直接解引用这个地址会触发 page fault,并且最终表现为内核崩溃而不是用户程序报错。验证函数不能只检查指针范围,还要逐个查页表:
/* 检查一段用户内存是否已经映射 */ static bool validate_user_buffer (const void *buffer, unsigned size) { uintptr_t start = (uintptr_t) buffer; uintptr_t end = start + size; /* 用户地址必须低于内核基址 */ if (start < USER_BASE || end < USER_BASE || end < start) return false; /* 每个包含数据的页都必须是已映射的 */ while (start < end) { if (pagedir_get_page (thread_current ()->pagedir, start) == NULL) return false; start += PGSIZE; } return true; }我一般会在验证函数里同时检查USER_BASE边界和逐页映射,缺一不可。还有一种常见错误是拿memcpy去拷贝用户 buffer,但用户可能传入超长的 size 导致越界,稳妥做法是换成strlcpy或限制 size 上限。记住一句话:用户态传入的任何指针和长度都不能直接信任,这是内核开发的基本底线。
4.3 文件描述符与进程退出:别把资源清理漏了
每个进程需要一张独立的文件描述符表,这张表挂在哪里是很多人的纠结点。常见做法是直接在 thread 结构体里加一个数组或指针,进程创建时初始化,退出时释放。fd 0 是标准输入,fd 1 是标准输出,都对应控制台而不是文件系统。
父进程等待子进程结束,用的是process_wait,它通过一个信号量阻塞,子进程退出时process_exit里唤醒父进程并返回退出码。我建议把退出码单独存到子进程的thread结构里,避免父进程去读一个已经被释放的外存。整个 Userprog 阶段的验收标准,就是所有syscall开头的测试全部 PASS,凡是出现kernel panic的,多半是用户指针没有验证或者资源释放不完整。
5. 避坑手记:五个让我翻车的设计细节与排查方法
这一章写的是我做这个实验时的血泪经验,每一条都对应一个具体现象、原因和解决办法。PintOS 的测试对时序非常敏感,有些 bug 只在特定的运行顺序下出现,比业务代码难调试得多。
5.1 同步与调度类:死锁和优先级反转
坑 1:alarm-multiple测试随机卡死,有时候跑十分钟正常,有时候一启动就挂。现象是 QEMU 窗口完全没有输出,CPU 占用却很高。原因是等待队列的操作没有全程关中断,两个中断在同一条waiters链表上并发操作,链表指针被改坏。解决:检查信号量和锁的所有list_push_back、list_pop_front操作,确认它们都被intr_disable保护。尤其是你额外加的优先级排序逻辑,很容易习惯性地放在关中断外面,必须全部包进去。
坑 2:实现了优先级调度后,priority-change测试仍然失败,低优先级线程先跑。现象是新创建的线程明明优先级更高,却被排在就绪队列末尾。原因是thread_create之后没有立刻重新调度,新线程要等当前线程时间片耗尽才被选中。解决:在thread_create的最后加上if (thread_current ()->priority < priority) thread_yield ();,并且确保thread_yield不是只在中断上下文里生效。
坑 3:优先级捐赠只做了一层,priority-donate-chain测试失败。现象是三级锁链的时候,持有最内层锁的线程优先级一直是低优先级,导致整条链阻塞很久。原因是没有维护捐赠者列表,锁释放时只能恢复原始优先级,丢失了中间层传上来的捐赠值。解决:在 thread 结构体里加一个嵌套深度字段和捐赠者优先级数组,锁释放时重新计算当前最高捐赠优先级,如果比原始优先级高就继续用捐赠值,否则恢复原始值。
5.2 运行与调试类:GDB 连不上和输出乱序
坑 4:pintos --gdb启动后,GDB 里target remote localhost:1234报connection refused。现象是 QEMU 已经跑起来了,调试端口却监听不上。原因是 pintos 脚本默认不会把--gdb参数传给 QEMU,需要在命令里显式加--qemu --gdb,并且确认端口没有被防火墙拦截。解决:把命令写成../utils/pintos --qemu --gdb -- run alarm-multiple,GDB 这边用gdb-multiarch加载build/threads/threads这个内核镜像,再target remote localhost:1234,连上后先continue到内核 main 入口再下断点。
坑 5:printf 输出乱序,或者自己加的调试打印和测试框架输出混在一起,导致判断测试结果错误。现象是明明逻辑正确,make check却显示 FAIL,去看.output文件发现输出行顺序不对。原因是 PintOS 的输出是直接写控制台的,多个线程同时在 printf,没有锁保护。解决:调试打印时把调试信息放在一个字符串里,然后在前后加intr_disable/enable,或者用测试框架的msg宏而不是裸printf。另外,改了代码之后一定要在对应目录make clean && make,否则编译产物是旧的,调了一天发现改的代码根本没跑进去,这个坑我至少遇到三次。
6. 让验证自动化:make check 加 GDB 脚本,一次定位线程切换问题
做完整套实验之后,我的体会是:手动敲命令跑单个测试效率太低,而且不好复现偶发问题。我习惯的做法是写一个小的 GDB 命令脚本,自动打印每次线程切换的线程名和优先级,这样能直观地看到优先级调度是否生效,也能快速定位是不是某个线程没被唤醒。
先跑一次完整测试集,确认失败的是哪一批:
cd src/threads make clean && make make check失败测试的输出会写在build/tests/threads/对应名字的.output文件里,先看它再决定要不要上 GDB。对于偶发的调度问题,我写一个.gdbinit:
set pagination off target remote localhost:1234 continue b thread_schedule_tail commands silent printf "switch to %s prio %d\n", thread_current()->name, thread_current()->priority continue end解释一下这几条命令的作用:断开 QEMU 的调试端口后,在thread_schedule_tail上下断点,这个函数是每次线程切换必经的地方,打印出当前线程名和优先级,然后自动继续运行。这样能完整记录每一次切换过程,按优先级看是否出现低优先级抢占的情况。如果所有切换都正常,再回头看具体是哪个测试失败,大概率就是系统调用或文件系统的问题。
从那以后,我每次改完调度器相关代码,都强制要求自己先跑一遍alarm-multiple、priority-change、priority-donate-chain这组同步测试,再动 Userprog 的代码。宁可多花几分钟跑自动验证,也不要在心里默念“应该没问题”——因为 PintOS 这类教学系统最会惩罚这种侥幸心理。希望帮到你。
本文还有配套的精品资源,点击获取