RISC-V 汇编最小集

不用会写汇编程序,只要能读懂 Lab1 里那十几行,和反汇编出来的简单函数。这一页先读内存与地址。

1. CPU 一直在做的事

CPU 里有一个特殊的寄存器 pc(程序计数器),存着“下一条指令的地址”。CPU 反复做三步:从 pc 指的地址取一条指令,执行它,pc 指向下一条(RISC-V 指令大多 4 字节,所以通常是加 4)。跳转指令就是直接改 pc。

汇编就是这些指令的文字写法,一行一条。编译器把 C 翻译成汇编,汇编器再把汇编翻译成机器码(真正存在内存里的那些字节)。

2. 寄存器

寄存器是 CPU 内部的几十个小存储格,每个 64 位,读写比内存快得多。CPU 做运算只能在寄存器之间做,内存里的数要先加载到寄存器,算完再存回去。

RISC-V 有 32 个通用寄存器,编号 x0–x31。为了好记,每个都有一个按用途起的名字,反汇编里看到的是这些名字:

名字用途
zero永远是 0,写进去也没用
ra返回地址:函数执行完该回到哪
sp栈指针,见第 5 节
tp线程指针。我们的内核拿它存“我是几号 CPU”
a0–a7函数参数;a0 也放返回值
t0–t6临时用,调别的函数后可能被改掉
s0–s11要保留的值,被调用的函数用之前得先存起来、用完还原

另外还有一类 CSR(控制与状态寄存器),比如 mhartid、mstatus,控制 CPU 自己的行为,只能用专门的指令读写,见特权级与异常。

3. 指令的格式

绝大多数指令长这样:操作名 目标, 源1, 源2,结果写进第一个寄存器。# 后面是注释。

add  a0, a1, a2     # a0 = a1 + a2
addi a1, a1, 1      # a1 = a1 + 1        i 表示第二个源是立即数(直接写在指令里的数)
mul  a0, a0, a1     # a0 = a0 * a1

4. Lab1 用到的指令

指令意思
li a0, 4096load immediate:a0 = 4096
la sp, CPU_stackload address:sp = 符号 CPU_stack 的地址
mv a5, a0a5 = a0
add / addi / mul / slli加、加立即数、乘、左移(slli a1,a1,2 就是 a1 = a1 << 2)
lw a4, 0(a5)load word:从地址 a5+0 读 4 字节到 a4
sw a5, 0(a4)store word:把 a5 的 4 字节写到地址 a4+0
ld / sd同上,但读写 8 字节(d 是 double word)
beq / bne / blez条件跳转:相等 / 不相等 / 小于等于 0 就跳
j spin无条件跳到标号 spin
call start调用函数:ra = 下一条指令的地址,然后跳到 start
ret返回:跳到 ra
csrr a1, mhartid读 CSR:a1 = mhartid
csrw mepc, a0写 CSR:mepc = a0

40(sp) 这种写法的意思是“地址 sp + 40”。括号里是基址寄存器,前面是偏移。所以 sd ra, 40(sp) 就是“把 ra 存到 sp+40 处”。

反汇编里还会看到几个“组合”:call 实际被拆成 auipc ra, ... + jalr ra 两条(先算出目标地址的高位,再跳),ret 实际是 jalr zero, 0(ra)。看到它们按 call 和 ret 理解就行。

标号、符号和伪指令

.section .text      # 以 . 开头的是给汇编器的命令:下面的内容放进 .text 段
.global _entry      # 让 _entry 这个名字在别的文件里也能看到
_entry:             # 标号:给下一条指令的地址起个名字
        la sp, CPU_stack
spin:
        j spin      # 跳回自己:原地死循环

标号和 C 里的函数名、全局变量名一样,都叫符号:一个名字对应一个地址,链接器负责把名字换成真正的地址。所以汇编里的 CPU_stack 能拿到 C 文件里定义的数组,C 里的 start 也能被汇编 call 到。

5. 栈与函数调用

函数调用有个麻烦:ra 只有一个。caller 调 leaf 时,call 会把 ra 改成“回到 caller”,那 caller 自己该回哪的地址就丢了。所以 caller 调别人之前,要把 ra 和其他要保留的寄存器存到内存里,回来后再读出来。

存这些东西的内存区域叫栈。sp 指向栈顶。RISC-V 的栈向低地址增长:函数一开始 addi sp, sp, -48,给自己腾出 48 字节;结束前 addi sp, sp, 48 还回去。局部变量放不下寄存器时,也放在这 48 字节里。

caller:
    addi sp, sp, -16    # 开空间
    sd   ra, 8(sp)      # 存 ra
    call leaf           # ra 被改掉了,没关系
    ld   ra, 8(sp)      # 取回 ra
    addi sp, sp, 16     # 还空间
    ret                 # 回到调用 caller 的地方

每调一层就往下开一块,返回时收回,像一摞盘子。这就是 Lab1 里“没设 sp 就不能进 C”的原因:sp 是乱的,第一条 sd 就会写到不知道哪去。

动手:对照 C 读汇编

cd prereq/examples
make asm      # 需要 RISC-V 交叉编译器,安装见 Lab0 §2

twice(x) 是 x*2+1:参数在 a0,slli a0,a0,0x1 左移一位就是乘 2,addiw 加 1,结果留在 a0,ret。编译器把乘法换成了更快的移位。

sum(a, n):a 在 a0、n 在 a1。blez a1 处理 n ≤ 0;.L3 是循环体:lw 读一个元素、addw 累加、addi a5,a5,4 指针后移 4 字节、bne 没到头就跳回去。这两个函数都不调别人,所以没碰栈。