RISC-V assembly (easy)
实验目标
lab4 的第一关是纯阅读 + 问答,不写代码,目的是通过一份真实的反汇编(call.asm)把 RISC-V 的基础机制摸透:
- 弄清楚参数寄存器、返回地址、栈帧在汇编层面长什么样。
- 理解函数如何被调用与返回(
auipc+jalr如何算出并跳到目标地址)。 - 见识内联 / 常量折叠对反汇编的影响,以及大小端、未定义行为这些容易踩坑的细节。
这一关是后面 Backtrace(栈回溯)、Alarm(陷阱处理)的底层地基——不把这些寄存器、指令、内存布局搞熟,后面寸步难行。
前置知识
1. 如何生成 call.asm
仓库里已有user/call.c:
/* * user/call.c */#include"kernel/param.h"#include"kernel/types.h"#include"kernel/stat.h"#include"user/user.h"intg(intx){returnx+3;}intf(intx){returng(x);}voidmain(void){printf("%d %d\n",f(8)+1,13);exit(0);}按题目要求,执行:
makefs.img即可编译并在user/call.asm中生成可读的汇编版本(由编译器/反汇编器自动产生,不是手写的)。后续所有分析都基于这个文件。
2. RISC-V 调用约定(重点,要非常熟悉)
| 寄存器 | 角色 | 说明 |
|---|---|---|
a0–a7 | 参数 / 返回值 | 前 8 个整型/指针参数依次放a0..a7;返回值也放a0 |
ra | 返回地址 | 调用指令自动把"下一条指令地址"写入ra |
sp | 栈指针 | 指向当前栈顶 |
s0/fp | 帧指针 | 指向当前栈帧基址,便于访问局部变量/保存的寄存器 |
3. 两个关键指令
auipc rd, imm:Add Upper Immediate to PC。把 20 位立即数imm左移 12 位后加上当前pc,结果存入rd。即rd = pc + (imm << 12)。用于基于 PC 构造地址(位置无关)。jalr rd, offset(rs1):jump and link register。跳转到(rs1 + offset),并把"下一条指令地址"(pc+4) 存入rd(通常rd=ra)。用于间接/位置无关调用。- 对比
jal rd, imm:目标pc + imm编码在指令里(编译期确定)的直接调用;jalr目标在寄存器里(运行时确定)。
4. 内联 / 常量折叠(解释"为什么 main 里找不到 f、g 的调用")
当函数体很简单、且调用点参数是编译期常量时,编译器会直接把结果算出来,而不是真的去jalr调用。本题f(8)+1就是典型:8 是常量、g(x)=x+3也是常量表达式,于是编译器常量折叠出8+3+1=12,在main里直接li a1,12,根本不会生成对f/g的调用指令。
5. 大小端
RISC-V 是小端(little-endian):一个多字节整数的低字节存放在低地址。这决定了int i = 0x00646c72在内存里的字节顺序,进而决定%s打印出的字符串。
实现思路
本实验不需要写代码,核心是"读 + 答"。推荐流程:
make fs.img生成user/call.asm。- 在
call.asm中搜<g>:、<f>:、<main>:、<printf>:、<exit>标签,记下各自起始地址与指令。 - 顺着
main的指令流,看参数如何装进a0/a1/a2、函数如何通过auipc+jalr被调用。 - 对照下方逐题解答核对。
代码实现(题目与标准解答)
原始材料:call.asm
/* * user/call.asm */ user/_call: file format elf64-littleriscv Disassembly of section .text: 0000000000000000 <g>: #include "kernel/param.h" #include "kernel/types.h" #include "kernel/stat.h" #include "user/user.h" int g(int x) { 0: 1141 addi sp,sp,-16 2: e422 sd s0,8(sp) 4: 0800 addi s0,sp,16 return x+3; } 6: 250d addiw a0,a0,3 8: 6422 ld s0,8(sp) a: 0141 addi sp,sp,16 c: 8082 ret 000000000000000e <f>: int f(int x) { e: 1141 addi sp,sp,-16 10: e422 sd s0,8(sp) 12: 0800 addi s0,sp,16 return g(x); } 14: 250d addiw a0,a0,3 16: 6422 ld s0,8(sp) 18: 0141 addi sp,sp,16 1a: 8082 ret 000000000000001c <main>: void main(void) { 1c: 1141 addi sp,sp,-16 1e: e406 sd ra,8(sp) 20: e022 sd s0,0(sp) 22: 0800 addi s0,sp,16 printf("%d %d\n", f(8)+1, 13); 24: 4635 li a2,13 26: 45b1 li a1,12 28: 00000517 auipc a0,0x0 2c: 7b050513 addi a0,a0,1968 # 7d8 <malloc+0xea> 30: 00000097 auipc ra,0x0 34: 600080e7 jalr 1536(ra) # 630 <printf> exit(0); 38: 4501 li a0,0 3a: 00000097 auipc ra,0x0 3e: 27e080e7 jalr 638(ra) # 2b8 <exit> ...Q1:哪些寄存器保存函数参数?main 调 printf 时 13 在哪个寄存器?
按 RISC-V 调用约定,前 8 个整型参数放在a0–a7。看main调用printf的汇编:
printf("%d %d\n", f(8)+1, 13); 24: 4635 li a2,13 26: 45b1 li a1,12 28: 00000517 auipc a0,0x0 2c: 7b050513 addi a0,a0,1968 # 7d8 <malloc+0xea> 30: 00000097 auipc ra,0x0 34: 600080e7 jalr 1536(ra) # 630 <printf>三个实参依次映射为:a0=格式串地址、a1=f(8)+1=12、a2=13。所以13 保存在a2寄存器。
Q2:main 中对 f 的调用在哪里?对 g 的调用在哪里?
main的反汇编里没有对f、g的jalr调用指令。原因在于编译器做了内联 + 常量折叠:f(8)中 8 是常量、g(x)=x+3也是常量表达式,编译器直接算出f(8)+1 = 12,在main里用li a1,12一步到位,根本不会去调用f/g。
注:虽然
<f>(0xe)与<g>(0x0)的标签和函数体仍保留在二进制中(各自就是一个a0+3的小函数),但main并没有跳转到它们。所以"调用点"的答案是:main 中没有对 f/g 的独立调用,结果已被内联/折叠为常数 12。
Q3:printf 函数位于哪个地址?
看 main 中调用 printf 的两行:
30: 00000097 auipc ra,0x0 34: 600080e7 jalr 1536(ra) # 630 <printf>auipc ra,0x0把pc(0x30) + 0 = 0x30存入ra;jalr 1536(ra)跳到ra + 1536(0x600) = 0x630,注释也标明# 630 <printf>。所以printf 的地址是0x630。
Q4:main 中 printf 的 jalr 执行后,ra 里是什么值?
回顾指令语义:
auipc rd, imm:rd = pc + (imm << 12)。jalr rd, offset(rs1):跳到rs1 + offset,并把"下一条指令地址" (pc+4) 写入rd。
对应到本题:
30: 00000097 auipc ra,0x0 # ra = 0x30 + 0 = 0x30 34: 600080e7 jalr 1536(ra) # 630 <printf>- 第一行:
imm=0,ra = 0x30 + (0<<12) = 0x30。 - 第二行:
jalr跳到ra(0x30) + 0x600 = 0x630(即 printf),同时把pc+4 = 0x34 + 4 = 0x38写入ra,作为 printf 返回后的续跑点。
所以jalr 执行后ra = 0x38(这是 printf 的返回地址,而非 printf 的入口)。
Q5:下列程序输出是什么?若为大端存储,i 该设成什么?57616 要改吗?
unsignedinti=0x00646c72;printf("H%x Wo%s",57616,&i);57616 = 0xE110,%x以小写十六进制打印 →"e110"。i = 0x00646c72,小端内存布局(低字节在低地址)为:0x72('r')、0x6c('l')、0x64('d')、0x00('\0')。%s从&i读到\0为止,得到字符串"rld"。
因此输出为He110 World(注意%x是小写,所以是e110而非E110)。
- 若 RISC-V 为大端,内存高字节在低地址,
%s会按相反顺序读取,要得到同样的"rld"须把i改为0x726c6400;而57616是作为%x的参数单独传入、与内存布局无关,因此不需要改变。
Q6:下列代码 “y=” 之后会打印什么?为什么?
printf("x=%d y=%d",3);格式化串要求两个%d,却只传入了一个参数(3 装入a1,对应第一个%d的x)。y对应的第二个%d会去读调用约定中本应装第二个参数的寄存器a2,而a2里是本次调用前残留的任意值。
所以"y="之后打印的结果不确定,取决于a2中之前保存的数据——这是典型的未定义行为(UB),也是为什么"参数个数与格式串不匹配"在 C 里是严重错误。
验证
make fs.img后确认user/call.asm已生成;- 能独立在
call.asm中定位g/f/main/printf/exit的地址,并解释auipc+jalr的计算过程; - 逐题答案能自圆其说(尤其 Q2 的内联、Q4 的返回地址
0x38、Q5 的大小端)。
补充:若把 Q5 那段代码放进一个 xv6 用户程序并
make qemu运行,屏幕上确实会打印出He110 World,可用来交叉验证你的推演。
复盘
本实验解决了什么
表面是"读汇编答几道题",实则是为整个 lab4 乃至 traps 机制做底层认知铺垫:
- 建立了"C 语句 ↔ 机器指令"的直觉——知道一个函数调用到底动了哪些寄存器。
- 吃透了
a0(参数/返回值)、ra(返回地址)、sp/s0(栈帧) 三件套,这正是后面Backtrace(内核栈回溯)要遍历的东西。 - 见识了编译器优化(内联/常量折叠)如何让"源码里的函数调用"在汇编里消失,以及大小端、未定义行为这类隐蔽坑——后面调试内核崩溃时,这些问题会以更诡异的方式出现。
为什么先学汇编再学 traps
traps 的本质是"控制权在用户态/内核态之间、在指令边界上转移,且必须完整保存/恢复现场"。如果连"普通函数调用怎么保存ra、怎么建栈帧、怎么用auipc+jalr跳转到目标"都不清楚,就完全无法理解"陷入内核时为什么要先把sepc/ra/sp等搬进trapframe"。所以这一关是刻意前置的。
强烈建议阅读
CSAPP第三章
收获
- 函数调用约定:能说清参数用寄存器传(RISC-V 的
a0-a7)而非全部压栈,以及返回值走a0;注意 RISC-V schema、X86-64 schema 的区别。 - 大小端(endianness):网络协议、文件格式、跨平台数据交换都绕不开,能用
0x00646c72在大小端下的内存布局举例说明,是基本功。 - 未定义行为(UB):如 Q6 的参数不匹配,能联系到"为什么编译器可以做激进优化"“为什么 UB 不能依赖”。
jalvsjalr/ 位置无关代码(PIC):能区分直接调用与间接调用,并联系动态链接、函数指针。
建议把"前置知识"里的寄存器约定表 +
auipc/jalr语义截图存一份——Backtrace 实验里你会亲手遍历今天读到的每一个栈帧。