简介:上海交通大学头歌实践教学平台Chcore操作系统实验整理版docx资料,定位为操作系统课程配套学习材料,适合正在完成内存管理、系统调用与缺页异常实验的本科生与自学者。文档以实验二内存管理和实验三系统调用与缺页异常为主线,系统梳理分页机制、页表管理、内存分配与回收、内存保护、换页流程、系统调用实现、中断与异常处理、缺页异常处理流程、页替换策略及缓存预读取等核心知识点,并给出评测可通的关键命令提示,方便对照环境操作和排错。压缩包内共1个docx文档,整体大小583KB,内容集中,适合直接阅读或打印。目前已有5031人浏览学习,适合借助具体实验深入理解操作系统底层机制,快速完成实验并提升系统编程能力。 如果你正在学操作系统、但又不想只看书刷题,那我强烈建议你去“头歌”上把 ChCore 操作系统实验完整做一遍。这个实验不是那种给一个虚拟教学系统写两行代码就算完事的作业,而是让你真正把一个教学操作系统内核从启动到进程调度跑起来。ChCore 是上海交大软件学院设计的教学操作系统,和很多学校用的 xv6、uCore 系列相比,它的代码结构更贴近现代内核的组织方式,实验题目的梯度也安排得比较合理。做完一遍之后,你对 Linux 内核里“进程”“虚拟内存”“系统调用”“调度器”这些概念的理解会完全不一样。
这篇文章我会从一个实际做完这套实验的视角,把 ChCore 实验的整体思路、核心知识点、实操流程和踩坑记录全部梳理一遍。不管你是刚开始接触操作系统实验的本科生,还是想补内核基础的在职开发者,只要愿意花时间跟着动手,这套实验都能给你带来实打实的提升。
1. 实验整体设计与思路拆解
1.1 ChCore 实验到底在做什么
ChCore 实验的目标很明确:让你从零开始,逐步实现一个可运行的操作系统内核。整个实验基于 ARM64 架构,这和大部分学校教的 x86 平台不太一样,但 ARM64 在现代移动设备和服务器领域已经是绝对主流,所以学这个并不吃亏。
实验被拆成多个 Lab,每个 Lab 对应内核的一个核心模块。按目前常见的版本,大致包括:启动与内核初始化、物理内存管理、虚拟内存与页表、异常与系统调用、进程管理与调度、进程间通信等。每个 Lab 内部又分为若干个小任务,任务之间有明显的递进关系,前一个任务没做好,后面的实验基本跑不起来。
这里我想先解释一个很多人困惑的问题:为什么操作系统实验不直接让大家去读 Linux 源码?因为 Linux 源码体量太大,一个初学者扎进去很容易迷失在复杂的驱动和兼容性代码里。ChCore 的做法是给出一个精简但五脏俱全的内核骨架,把核心机制用清晰的文件组织起来,然后在关键位置留空给你填。这样既能让你理解真实内核的工作方式,又不会因为代码量过大而劝退。
1.2 为什么这套实验值得认真做
我自己做过不少教学内核实验,横向对比下来,ChCore 有几个特点很突出。
第一,它的代码结构非常接近真实工程。ChCore 不是把几个 .c 文件堆在一起的教学玩具,而是有相对完整的构建系统、Makefile、链接脚本和内核加载流程。做完实验,你会对“一个内核是怎么被编译、链接、引导起来的”有一个整体认识,这种认知在只做算法题或者写应用层代码时很难获得。
第二,实验里涉及的概念都是操作系统课程的核心考点。页表、异常向量表、上下文切换、调度队列、锁,这些既是面试常问的内容,也是理解现代操作系统不能绕开的东西。你在 ChCore 里亲手写过一遍之后,再去看到 Linux 里对应的实现,会有一种“原来如此”的贯通感。
第三,头歌平台本身做了很好的实验流程封装。你不需要手动搭建交叉编译环境,也不用愁怎么把内核烧到开发板上,平台已经把编译器和运行环境都准备好了,你只需要在线编写代码、提交验证。对很多被实验环境折腾到放弃的同学来说,这一点非常友好。
2. 核心知识点解析与关键技术点
2.1 内核启动与初始化:第一次接触链接脚本和启动代码
ChCore 实验的第一个拦路虎通常是 Lab1 的启动部分。这里涉及两个关键内容:链接脚本和启动汇编代码。
链接脚本(linker script)的作用是告诉链接器内核的各个段应该放在什么地址。ChCore 运行在 ARM64 平台,内核一般会被加载到高地址。如果你不理解链接脚本,就会出现一种很诡异的现象:代码烧进去后,PC(程序计数器)跳到一个不存在的地址,然后整个内核静默死掉。
启动代码则是用汇编写的。ARM64 的启动过程要设置栈指针、清零 BSS 段、设置异常向量表,最后跳转到 C 语言的入口函数。这些步骤听起来简单,但顺序错了或者寄存器用错了,都会导致内核无法启动。
我个人的学习经验是:这里不要急着跳过去,哪怕你把代码抄一遍,也必须搞清楚每一行汇编在做什么。因为后面异常处理、上下文切换里还会出现大量汇编代码,启动阶段的基础没打好,后面会越看越懵。
2.2 物理内存与虚拟内存:看清地址转换的本质
内存管理是操作系统实验的重头戏,ChCore 在这里的设计也很有代表性。
物理内存管理模块负责把物理页分配出去。你会实现一个页分配器,管理哪些物理页是空闲的、哪些已经被占用。这里往往要求用链表或者位图来记录页的状态。听起来不太难,但要做到分配效率高、碎片少,还是需要花点心思的。
虚拟内存部分则涉及页表。ARM64 使用多级页表,你需要理解页表项的格式、地址翻译的过程,以及如何在内核里操作页表。ChCore 会让你实现地址映射、取消映射、权限设置这些操作。写这部分代码的时候,你会对“虚拟地址和物理地址是怎么一一对应的”有非常直观的体会。
再往后,你会遇到“缺页异常”。当一个程序访问了还没有映射到物理页的虚拟地址时,CPU 会触发缺页异常,内核需要在这个异常处理函数里完成页的分配和映射。这个过程要是实现得不对,用户程序就会莫名其妙地崩溃。排这类问题往往是实验里最痛苦但也最有收获的环节。
2.3 异常处理与系统调用:内核态和用户态的桥梁
ChCore 的异常处理实验会让你实现异常向量表,处理来自用户态的系统调用。系统调用的流程是:用户程序通过一条特殊的指令陷入内核,CPU 跳到异常向量表中对应的入口,然后内核保存现场、执行系统调用处理函数、恢复现场,最后返回用户态。
这套机制看起来很标准,但实际写的时候问题很多。比如:怎么区分异常来源是内核态还是用户态?怎么正确的保存和恢复寄存器?系统调用号和参数怎么传递?返回值怎么写给用户程序?这些细节任何一个出错,用户程序都会拿不到正确的结果。
调试这个阶段的问题通常需要结合 GDB 或者打印日志来看。我建议你从一开始就给关键函数加上清晰的打印,内核崩溃的时候至少能看出来是死在哪个函数、哪个地址,不然光靠猜真的会疯掉。
2.4 进程管理与调度:让多个程序轮流跑起来
进程管理实验的目标是让内核能够创建、切换和调度多个进程。这里最核心的一段代码是上下文切换函数,通常叫switch_to或者context_switch。它负责保存当前进程的寄存器现场,恢复下一个进程的寄存器现场,然后跳转到下一个进程的指令流里。
在这个阶段,你会实现进程控制块(PCB),用一个结构体来记录进程的状态、栈、调度信息等。然后实现调度器,从就绪队列里挑一个进程来运行。ChCore 里通常会有时间片轮转或者优先级调度的要求,你需要选择合适的调度策略并保证正确性。
调度实验的难点在于并发。多个进程共用一个 CPU,稍不注意就会互相踩内存、破坏栈,出现各种奇奇怪怪的错误。很多你在应用层写代码时不会遇到的内存破坏问题,在这里会集中爆发。
3. 实操过程:从环境准备到逐关完成
3.1 环境准备与工程结构
如果你是在头歌平台做实验,环境问题基本被解决了大半。你只需要登录平台,进入对应的 ChCore 实验课程,打开在线代码仓库,就能直接开始写代码。
平台通常会提供一个仓库地址和一个远程代码提交入口。你可以在线编辑代码,也可以把仓库下载到本地开发,再推送到远程去验证。我个人的习惯是下载到本地用 IDE 写,因为本地代码补全、跳转和调试都更方便。
ChCore 工程的目录结构大致如下:
kernel/ common/ 公共工具,如链表、日志 mm/ 内存管理相关代码 arch/ 架构相关代码,启动、异常、汇编 process/ 进程管理和调度 syscall/ 系统调用实现 ipc/ 进程间通信 CMakeLists.txt 构建配置 Makefile 编译脚本你的任务,就是把这些目录里标识了 TODO 或者留空的地方补全。每个实验的题目会明确指出你要在哪个文件里完成什么函数,照着要求一步步做就行。
3.2 实验 1:启动与基础输出
Lab1 一般要求你完成内核的链接脚本配置,让内核能够被正确加载到高地址,然后建立基本的输出能力。这里你可能需要修改的是链接脚本里的内存布局,以及在汇编启动代码里初始化串口输出。
我第一次做的时候就在这里栽了跟头。链接脚本里某个段的加载地址和执行地址没区分开,导致内核虽然被加载到了正确位置,但运行时访问的地址不对,整个系统直接卡死。后来我把链接脚本里AT和运行地址的区别搞明白之后,才真正理解“加载地址”和“运行地址”不是一回事。
3.3 实验 2/3:内存管理代码实战
内存管理相关的实验建议按照“先物理内存、再虚拟内存、后缺页异常”的顺序来做。物理内存分配器写起来相对简单,关键是理解页的数据结构设计。ChCore 用了一个经典的思路:用一个全局的struct page数组来管理所有物理页,每个页结构里用链表指针把空闲页串起来。
写虚拟内存相关代码的时候,页表操作要非常小心。ARM64 的页表项里有很多位段,比如类型位、有效位、访问权限位等。你在设置映射的时候,必须按照规范把这些位填好,否则访问内存就会触发异常。
这里分享一个提升效率的技巧:你可以先实现一个简单的地址打印函数,在映射完成后把虚拟地址和物理地址的对齐关系打印出来,肉眼比对一下是否和预期一致。这比直接用 GDB 打断点要直观得多。
3.4 实验 4/5:系统调用与进程调度实现
系统调用实验的核心是实现syscall的派发逻辑和处理函数。你需要定义一个系统调用号到处理函数的映射表,然后根据用户传入的系统调用号跳转执行。
一个常见的设计是:syscall总入口是一个汇编函数,它把当前进程的寄存器现场压入内核栈,然后调用 C 语言的syscall_handler。在syscall_handler里,你用 switch-case 或者函数指针表找到对应的处理函数,执行完成后再把返回值写回到保存的寄存器里。
调度器的实现则要看装备的是哪种调度算法。如果要求时间片轮转,你就需要维护一个 FIFO 的就绪队列,每次定时器中断触发时,把当前进程放到队尾,然后取出队首进程来运行。
写到这里我想特别提醒:上下文切换函数必须非常谨慎地处理栈指针和返回地址。很多调度器崩溃都是因为切换时栈没有切换对,导致返回到了错误的地址。我一般会在切换函数前后打印当前进程的 PID,这样一旦崩溃,能快速定位是哪个进程切出去了。
3.5 调试与验证:让输出成为你的朋友
内核调试和普通应用调试很不一样。在内核里,你不能轻易用printf,因为可能你连堆都还没初始化好。ChCore 提供了一些日志接口,你可以用它来输出调试信息,但要注意在关键路径上输出太多日志会影响时序,反过来导致 bug 更难复现。
我的经验是,尽量把日志打印放在“状态变化”的位置,而不是“每个循环”里。比如在进程状态从就绪变为运行时打一条,在页表映射成功时打一条,这样既能追踪流程,又不会刷屏。
如果平台支持 GDB 远程调试,那是最好不过的。你可以在内核入口处打断点,单步执行启动流程,观察寄存器的变化。这种断点级别的调试对理解底层代码帮助巨大。
4. 常见问题与排查技巧实录
4.1 内核启动即崩溃,没有任何输出
这类问题十有八九是链接脚本、启动汇编或者最基本的串口配置出了问题。
排查顺序建议是:
- 检查链接脚本里的入口地址是否正确;
- 检查启动汇编里是否设置了栈指针;
- 检查 BSS 段是否清空;
- 检查串口的初始化代码是否被编译进内核。
如果你用的是头歌的在线环境,可以试着干净地重新编译一次,因为有时候是缓存导致的链接错误。
4.2 分配物理页后,访问出现异常
物理页分配好之后,如果不做映射或者映射的权限不对,访问就会出问题。这里需要确认两点:一是页的虚拟地址是否被正确映射到分配的物理地址;二是页表项里的权限位是否允许当前 CPU 模式访问。
一个容易忽略的地方是:内核态访问用户态内存时,权限位检查的逻辑可能和你想的不一样。ARM64 的权限控制里区分了 EL1(内核)和 EL0(用户态),你得确保页表项设置了AP[1]或类似允许内核访问的位。
4.3 系统调用返回后,用户程序依旧崩溃
系统调用执行完,安全返回用户态是一个很精密的环节。你可以检查以下几个方面:
- 广东省保存和恢复的寄存器数量是否一致;
- 系统调用返回值是否写到了保存的寄存器里;
- 返回地址是否被正确恢复到了发生异常时的 PC;
- 是否把
spsr和elr也正确处理了。
最经典的错误是:在异常处理里用了一个临时变量,结果在恢复现场时把原本的寄存器给覆盖了。这种问题盯着代码看半天看不出来,最好用 GDB 在异常返回的位置打一个断点,观察每个寄存器的值是否符合预期。
4.4 调度器切了几次就死机
调度器崩溃常见原因有两个:一个是就绪队列指针被破坏,另一个是上下文切换时栈搞错了。你可以把就绪队列的插入和删除操作都加上日志,看队列长度是否正确;同时在切换时打印“从进程 A 切到进程 B”,如果发现进程号突然出现异常值,就说明 PCB 的内存被污染了。
另外,调度器里面如果有临界区资源,要记得加锁。两个进程同时修改就绪队列会造成链表环,这种偶发问题特别难查。
4.5 常见问题速查表
| 现象 | 优先排查点 | 补充说明 |
|---|---|---|
| 启动无输出 | 链接脚本地址、栈指针、串口初始化 | 可以先用最小的串口输出代码逐一验证 |
| 访问内存触发异常 | 页表映射、权限位、地址对齐 | 用地址打印函数验证映射关系 |
| 用户程序崩溃 | 系统调用返回值、现场恢复 | 重点检查 ELR、SPSR、通用寄存器 |
| 调度几次后死机 | 队列指针、栈切换、竞态条件 | 给队列操作加日志,确认 PCB 未被污染 |
| 内核打印乱码 | 串口波特率、时钟初始化 | 检查时钟和外设的初始化顺序 |
5. 写在最后的一点个人体会
我把 ChCore 实验完整做完之后,最大的感受是:操作系统里那些抽象概念,真的是“纸上得来终觉浅”。你背十遍“页表是多级映射的”,不如实际写一遍页表操作来得深刻。那些在课堂上听起来云里雾里的术语,比如“陷入内核”“上下文切换”“就绪队列”,在亲手敲完代码之后,突然就变成了非常具体的东西。
如果你是自学的,建议严格按照 Lab 顺序走,不要跳关。前面的启动和内存管理没搞懂,后面的进程调度会举步维艰。同时一定要学会利用头歌平台的验证机制,每完成一个小任务就及时提交看结果,拖到最后一次性排一堆 bug,那是真的绝望。
最后再分享一个小技巧:如果你在某个实验里卡了很久,不妨把题目要求重新读一遍,然后把你已经实现的部分全部注释掉,从头再按自己的思路实现一遍。很多时候,你认为“这里应该这么写”和题目真正要求的“你应该在这里做什么”之间,存在一个被忽略的差距。重写一遍,往往能让你意识到自己思路里的盲区。
本文还有配套的精品资源,点击获取