子例程
- 子程序(program, procedure)
- 将一个组件的功能与其实现的细节分隔开来
 
- 程序员只需理解组件的结构,不需要考虑其实现的细节,就能够把该组件作为一个程序块使用
 
- 子程序使程序员能够以模块化的方式写程序,提高了构建复杂计算系统的能力
 
 
- 子例程(subroutine, function)
- 在一个程序中,多次执行某个程序片段
- 在程序内,不必每次给出这个程序片段
 
- 通过多次调用该程序片段实现
 
 
 
调用-返回机制

通常设置 jal/jalr 目的寄存器为 x1,保存返回地址/链接;基址寄存器一般为 x5。
jal
- 计算出的地址被限制于内存一定范围中
 
- 偏移范围为 −2020∽220−2
 
 
jalr
 
伪指令 call 用于调用子例程
等价于
1 2
   | auipc RD, offsetHi       # offsetHi 为 offset 高 20 位 jalr  x1, offsetLo(RD)   # offsetLo 为 offset 低 12 位
   | 
 
伪指令 ret 用于从子例程返回
等价于
被调用者保存寄存器(callee-saved register)
由被调用者(callee)完成寄存器保存/恢复工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
   | 01              .data 02              .align  2 03 SaveReg1:    .word   0               # 保存寄存器的空间 04 # 05              .text 06              .align  2 07 # 省略代码,x8 <- x 08              addi    x10, x0, 3      # x10 <- 3,被乘数 09              addi    x11, x0, -2     # x11 <- -2,乘数 0A              call    Multiply 0B              ......                  # 使用 x8 做计算 0C              ......                  # 下一个任务 0D # 0E Multiply:    la      x5, SaveReg1 0F              sw      x8, 0(x5)       # callee save 10              andi    x9, x9, 0       # x9,积 11 Mloop:       beqz    x11, Mexit      # x11,乘数 12              andi    x8, x11, 1 13              beqz    x8, Mnext 14              add     x9, x9, x10     # x10,被乘数 15 Mnext:       srli    x11, x11, 1 16              slli    x10, x10, 1 17              j       Mloop 18 Mexit:       lw      x8, 0(x5)       # 寄存器恢复 19              ret
   | 
 
0E 和 0F 行将 x8 的值 x 保存到预留的空间中;18 行将 x8 的值恢复。
参数和返回值
1 2 3 4 5 6 7 8
   | ......          ...... 08              addi    x10, x0, 3      # x10 <- 3,被乘数 09              addi    x11, x0, -2     # x11 <- -2,乘数 0A              call    Multiply        # 调用子例程 ......          ...... 10              andi    x9, x9, 0       # x9,积 14              add     x9, x9, x10     # x10,被乘数 19              ret                     # 从子例程返回调用者
   | 
 
- 对于 Multiply 子例程:
 
- 对于调用 Multiply 子例程的程序
- 先传递参数值,即为 x10 和 x11 赋值
 
- 调用返回后,通过返回值 x9 得到乘法计算结果
 
 
调用者保存寄存器(caller-saved register)
由调用者(caller)完成的寄存器保存/恢复工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
   | ...... 09 SaveReg2:    .word   0, 0 ...... 1F              la  x6, SaveReg2 20              sw  x11, 0(x6)          # caller save ...... 22              addi    x11, x0, 10     # x11,乘数 23              call    Multiply        # x9 = x10 * 10 ...... 25              la  x6, SaveReg2 26              lw  x11, 0(x6)          # 寄存器恢复 ...... 29              addi    x11, x11, 1     # x11 指向下一个字符 ...... 2C DoneS2I:     ret
   | 
 
09 行使用数据区的存储单元作为保存寄存器的空间;1F 和 20 行将 x11 的值保存到预留的空间中;25 和 26 行:将 x11 的值恢复。
在 23 行执行 call 后,x1 被改变(为 24 行 PC 值),待该子例程在 2C 行 ret 返回时,会返回到 24 行,发生了错误!
1 2 3 4 5 6 7 8 9 10 11 12 13 14
   | ...... 09 SaveReg2:    .word 0, 0 ...... 1F              la  x6, SaveReg2 ...... 21              sw  x1, 4(x6)        # caller save ...... 23              call  Multiply       # x9 = x10 * 10 ...... 25              la  x6, SaveReg2 ...... 27              lw  x1, 4(x6)        # 寄存器恢复 ...... 2C DoneS2I:     ret
   | 
 
09 行使用数据区的存储单元作为保存寄存器的空间;21 行将 x1 的值保存到预留的空间中;27 行将 x1 的值恢复。
寄存器的保存/恢复
基本原则:如果一个寄存器内的值将在该寄存器的值被改变之后再次被用到,必须在其值被改变之前将其保存,在再次使用它之前将其恢复。
- 通过将寄存器的值存进内存,来保存它的值
 
- 通过将内存保留的值加载回寄存器,来恢复它的值
 
如果在子例程中又调用了子例程,必须采用 caller-saved(调用者保存)的策略,保存/恢复返回地址 x1
 
RISC-V 各寄存器的 Saved by 可见 5-指令集体系。
- caller-saved 寄存器
- 维护责任:调用者
 
- 调用者要在子例程的调用前/后,保存/恢复寄存器
 
- 被调用者不用关心这些寄存器旧值,可以直接覆写
 
- 必须在每次发生子例程调用前后都进行保存恢复
 
 
- callee-saved 寄存器
- 维护责任:被调用者
 
- 这些寄存器在使用前/后必须由被调用者保存/恢复
 
- 调用者可以认为子例程返回后这些寄存器是没有被改写的
 
- 在子例程调用时不需要调用者做任何动作
 
 
递归过程
即在一个子例程中调用这个子例程本身,称为递归子例程。
计算前 n 个正整数的和(本例取 n=3):
Sn=Sn−1+n
初始条件 S1=1。
将 n 的值赋给 x10,调用 Sn 子例程,返回结果在 x11 中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
   | 01              .data 02              .align  2 03 SaveReg:     .word   0 04 # 05              .text 06              .align  2 07              .globl  main 08 main:        addi    x10, x0, 3       # n = 3 09              jal     x1, Sn           # call  Sn 0A              j       end 0B # 0C Sn:          la      x5, SaveReg 0D              sw      x1, 0(x5)        # caller save 0E              addi    x6, x0, 1 0F              beq     x10, x6, exit1   # n == 1 ? 10              addi    x10, x10, -1     # n - 1 11              jal     x1, Sn           # 调用 S(n-1) 12              addi    x10, x10, 1      # n 13              add     x11, x11, x10    # S(n) = S(n-1) + n 14              j       exit2 15 exit1:       addi    x11, x0, 1       # S(1) = 1 16 exit2:       la      x5, SaveReg 17              lw      x1, 0(x5) 18              ret
   | 
 
但是这样每一层递归都将 x1 保存在了 SaveReg 中,导致了浅层次保存的结果被覆盖,因此会产生无限循环。
即递归调用子例程时,保存返回地址 x1 的指令将前一次保存的寄存器值覆盖了.
采用栈机制解决。
栈
栈是一种后进先出(LIFO, Last In, First Out)的数据结构,栈顶指针指向栈顶元素。
栈操作:
- 压栈(push):将数据压入栈顶,栈顶指针加 1
 
- 出栈(pop):将栈顶数据弹出,栈顶指针减 1
 
栈指针:
内存中的栈实现:
初始地址 x2 指向 0xBFFF FFF0。栈从高地址向低地址,即栈顶地址最小,栈顶在下(底)。
将保存在 x9 中的数值压栈:
1 2
   | push:   addi    x2, x2, -4         sw      x9, 0(x2)
   | 
 
将栈顶的数值弹出到 x9:
1 2
   | pop:    lw      x9, 0(x2)         addi    x2, x2, 4
   | 
 
栈协议:
- 是一组对内存的访问控制规则
 
- 满足后进先出(LIFO)原则
 
- 包含方法:PUSH, POP
 
改进后的递归调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
   | 01              lui    x2, 0xc0000 02              addi   x2, x2, -16       # x2 = 0xBFFF FFF0 03              addi   x10, x0, 3        # n = 3 04              jal    x1, Sn            # call Sn 05              j      end 06 # 07 Sn:          addi    x2, x2, -4       # push x1 08              sw      x1, 0(x2) 09              addi    x6, x0, 1 0A              beq     x10, x6, exit1   # n == 1 ? 0B              addi    x10, x10, -1     # n - 1 0C              jal     x1, Sn           # S(n-1) 0D              addi    x10, x10, 1      # n 0E              add     x11, x11, x10    # S(n) = S(n-1) + n 0F              j       exit2 10 exit1:       addi    x11, x0, 1       # x11,S(1) = 1 11 exit2:       lw      x1, 0(x2)        # pop x1 12              addi    x2, x2, 4 13              ret
   |