wnqzx头像
关注

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.h user/usys.pl kernel/syscall.h kernel/syscall.c Makefile kernel/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 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_trapframekalloc 一页,并初始化所有 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 必须单独释放且只释放一次)。


复盘

本实验解决了什么

  1. 真正打通了"用户态中断"的完整闭环:注册(sigalarm)→ 触发(usertrap 改 epc)→ 执行(handler)→ 恢复(sigreturn 还原 trapframe)。这条链路把 Lab2 学的系统调用、Lab4 前半段学的 trap 流程全部串起来了。
  2. trapframe 备份的思想是核心收获:一旦意识到"handler 里再陷内核会覆盖 trapframe",就必须存一份独立备份——这个"现场保存/恢复"的抽象,和后面学上下文切换(context switch)、进程调度是同一个套路。
  3. 重入保护(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

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--