手撕系统调用:从time(NULL)到内核timer的裸机实现

发布时间:2026/11/2 17:01:29
手撕系统调用:从time(NULL)到内核timer的裸机实现

简介:本资源是重庆大学软件学院《操作系统》课程实验一的完整配套文档,面向计算机专业本科生及操作系统初学者,聚焦系统调用原理与内核级编程实践。文档详细解析了基于EPOS嵌入式POSIX操作系统的实验全流程:从SVN拉取源码、Notepad+++命令行开发环境搭建,到在QEMU中编译运行与Bochs调试;重点覆盖内核空间(machdep.c/syscall-nr.h/kernel.h)与用户空间(syscall-wrapper.S/syscall.h/main.c)双端协同实现time系统调用的完整代码结构、接口定义与调用验证逻辑。资源为1个740KB的docx文件,内容含实验步骤详解、关键代码段注释、make命令说明(run/debug/clean)、汇编与C语言映射关系提示及典型排错思路,结构清晰、实操导向强。已有185人学习下载,适合需深入理解系统调用机制、掌握OS内核开发入门路径的学习者系统研读与复现。

1. 这不是“写个 printf 就完事”的系统调用实验:它让你第一次亲手把用户代码的time(NULL)变成内核里跳动的g_timer_ticks/HZ,全程在 QEMU 里裸跑,不依赖 Linux、不碰 glibc、连 libc 都没影——重大软院操作系统实验一,专治“以为系统调用就是封装好的 API”这种认知幻觉

你写time(NULL),它真就调进去了;你改g_startup_time,QEMU 里打印的数字立刻变;你删掉WRAPPER(time)那一行汇编,main.c编译直接报 undefined reference;你漏加case SYSCALL_time分支,QEMU 启动后黑屏三秒然后 panic —— 这不是模拟器教学 demo,是 EPOS 这个嵌入式 POSIX 操作系统的真实内核现场。它没有 systemd、没有 procfs、没有动态链接器,只有.S汇编启动代码、.c内核逻辑、.h接口契约,和一条从main.c到machdep.c再到timer.S的、肉眼可追踪的执行链路。适合刚啃完《操作系统导论》前六章、手痒想摸真实内核脉搏的本科生,也适合被 Linux 内核巨构吓退、需要一个“能在一个下午跑通并改出结果”的轻量级入口的自学者。它不教你怎么写调度器,但教会你:所谓“系统调用”,本质是一次受控的特权级切换 + 寄存器传参 + 内核函数分发 + 返回值回填 —— 全部手动抠出来,没有魔法。


2. 环境搭建与项目结构:从 setvars.bat 到 epos/app,看清这个“微型 OS 实验箱”的物理边界

2.1 expenv-with-notepad-plus-plus:不是 IDE,是定制化沙盒环境

expenv是重大软院为本实验预置的 Windows 开发沙盒。它打包了三样东西:

  • Notepad++:作为轻量编辑器(非 VS Code/CLion),原因很实在——它能高亮.S汇编语法、支持宏录制(后续批量改 syscall-nr.h 时你会感谢这点)、插件少启动快,避免学生在配置 LSP 上卡半天;
  • MinGW-w64 工具链:提供gcc(编译 C)、as(汇编)、ld(链接)等,关键在于其i686-elf-gcc前缀,表明这是面向裸机(no OS)的交叉编译器,生成的是 ELF 格式可执行文件,而非 Windows PE;
  • setvars.bat:双击即执行的环境初始化脚本,核心动作有三:
    @echo off set PATH=%~dp0mingw\bin;%PATH% set EPOS_ROOT=%~dp0epos start notepad++.exe cmd /k
    它把 MinGW 的bin目录塞进PATH,设好EPOS_ROOT环境变量(后续make依赖此路径找头文件),再同时拉起 Notepad++ 和干净的cmd。注意:cmd是独立进程,setvars.bat关闭不影响其环境变量——这是 Windows 批处理的坑点,也是为什么你必须在该cmd里操作,而不是另开一个。

提示:若setvars.bat双击后一闪而过,说明mingw\bin下缺少gcc.exe或路径有空格未转义。用记事本打开setvars.bat,在末尾加pause临时排查。

2.2 SVN checkout 与 epos 目录树:理解 EPOS 的“分层裸机架构”

执行svn checkout http://isse.cqu.edu.cn/svn/epos后,expenv\epos目录结构如下(精简关键路径):

epos/ ├── kernel/ # 内核核心:中断处理、内存管理、调度框架 ├── arch/ # 架构适配层:x86 相关寄存器定义、IDT 设置、汇编启动代码 │ └── x86/ # 当前实验锁定 x86 │ ├── boot.S # 实模式 → 保护模式切换,加载内核到 0x100000 │ ├── timer.S # PIT(可编程间隔定时器)驱动,暴露 g_timer_ticks 符号 │ └── syscall.S# 系统调用入口:int 0x80 中断向量处理,保存/恢复寄存器 ├── machdep/ # 机器相关依赖:当前实验聚焦于此,含 machdep.c ├── app/ # 用户应用目录:所有实验代码放这里,独立于内核编译 │ ├── main.c # 入口点,链接时作为用户程序起点 │ ├── syscall-wrapper.S # 用户态系统调用胶水汇编 │ └── syscall.h # 用户态 C 接口声明 ├── include/ # 全局头文件:kernel.h, syscall-nr.h 等在此 └── Makefile # 根目录 Makefile,定义 all: kernel app run debug clean 等目标

关键认知:epos/kernel/和epos/app/是完全分离编译的。make run实际执行两步:

  1. cd kernel && make→ 生成kernel.bin(内核镜像);
  2. cd app && make→ 生成app.bin(用户程序镜像);
  3. qemu-system-i386 -kernel kernel.bin -initrd app.bin→ QEMU 加载二者,kernel.bin初始化硬件后跳转到app.bin入口。
    这解释了为何app/main.c能调用time()却不链接 glibc:app.bin的符号表里,time是syscall-wrapper.S里WRAPPER(time)生成的桩函数,它最终触发int 0x80进入内核。

2.3 make run 与 QEMU 的启动逻辑:看懂那个“黑窗口”里发生了什么

make run命令展开后,核心是:

run: kernel app qemu-system-i386 -m 64M -nographic -kernel $(EPOS_ROOT)/kernel/kernel.bin \ -initrd $(EPOS_ROOT)/app/app.bin -append "console=ttyS0" \ -serial stdio -d in_asm,cpu_reset

参数含义:

  • -nographic:禁用图形界面,所有输出重定向到cmd窗口(即你看到的“黑屏”);
  • -serial stdio:将串口输出映射到标准输入输出,printf才能在cmd里显示;
  • -d in_asm,cpu_reset:开启调试日志,in_asm让 QEMU 在每条指令执行前打印汇编(调试时很有用,但默认关闭以提速);
  • -append "console=ttyS0":告诉内核把控制台输出到串口 0,与-serial stdio配合。

当你看到 QEMU 窗口打出1449985033,这意味着:

  1. app.bin的main()执行time(NULL);
  2. syscall-wrapper.S的time桩函数将eax=2014(SYSCALL_time号)、esp+4指向NULL地址压栈,执行int 0x80;
  3. arch/x86/syscall.S捕获中断,保存用户寄存器到栈,跳转到machdep.c的syscall()函数;
  4. syscall()查eax得2014,进入case SYSCALL_time分支;
  5. sys_time()计算g_startup_time + g_timer_ticks/HZ并返回;
  6. 结果写入ctx->eax,syscall.S恢复寄存器,iret返回用户态;
  7. main.c读取eax值并printf。
    整个链路无任何中间层,纯手工接线。

3. 核心模块拆解:从 sys_time() 到 time(),逐行解析系统调用的完整数据流

3.1 内核空间实现:machdep.c 中的 sys_time() 与 syscall() 分发逻辑

machdep.c是 EPOS 中“机器相关功能”的集中地,实验一要求在此实现时间系统调用。关键代码段如下:

// machdep.c #include "kernel.h" #include "arch/x86/timer.h" // 提供 g_timer_ticks, HZ 定义 // K1: 系统调用函数实现 time_t sys_time() { return g_startup_time + g_timer_ticks / HZ; } // K4: 系统调用分发函数(位于 machdep.c 的 syscall() 函数内) void syscall(Context * ctx) { switch(ctx->eax) { // ... 其他 case 省略 case SYSCALL_time: { // 匹配 syscall-nr.h 定义的 2014 time_t *loc = *(time_t **)(ctx->esp + 4); // 从用户栈取第1个参数(loc指针) ctx->eax = sys_time(); // 执行内核函数,结果存入 eax if(loc != NULL) // 若 loc 非空,将结果写回用户内存 *loc = ctx->eax; } break; // ... 其他 case } }

逐行逻辑说明:

  • g_startup_time:内核启动时记录的jiffies(滴答数),类型time_t(通常为long);
  • g_timer_ticks:全局变量,在arch/x86/timer.S中由 PIT 中断服务程序每1/HZ秒递增一次;
  • HZ:在include/kernel/config.h中定义为100,即每秒 100 次定时器中断;
  • ctx->esp + 4:Context结构体保存了用户态中断前的寄存器快照。int 0x80触发后,CPU 自动将eip(下条指令地址)、cs、eflags、esp、ss压栈,此时用户栈顶是eip,esp+4即原用户esp值,也就是time()调用时压入的第一个参数地址(loc)。此处强制类型转换(time_t **)是为了双重解引用:先取loc的值(用户内存地址),再将sys_time()结果写入该地址。
  • ctx->eax = ...:系统调用约定,返回值通过eax传递给用户态。

注意:sys_time()本身不检查loc是否合法(如指向内核地址),这是 EPOS 教学设计的简化——真实内核会用copy_to_user()做地址验证。

3.2 用户空间胶水:syscall-wrapper.S 中的 WRAPPER 宏与调用约定

epos/app/syscall-wrapper.S是用户态与内核的“协议翻译器”。实验要求在文件末尾添加:

// syscall-wrapper.S #include "include/syscall-nr.h" // U1: WRAPPER 宏定义(通常在文件头部已存在,只需调用) .macro WRAPPER name pushl %ebp movl %esp, %ebp pushl 8(%ebp) // 将第1个参数(loc)压栈(符合 cdecl 调用约定) movl $SYSCALL_\name, %eax // 将系统调用号(2014)装入 eax int $0x80 // 触发系统调用中断 popl %ebp ret .endm // 在文件末尾添加: WRAPPER time

汇编逻辑说明:

  • pushl %ebp; movl %esp, %ebp:建立标准栈帧,便于调试时回溯;
  • pushl 8(%ebp):8(%ebp)是第一个参数地址(%ebp+8),pushl将其值(即loc指针)压栈。注意:time(NULL)时压入的是0,time(&t)时压入的是&t的地址;
  • movl $SYSCALL_time, %eax:$表示立即数,SYSCALL_time是syscall-nr.h中的2014;
  • int $0x80:x86 经典系统调用中断,CPU 切换到内核态,跳转至arch/x86/syscall.S的入口;
  • popl %ebp; ret:恢复栈帧并返回。

此段汇编生成的符号名为time,与syscall.h中声明的 C 函数名一致,链接器自动匹配。

3.3 用户接口与调用:syscall.h 声明与 main.c 的两种调用方式

epos/app/syscall.h添加声明:

// U2: C 语言接口声明 #ifndef _SYSCALL_H_ #define _SYSCALL_H_ #include <types.h> // time_t 定义在 types.h 中,通常为 long time_t time(time_t *loc); #endif

epos/app/main.c中的调用分为两种场景:

场景一:time(NULL)—— 只获取时间戳

// U3a: main.c 片段 #include "syscall.h" #include "stdio.h" int main() { time_t t = time(NULL); // loc 为 NULL,内核只返回值,不写内存 printf("Current time: %ld\n", t); return 0; }

此时WRAPPER time压入的参数是0,内核syscall()中*(time_t **)(ctx->esp+4)解引用得到NULL,跳过*loc = ctx->eax,仅返回值。

场景二:time(&t)—— 获取并存入变量

// U3b: main.c 片段 int main() { time_t t; time_t *pointer = &t; // pointer 指向栈上变量 t time(pointer); // 压入 pointer 的值(即 &t 地址) printf("Time via pointer: %ld\n", *pointer); // 输出与 t 相同 return 0; }

此时WRAPPER time压入的是&t的地址,内核成功将sys_time()结果写入用户栈变量t。

关键区别:time(NULL)返回值在eax,time(&t)除返回值外还修改了用户内存。这是系统调用“传参”与“返回”的经典分离设计。


4. 调试与排错:Bochs Enhanced Debugger 不是玄学,是寄存器级的显微镜

4.1 make debug 启动 Bochs:理解调试器与 QEMU 的分工

make debug实际执行:

bochs -q -f bochsrc-debug -log debug.log

其中bochsrc-debug配置文件指定:

  • megs: 64:内存大小;
  • romimage: file=$BXSHARE/BIOS-bochs-latest:BIOS 镜像;
  • boot: disk:从磁盘启动(即kernel.bin);
  • magic_break: enabled=1:启用xchg bx,bx断点指令;
  • debug: action=report:调试日志级别。

Bochs 与 QEMU 的核心差异:

  • QEMU:侧重性能,-d in_asm只能看指令流,无法修改寄存器或内存;
  • Bochs:全功能仿真器,支持单步(s)、断点(b)、寄存器查看(r)、内存查看(x)、汇编反汇编(u),是真正的“裸机调试器”。

当make debug启动后,你看到的Bochs Enhanced Debugger提示符(bx_dbg),就是你的控制台。

4.2 定位系统调用失败的三板斧:从 panic 日志到寄存器快照

常见问题及 Bochs 调试法:

现象:QEMU 启动后黑屏,几秒后打印PANIC: Invalid system call number: 2014

原因:syscall()函数中未定义case SYSCALL_time,或SYSCALL_time宏值与ctx->eax不匹配。
解决:

  1. 在machdep.c的syscall()函数开头加printf("syscall: eax=%d\n", ctx->eax);,重新make run看输出;
  2. 若确认eax=2014,检查syscall-nr.h中#define SYSCALL_time 2014是否拼写错误(如多空格、分号);
  3. 在 Bochs 中,c运行至 panic 前,r查看eax值,u反汇编syscall函数,确认switch的 jump table 是否包含2014分支。
现象:printf输出乱码或0,而非时间戳

原因:sys_time()计算错误,或g_timer_ticks未被正确初始化/更新。
解决:

  1. 在sys_time()中插入printf("g_startup_time=%ld, g_timer_ticks=%ld, HZ=%d\n", g_startup_time, g_timer_ticks, HZ);;
  2. 若g_timer_ticks恒为0,说明timer.S的 PIT 中断未启用。检查arch/x86/timer.S中timer_init()是否被kernel/main.c调用;
  3. 在 Bochs 中,b *0x100000(内核入口),c,s单步至timer_init(),x/10i $eip查看初始化指令是否执行。
现象:time(&t)后*pointer仍是随机值,未被修改

原因:ctx->esp+4解引用错误,或loc地址非法。
解决:

  1. 在syscall()的case SYSCALL_time中加printf("loc=%p, *loc before=%ld\n", loc, loc ? *loc : 0);;
  2. 若loc显示为0x00000000,说明WRAPPER time未正确压入参数,检查syscall-wrapper.S中pushl 8(%ebp)是否写成pushl 4(%ebp)(后者是ebp自身);
  3. 在 Bochs 中,c至case分支,r查看esp值,x/wx $esp+4查看esp+4处存储的值(应为&t地址),再x/wx <addr>验证该地址内容是否可写。

提示:Bochs 中x/10wx $esp可查看用户栈顶 10 个字,x/10i $eip查看当前指令,s单步执行一条汇编,c继续运行。记住s是单步(step into),n是单步(next over),对call指令效果不同。


5. 二次开发实战:如何安全地扩展第二个系统调用?一套可复用的 checklist

5.1 新增系统调用的标准化流程:从编号分配到跨空间验证

假设你要新增getpid()系统调用(返回当前进程 ID),需严格遵循以下步骤,每步都对应一个可验证的检查点:

步骤文件位置操作验证方法
1. 分配唯一编号include/syscall-nr.h#define SYSCALL_getpid 2015grep SYSCALL_getpid include/syscall-nr.h应输出一行
2. 内核实现函数machdep/machdep.cpid_t sys_getpid() { return current->pid; }(需先确保current指针可用)make kernel成功,无未定义符号
3. 声明函数include/kernel.hpid_t sys_getpid(void);grep sys_getpid include/kernel.h存在
4. 分发分支machdep/machdep.c的syscall()case SYSCALL_getpid: ctx->eax = sys_getpid(); break;make kernel后,nm kernel/kernel.bin | grep sys_getpid应显示T sys_getpid(T 表示已定义)
5. 用户胶水app/syscall-wrapper.SWRAPPER getpidmake app成功,nm app/app.bin | grep getpid应显示T getpid
6. 用户声明app/syscall.hpid_t getpid(void);gcc -Iinclude -c app/main.c应无警告
7. 用户调用app/main.cprintf("PID: %d\n", getpid());make run后 QEMU 输出数字

注意:current是 EPOS 中指向当前进程控制块(TCB)的全局指针,定义在kernel/process.h,需在machdep.c中#include "kernel/process.h"。若current未初始化,getpid()会返回垃圾值——此时需检查kernel/init.c中init_process()是否正确设置了current。

5.2 避坑指南:三个血泪经验总结

坑一:syscall-nr.h 中编号重复或越界

  • 现象:make kernel报错error: ‘SYSCALL_time’ undeclared here,或make run后内核 panic。
  • 原因:SYSCALL_time定义在#ifdef __KERNEL__条件编译块外,而app/目录编译时未定义__KERNEL__,导致syscall-wrapper.S中SYSCALL_time展开失败。
  • 解决:确认syscall-nr.h中所有#define SYSCALL_xxx都在#ifndef __KERNEL__/#endif之外,或统一用#pragma once替代条件编译。

坑二:WRAPPER 宏调用后忘记在 syscall.h 声明

  • 现象:main.c编译报warning: implicit declaration of function ‘getpid’,链接时报undefined reference to ‘getpid’。
  • 原因:WRAPPER getpid生成了getpid符号,但syscall.h未声明,gcc默认按int getpid()推断,与实际pid_t返回值不匹配。
  • 解决:声明必须与实现严格一致,pid_t getpid(void)中的void表示无参数,不可省略。

坑三:用户态传参地址在内核态不可访问

  • 现象:time(&t)后*pointer为0或0xffffffff,内核panic: page fault。
  • 原因:EPOS 的app.bin运行在用户态(CPL=3),其虚拟地址空间与内核(CPL=0)隔离。loc指针是用户地址,内核可直接解引用(因 EPOS 未启用分页或使用简单映射),但若未来启用 MMU,必须用copy_from_user()。
  • 解决:当前实验无需处理,但需意识到这是教学简化。真实开发中,所有用户指针都需验证:if (!access_ok(VERIFY_WRITE, loc, sizeof(time_t))) return -EFAULT;。

5.3 快速验证技巧:用 Bochs 截获一次完整的 time() 调用

想亲眼看到time(NULL)如何一步步变成1449985033?用 Bochs 做一次端到端跟踪:

  1. 在app/main.c的time(NULL)上一行加asm volatile ("xchg bx,bx");(Bochs 断点指令);
  2. make debug启动 Bochs;
  3. (bx_dbg)下输入c运行,停在xchg处;
  4. 输入u 10查看接下来 10 条汇编,找到call time指令;
  5. 输入s单步进入time函数,u查看WRAPPER time汇编;
  6. s执行int $0x80,Bochs 自动切到内核态,停在arch/x86/syscall.S的syscall_entry;
  7. s单步至call syscall,再s进入machdep.c的syscall();
  8. p ctx->eax确认值为2014,s进入case SYSCALL_time;
  9. p g_timer_ticks和p g_startup_time,计算g_startup_time + g_timer_ticks/HZ,与最终printf输出比对。

这套流程走一遍,系统调用再也不是黑匣子。从那以后我每次新增 syscall,都强制走一遍 Bochs 单步,哪怕只是确认eax值对得上——这比反复make clean && make run看输出高效十倍。希望帮到你。

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