简介:本资源是重庆大学软件学院《操作系统》课程实验一的完整配套文档,面向计算机专业本科生及操作系统内核初学者,聚焦系统调用原理与实践,解决从环境搭建、源码理解到内核/用户态协同开发的核心难点。压缩包为单个740KB的docx文件,内容涵盖SVN拉取EPOS教学操作系统源码、QEMU虚拟机运行与Bochs调试实操、系统调用time()的全流程实现(含machdep.c内核函数、syscall-nr.h编号定义、syscall-wrapper.S汇编封装及main.c调用验证),并附关键代码片段、make命令说明与常见排错提示。文档结构清晰,按Kernel Space与User Space双线展开,突出C与汇编混合编程、寄存器级调试逻辑及时间戳获取机制等硬核知识点。目前已有185人学习下载,适合需要手把手复现教学OS系统调用、建立内核态/用户态交互直觉的进阶学习者。
1. 重大软院操作系统实验一:系统调用不是“调个函数”那么简单,而是打通用户态与内核态的生死门
你写完time(NULL),终端打印出1449985033,看起来只是个时间戳——但背后是 CPU 模式切换、栈帧重定向、寄存器劫持、特权级跳转四重门全开。这不是 Linuxglibc封装好的黑盒调用,而是在 EPOS 这个教学型嵌入式 POSIX 系统里,亲手在machdep.c里焊死一条从用户main.c直通内核g_timer_ticks的钢缆。重大软院这个实验一,表面是加一个sys_time(),实则是把「系统调用」从教科书定义(“用户请求内核服务的接口”)砸成可触摸的二进制脉搏:.S汇编 wrapper 怎么把eax=2014塞进中断门;ctx->esp+4为什么偏移 4 字节才能拿到用户传的指针;QEMU 启动后一闪而过的kernel.bin是怎么被make run链成bootsector + kernel + app三段式镜像的。它专为刚撕开printf外壳、正对着int 0x80发愣的新手设计——不考算法,不拼性能,只逼你直面“为什么必须用汇编写 wrapper”“为什么make debug启的是 Bochs 而不是 GDB”“为什么g_startup_time必须是全局变量”。如果你卡在make run报undefined reference to 'sys_time',别急着搜错误,先翻epos/kernel/Makefile里machdep.o是否进了OBJS列表——这才是重大软院想塞进你脑子的第一课:构建链即控制流。
2. 从 SVN 拉取到 QEMU 启动:EPOS 实验环境的完整链路还原
2.1 解压 setvars.bat 后的环境初始化逻辑:为什么必须双击运行而非直接 cmd 执行
expenv-with-notepad-plus-plus解压后,目录结构固定为:
expenv/ ├── Notepad++/ ├── setvars.bat ├── tools/ # 包含 mingw32-make、qemu-system-i386、bochs、svn 等 └── ...setvars.bat的核心作用不是启动 GUI,而是永久注入环境变量。其关键代码段如下(已脱敏还原):
@echo off set EXPENV_ROOT=%~dp0 set PATH=%EXPENV_ROOT%tools\mingw32-make;%EXPENV_ROOT%tools\qemu;%EXPENV_ROOT%tools\bochs;%EXPENV_ROOT%tools\svn;%PATH% set EPOS_ROOT=%EXPENV_ROOT%epos echo Environment variables set for EPOS development. pause注意:
%~dp0获取批处理所在目录的绝对路径,避免相对路径在子目录执行时失效;PATH中工具路径必须前置,否则系统自带make(如 Windows 自带的make.exe)会优先被调用,导致make run因语法不兼容直接失败。
双击运行的本质是:以当前目录为工作路径启动 CMD,并加载上述变量。若手动打开 CMD 再cd进入expenv目录执行setvars.bat,变量仅在该 CMD 实例生效;而后续svn checkout或make若在新 CMD 窗口执行,变量丢失,qemu-system-i386命令将报'qemu-system-i386' is not recognized。这是新手最常翻车的第一步——看似只是“双击一下”,实则是环境变量生命周期的硬性约束。
2.2 SVN checkout 的深层含义:EPOS 仓库结构与文件职责映射
执行svn checkout http://isse.cqu.edu.cn/svn/epos后,生成的epos/目录是典型教学 OS 分层架构:
| 目录路径 | 关键文件类型 | 核心职责 |
|---|---|---|
epos/kernel/ | .c,.S,.h | 内核空间:machdep.c(平台相关代码)、syscall.c(系统调用分发)、timer.c(时钟中断) |
epos/app/ | .c,.S,.h | 用户空间:main.c(入口)、syscall-wrapper.S(用户态汇编胶水)、syscall.h(C 接口声明) |
epos/boot/ | .S,.asm | 引导扇区:bootsector.S(实模式加载)、loader.asm(保护模式切换) |
epos/include/ | .h | 全局头文件:kernel.h(内核函数声明)、syscall-nr.h(系统调用号定义) |
特别注意.S文件(大写 S)与.s文件(小写 s)的区别:EPOS 中所有.S文件由 GCC 预处理器先行处理(支持#include、#define),再交由汇编器;而.s是纯汇编,无预处理。syscall-wrapper.S必须是大写 S,否则WRAPPER(time)宏无法展开。
2.3make run的完整执行链条:从 Makefile 到 QEMU 镜像生成
epos/app/下执行make run并非只编译app/,而是触发顶层Makefile的跨目录依赖。关键流程如下:
顶层
Makefile(epos/Makefile)解析:run:目标依赖$(KERNEL) $(APP),其中$(KERNEL)指向kernel/kernel.bin,$(APP)指向app/app.bin。内核构建:
cd kernel && $(MAKE)→ 编译machdep.c、syscall.c等,链接生成kernel.bin(扁平二进制镜像,无 ELF 头)。应用构建:
cd app && $(MAKE)→ 编译main.c、syscall-wrapper.S,链接生成app.bin(同样为扁平二进制)。镜像合成:
调用tools/mkimage工具,将bootsector.bin(512B)、kernel.bin、app.bin拼接为epos.img(软盘镜像格式)。QEMU 启动:
执行qemu-system-i386 -fda epos.img -serial stdio,QEMU 加载epos.img,CPU 从0x7c00开始执行bootsector.S,最终跳转至内核入口,再加载app.bin。
验证方法:执行make run后,在epos/目录下检查是否存在epos.img,并用fdisk -l epos.img(Linux)或binwalk epos.img(需安装 binwalk)查看分区结构——你会看到三个连续扇区:boot(512B)、kernel(可变长)、app(可变长)。这印证了 EPOS 的极简设计哲学:没有文件系统,靠物理扇区偏移定位模块。
3. 系统调用全流程实现:从内核函数到用户调用的七步穿刺
3.1 内核空间四步落子:sys_time()的生存环境分析
sys_time()不是独立函数,而是寄生在machdep.c的硬件抽象层中。其存在前提有三:
- 时间源必须就绪:
g_timer_ticks由timer.c中的时钟中断服务程序(ISR)每1/HZ秒自增(HZ=100即每 10ms 加 1); - 启动时间必须固化:
g_startup_time在kernel/main.c的kmain()中首次读取 RTC(实时时钟)并赋值,此后永不更新; - 上下文必须可达:
ctx参数来自syscall()函数的中断栈帧,ctx->esp指向用户态栈顶,ctx->eax是返回值寄存器。
因此sys_time()的实现本质是原子读取:
time_t sys_time() { // g_timer_ticks 是 volatile,防止编译器优化掉读取 // HZ 是宏定义,值为 100,确保整数除法精度 return g_startup_time + (time_t)(g_timer_ticks / HZ); }参数说明:
g_timer_ticks类型为unsigned long,HZ为#define HZ 100,强制(time_t)转换避免符号扩展错误。若省略转换,当g_timer_ticks较大时,g_timer_ticks / HZ可能溢出int范围。
3.2 系统调用号注册:syscall-nr.h的不可替代性
#define SYSCALL_time 2014不是随意数字,而是 EPOS 的系统调用号分配策略:
- 前 2000 个号码(0–1999)预留给 POSIX 标准调用(如
read=3,write=4); - 2000–2999 为教学扩展区,
2014属于此区间,避免与标准调用冲突; - 所有调用号必须在
syscall-nr.h中全局唯一定义,因为syscall-wrapper.S中的WRAPPER(time)宏会直接引用该符号。
WRAPPER宏展开逻辑(见epos/app/syscall-wrapper.S):
.macro WRAPPER, name .globl _##name _##name: pushl $SYSCALL_##name // 将调用号压栈(如 2014) jmp syscall_entry // 跳转至统一入口 .endm若SYSCALL_time未定义,汇编阶段报错error: undefined symbol SYSCALL_time,且错误位置指向syscall-wrapper.S第 N 行——这是新手常误判为“汇编写错”的根源。
3.3 用户态汇编胶水:syscall-wrapper.S的寄存器劫持术
epos/app/syscall-wrapper.S末尾添加WRAPPER(time)后,实际生成的符号是_time(下划线前缀)。其汇编逻辑必须满足 x86 保护模式调用规范:
// 生成的 _time 函数(简化版) _time: pushl $2014 // 系统调用号 call syscall_entry // 调用内核入口 addl $4, %esp // 清理栈:弹出压入的 4 字节调用号 ret // 返回用户态关键点在于syscall_entry的实现(位于epos/kernel/syscall.c):
- 保存用户寄存器到
ctx结构体(ctx->eax,ctx->ebx...); - 从
ctx->esp读取调用号(即pushl $2014压入的值); - 查表分发至对应
sys_*函数; - 将
sys_time()返回值写入ctx->eax; - 恢复用户寄存器并
iret返回。
血泪经验:若忘记在
syscall-wrapper.S中添加WRAPPER(time),main.c中调用time(NULL)时链接器报错undefined reference to '_time'。此时nm app.o | grep time可确认符号缺失,而非函数实现错误。
3.4 用户态 C 接口封装:syscall.h的声明陷阱
epos/app/syscall.h中声明time_t time(time_t *loc);时,必须与内核sys_time()的签名严格对齐:
time_t类型在epos/include/types.h中定义为long int(32 位);- 参数
time_t *loc是用户传入的地址,内核通过*(time_t **)(ctx->esp+4)解引用获取该地址; ctx->esp+4的 4 字节偏移,是因为call _time指令将返回地址压栈(4 字节),_time函数内pushl $2014再压 4 字节,故用户参数实际位于esp+8,但syscall_entry在保存上下文时已将esp调整为指向参数起始位置,ctx->esp即为main.c中time(pointer)调用时的栈顶,ctx->esp+4正好是pointer的值(即&t的地址)。
若声明为time_t time(void)(无参),则内核侧*(time_t **)(ctx->esp+4)会读取随机内存,导致 QEMU 崩溃或输出乱码。
4. 调试与排错:Bochs 调试器下的五类高频翻车现场
4.1 现象:make debug启动 Bochs 后黑屏无响应,或报Bochs is not installed
原因:setvars.bat未正确设置PATH,或tools/bochs/目录下缺少bochs.exe和bochsrc.bxrc配置文件;更隐蔽的原因是epos/.bochsrc被误删,该文件由make debug自动生成,若生成失败则 Bochs 无法加载磁盘镜像。
解决:
- 在
expenv/目录下手动执行setvars.bat,确认输出Environment variables set...; - 运行
bochs -help,验证是否识别; - 删除
epos/.bochsrc,重新执行make debug,观察是否生成新配置; - 若仍失败,检查
epos/Makefile中BOCHSRC变量路径是否指向$(EPOS_ROOT)/.bochsrc。
4.2 现象:QEMU 中printf("%ld", t)输出负数(如-123456789)或极大值(2147483647)
原因:sys_time()返回值类型与printf格式符不匹配。time_t是long int,但若main.c中声明int t;并调用t = time(NULL),则高位截断;或printf用%d代替%ld,导致符号位解析错误。
解决:
- 严格使用
time_t t;声明变量; printf必须用%ld(Linux/MinGW 下long为 32 位);- 在
epos/include/types.h中确认typedef long int time_t;。
4.3 现象:make run报undefined reference to 'sys_time'
原因:machdep.c未被加入内核编译目标。epos/kernel/Makefile中OBJS变量必须包含machdep.o,且machdep.c的路径需相对于epos/kernel/。
解决:
- 打开
epos/kernel/Makefile,找到OBJS = ...行; - 确认包含
machdep.o(如OBJS = machdep.o syscall.o timer.o ...); - 若
machdep.c在子目录(如epos/kernel/arch/x86/machdep.c),则OBJS应写为arch/x86/machdep.o,且VPATH需添加arch/x86。
4.4 现象:time(pointer)执行后*pointer值为0,但time(NULL)返回正常值
原因:内核侧case SYSCALL_time分支中,if(loc != NULL) *loc = ctx->eax;语句未执行,因loc解引用失败。根本原因是ctx->esp+4读取的地址loc是用户态虚拟地址,而内核直接解引用会访问内核地址空间,导致写入失败或崩溃。EPOS 的解决方案是:loc必须是用户栈上有效地址,且ctx->esp+4计算正确。
解决:
- 在
main.c中确保pointer指向栈变量(如time_t t; time_t *pointer = &t;),而非堆或全局区; - 检查
machdep.c中syscall函数的case SYSCALL_time分支,确认*(time_t **)(ctx->esp+4)前有if (ctx->esp >= USER_STACK_BASE)边界检查(EPOS 默认用户栈基址为0x80000000); - 若无检查,添加
if (ctx->esp < 0x80000000) return -EFAULT;防御。
4.5 现象:make clean后make run仍报旧错误,或 QEMU 输出未更新
原因:make clean仅删除*.o和*.bin,但epos.img未被清除,QEMU 仍在运行旧镜像;或epos/app/app.bin未被重新生成,因main.c修改后make未检测到依赖变化。
解决:
- 手动删除
epos.img和epos/app/app.bin; - 执行
make -C epos/app clean强制清理应用层; - 使用
make -d查看 make 的依赖图,确认main.c是否在app.bin的依赖列表中。
5. 验证与进阶:用 Bochs 调试器逆向追踪time(NULL)的 17 条指令链
5.1 Bochs 调试器基础操作:从启动到断点设置
make debug启动 Bochs 后,输入以下命令进入调试状态:
bochs:1> c # continue,运行至内核初始化完成 bochs:2> lb 0x100000 # 在内核入口(0x100000)设断点 bochs:3> c # 继续,停在内核第一条指令 bochs:4> trace-on # 开启指令跟踪(慎用,日志爆炸)但针对time(NULL),更高效的方式是在系统调用入口设断点。EPOS 中syscall_entry符号在kernel.bin中,需先获取其地址:
- 编译内核后,执行
nm epos/kernel/kernel.bin | grep syscall_entry,得到类似00102a3c T syscall_entry; - 在 Bochs 中
lb 0x102a3c设断点; c运行,当main.c执行time(NULL)时,Bochs 将停在此处。
此时reg命令可查看寄存器:eax=2014(调用号),esp=0x7fffeffc(用户栈顶),eip=0x102a3c(即将执行syscall_entry)。
5.2 逆向追踪time(NULL)的 17 条关键指令(精简版)
从syscall_entry断点开始,单步n(next)执行,记录关键指令流(x86 32位):
| 步骤 | 指令(简化) | 寄存器变化 | 作用说明 |
|---|---|---|---|
| 1 | pusha | 将eax-edi全部压栈 | 保存用户态全部寄存器到内核栈 |
| 2 | mov %esp, %ebx | ebx = esp(当前内核栈顶) | 为构造ctx结构体准备基址 |
| 3 | sub $52, %esp | esp -= 52(分配ctx结构体空间) | ctx大小为 52 字节(13 个 4 字节寄存器) |
| 4 | mov %ebx, (%esp) | ctx->esp = ebx | 将用户栈顶存入ctx |
| 5 | mov %eax, 4(%esp) | ctx->eax = eax(即 2014) | 保存调用号 |
| ... | ... | ... | ... |
| 12 | mov 4(%ebx), %eax | eax = *(user_esp+4)(即NULL地址) | 从用户栈读取第一个参数(loc) |
| 13 | test %eax, %eax | ZF=1(因eax=0) | 判断loc是否为空 |
| 14 | je skip_store | 跳转至skip_store标签 | 因loc==NULL,跳过写回操作 |
| 15 | call sys_time | 调用内核函数,eax得到时间戳 | sys_time()返回值存入eax |
| 16 | mov %eax, 4(%esp) | ctx->eax = eax(返回值) | 将时间戳写入ctx->eax |
| 17 | popa+iret | 恢复用户寄存器,eip返回main.c | 退出内核,回到用户态继续执行 |
玄学提示:步骤 12 中
mov 4(%ebx), %eax的4(%ebx)即user_esp+4,正是main.c中time(NULL)调用时,call _time指令压入返回地址后,_time函数内pushl $2014再压入的调用号位置。用户参数NULL就在user_esp+4,这是 x86 cdecl 调用约定的铁律。
5.3 进阶技巧:用 Bochs 修改寄存器强行绕过loc判空
当需要验证*loc写入逻辑时,可在syscall_entry中test %eax, %eax后强制修改eax:
bochs:5> set reg eax = 0x7fffeffc # 将 eax 设为用户栈上一个有效地址 bochs:6> n # 执行 test 指令,ZF=0 bochs:7> n # 执行 je,不跳转,进入 store 分支随后n单步至mov %eax, (%eax)行,用xp /1dw 0x7fffeffc查看该地址内容,确认是否被写入时间戳。这是调试指针解引用问题的后悔药——无需改代码,直接在运行时篡改数据流。
从那以后我每次验证系统调用参数传递,都强制在 Bochs 中reg查esp、xp查栈内容、u反汇编当前函数,三者交叉印证。因为ctx->esp+4这个偏移,看着简单,却是用户栈布局、调用约定、内核上下文保存三重契约的交汇点,漏看一步,*loc就永远是0。希望帮到你。
本文还有配套的精品资源,点击获取