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.huser/usys.plkernel/syscall.hkernel/syscall.cMakefilekernel/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 statestructproc{...// 时钟相关intalarm_interval;// 时钟周期,为 0 表示禁用时钟void(*alarm_handler)();// 时钟回调处理函数intalarm_ticks;// 当前时钟信号数(ticks数)structtrapframe*alarm_trapframe;// 时钟中断时刻进程的陷阱帧,用于恢复中断前的状态intalarm_goingoff;// 是否已经有一个时钟中断正在执行且未返回};五个字段各司其职:alarm_interval是周期(0 即停用)、alarm_handler是回调、alarm_ticks是剩余倒计时、alarm_trapframe是现场备份、alarm_goingoff防重入。
kernel/proc.c—— 分配与释放
allocproc里在分配完普通trapframe之后,紧接着为alarm_trapframe也kalloc一页,并初始化所有 alarm 字段:
/* * kernel/proc.c */staticstructproc*allocproc(void){...found:p->pid=allocpid();// Allocate a trapframe page.if((p->trapframe=(structtrapframe*)kalloc())==0){release(&p->lock);return0;}// 为 alarm_trapframe 分配陷阱帧if((p->alarm_trapframe=(structtrapframe*)kalloc())==0){release(&p->lock);return0;}// 进程创建时初始化 alarm 相关变量p->alarm_interval=0;p->alarm_handler=0;p->alarm_ticks=0;p->alarm_goingoff=0;...}freeproc负责回收:
/* * kernel/proc.c */staticvoidfreeproc(structproc*p){if(p->trapframe)kfree((void*)p->trapframe);p->trapframe=0;// 释放 alarm_trapframeif(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 */uint64sys_sigalarm(void){intn;// n 个 ticksuint64 fn;// 时钟回调函数if(argint(0,&n)<0)// 获取第一个参数return-1;if(argaddr(1,&fn)<0)// 获取第二个参数return-1;returnsigalarm(n,(void(*)())(fn));// 调用并返回 sigalarm 函数}uint64sys_sigreturn(void){returnsigreturn();}kernel/trap.c—— 核心实现与触发点
sigalarm注册、sigreturn恢复现场:
/* * kernel/trap.c */// 设置进程中时钟的相关属性intsigalarm(intticks,void(*handler)()){structproc*p=myproc();p->alarm_interval=ticks;p->alarm_handler=handler;p->alarm_ticks=ticks;return0;}// 将进程恢复到时钟中断前的状态intsigreturn(void){structproc*p=myproc();*p->trapframe=*p->alarm_trapframe;p->alarm_goingoff=0;return0;}注意:
sigreturn把整份alarm_trapframe拷回trapframe,包括epc——于是返回用户态时sepc = 原指令地址,原程序从被打断处重新执行(xv6 的 alarm 不跳过指令,设计如此)。a0等寄存器也一并复原,handler 的"返回值"对用户而言并不存在(因为它根本没"返回",而是被整体替换回原现场)。
在usertrap的定时器分支里插桩触发逻辑(这是把整套机制串起来的关键):
/* * kernel/trap.c */voidusertrap(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...intsigalarm(int,void(*)());intsigreturn(void);.../* * user/usys.pl */...entry("sigalarm");entry("sigreturn");/* * kernel/syscall.h */#defineSYS_sigalarm22#defineSYS_sigreturn23/* * kernel/syscall.c */...externuint64sys_sigalarm(void);externuint64sys_sigreturn(void);staticuint64(*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.cexternuint ticks;voidtrapinit(void);voidtrapinithart(void);externstructspinlocktickslock;voidusertrapret(void);intsigalarm(int,void(*)());intsigreturn(void);...注:系统调用编号在你本机可能因已有 syscall 数量略有不同,以
kernel/syscall.h里现有最大值为准顺延即可(标准 xv6-2020 traps 环境下SYS_uptime=21,所以 22/23 是正确且空闲的)。
验证
方式一:跑官方测试程序
makeqemu在 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 信号源码,这套"现场保存 / 恢复 / 防重入"的思维模型会反复出现。