☰
EPOS系统调用实现:从用户态到内核态的七步穿刺
2026/11/2 13:02:31 网站建设 项目流程

简介:本资源是重庆大学软件学院《操作系统》课程实验一的完整配套文档,面向计算机专业本科生及操作系统内核初学者,聚焦系统调用原理与实践,解决从环境搭建、源码理解到内核/用户态协同开发的核心难点。压缩包为单个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的跨目录依赖。关键流程如下:

  1. 顶层Makefile(epos/Makefile)解析:
    run:目标依赖$(KERNEL) $(APP),其中$(KERNEL)指向kernel/kernel.bin,$(APP)指向app/app.bin。

  2. 内核构建:
    cd kernel && $(MAKE)→ 编译machdep.c、syscall.c等,链接生成kernel.bin(扁平二进制镜像,无 ELF 头)。

  3. 应用构建:
    cd app && $(MAKE)→ 编译main.c、syscall-wrapper.S,链接生成app.bin(同样为扁平二进制)。

  4. 镜像合成:
    调用tools/mkimage工具,将bootsector.bin(512B)、kernel.bin、app.bin拼接为epos.img(软盘镜像格式)。

  5. 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 无法加载磁盘镜像。
解决:

  1. 在expenv/目录下手动执行setvars.bat,确认输出Environment variables set...;
  2. 运行bochs -help,验证是否识别;
  3. 删除epos/.bochsrc,重新执行make debug,观察是否生成新配置;
  4. 若仍失败,检查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/。
解决:

  1. 打开epos/kernel/Makefile,找到OBJS = ...行;
  2. 确认包含machdep.o(如OBJS = machdep.o syscall.o timer.o ...);
  3. 若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中,需先获取其地址:

  1. 编译内核后,执行nm epos/kernel/kernel.bin | grep syscall_entry,得到类似00102a3c T syscall_entry;
  2. 在 Bochs 中lb 0x102a3c设断点;
  3. 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位):

步骤指令(简化)寄存器变化作用说明
1pusha将eax-edi全部压栈保存用户态全部寄存器到内核栈
2mov %esp, %ebxebx = esp(当前内核栈顶)为构造ctx结构体准备基址
3sub $52, %espesp -= 52(分配ctx结构体空间)ctx大小为 52 字节(13 个 4 字节寄存器)
4mov %ebx, (%esp)ctx->esp = ebx将用户栈顶存入ctx
5mov %eax, 4(%esp)ctx->eax = eax(即 2014)保存调用号
............
12mov 4(%ebx), %eaxeax = *(user_esp+4)(即NULL地址)从用户栈读取第一个参数(loc)
13test %eax, %eaxZF=1(因eax=0)判断loc是否为空
14je skip_store跳转至skip_store标签因loc==NULL,跳过写回操作
15call sys_time调用内核函数,eax得到时间戳sys_time()返回值存入eax
16mov %eax, 4(%esp)ctx->eax = eax(返回值)将时间戳写入ctx->eax
17popa+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。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询