先来回顾一下这个 **backtrace** 函数做了什么,再解释它为什么这样写——主要和 **RISC-V 的函数栈帧布局**、**xv6 内核栈大小** 以及 **如何检测栈回溯的终点** 有关。 ```c void backtrace(void) { uint64 cur_fp = r_fp(); // 1. 获取当前函数的帧指针 (s0) while(cur_fp != PGROUNDDOWN(cur_fp)) // 2. 检查是否超过当前栈页的边界 { printf("%p\n", *(uint64 *)(cur_fp - 8)); // 3. 打印返回地址 (ra) cur_fp = *(uint64 *)(cur_fp - 16); // 4. 更新 fp 为上层函数的帧指针 } } ``` 让我们分步骤看其中的关键点。 --- ## 1. RISC-V 函数栈帧布局 按照 GCC 在 RISC-V 平台上的调用约定,每个函数的大致栈帧 (frame) 结构如下(从高地址到低地址): ```Java high addresses +-------------------+ | ... | (可能还有函数的局部变量) (frame base) ->+-------------------+ s0(fp) ->| old s0 (上一个函数的帧指针) | (存放在 fp-16) | return address (ra) | (存放在 fp-8) +-------------------+ | ... | low addresses ``` - **s0 (fp)**:当前函数的帧指针寄存器。 - **(fp - 8)**:存放函数的返回地址 (ra),用来在函数结束时跳回调用者。 - **(fp - 16)**:存放上一层函数的帧指针 (old s0),用来回溯调用栈。 在 xv6 中,为了做 backtrace,就需要: 1. 读出当前函数的帧指针。 2. 从 `(fp - 8)` 拿到当前函数保存的返回地址 (ra),打印它。 3. 再从 `(fp - 16)` 拿到前一个函数的帧指针,继续往上层函数回溯。 --- ## 2. 为什么用 `while (cur_fp!= PGROUNDDOWN(cur_fp))` 判断结束? xv6 的内核线程栈大小是一页(4KB),是 **页对齐** 的。也就是说,每个内核线程有一页专门做它的内核栈,起始地址是 `PGROUNDDOWN(stack_top)`,结束地址是 `PGROUNDUP(stack_top)`。 当我们回溯栈帧时,一旦 `cur_fp`(当前帧指针)**走到或跨过这页的边界**,就说明已经超出了这条内核栈的范围,再继续回溯就会访问到不属于该栈或者无效的内存区域,可能会崩溃或读到垃圾数据。 - `PGROUNDDOWN(x)`:把地址 `x` 下舍到所在的页边界。例如,如果 x 在 0xABCDEF,那么它对应页边界可能是 0xABCDE000(假设 4KB 一页)。 - 所以 `cur_fp!= PGROUNDDOWN(cur_fp)` 这个判断相当于 "**只要 `cur_fp` 还不是等于它所在页的起始地址,就继续回溯**"。若相等,说明我们到底了,必须停下来。 也有人会采用其他判断方式,比如"只要 `cur_fp` 落在[stack_bottom, stack_top]之间就继续",原理类似,都是为了避免访问越界。 --- ## 3. 打印返回地址并回溯到上一帧 在循环里: 1. `*(uint64 *)(cur_fp - 8)`:这正是栈帧中的返回地址 ra。 2. `cur_fp = *(uint64 *)(cur_fp - 16)`:将当前帧指针更新为"上一个函数"的帧指针 old s0。 周而复始,把每一层函数的返回地址都打印出来,就得到了「调用栈回溯」的地址列表。 在 GDB 里,你看到的 "backtrace" 命令做了类似的事情,只不过它还会把地址映射到具体的函数名、行号(通过符号表 / 调试信息)。 --- ### 总结一下 - **帧指针 (fp = s0)**:RISC-V / GCC 的函数约定把老的 fp 和 ra 分别存在 `fp-16`、`fp-8`。 - **循环回溯**:从当前帧指针往回一个个找老的 fp,并打印 ra。 - **终止条件**:在 xv6 里,每个内核栈是一页大小,一旦 `cur_fp` 减小到等于该页的下边界(`PGROUNDDOWN`),说明已经退到栈底了,必须停止。否则就越界了。 所以,这就是为什么 backtrace 会以这种形式实现:**因为栈帧布局固定**,并且 **xv6 每条内核栈只有一页**,故用这样的简单循环即可实现堆栈回溯。 ## 为什么需要 alarm 打断 - **操作系统要"分配时间"给大家** 你的电脑可能同时要运行很多程序,比如游戏、浏览器、音乐播放器。要是其中一个程序一直霸占着 CPU,不让出时间,其他程序就没法顺利工作。 - **时钟"叮"一下,是给操作系统发个信号** 操作系统设置了一个"小闹钟"(硬件定时器),让它定时"叮"一下,提醒操作系统:"诶,现在过了一个时间片了,你要不要换个程序来跑啊?" - **被时钟"打断",进入内核** 当这个小闹钟响了,CPU 立刻暂停正在运行的用户程序,跳到内核里运行一段"特殊程序"(时钟中断处理例程)。这个程序会决定是否要让当前进程再继续跑,还是切换到别的进程去跑,或者做一些别的周期性工作(比如维护系统时间等)。 - **然后再回去** 处理完以后,操作系统又把 CPU 切回给某个进程(可能是原来那个,也可能是别的)。于是,用户程序继续从刚才被打断的地方继续运行。 **防止重复 alarm**:处理闹钟函数时,不要再被闹钟打断,否则会乱套。 下面给出一个整体的思路指南,帮助理解和实现这个 Lab 的关键步骤和原理。各个部分互相关联,需要在阅读 xv6 相关源码 (trap.c、trampoline.S、syscall.c、proc.c 等) 和 Lab 文档的基础上逐步实现。 --- ## 1. 理解 RISC-V 汇编与栈帧 Lab 的第一部分,先让你去阅读并理解一些 RISC-V 汇编代码,以及函数调用过程中的寄存器使用、压栈和回栈逻辑。这为后面做 backtrace(回溯调用栈)和手动保存/恢复寄存器(alarm 机制)做准备。 - **函数参数与返回值** RISC-V 调用约定中,a0~a7 作为函数参数和返回值寄存器;ra (return address) 用来存储返回地址;s0 (fp/frame pointer) 用来存储当前函数的帧指针等。 - **函数调用的 inlining** C 编译器有时会把一些小函数"内联"到调用处,导致在汇编里看不到对应的 `call` 指令。需要仔细查看生成的汇编(`.asm`)以确定哪些函数被真正调用、哪些被内联。 - **小端与大端** RISC-V 是小端序 (little-endian),这会影响到像 `0x00646c72` 这样的数在内存中的存储顺序,以及在 printf 时如何被解释成字符串。如果切换到大端序,需要调整字节顺序。 --- ## 2. Backtrace(回溯调用栈) 在 xv6 中,如果发生错误或想要调试,就希望能打印出内核函数的调用栈。为了拿到调用栈上的各个返回地址,需要: 1. **了解栈帧结构** - 每个函数在进入时,会把前一个函数的帧指针 (s0) 和返回地址 (ra) 等压栈,然后更新 s0,构造新的栈帧。 - 对于 RISC-V/GCC,`s0` (fp) 指向当前栈帧的底部。 - `(fp - 8)` 处存返回地址 `ra`; - `(fp - 16)` 处存老的 fp; - 然后再往下是函数的局部变量区域等。 2. **在内核里实现 `backtrace()` 函数** - 可以在 `kernel/printf.c` 添加一个函数 `backtrace()`,利用内联汇编或者写一个 `r_fp()` 读取当前 `s0` (即当前帧指针)。 - 循环地根据内存结构,依次找出保存的返回地址、前一个帧指针,再往上回溯,直到超过该内核栈所在的地址区间 (例如用 `PGROUNDDOWN(fp)` 和 `PGROUNDUP(fp)` 来确定栈的边界)。 - 每找到一个返回地址,就用 `printf` 打印出来。 3. **调用 backtrace()** - 在 `sys_sleep()` 或者在 `panic()` 等函数中调用 `backtrace()`,便可以观察到函数调用栈。 --- ## 3. Alarm (用户态定时"中断"/陷入处理) 这一部分让你在用户空间里模拟出一个"信号"或"定时器"处理机制:进程在用户态运行一段时间后,被时钟中断打断,进入内核,然后从内核"返回"到用户态时,跳转执行一个用户态的"handler"函数;用户态"handler"执行完后,再返回原来的代码继续执行。 ### 3.1 新增系统调用 - **`sigalarm(int ticks, void (*handler)())`** 用来设置: 1. `interval`:进程每运行 `ticks` 个时钟周期后,就触发一次"定时报警"。 2. `handler`:报警时要执行的用户态函数。 如果传入 `(0, 0)`,表示取消报警。 - **`sigreturn()`** 用户态 `handler` 执行完后,要通过 `sigreturn()` 返回原来的执行现场,继续执行被打断的用户程序。 要做的事: 1. 在 `user/user.h` 中声明函数原型。 2. 在 `kernel/syscall.c` 和 `kernel/syscall.h` 里添加对应的 syscall 编号和分发逻辑。 3. 在 `kernel/sysproc.c` 中实现 `sys_sigalarm()` 和 `sys_sigreturn()` 函数体。 4. 在 `proc.h` 的进程结构 `struct proc` 里加几个字段,用来记录: - `alarm_interval`:`sigalarm(ticks, handler)` 传入的间隔。 - `alarm_handler`:指向用户传入的函数地址。 - `alarm_ticksleft`:距离下次触发 alarm 还剩多少 tick。 - 以及防止递归重入的标志位,如 `alarm_in_handler` 表明当前是否已经在执行 alarm handler。 ### 3.2 在内核捕获时钟中断并切换到用户 handler 时钟中断发生在 `trap.c` 的 `usertrap()` 里,每次时钟中断 (`which_dev == 2`) 都会让当前正在运行的进程 `p->alarm_ticksleft` 自减。若减到 0,表示需要调用用户态 alarm handler 了: 1. **判断是否已经在 handler 里** - 如果已经在 handler 里了,就不要再进,以防止在 handler 里又被时钟打断,再次进 handler,陷入无限递归。 2. **保存现场** - 这里的"现场"包括所有通用寄存器(a0~~a7、t0~~t6、s0~s11、ra、sp 等)、原始的程序计数器 (PC) 等。 - xv6 提供了 `struct trapframe` 记录用户态大部分寄存器,但你需要确保把"足以恢复"的信息都保存下来。尤其是 PC、ra、sp、s0 等对后面恢复很关键的寄存器。 - 将这些寄存器保存到 `proc` 里专门为 alarm 准备的保存区,比如可以再额外加一个 `struct trapframe alarm_trapframe`,或者把需要保存的字段先缓存。 3. **修改 trapframe** - 把 `trapframe->epc`(即下一条将要执行的指令)改为 `alarm_handler` 的地址,这样在 `usertrap()` 返回用户态时,会从 `alarm_handler` 的入口执行。 - 同时可以把 `alarm_ticksleft` 重置成 `alarm_interval`,这样过了 interval 之后又会触发下一次 alarm。 ### 3.3 `sigreturn` 恢复现场 在用户态的 `handler` 函数执行完毕,会调用 `sigreturn()`。进入内核的 `sys_sigreturn()` 后,你需要: 1. **从 `proc` 中取出先前保存好的现场信息** - 将保存的所有寄存器值、PC 等还原到当前进程的 `trapframe`。 2. **将 `alarm_in_handler` 标志位清 0**,允许下次 alarm 发生。 这样在下一次 `usertrap()` 返回用户态时,就能回到原本被打断的指令处继续执行,好像什么都没发生过一样。 --- ## 4. 关键注意点 - **防止重复进入 handler** 如果 handler 还没执行完就又发生时钟中断,要么忽略这次中断,要么累加计数但不调用新的 handler,以免出现严重错误或无限递归。 - **保存/恢复足够的寄存器** 如果漏保存某些寄存器值,handler 返回后用户程序可能会出现奇怪的 bug。 - **测试** - `alarmtest`:包括了 test0 / test1 / test2 等,逐渐会检查是不是能正确打印 `"alarm!"`、能否多次周期性地调用 handler、能否在 handler 中嵌套等。 - `usertests`:检查 xv6 全部其他功能是否被破坏。 --- ## 5. 提交与打包 - `make grade` 确保本 lab 所有测试都能通过。 - `make handin` 根据课程网站要求,上传你的提交。 --- ### 总结 **实现思路简要概括**: 1. **前置准备**:理解 RISC-V 调用约定和栈帧结构。 2. **Backtrace**:利用 s0(fp) 链式回溯,每个函数的返回地址保存在 `fp-8`。循环向上直到越过当前栈的边界停止。 3. **Alarm**: - 新增 `sigalarm()`/`sigreturn()` 系统调用,扩展进程结构存储 alarm 的状态 (interval / handler / 是否在执行 handler / 剩余 ticks / 保存现场)。 - 时钟中断时,在 `usertrap()` 判断是否到期执行用户态 handler;执行前保存寄存器现场,修改返回的 PC 指向 handler;handler 结束后用 `sigreturn()` 恢复原先被打断的上下文。 - 重复此过程,实现周期性"闹钟"功能。 整体比较考验对系统调用流程、RISC-V trap 机制、栈帧布局和寄存器保存的理解。只要理清 trap→kernel→修改 trapframe→返回 user space 以及再从 user space→sigreturn→kernel→恢复 trapframe 这一来一回的过程,并 carefully 处理好寄存器保存/恢复,就能顺利完成。祝一切顺利!