Alarm (hard)
实验目标
本实验要实现两个系统调用 sigalarm 与 sigreturn,为用户进程增加周期性通知能力:进程每使用 CPU 若干个 tick 后,内核就自动把它"拽"去执行一个用户态的回调函数(handler),执行完再原封不动地退回原处继续跑。
本质上这是用户态的"定时中断 / 异常处理"——内核模拟了硬件中断的语义,但 handler 运行在用户空间。
练习目标:
- 打通
sigalarm(n, fn)(注册:每n个 tick 调一次fn)与sigreturn()(handler 结束后恢复现场)的完整链路。 - 吃透 trapframe 的保存与恢复——这是整个实验的灵魂:为什么必须单独存一份
alarm_trapframe? - 处理好重入(re-entrancy):handler 自己还没返回时闹钟又响了怎么办?用
alarm_goingoff标志挡住嵌套(对应官方test2)。
这一关是 lab4 的硬骨头,直接考验你对 trap 全流程(用户态↔内核态切换、现场保存恢复)的理解是否真的落地。
前置知识
1. 什么是 trapframe,为什么需要"另一份"
trapframe 是 xv6 为每个进程准备的一页结构体,陷入内核时用户寄存器现场(epc、ra、sp、a0-a7、s0-s11…)全部原样保存在这里,返回用户态时再搬回去。结构见 kernel/trapframe.h(本质上是按 RISC-V 规范排布的一串寄存器槽位)。
关键在于:handler 是用户函数,它运行期间如果又做了系统调用(比如 sigreturn 本身,甚至 printf),内核会再次覆盖同一个 trapframe。所以一旦决定要跳去执行 handler,就必须先把"被打断那一刻的用户现场"另存一份到 alarm_trapframe,否则原程序的寄存器现场就永远丢了、回不去了。alarm_trapframe 就是为此而生的"备份现场"。
2. 定时器中断路径(usertrap)
xv6 的时钟中断来自 CLINT,usertrap() 里通过 which_dev == 2 判定是定时器中断。每次时钟 tick,ticks 全局计数器 +1,随后 usertrap 调用 yield() 让出 CPU。我们要做的,就是在这条路径上"插桩":tick 到点了,把 epc 改成 handler 地址,用户态返回时就会跑去 handler 而非原指令。
3. 重入问题与 alarm_goingoff
如果 handler 执行时间较长,期间又过了若干 tick,闹钟可能"再次到期"。若此时直接再次跳去 handler,会覆盖 alarm_trapframe(上一次现场还没恢复),导致第一次调用永远无法返回——灾难。解决:用一个标志 alarm_goingoff 表示"当前已有 handler 在跑",未返回前绝不再触发,把这次到期顺延到 handler 结束后。这正是官方 alarmtest 的 test2 要测的场景。
4. 系统调用注册链路(Lab2 复习)
新增一个系统调用,需要同步改 6 处(详见本博客"代码实现"末节):
user/user.h(声明)→ user/usys.pl(生成 usys.S 桩)→ kernel/syscall.h(分配编号)→ kernel/syscall.c(分发表 + extern 声明)→ kernel/sysproc.c(sys_xxx 实现)→ kernel/defs.h(内核内声明)。
5. 需要改动 / 新增的文件
| 文件 | 改动 |
|---|---|
kernel/proc.h | 在 struct proc 增加 5 个 alarm 字段 |
kernel/proc.c | allocproc 分配并初始化、freeproc 释放 |
kernel/sysproc.c | sys_sigalarm / sys_sigreturn 取参并调用内核实现 |
kernel/trap.c | sigalarm / sigreturn 真正实现;usertrap 中插桩触发 |
user/user.h user/usys.pl kernel/syscall.h kernel/syscall.c Makefile kernel/defs.h | 系统调用注册链路 |
实现思路
整体数据流如下:
- 注册:用户调
sigalarm(n, fn)→ 内核把n/fn存进进程,并把alarm_ticks(剩余倒计时)初始化为n。 - 触发:每次定时器中断,
usertrap里alarm_ticks倒计时归零且alarm_goingoff==0时:把当前trapframe备份到alarm_trapframe、把trapframe->epc改成 handler 地址、alarm_goingoff=1。随后照常usertrapret回到用户态——但这次回去是去执行 handler。 - 返回:handler 干完活调
sigreturn()→ 内核把alarm_trapframe拷回trapframe(现场复原),清alarm_goingoff=0。再usertrapret回去,就回到了被打断的那条指令,原程序无感知地继续。
代码实现
kernel/proc.h —— 进程结构体新增字段
/*
* kernel/proc.h
*/
// Per-process state
struct proc {
...
// 时钟相关
int alarm_interval; // 时钟周期,为 0 表示禁用时钟
void(*alarm_handler)(); // 时钟回调处理函数
int alarm_ticks; // 当前时钟信号数(ticks数)
struct trapframe* alarm_trapframe; // 时钟中断时刻进程的陷阱帧,用于恢复中断前的状态
int alarm_goingoff; // 是否已经有一个时钟中断正在执行且未返回
};
五个字段各司其职:alarm_interval 是周期(0 即停用)、alarm_handler 是回调、alarm_ticks 是剩余倒计时、alarm_trapframe 是现场备份、alarm_goingoff 防重入。
kernel/proc.c —— 分配与释放
allocproc 里在分配完普通 trapframe 之后,紧接着为 alarm_trapframe 也 kalloc 一页,并初始化所有 alarm 字段:
/*
* kernel/proc.c
*/
static struct proc*
allocproc(void)
{
...
found:
p->pid = allocpid();
// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
release(&p->lock);
return 0;
}
// 为 alarm_trapframe 分配陷阱帧
if((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0){
release(&p->lock);
return 0;
}
// 进程创建时初始化 alarm 相关变量
p->alarm_interval = 0;
p->alarm_handler = 0;
p->alarm_ticks = 0;
p->alarm_goingoff = 0;
...
}
freeproc 负责回收:
/*
* kernel/proc.c
*/
static void
freeproc(struct proc *p)
{
if(p->trapframe)
kfree((void*)p->trapframe);
p->trapframe = 0;
// 释放 alarm_trapframe
if (p->alarm_trapframe)
kfree((void*)p->alarm_trapframe);
p->alarm_trapframe = 0;
if(p->pagetable)
proc_freepagetable(p->pagetable, p->sz);
p->pagetable = 0;
p->sz = 0;
p->pid = 0;
p->parent = 0;
p->name[0] = 0;
p->chan = 0;
p->killed = 0;
p->xstate = 0;
p->alarm_interval = 0;
p->alarm_handler = 0;
p->alarm_ticks = 0;
p->alarm_goingoff = 0;
p->state = UNUSED;
}
kernel/sysproc.c —— 两个系统调用的入口
从用户态取出参数后,转交给 trap.c 里的真正实现。argint 取整型(n),argaddr 取函数指针(fn 在用户空间是地址):
/*
* kernel/sysproc.c
*/
uint64
sys_sigalarm(void)
{
int n; // n 个 ticks
uint64 fn; // 时钟回调函数
if (argint(0, &n) < 0) // 获取第一个参数
return -1;
if (argaddr(1, &fn) < 0) // 获取第二个参数
return -1;
return sigalarm(n, (void(*)())(fn)); // 调用并返回 sigalarm 函数
}
uint64
sys_sigreturn(void)
{
return sigreturn();
}
kernel/trap.c —— 核心实现与触发点
sigalarm 注册、sigreturn 恢复现场:
/*
* kernel/trap.c
*/
// 设置进程中时钟的相关属性
int
sigalarm(int ticks, void(*handler)())
{
struct proc* p = myproc();
p->alarm_interval = ticks;
p->alarm_handler = handler;
p->alarm_ticks = ticks;
return 0;
}
// 将进程恢复到时钟中断前的状态
int
sigreturn(void)
{
struct proc* p = myproc();
*p->trapframe = *p->alarm_trapframe;
p->alarm_goingoff = 0;
return 0;
}
注意:
sigreturn把整份alarm_trapframe拷回trapframe,包括epc——于是返回用户态时sepc = 原指令地址,原程序从被打断处重新执行(xv6 的 alarm 不跳过指令,设计如此)。a0等寄存器也一并复原,handler 的"返回值"对用户而言并不存在(因为它根本没"返回",而是被整体替换回原现场)。
在 usertrap 的定时器分支里插桩触发逻辑(这是把整套机制串起来的关键):
/*
* kernel/trap.c
*/
void
usertrap(void)
{
...
if(which_dev == 2){
if (p->alarm_interval != 0 && --p->alarm_ticks <= 0 && p->alarm_goingoff == 0){
/* 是否设置了时钟 && 时钟倒计时是否结束 && 没有其他时钟正在运行
* 如果一个时钟到期的时候已经有一个时钟处理函数正在运行,
* 则会推迟到原处理函数运行完成后的下一个 tick 才触发这次时钟
*/
p->alarm_ticks = p->alarm_interval;
*p->alarm_trapframe = *p->trapframe; // 保存当前进程陷阱帧
p->trapframe->epc = (uint64)p->alarm_handler; // 跳转到时钟回调函数
p->alarm_goingoff = 1; // 标记当前已经有时钟在运行
}
yield();
}
usertrapret();
}
逻辑要点:倒计时 --alarm_ticks <= 0 归零、且没有 handler 在跑(goingoff==0),才触发;触发时先备份现场、再改 epc 指向 handler、置 goingoff=1、重置倒计时为周期。最后无条件 yield() 让出 CPU(定时器中断的常规动作)。
系统调用注册链路(Lab2 复习,建议自己默写)
/*
* user/user.h
*/
// system calls
...
int sigalarm(int, void(*)());
int sigreturn(void);
...
/*
* user/usys.pl
*/
...
entry("sigalarm");
entry("sigreturn");
/*
* kernel/syscall.h
*/
#define SYS_sigalarm 22
#define SYS_sigreturn 23
/*
* kernel/syscall.c
*/
...
extern uint64 sys_sigalarm(void);
extern uint64 sys_sigreturn(void);
static uint64 (*syscalls[])(void) = {
...
[SYS_sigalarm] sys_sigalarm,
[SYS_sigreturn] sys_sigreturn,
};
/*
* Makefile
*/
ifeq ($(LAB),traps)
UPROGS += \
$U/_call\
$U/_bttest\
$U/_alarmtest\
$U/_usertests
endif
/*
* kernel/defs.h
*/
...
// trap.c
extern uint ticks;
void trapinit(void);
void trapinithart(void);
extern struct spinlock tickslock;
void usertrapret(void);
int sigalarm(int, void(*)());
int sigreturn(void);
...
注:系统调用编号在你本机可能因已有 syscall 数量略有不同,以
kernel/syscall.h里现有最大值为准顺延即可(标准 xv6-2020 traps 环境下SYS_uptime=21,所以 22/23 是正确且空闲的)。
验证
方式一:跑官方测试程序
make qemu
在 xv6 shell 里执行:
alarmtest
应依次通过 test0(基础定时回调)、test1(不会过早触发)、test2(handler 未返回期间不重入,靠 goingoff 保证)。
方式二:评分脚本
./grade-lab-traps alarm
应看到 alarmtest 相关子测试全部 OK。若 test2 失败,优先检查 usertrap 里是否漏了 alarm_goingoff == 0 守卫,或 freeproc 释放逻辑是否合理(alarm_trapframe 必须单独释放且只释放一次)。
复盘
本实验解决了什么
- 真正打通了"用户态中断"的完整闭环:注册(sigalarm)→ 触发(usertrap 改 epc)→ 执行(handler)→ 恢复(sigreturn 还原 trapframe)。这条链路把 Lab2 学的系统调用、Lab4 前半段学的 trap 流程全部串起来了。
- trapframe 备份的思想是核心收获:一旦意识到"handler 里再陷内核会覆盖 trapframe",就必须存一份独立备份——这个"现场保存/恢复"的抽象,和后面学上下文切换(context switch)、进程调度是同一个套路。
- 重入保护(goingoff) 是工业级代码的必备意识:任何"回调 / 信号处理 / 中断"机制都必须考虑"回调执行期间事件再次到来"的情况,否则现场互相覆盖、程序崩溃。
与真实操作系统信号(signal)的对比
xv6 的 alarm 是极度简化版的 Unix signal:
- 真实 Linux 的
SIGALRM同样靠内核在定时器到期时打断进程、跳转 handler;但 Linux 信号有屏蔽字(mask)、可重入(也可嵌套)、有sa_restart等复杂语义。 - xv6 这里不跳过被打断的指令(恢复后从原
epc重执行),也没有信号嵌套/排队,理解起来更直观,性价比极高。
收获
- trapframe 与上下文保存:能讲清"为什么需要单独一份
alarm_trapframe",这是 OS 面试里 trap/interrupt 章节的高频追问。 - 用户态↔内核态切换全流程:
ecall陷入 →usertrap保存现场 → 处理 →usertrapret恢复 →sret返回,能把 epc/trapframe/sepc 的关系说顺。 - 重入与竞态:
goingoff标志是"用一个布尔变量防止回调重入"的最小可用范例,可延伸到"中断下半部""可重入函数"等话题。 - 系统调用链路:6 处同步修改已能闭眼默写,是后续任何 syscall 实验的基础肌肉记忆。
建议把"前置知识里的 trapframe 备份图"和"
usertrap触发逻辑"存下——后面做调度、做 COW fork、甚至以后读 Linux 信号源码,这套"现场保存 / 恢复 / 防重入"的思维模型会反复出现。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2603_95068735/article/details/163570410



