ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

操作系统课程C语言源码阅读指南:从AbstractMachine到线程切换

操作系统课程C语言源码阅读指南:从AbstractMachine到线程切换 简介基于南京大学蒋炎岩教授2024春学期《操作系统》课程的源码包是操作系统课程配套代码资源面向高校学生、考研复习者及对OS底层机制感兴趣的自学者能帮助读者结合真实代码理解操作系统设计与实现。压缩包共19个文件包含6个rv32i-bin二进制模拟文件、5个C源文件、2个头文件、2个Python脚本、2个Makefile文件、1个说明文档及1个Git忽略文件整体大小仅34KB结构紧凑。其中Logisim模拟与Mini-RV32IMA模拟模块尤其值得关注可直观展示数字逻辑电路、CPU与总线设计、RISC-V指令集执行流程并延伸到操作系统如何管理硬件资源。已有491人学习下载适合动手实践型学习者通过阅读与修改源码可深入理解进程管理、内存分配、调度与回收等关键机制将抽象理论与可运行代码对应起来是夯实系统能力的实用材料。1. 为什么这份 C 语言源码值得系统过一遍一份操作系统课程的 C 语言源码不是拿来当资料囤的。蒋炎岩jyy2024 春学期这门课的核心讲法是“把操作系统当成一个 C 程序来写”内核、运行时、用户态程序在同一套工具链下编译在 QEMU 或 NEMU 里真实启动而不是停留在 PPT 的箭头图上。课程配套源码的特点是规模小、依赖少、每一行都能用调试器跑通特别适合把中断、进程、虚拟内存这些概念落回 C 语言实现去理解。如果你正在准备操作系统的课程设计或者期末复习想弄清 C 语言指针、内存管理在真实内核里怎么用这份源码值得系统过一遍。相比直接啃 Linux 内核源码它能让你在更短时间里看到操作系统骨架的全貌。2. 源码树入门AbstractMachine 的目录结构与最小构建命令2.1 先搞清楚三层代码分别管什么拿到源码后先别急着 make。常见的目录布局是底层一个 AbstractMachine 运行时中间是内核逻辑上面是测试程序。AbstractMachine下文简称 AM是一层很薄的硬件抽象它把 x86、RISC-V 等架构的差异封装成统一 API让内核代码不用关心具体寄存器怎么配、中断门怎么设。AM 内部又分成am/架构相关实现、klib/精简 C 标准库、tests/冒烟测试几个区域。把这三层分开看阅读顺序就清楚了先看 AM 给内核提供了哪些原语再看内核怎么用这些原语管理进程和内存最后看测试程序怎么验证行为。很多人在这一步容易犯的错是直接钻进某个.c文件逐行读结果被链接脚本和启动汇编绕晕。正确的姿势是先编译、再运行、最后带着问题看代码。2.2 AM 对外暴露的是一组 C 函数而不是系统调用AM 的公共头文件是am.h它定义了一组架构无关的接口。下面是一个最简 AM 程序它做的事情只是往串口输出一行字符然后关机#include am.h #include klib.h int main() { for (const char *s hello, os\n; *s; s) { putch(*s); } halt(0); return 0; }putch是 AM 抽象出来的字符输出原语x86 上它实现为向串口或 VGA 显存写数据halt让整个虚拟机停机。这段代码不依赖 libc也不依赖任何操作系统服务因为编译目标就是裸机。逻辑很简单但它说明了一个关键事实在这套课程体系里内核和用户程序最终都是跑在 AM 之上的 C 程序区别只在于链接了哪些库、运行在什么特权级。编译运行用 Makefile 传参指定架构即可make ARCHx86_64-qemu run # 跑在 QEMU 上 make ARCHriscv32-nemu run # 跑在 RISC-V 模拟器上 make ARCHx86_64-qemu gdb # 启动 QEMU 并等待 gdb 连接ARCH参数决定了 AM 选择哪一套架构实现也决定了最终的链接脚本和启动代码。x86_64-qemu 适合在 PC 上快速验证riscv32-nemu 适合跟着指令级模拟器做逐条指令调试。首次运行如果报缺少qemu-system-x86_64或gdb-multiarch按系统包管理器装上即可。2.3 常用 AM API 速查表接口作用典型实现位置putch(char c)输出单字符am/arch/*/src/halt(int code)关机并返回状态码am/arch/*/src/cte_init(handler)设置异常/中断处理入口am/src/cte.cvme_init(pgalloc, pgfree)启用分页并注册页表分配器am/src/vme.cio_read/io_write访问设备寄存器amdev.h中的设备抽象这些 API 的命名和参数在课程中会反复出现。我一般建议先跑通tests/里自带的冒烟测试再开始动内核代码——至少证明工具链和模拟器是好的出问题时不至于分不清是环境故障还是代码 bug。3. 内核代码阅读路径CTE、VME 与内存管理的 C 语言实现细节3.1 把计算机看作状态机再看内核在忙什么这门课最反直觉的一个点是不要从“进程”“文件”“设备”这些高层概念去读源码而是回到“计算机就是状态机”这个模型。CPU 取指执行改变寄存器与内存操作系统做的事情就是管理多个并发状态机之间的切换与共享。顺着这个思路内核源码的核心就是三块控制状态机切换的 CTE异常与中断处理、控制状态机视角的内存映射的 VME虚拟内存、以及在多个状态机间分配物理资源的 PMM物理内存管理。这套模型最大的好处是让排错有抓手。用户程序崩溃、死循环、访问非法地址本质都是某个状态机走到了非法状态内核要做的事不外乎保存现场、查原因、决定是修复还是杀掉。看代码时只要不断问“当前 PC 在哪、栈在哪、页表在哪”就不会迷失在函数调用海里。3.2 CTE 的实现骨架一条异常从触发到分发CTE 要解决的问题是CPU 在用户态执行到一条syscall或触发缺页时怎么安全地跳进内核。常见做法是硬件或模拟器先把控制流转移到一段汇编入口保存全部寄存器到一个结构体再调用统一的 C 处理函数。这段逻辑在不同架构上差异很大但入口往后基本长这样#define NR_EVENTS 32 typedef struct { uintptr_t epc; // 触发异常的指令地址 uintptr_t cause; // 异常原因编号 uintptr_t gpr[32]; // 通用寄存器快照 } Context; static Context *(*handler[NR_EVENTS])(Context *c); Context *__am_irq_handle(Context *c) { if (c-cause NR_EVENTS handler[c-cause]) { return handler[c-cause](c); // 分发给对应事件处理函数 } panic(unhandled exception: %d, c-cause); return c; }Context结构把异常现场装成一份普通 C 结构体内核处理完后再通过__am_asm_trap恢复现场返回用户态。注意handler是一个函数指针数组这也是 C 语言指针在操作系统里最典型的用法用异常号做下标跳转到不同处理逻辑。3.3 内存管理中的地址检查与回收策略物理内存管理相对独立它服务上层模块的方式是提供pmm_alloc/pmm_free和kmalloc/kfree两组接口。前者以页为单位分配来自物理内存的页帧后者在页之上切出小块内存给内核对象如task_struct、文件描述符使用。课程实验里通常要求自己实现一个简单的空闲链表或伙伴系统而不是直接调用现成库。C 语言内存管理的坑集中在两处。第一处是内存对齐分配出去的块首地址必须满足架构对齐要求否则结构体访问会出异常第二处是「怎么检验非法地址 c 语言」——用户程序传入的缓冲区指针是虚拟地址内核在使用前要判断它是否落在合法用户段内。常见的处理是先检查页表映射存在、再检查地址区间不越界全部通过才做拷贝。这个检查写错轻则数据错乱重则让用户态能读写内核内存。4. 本地环境跑通最小内核物理内存管理到线程切换4.1 搭环境的依赖清单与常见失败点把源码跑起来的步骤不算多但每一步都可能因为版本不匹配卡一阵。我在本地一般按下面顺序装依赖再编译运行# 按 Debian/Ubuntu 为例 sudo apt install build-essential git qemu-system-x86 gdb-multiarch make ARCHx86_64-qemu rungdb-multiarch比架构专属的gdb更省心它能同时调试 x86 和 riscv 的内核。qemu-system-x86提供-s -S调试端口支持这个是后面第 5 章排错依赖的基础。如果make run之后黑屏没有任何输出先确认putch对应的串口地址是否被-nographic参数导向了 stdout如果直接编译报错优先检查ARCH大小写和 Makefile 里AM_HOME路径指向。4.2 推荐的实现推进顺序拿到源码后建议先别急着读而是把主线实验按“能编译 → 能输出 → 能切换 → 能在用户态跑程序”推进。走到线程切换这一步时代码里一定会出现一个类似下面的调度循环#define NR_TASKS 4 static Task *tasks[NR_TASKS]; static int current; // 当前任务下标 void schedule() { current (current 1) % NR_TASKS; // 时间片轮转 switch_to(tasks[current]-ctx); }switch_to要做的事情是保存当前寄存器和栈指针到旧任务的context再从新任务恢复。很多实现为简化在切换前关中断避免切换过程中被时钟中断打断造成上下文损坏。时间片轮转里NR_TASKS决定最大并发任务数current的取模逻辑是简单可靠的调度选择想调时间片长度就去改触发切换的时钟中断频率。4.3 用并发任务验证切换是否正确线程切换这种逻辑光看代码很难发现错误比较有效的验证方式是让两个任务各自打印一条规律性输出例如任务 A 打印递增的奇数列、任务 B 打印偶数列。如果输出存在交叉缺失说明现场恢复有问题如果打印完全错乱先查栈指针是否分配了独立内核栈。这里也能顺便复习协程与线程的关系线程切换的机制和协程很像区别在于线程由时钟中断抢占触发协程是主动让出。实验环境跑起来后offline-tests类的自动化用例会有帮助。它模拟外部对输出结果的检查把实际输出与预期文本做 diff。每一次 diff 失败都是在告诉你当前实现的行为和设计意图不一致。此时不要急着改算法先确认是「状态没保存对」还是「分页没切对」。5. 排错手段组合gdb 远程调试、QEMU monitor 与 Sanitizer 检查5.1 让内核停在任意一条指令上没有调试器的操作系统开发寸步难行。QEMU 天然支持 gdb stub只要启动时加了-s -S就能让模拟器等待 gdb 从:1234端口接入。课程的 Makefile 通常封装了make gdb这条命令它会自动带上对应架构参数。一个基础.gdbinit脚本长这样file build/kernel # 加载带符号的内核镜像 target remote :1234 # 连接 QEMU 的调试端口 hbreak _start # 在入口处打断点 continue x/12i $pc # 反汇编当前 PC 附近指令 info registers # 查看寄存器现场hbreak是硬件断点适合打断点目标还没被加载到内存的情况x/12i能快速确认当前执行流是否如预期。调试内核时最好不要单步太大段汇编否则很容易被中断岔开。5.2 用 QEMU monitor 直接观察机器状态gdb 看到的是 CPU 视角有些问题换到机器视角才看得清。在 QEMU 窗口或串口终端里按CtrlA再按C会进入 monitor 模式。此时可以用info registers看物理寄存器info tlb查当前页表缓存xp /8wx 0x...直接读物理地址内容。这套手段在排查页表映射错误时尤其好用——虚拟地址解出来的物理地址落在哪一读便知。5.3 给 C 语言源码加一层运行期检查内核代码大量使用指针和内存操作单靠 printf 定位太慢。常见做法是编译时打开 Sanitizer 系列检查虽然模拟器上性能会变差但换来的报错信息stack-buffer-overflow、use-after-free往往直接指出问题文件和行号。报错场景可能原因建议手段打印无限重复且无规律栈指针未正确切换检查 switch_to 是否保存了 rsp用户程序写入内核地址成功段检查缺失在 copy_from_user 加地址范围校验系统启动即 triple fault页表映射错误用 monitor 检查 CR3 与物理地址Sanitizer 不能直接用于全部内核代码因为有些操作如访问 MMIO是故意绕过常规内存模型的。我一般只对klib和自己写的分配器开检查跑普通用例足够暴露大部分问题。至于非法地址的检验最管用的还是断言assert(addr USER_STACK_TOP)比事后追查现场成本低得多。6. 扩展实验在课程源码上加一个系统调用计数器6.1 一个最小实现理解了 do_syscall 的分发路径后可以加一个很小的模块统计每次系统调用的调用次数。实现思路是在handler外层包一层计数器不改动原有分发逻辑。#define NR_SYSCALLS 64 static uint64_t syscall_count[NR_SYSCALLS]; Context *do_syscall(Context *c) { int nr c-gpr[10]; // 按调用约定取系统调用号 if (nr 0 nr NR_SYSCALLS) { syscall_count[nr]; } // 再交给正常处理逻辑 return syscall_dispatch(c); }这里用gpr[10]是因为课程常见的 RISC-V 调用约定用 a0 传系统调用号。加上计数器后跑一组测试程序就能看到哪些系统调用最频繁进而思考哪些路径最值得优化。这么做还能顺便验证一个判断题频繁调用write和频繁调用read的性能开销差异到底来自内核代码本身还是来自用户态和内核态的往返成本。6.2 验证计数器真的准验证方法很简单写一个只调用SYS_write10 次的用户程序运行后通过一个自定义调试命令导出统计表。计数对得上说明拦截点位置正确对不上去查自己的计数逻辑是否放在了上下文切换覆盖的范围外。最后的技巧是把这类小工具输出做成和offline-tests同构的文本比对形式以后改内核代码时顺手跑一遍能防止引入回归。本文还有配套的精品资源点击获取
返回列表