:Backtrace (moderate))
Backtrace (moderate)实验目标Backtrace调用栈回溯是内核调试里最常用、也最基础的能力——当系统崩溃或 panic 时打印出是谁调用了谁才能顺藤摸瓜定位 bug。本实验目标实现一个backtrace()函数从当前函数开始顺着栈帧一路向上打印所有返回地址return address。吃透 RISC-V 的栈帧布局帧指针fp(s0)、返回地址ra、上一层帧指针三者如何串成一条帧链。理解 xv6内核栈只有一页并据此写出正确的遍历终止条件这是本实验最容易写错的地方。这一关把上一关RISC-V assembly里读懂的s0/ra直接变成了可遍历的数据结构是后面 Alarm、乃至以后读内核崩溃日志的必备技能。前置知识1. 什么是 backtrace程序运行时每调用一层函数就会在栈上压一个栈帧stack frame。帧里保存着这一层执行完后该回到哪条指令返回地址ra、“上一层帧在哪”前一帧指针。backtrace就是顺着这条链往回走把每一层的返回地址依次打印出来——相当于把调用栈摊开给你看。2. RISC-V 栈帧布局结合 call.asm回顾上一关读过的g反汇编一个函数进出栈帧的动作一目了然int g(int x) { 0: 1141 addi sp,sp,-16 // 扩张调用栈得到 16 字节栈帧 2: e422 sd s0,8(sp) // 把上一层 fp存到本帧的 fp-8 处 4: 0800 addi s0,sp,16 // s0(本帧 fp) 指向本帧起点高地址端 return x3; } 6: 250d addiw a0,a0,3 8: 6422 ld s0,8(sp) // 从 fp-8 恢复上一层 fp a: 0141 addi sp,sp,16 // 回收栈帧 c: 8082 ret // 返回即跳到 ra栈的生长方向是从高地址到低地址于是扩张是-16回收是16。结合上面的指令可以总结出每个栈帧内部固定的布局fp即s0指向当前栈帧的起点高地址端sp指向栈帧结束低地址端所以fp的地址比sp高。fp - 88 字节返回地址return address——当前这层调用结束后应返回到的指令地址。fp - 168 字节上一层帧的fpprevious frame pointer顺着它就能走到调用者。其余空间放被保存的寄存器、局部变量等。一个栈帧至少 16 字节但大小不固定。注意打印出的ra是调用点的下一条指令地址不是被调函数的入口地址。调试时要到kernel.asm里按地址反查函数名。3. 内核栈只有一页遍历如何终止xv6 手册 / 实验提示xv6 为每个进程分配一个页PGSIZE 4096 字节作为内核栈。既然整块内核栈就落在一个页里那么帧链是否还在合法范围内可以用页边界来判断PGROUNDDOWN(fp)把fp向下舍入到所在页的起始地址。只要fp仍然落在最初那个内核栈页内PGROUNDDOWN(fp)就恒等于栈页底一旦fp越出该页或prev fp链走到尽头变为 0就该停止遍历。4. 需要改动的文件文件改动kernel/riscv.h新增r_fp()读取当前s0帧指针kernel/printf.c新增backtrace()遍历实现kernel/defs.h声明backtrace()kernel/sysproc.c在sys_sleep()里调用backtrace()触发测试实现思路取帧指针用内联汇编把s0读出来作为遍历起点。顺链向上在循环里先读*(fp-8)得到本层返回地址并打印再读*(fp-16)得到上一层fp继续。正确终止记录初始栈页底循环条件限定fp仍在该页内且不为 0。接线在defs.h声明、在sys_sleep里调用这样执行任何用到sleep系统调用的命令如sleep 10就能看到回溯输出。代码实现kernel/riscv.h—— 获取当前帧指针/* * kernel/riscv.h */staticinlineuint64r_fp(){uint64 x;asmvolatile(mv %0, s0:r(x));returnx;}用一条mv把s0即fp搬到一个 C 变量里。asm volatile防止编译器把它优化掉。kernel/printf.c —— backtrace()实现/* * kernel/printf.c */// 遍历帧指针打印函数地址voidbacktrace(void){uint64 fpr_fp();uint64 stack_bottomPGROUNDDOWN(fp);// 内核栈所在页的起始地址printf(backtrace:\n);while(fp!0PGROUNDDOWN(fp)stack_bottom){uint64 ra*(uint64*)(fp-8);// 当前帧的返回地址printf(%p\n,ra);fp*(uint64*)(fp-16);// 上一层帧的 fp}}说明PGROUNDDOWN/PGROUNDUP/PGSIZE等宏已在kernel/riscv.h中定义无需额外引入。kernel/defs.h—— 声明函数/* * kernel/defs.h */// printf.cvoidprintf(char*,...);voidpanic(char*)__attribute__((noreturn));voidprintfinit(void);voidbacktrace(void);// 加上声明kernel/sysproc.c—— 在 sys_sleep 中触发/* * kernel/sysproc.c */uint64sys_sleep(void){intn;uint ticks0;backtrace();// 加上if(argint(0,n)0)return-1;...}把backtrace()放在sys_sleep最开头任何进入sleep系统调用的路径都会先打印一次调用栈。验证方式一手动在 xv6 里看makeqemu# 启动 xv6在 xv6 shell 里执行任意会触发sleep系统调用的命令sleep10控制台会在sys_sleep执行时打印出类似地址因编译而异backtrace: 0x00000000800021c8 0x0000000080002abc 0x00000000800030f4...这些地址从sys_sleep的返回地址开始一路向上经过syscall()、usertrap()等直到内核栈底。把地址翻译成函数名拿到地址后可在 Linux 端用addr2line-ekernel/kernel 0x00000000800021c8或直接打开kernel/kernel.asm反查确认每一层对应哪个函数。方式二用评分脚本测./grade-lab-traps backtrace应看到 backtrace 相关子测试通过脚本会检查输出格式是否为backtrace:后跟若干%p地址。复盘本实验解决了什么第一次把栈帧从概念变成可操作的数据结构——fp-8是ra、fp-16是 prev fp这条链就是 backtrace 的全部依据。第一次意识到内核栈的空间约束只有一个页所以遍历必须靠页边界 fp 归零双重保险来终止否则会越界读非法内存。和上一关直接呼应RISC-V assembly 里读懂的s0/ra在这里被亲手遍历出来——这就是为什么那个实验要放在前面。收获调用栈 / 栈帧结构能画出fp 指向帧起点、fp-8 是返回地址、fp-16 指向上一帧的布局并解释为什么需要帧指针比单纯用 sp 更方便定位局部变量/保存的寄存器。backtrace 原理能说清顺着帧指针链读返回地址的实现思路以及为什么需要终止条件栈有边界、链有尽头。内核栈大小限制xv6 每进程内核栈仅 4KB深层递归或在栈上分配大数组极易栈溢出overflow into guard page→ panic。这是 OS 面试里为什么内核栈不能无限大栈溢出后果的常考点。建议把backtrace和栈帧布局图一起存下——以后任何内核 panic、死循环第一反应就应该是打印 backtrace 看调用链。