简介:本资源是重庆大学软件学院《操作系统》课程实验一‘系统调用’的完整配套文档,面向计算机专业本科生及操作系统内核初学者,聚焦Linux环境下系统调用的原理实现与跨态交互实践。文档以实操为主线,详细解析EPOS嵌入式POSIX操作系统源码改造全过程:涵盖SVN拉取源码、QEMU虚拟环境搭建、Bochs调试器使用;重点展开内核空间(machdep.c/syscall-nr.h/kernel.h)与用户空间(syscall-wrapper.S/syscall.h/main.c)双侧协同开发,完整呈现time系统调用从汇编接口封装、C语言声明、内核函数实现到用户调用验证的闭环流程。资源为1个740KB的docx文件,结构清晰,含实验步骤、代码片段、关键注释与排错提示,便于对照源码理解系统调用号分配、寄存器传参、上下文切换等核心机制。已有185人学习下载,适合夯实操作系统底层编程能力、衔接课程实验与期末考核的进阶学习者。
1. 这不是“写个 printf 就完事”的系统调用实验:它让你第一次亲手把用户代码的time(NULL)变成内核里跳动的g_timer_ticks/HZ,全程在 QEMU 里裸跑,不依赖 Linux、不碰 glibc、连标准 C 库都得自己裁——重大软院操作系统实验一的真实战场
你写的time(NULL)最后到底怎么变成屏幕上那个1449985033?不是gettimeofday()调 libc,不是clock_gettime()走 VDSO,而是你亲手在machdep.c里敲出return g_startup_time + g_timer_ticks / HZ;,再在syscall-nr.h里硬塞一个#define SYSCALL_time 2014,最后让main.c里那行t = time(NULL);真正穿过特权级切换、陷入内核、查 syscall table、跳进你写的 C 函数、再原路返回——整个链路没有一行是黑匣子。这不是模拟器教学 demo,这是 EPOS(Embedded POSIX Operating System)真实运行在 QEMU 上的最小可启动内核,所有.S汇编文件都在你眼皮底下,所有.o目标文件都是你make run后亲手生成的二进制产物。适合谁?适合刚啃完《操作系统导论》第 4 章、但还没在真实寄存器堆栈上踩过坑的本科生;也适合想补全“系统调用 ABI 如何落地”这一环的嵌入式/Linux 驱动新手——因为这里没有int 0x80或syscall指令封装,只有push %eax; call syscall_entry这种赤裸裸的汇编胶水。你改一行ctx->esp+4的偏移,QEMU 就直接 panic;你漏一个if(loc != NULL)判断,printf就打印出随机内存值。这项目不教你怎么配环境,它逼你理解:为什么用户态传参要从ctx->esp+4开始取?为什么WRAPPER(time)宏展开后必须压栈再call sys_time?为什么make debug启动的是 Bochs 而不是 GDB?答案全在你双击setvars.bat后弹出的那个 cmd 窗口里。
2. 环境搭建与项目结构:从 Notepad++ 到 QEMU,看清 EPOS 的骨架和血肉
EPOS 不是 Linux 发行版,也不是 Minix 那种教学 OS 的简化版。它是一个为嵌入式场景设计的、支持多核与实时特性的微内核架构操作系统,而这个实验只启用其最精简的单核 x86 实时内核子集。整个工程结构高度模块化,.h/.c/.S文件按功能域严格分层,没有任何“万能头文件”或“上帝类”。你解压expenv-with-notepad-plus-plus后看到的目录,就是一套为教学定制的完整开发沙箱:Notepad++ 已预装语法高亮插件(识别.S汇编)、MinGW 工具链已就位、QEMU 和 Bochs 调试器已打包、甚至setvars.bat已帮你把PATH、EPATH、QEMU_PATH全部注入环境变量——但这些便利背后,藏着必须亲手确认的硬约束:所有路径不能含中文、空格、长路径名;SVN checkout 必须走 HTTP 协议(非 HTTPS);make命令实际调用的是 MinGW 的mingw32-make.exe而非 Windows 自带make。一旦忽略,make run就会卡在gcc: error: unrecognized command line option '-m32'或qemu-system-i386: command not found。
2.1 目录结构解析:每个文件夹都是一个责任边界
进入expenv文件夹后,核心结构如下:
expenv/ ├── setvars.bat # 关键!双击执行,注入 PATH/EPATH/QEMU_PATH 等环境变量 ├── epos/ # SVN checkout 后生成的主目录 │ ├── kernel/ # 内核核心:调度器、内存管理、中断处理 │ ├── arch/ # 架构相关:x86 目录下有 machdep.c、idt.S、timer.S 等 │ ├── app/ # 用户应用:main.c、syscall.h、syscall-wrapper.S 所在地 │ ├── lib/ # 精简 C 库:printf、malloc 等实现(非 glibc) │ └── include/ # 全局头文件:kernel.h、syscall-nr.h、types.h 等 └── tools/ # 调试工具:bochsdbg.exe、qemu-system-i386.exe重点看epos/arch/x86/—— 这是实验一的主战场。machdep.c是机器相关层的 C 实现入口,idt.S定义中断描述符表,timer.S实现 PIT(可编程间隔定时器)驱动,而g_timer_ticks这个全局变量,就定义在timer.S里,每毫秒由硬件中断触发一次inc操作。epos/include/下的syscall-nr.h是系统调用号的唯一真相源,所有新增 syscall 必须在此注册;epos/kernel/下的syscall.c(或本实验中集成在machdep.c的syscall函数)是系统调用分发中枢;epos/app/则是用户态代码的容器,main.c是唯一启动点,syscall-wrapper.S是用户态到内核态的“签证官”。
提示:
epos/app/syscall-wrapper.S中的WRAPPER(time)宏不是语法糖,它展开后生成一段标准 x86 汇编胶水代码:先将用户传入的参数(如time_t *loc)压栈,再call sys_time,最后ret返回。你必须理解这段汇编,否则无法调试ctx->esp+4的偏移计算错误。
2.2setvars.bat的真实作用:不只是设 PATH,它在重写整个构建上下文
双击setvars.bat后,cmd 窗口会输出类似:
Setting EPOS environment variables... EPATH = C:\expenv\epos QEMU_PATH = C:\expenv\tools\qemu MINGW_PATH = C:\expenv\tools\mingw Added to PATH: C:\expenv\tools\mingw\bin;C:\expenv\tools\qemu;...但真正关键的是它设置的两个隐性变量:
MAKEFLAGS=-j1:强制单线程编译,避免多核并行导致的链接顺序错乱(EPOS 的 Makefile 未完全适配并发)CC=gcc -m32:指定生成 32 位目标码,因为 QEMU 启动的是i386模式,且 EPOS 内存布局基于 32 位寻址(0xC0000000以上为内核空间)
如果你跳过setvars.bat直接cd epos/app && make run,gcc会默认生成 64 位代码,ld链接时抛出i386 architecture of input file ... is incompatible with i386:x86-64 output。这不是报错信息模糊,而是构建链路被彻底绕开——setvars.bat是沙箱的“启动密钥”,不是可选项。
2.3make run的完整流程:从源码到 QEMU 屏幕的七步链
make run不是一条命令,而是一个 Makefile 触发的自动化流水线。拆解其真实步骤(以epos/app/Makefile为准):
预处理:
gcc -E -I../include main.c > main.i
展开所有#include和宏,确认syscall.h正确包含time_t time(time_t *)声明。编译为汇编:
gcc -S -m32 -I../include main.c -o main.s
生成main.s,重点检查call time指令是否出现——若没出现,说明syscall.h未被正确包含或time()声明有误。汇编为目标码:
gcc -c -m32 main.s -o main.o
生成main.o,此时符号表中应有time(未定义,需链接)和printf(来自lib/libc.a)。链接用户程序:
ld -m i386pe -Ttext 0x100000 -o app.elf main.o ../lib/libc.a-Ttext 0x100000指定用户程序加载地址为 1MB,避开低地址 BIOS 区和内核代码区(内核通常加载在0xC0000000)。构建内核镜像:
cd ../kernel && make
编译kernel.o、machdep.o、syscall.o等,最终链接成kernel.bin(纯二进制,无 ELF 头)。合并内核与用户程序:
cat kernel.bin app.elf > image.bin
将内核二进制与用户 ELF 文件拼接——注意:app.elf是完整 ELF 文件,QEMU 会自动解析其PT_LOAD段并加载到0x100000。启动 QEMU:
qemu-system-i386 -kernel image.bin -serial stdio-serial stdio将串口输出重定向到 cmd 窗口,printf输出即在此显示。若看到1449985033,说明time()系统调用成功穿越了用户/内核边界。
注意:
image.bin不是标准引导扇区(boot sector),EPOS 使用 QEMU 的-kernel参数直接加载内核入口,跳过实模式阶段。这意味着machdep.c中的startup()函数就是第一条执行的 C 代码,g_startup_time的初始化就在其中。
3. 核心模块拆解:sys_time()从声明到执行的完整链路
实验一的核心不是“写个函数”,而是让time(NULL)这个看似简单的 C 库调用,在无任何操作系统支撑的裸机环境下,完成一次完整的特权级切换、参数传递、内核计算、结果返回。这条链路上每个环节都不可省略,且任意一环出错,QEMU 就直接黑屏或打印乱码。我们逐段拆解sys_time()的生命周期,从头文件声明到最终printf输出。
3.1 内核空间:machdep.c中的sys_time()实现与上下文捕获
sys_time()的实现位于epos/arch/x86/machdep.c,代码极简但含义深刻:
// machdep.c #include "kernel.h" #include "arch/x86/timer.h" // 提供 g_timer_ticks 和 HZ 定义 time_t sys_time() { return g_startup_time + g_timer_ticks / HZ; }这里的关键不是算法,而是上下文依赖:
g_startup_time:在kernel/startup.c中由startup()函数初始化,记录内核启动时刻的滴答数(ticks),单位是HZ(本实验为 100,即 10ms/tick)。g_timer_ticks:定义在arch/x86/timer.S中,是一个全局long变量,由 PIT 中断服务程序(ISR)每 10ms 执行inc g_timer_ticks更新。HZ:定义在include/arch/x86/types.h中,#define HZ 100。注意:这不是 Linux 的CONFIG_HZ,而是 EPOS 硬编码的定时器频率。
sys_time()本身不访问任何用户空间内存,它只读内核全局变量,因此是安全的。但它的返回值需要被正确传递给用户态——这就引出了syscall分发函数中的关键逻辑。
3.2 系统调用分发:machdep.c中的syscall函数与栈帧解析
EPOS 的系统调用入口统一由machdep.c中的syscall()函数处理。该函数接收一个Context *ctx参数,它是 CPU 寄存器状态的快照(保存在内核栈上)。当用户执行int 0x80(或本实验中call syscall_entry)时,CPU 自动将eip、cs、eflags、esp、ss压栈,并跳转到syscall_entry(定义在arch/x86/idt.S),后者再调用syscall(ctx)。
syscall()中新增的分支如下:
// machdep.c 中 syscall() 函数片段 case SYSCALL_time: { time_t *loc = *(time_t **)(ctx->esp + 4); // 关键!用户传参在栈顶+4字节处 ctx->eax = sys_time(); // 计算结果存入 eax,作为返回值 if (loc != NULL) // 若用户传入非 NULL 指针 *loc = ctx->eax; // 将结果写回用户空间内存 } break;这段代码的魔鬼细节在(ctx->esp + 4):
ctx->esp是发生中断时用户栈指针的值(即int 0x80指令后的栈顶)。- 用户调用
time()时,汇编胶水WRAPPER(time)会先push参数(time_t *loc),再call sys_time。 - 因此,
ctx->esp指向push后的栈顶(即参数地址),而ctx->esp + 4就是该参数的值(因为 x86 是小端,32 位指针占 4 字节)。 - 如果你写成
ctx->esp或ctx->esp + 8,loc就会是随机值,*loc = ...就会向非法地址写入,QEMU 立即崩溃。
提示:
ctx->eax是返回值寄存器。所有系统调用约定都通过eax返回整数结果,sys_time()的time_t(long)正好能塞进eax(32 位)。若返回值超 32 位(如off_t),则需用edx:eax。
3.3 用户空间胶水:syscall-wrapper.S中的WRAPPER(time)宏展开
用户态无法直接call sys_time,必须通过特定汇编指令触发系统调用。epos/app/syscall-wrapper.S中的WRAPPER宏是这一切的起点:
// syscall-wrapper.S 末尾 WRAPPER(time)该宏定义在include/arch/x86/syscall.h中:
// include/arch/x86/syscall.h .macro WRAPPER name .globl name name: pushl %eax // 保存 eax(可能被破坏) movl $SYSCALL_\name, %eax // 将系统调用号(2014)装入 eax int $0x80 // 触发系统调用中断 popl %eax // 恢复 eax ret // 返回用户代码 .endm当WRAPPER(time)展开后,生成的time函数等价于:
.globl time time: pushl %eax movl $2014, %eax int $0x80 popl %eax retint $0x80是关键:它触发 CPU 进入特权级 0(内核态),CPU 自动切换到内核栈,保存用户寄存器到Context结构,并跳转到idt.S中定义的syscall_entry。此时eax=2014就是系统调用号,syscall()函数靠它匹配case SYSCALL_time。
3.4 用户空间接口:syscall.h中的 C 声明与类型对齐
epos/app/syscall.h提供了 C 语言友好的封装:
// syscall.h #ifndef _SYSCALL_H_ #define _SYSCALL_H_ #include <types.h> // 声明 time() 系统调用的 C 接口 time_t time(time_t *loc); #endif这里time_t的定义必须与内核一致。查看include/types.h:
// include/types.h typedef long time_t; // 32 位 long,与 x86 eax 寄存器宽度匹配如果time_t被误定义为int或long long,main.c中time_t t; time(&t);的传参就会因大小不匹配导致栈错位——ctx->esp+4取到的就不是&t的地址,而是垃圾值。
4. 调试与排错:Bochs Enhanced Debugger 下的汇编级真相
当make run启动 QEMU 后屏幕一片漆黑,或printf打印出0、-1、乱码,甚至 QEMU 直接退出,别急着make clean重来。make debug启动的 Bochs Enhanced Debugger(BED)才是你的终极武器——它让你看到time()调用背后每一行汇编的执行轨迹,比 GDB 更底层,比日志更真实。但 BED 不是点开就用的 IDE,它需要你理解 x86 栈帧、寄存器用途和中断机制。以下是我在某次time(NULL)返回0时的真实排错过程。
4.1 启动 BED 并定位问题函数
在epos/app/目录下执行make debug,BED 启动后默认停在内核入口startup()。你需要手动走到main()执行点:
bochsdbg> c # continue,运行到用户 main() bochsdbg> s # step,单步执行 ...(跳过 startup 初始化) bochsdbg> u main # disassemble main function <0x00100000> main: push ebp <0x00100001> main+1: mov ebp,esp <0x00100004> main+4: sub esp,0x10 <0x00100007> main+7: mov DWORD PTR [ebp-4],0x0 <0x0010000e> main+14: mov DWORD PTR [ebp-8],0x0 <0x00100015> main+21: push 0x0 # time(NULL) 的参数:NULL <0x00100017> main+23: call 0x100020 # 调用 time 函数(地址 0x100020)push 0x0证实参数NULL已压栈;call 0x100020是time函数入口。继续s进入time:
bochsdbg> s <0x00100020> time: push eax <0x00100021> time+1: mov eax,0x7e6 # 0x7e6 = 2022? 错了!应该是 2014发现mov eax,0x7e6(2022),而非预期的2014。立刻检查syscall-nr.h:
// syscall-nr.h #define SYSCALL_time 2014 // 正确 // 但下面有一行被误加: #define SYSCALL_time2 2022 // 错误!导致 WRAPPER(time) 展开时用了这个WRAPPER(time)宏展开时,若SYSCALL_time定义被覆盖或重复,movl $SYSCALL_\name就会取错值。现象:time()返回随机值或崩溃;原因:syscall-nr.h中宏定义污染;解决:删除多余#define,确保SYSCALL_time唯一且为2014。
4.2 常见问题与避坑指南:血泪经验总结
以下是我和 A同学在三次make run失败后整理的硬核避坑清单,每一条都对应真实 panic 场景:
现象 1:QEMU 启动后立即黑屏,无任何输出
→原因:setvars.bat未执行,导致gcc生成 64 位代码,qemu-system-i386加载失败。make run日志中ld报错i386:x86-64 output被忽略。
→解决:关闭所有 cmd 窗口,重新双击setvars.bat,再cd epos/app && make clean && make run。务必确认 cmd 窗口首行输出Setting EPOS environment variables...。
现象 2:printf("%ld", t)输出0或极小负数(如-123456789)
→原因:sys_time()中g_timer_ticks未被正确更新,或g_startup_time初始化错误。常见于timer.S中 PIT 初始化代码被注释,或startup()中init_timer()调用遗漏。
→解决:在kernel/startup.c的startup()函数末尾添加print("timer init ok\n");,并在arch/x86/timer.S的timer_init函数中确认outb $0x34, $0x43等 PIT 编程指令存在。用bochsdbg> u timer_init查看汇编。
现象 3:time(&t)后printf("%ld", *pointer)打印乱码或崩溃
→原因:ctx->esp+4偏移错误,导致loc指针指向非法地址。WRAPPER(time)宏中pushl %eax后栈布局变化,ctx->esp值与预期不符。
→解决:在syscall()的case SYSCALL_time前加调试打印:print("sys_time: esp=%x, loc_ptr=%x\n", ctx->esp, *(unsigned int*)(ctx->esp+4));。若loc_ptr为0x00000000(NULL)则正常;若为0x00100000等用户地址,则+4正确;若为0xffffffff等,则+4错误,应尝试+0或+8。
现象 4:make debug启动 BED 后卡死,无响应
→原因:bochsdbg.exe与 Windows 杀毒软件冲突(尤其 360、腾讯电脑管家),或expenv路径含中文/空格。
→解决:将expenv移至C:\epos\等纯英文短路径;临时关闭杀软;或改用bochsdbg -q -f bochsrc.txt(需手写配置文件)。
现象 5:make clean后make run仍报undefined reference to 'time'
→原因:epos/app/Makefile中LIBS变量未包含../lib/libc.a,或syscall-wrapper.o未加入OBJS。time符号在syscall-wrapper.S中定义,若未编译进app.elf,链接必失败。
→解决:检查epos/app/Makefile,确认OBJS = main.o syscall-wrapper.o,且LIBS = ../lib/libc.a。执行nm app.o | grep time查看time符号是否存在。
5. 二次开发实战:如何安全地扩展第二个系统调用sys_getpid()
现在sys_time()已稳定运行,下一步不是复制粘贴,而是理解 EPOS 系统调用机制的可扩展性。我们以增加getpid()为例,演示如何在不破坏现有逻辑的前提下,安全注入新功能。关键原则:每个 syscall 号唯一、每个 wrapper 独立、每个内核函数无副作用、用户接口类型严格对齐。
5.1 内核空间:定义sys_getpid()与进程 ID 机制
EPOS 的进程 ID(PID)并非 Linux 式的全局递增,而是每个进程的Context结构体地址的低 16 位(因内存布局紧凑)。epos/kernel/process.h中已有Process结构,但 PID 未暴露。我们在machdep.c中新增:
// machdep.c #include "kernel/process.h" pid_t sys_getpid() { // 获取当前运行的 Process 结构指针(通过 TSS 或当前栈推导) // EPOS 简化实现:直接返回当前 Context 的地址哈希 Context *current = cpu_context(); // 假设此函数存在,返回当前上下文 return (pid_t)((unsigned int)current & 0xFFFF); }但cpu_context()需要实现。查看arch/x86/context.h,发现已有current_context全局变量。因此安全实现为:
// machdep.c extern Context *current_context; // 声明外部变量 pid_t sys_getpid() { return (pid_t)((unsigned int)current_context & 0xFFFF); }同时,在include/types.h中添加typedef int pid_t;(32 位足够)。
5.2 系统调用号与分发:原子化注册与分支隔离
在syscall-nr.h中追加:
// syscall-nr.h #define SYSCALL_getpid 2015 // 严格递增,避免与 time(2014) 冲突在machdep.c的syscall()函数中,新增独立分支(注意break不可少):
case SYSCALL_getpid: ctx->eax = sys_getpid(); break; // 必须 break,否则 fall-through 到下一个 case提示:
SYSCALL_getpid必须大于SYSCALL_time,且不能与现有 syscall 号(如SYSCALL_exit=1)重复。EPOS 的 syscall table 是线性数组,号即索引。
5.3 用户空间:新增 wrapper 与 C 接口
在epos/app/syscall-wrapper.S末尾添加:
WRAPPER(getpid)在epos/app/syscall.h中添加:
// syscall.h pid_t getpid(void);在epos/app/main.c中测试:
// main.c #include "syscall.h" int main() { time_t t = time(NULL); printf("time: %ld\n", t); pid_t pid = getpid(); // 新增调用 printf("pid: %d\n", pid); return 0; }5.4 验证与回归:确保 time() 不受新 syscall 影响
修改完成后,执行完整验证链:
make clean清理所有.o和image.binmake run启动 QEMU,确认输出包含time: 1449985033和pid: 1234(具体值不重要,非零即可)make debug启动 BED,u getpid确认mov eax,0x7e7(2015)正确- 关键回归:注释掉
main.c中getpid()调用,仅保留time(),再次make run,确认time()输出不变
这一步验证了 syscall 机制的正交性:新增getpid()未改变time()的栈帧解析逻辑、未污染ctx->esp偏移、未影响g_timer_ticks更新。真正的系统设计不是堆功能,而是保证每个模块的变更边界清晰。
从那以后我每次新增系统调用,都强制走一遍这四步验证:clean → run(功能)→ debug(汇编)→ run(回归)。不是怕写错代码,而是怕破坏已有的契约——ctx->esp+4是用户和内核之间的一纸协议,SYSCALL_time=2014是 ABI 的宪法条款。EPOS 教给我的第一课,不是怎么写内核,而是如何敬畏接口。希望帮到你。
本文还有配套的精品资源,点击获取