CS61C – 2 RISC – V Assembly Language(2)

这一部分对应的是 Lec 10 ~ 12 (哔大上的参考课程为[Summer 20]),主要对函数调用进行总结梳理并解释了 RISC – V 基本的指令格式。

Stack – 栈

前文提及函数调用时存在压栈出栈操作,即在运行函数代码前需要将函数的局部变量和保存的寄存器在栈上分配空间,并在存放返回值(在a0a1中)后释放栈空间。

为什么需要这样做?

当一个函数(被调用者 Callee)需要使用某些寄存器(如 s0s11)时,它可能会覆盖调用者(Caller)正在使用的值。而且对于递归或嵌套调用,ra 和 a0a7 也会被覆盖。

如果学过 Data Structure 那么很容易就想到了栈这个结构,它 LIFO (Last In First Out) 的特性完美体现了保存和恢复的特性.显然,保存和回复我们需要使用一个指针维护当前顶部,在 RISC – V 中我们使用一个特殊寄存器 sp(Stack Pointer) 来保存栈顶指针。

RISC – V 约定栈从高内存地址向低内存地址增长,那么压栈操作便可以通过减小 sp 的值来分配空间,然后使用 sw 将数据存入新分配的空间;出栈则通过 lw 将指令从栈中取出,然后增加 sp 的值来释放空间

栈帧(Stack Frame)与寄存器约定(Register Conventions)

每次函数调用时会在栈上分配的一块连续内存区域,用于存储该函数特有的信息,该区域即为栈帧。而函数调用时部分寄存器的值需要被保留,相关规则则称为寄存器约定

约定包括两大类,Caller-Saved (Temporary) Registers以及Callee-Saved (Saved) Registers。前者由调用者负责保存,被调用函数可以随意修改这些寄存器的值。如果调用者在函数调用后还需要这些寄存器里的旧值,它必须在调用 jal 之前自行将它们保存到栈上;后者由被调用者负责保存,被调用函数如果想要使用这些寄存器,它必须在修改它们之前将其旧值保存到自己的栈帧中,并在函数返回之前将其恢复。这样,调用者可以确信这些寄存器的值在函数调用前后没有变化。

考虑一个叶子函数([注]:leaf Function指不调用其他函数的函数):

int Leaf(int g, int h, int i, int j) { // in a0, a1, a2, a3
    int f; // We'll use s0 for f
    f = (g + h) - (i + j);
    return f;
}

那么它的汇编是:

Leaf:
    addi sp, sp, -8     # Step 3: Allocate space for 2 words (8 bytes) on stack
    sw   s1, 4(sp)      # Save old s1 (Callee-Saved, we need it as a temp)
    sw   s0, 0(sp)      # Save old s0 (Callee-Saved, we use it for 'f')
    
    add  s0, a0, a1     # f = g + h  (Step 4: Perform task)
    add  s1, a2, a3     # temp = i + j
    sub  a0, s0, s1     # Step 5: Put return value in a0

    lw   s0, 0(sp)      # Restore old s0 for caller
    lw   s1, 4(sp)      # Restore old s1 for caller
    addi sp, sp, 8      # Step 5: Release the stack space
    ret                 # Step 6: Return (jr ra)

可见 Leaf 函数在开头保存它要使用的 s0s1,在结尾恢复它们,并平衡了栈指针。

而针对嵌套调用的函数,则需要主动保持在接下来的过程中可能被破坏的寄存器:

int sumSquare(int x, int y) { // x in a0, y in a1
    return mult(x, x) + y; // Assume mult is another function
}

汇编为:

sumSquare:
    addi sp, sp, -8     # Make space on stack for 2 items
    sw   ra, 4(sp)      # Save ra (Caller-Saved! jal mult will overwrite it)
    sw   a1, 0(sp)      # Save y (a1 is Caller-Saved, we need it after mult returns)
    
    mv   a1, a0         # Set up 2nd arg for mult: mult(x, x)
    jal  mult           # Call mult (Steps 1&2). ra is now overwritten.

    lw   a1, 0(sp)      # Restore y after the call
    add  a0, a0, a1     # return value = mult() + y (Step 4&5)

    lw   ra, 4(sp)      # Restore the return address to return to sumSquare's caller
    addi sp, sp, 8      # Pop the stack
    ret                 # Return

注意到 sumSquare 在调用 jal mult 之前,主动保存了它认为 mult 会破坏且自己之后还需要用的寄存器(raa1

程序内存布局

讲座给出了RISC – V 32 的典型内存空间布局,包括:

  • Text (代码段)0x00010000 开始。存放程序的指令(机器码)。
  • Static Data (静态数据段)0x10000000 开始。存放全局变量、静态变量和常量。寄存器 gp (Global Pointer) 指向这里,帮助高效访问。
  • Heap (堆):位于静态数据段之上,向高地址增长。由 mallocnew 等动态分配的内存位于此。
  • Stack (栈)0xBFFFFFF0 开始,向低地址增长。用于函数调用和局部变量。
  • Reserved (保留区域):低地址区域通常保留,用于捕捉空指针等错误。

以上为讲座关于函数调用及栈的相关总结梳理,下面是第二部分 —— RISC – V 的指令格式

储存程序计算机

当代计算机的基本架构是 Von Neumann Architecture – 冯 · 诺伊曼架构,架构的核心是指令和数据都以二进制数字的形式存储在同一个内存中,这使得计算机不再是像 ENIAC 一样需要通过插拔线缆来编程的固定功能机器。要改变计算机的功能,只需改变内存中的程序(指令序列),而不是重新布线。这为其带来了无与伦比的灵活性和通用性。在这种架构下每条指令、每个数据字在内存中都有一个地址。指针的本质就是内存地址。

那么为了取指,设立了一个特殊的寄存器 – 程序计数器(PC),其内部永远保存着下一条将要执行指令的地址。

所以计算机运行的基本原理就很明确了 —— 取指 (IF) -> 解码(DI) -> 执行(Ex) -> 更新,这也就是所谓的“程序是个状态机”

RISC – V 指令格式

RISC-V 追求简洁性,所有指令都编码为固定的 32 位长度。指令被划分为多个字段(Fields),每个字段承载指令的不同信息。

 R-Format (Register-Format)

 R-Format 用于寄存器-寄存器操作指令,如 addsubandorsll 等,其布局为:

| 31 ~ 25 | 24 ~ 20 | 19 ~ 15 | 14 ~ 12 | 11 ~ 7 |  6 ~ 0  |
| funct7  |   rs2   |   rs1   |  funct3 |   rd   |  opcode |
  • funct7 (7 bits): 功能码,与 funct3 和 opcode 共同确定具体操作(如区分 add/sub)。
  • rs2 (5 bits): 第二个源操作数寄存器。
  • rs1 (5 bits): 第一个源操作数寄存器。
  • funct3 (3 bits): 功能码,与 opcode 一起确定操作类型。
  • rd (5 bits): 目的寄存器,用于存放操作结果。
  • opcode (7 bits): 操作码,标识这是一条 R-type 指令 (0110011)

I-Format (Immediate-Format)

I-Format 用于寄存器-立即数操作(如 addiandi) 和加载指令lwlb 等),其布局为:

|  31 ~ 20  |  19 ~ 15 | 14 ~ 12 | 11 ~ 7 |  6 ~ 0  |
| imm[11:0] |    rs1   |  funct3 |   rd   |  opcode |
  • imm[11:0] (12 bits): 12位有符号立即数,用于算术操作或地址偏移。
  • rs1 (5 bits): 源操作数寄存器(或基址寄存器)。
  • funct3 (3 bits): 功能码,确定操作类型(如 addi)或加载数据大小(如 lw)。
  • rd (5 bits): 目的寄存器,用于存放操作结果或从内存加载的数据。
  • opcode (7 bits): 操作码,标识指令类型(如 0010011 用于运算, 0000011 用于加载)。

S-Format (Store-Format)

S-Format 用于存储指令swsbsh),其布局为:

|  31 ~ 25  | 24 ~ 20 | 19 ~ 15 | 14 ~ 12 |  11 ~ 7  |  6 ~ 0  |
| imm[11:5] |   rs2   |   rs1   |  funct3 | imm[4:0] |  opcode |
  • S – Format 将 12 位立即数拆分成两个部分,并重新利用 rd 的位域。
    • imm[11:5] 放在原来 funct7 的位置。
    • imm[4:0] 放在原来 rd 的位置。
    • rs1 和 rs2 的位置保持不变,保证了寄存器字段的一致性。

B – Format (Branch – Format)

B-Format 用于实现循环和条件判断(if-elsewhilefor),如 beqbneblt,具体布局为:

|   31    |  30 ~ 25  | 24 ~ 20 | 19 ~ 15 |  14 ~ 12  |  11 ~ 8   |  7 ~ 0  |
| imm[12] | imm[10:5] |   rs2   |   rs1   |   funtc3  |  imm[4:1] |  opcode |
  • imm[12], imm[10:5] (7+6 bits): 13位偏移量的高7位和中间6位。
  • rs2 (5 bits): 第二个用于比较的源操作数寄存器。
  • rs1 (5 bits): 第一个用于比较的源操作数寄存器。
  • funct3 (3 bits): 功能码,确定分支条件(如 000 for beq)。
  • imm[4:1], imm[11] (4+1 bits): 13位偏移量的低4位和倒数第二位。
  • opcode (7 bits): 操作码,标识这是一条 B-type 分支指令 (1100011)

U-Format

U-Format 用于构建大立即数,解决了 I – Type 的立即数只有 12 位的问题

|    31   |  30 ~ 21  |   20   |   19 ~ 12  | 11 ~ 7 |  6 ~ 0  |
| imm[20] | imm[10:1] | imm[11]| imm[19:12] |   rd   |  opcode |
  • imm[31:12] (20 bits): 20位立即数,用于填充目标寄存器的高20位。
  • rd (5 bits): 目的寄存器。
  • opcode (7 bits): 操作码,标识指令类型(如 0110111 用于 lui
  • 需要注意的是,如果 addi 的立即数最高位是 1,它会被符号扩展为一个负数。因此,有时需要调整 lui 中的值来补偿。

J – Format(Jump – Format)

J – Format 用于跳转并链接,即指令中的 jal,其布局为:

|   31   |   30 ~ 21  |   20    |  19 ~ 12   | 11 ~ 7 |  6 ~ 0  |
| imm[20]|  imm[10:1] | imm[11] | imm[19:12] |   rd   |  opcode |
  • imm[20] (1 bit): 21位偏移量的最高位。
  • imm[10:1] (10 bits): 21位偏移量的中间10位。
  • imm[11] (1 bit): 21位偏移量的第11位。
  • imm[19:12] (8 bits): 21位偏移量的低8位。
  • rd (5 bits): 目的寄存器,用于存放返回地址 (PC + 4)。
  • opcode (7 bits): 操作码,标识这是一条 J-type 跳转指令 (1101111)

至此,RISC – V 的第二阶段告一段落,对应 Lab 为 04 ~ 05,下一阶段为数电入门

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注