ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MIT 6.S081 traps 实验篇(lab4):Alarm (hard)

2026/8/10 10:09:46 拓冰建站 浏览量
MIT 6.S081 traps 实验篇(lab4):Alarm (hard)

Alarm (hard)


实验目标

本实验要实现两个系统调用sigalarmsigreturn,为用户进程增加周期性通知能力:进程每使用 CPU 若干个 tick 后,内核就自动把它"拽"去执行一个用户态的回调函数(handler),执行完再原封不动地退回原处继续跑。

本质上这是用户态的"定时中断 / 异常处理"——内核模拟了硬件中断的语义,但 handler 运行在用户空间。

练习目标:

  1. 打通sigalarm(n, fn)(注册:每n个 tick 调一次fn)与sigreturn()(handler 结束后恢复现场)的完整链路。
  2. 吃透trapframe 的保存与恢复——这是整个实验的灵魂:为什么必须单独存一份alarm_trapframe
  3. 处理好重入(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 结束后。这正是官方alarmtesttest2要测的场景。

4. 系统调用注册链路(Lab2 复习)

新增一个系统调用,需要同步改 6 处(详见本博客"代码实现"末节):
user/user.h(声明)→user/usys.pl(生成usys.S桩)→kernel/syscall.h(分配编号)→kernel/syscall.c(分发表 + extern 声明)→kernel/sysproc.csys_xxx实现)→kernel/defs.h(内核内声明)。

5. 需要改动 / 新增的文件

文件改动
kernel/proc.hstruct proc增加 5 个 alarm 字段
kernel/proc.callocproc分配并初始化、freeproc释放
kernel/sysproc.csys_sigalarm/sys_sigreturn取参并调用内核实现
kernel/trap.csigalarm/sigreturn真正实现;usertrap中插桩触发
user/user.huser/usys.plkernel/syscall.hkernel/syscall.cMakefilekernel/defs.h系统调用注册链路

实现思路

整体数据流如下:

  1. 注册:用户调sigalarm(n, fn)→ 内核把n/fn存进进程,并把alarm_ticks(剩余倒计时)初始化为n
  2. 触发:每次定时器中断,usertrapalarm_ticks倒计时归零且alarm_goingoff==0时:把当前trapframe备份到alarm_trapframe、把trapframe->epc改成 handler 地址、alarm_goingoff=1。随后照常usertrapret回到用户态——但这次回去是去执行 handler。
  3. 返回: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_trapframekalloc一页,并初始化所有 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必须单独释放且只释放一次)。


复盘

本实验解决了什么

  1. 真正打通了"用户态中断"的完整闭环:注册(sigalarm)→ 触发(usertrap 改 epc)→ 执行(handler)→ 恢复(sigreturn 还原 trapframe)。这条链路把 Lab2 学的系统调用、Lab4 前半段学的 trap 流程全部串起来了。
  2. trapframe 备份的思想是核心收获:一旦意识到"handler 里再陷内核会覆盖 trapframe",就必须存一份独立备份——这个"现场保存/恢复"的抽象,和后面学上下文切换(context switch)、进程调度是同一个套路。
  3. 重入保护(goingoff)是工业级代码的必备意识:任何"回调 / 信号处理 / 中断"机制都必须考虑"回调执行期间事件再次到来"的情况,否则现场互相覆盖、程序崩溃。

与真实操作系统信号(signal)的对比

xv6 的 alarm 是极度简化版的 Unix signal:

收获

建议把"前置知识里的 trapframe 备份图"和"usertrap触发逻辑"存下——后面做调度、做 COW fork、甚至以后读 Linux 信号源码,这套"现场保存 / 恢复 / 防重入"的思维模型会反复出现。