跳转到内容

理解函数调用和内存布局

本节的目的在于理解函数调用和栈空间的相关理论.

在调用函数时, 不同于分支、循环等其他控制流结构, 被调用函数返回时, 需要跳转到一个运行时确定(确切地说是在函数调用发生的时候)的地址, 而不是一个编译期固定下来的地址.

Function Call

对此, 指令集必须给用于函数调用的跳转指令一些额外的能力, 而不只是单纯的跳转. 在 RISC-V 架构上, 有两条指令即符合这样的特征.

名称指令指令功能
跳转并链接jal rd, immrd <- pc + 4
pc <- pc + imm
寄存器跳转并链接jalr rd, imm(rs)rd <- pc + 4
pc <- rs + imm

这两条指令除了设置 PC 寄存器完成跳转功能之外, 还将当前跳转指令的下一条指令地址保存在 rd 寄存器中. 在 RISC-V 架构中, 通常使用 ra 寄存器(即 x1 寄存器)作为其中的 rd, 因此在函数返回的时候, 只需跳转回 ra 所保存的地址即可.

不熟悉 RISC-V 寄存器相关内容的同学可以看下方的这个表. 通用寄存器包括 x0~x31,它们一般都有对应的 ABI 名称 (也就是别名). 例如函数返回值通常存在 a0,t 开头的寄存器通常作为临时寄存器,属于调用者保存 (Caller Saved) 的寄存器;s 开头的寄存器通常作为保存寄存器,属于被调用者保存 (Callee Saved) 的寄存器. 此处仅列出部分寄存器,感兴趣的同学可以阅读 调用规范.

寄存器ABI 名称描述保存者
x0zero硬编码为 0
x1ra返回地址调用者
x2sp栈指针被调用者
x5–7t0–2临时寄存器调用者
x8s0/fp保存寄存器/帧指针被调用者
x9s1保存寄存器被调用者
x10–11a0–1函数参数/返回值调用者
x12–17a2–7函数参数调用者
x18–27s2–11保存寄存器被调用者
x28–31t3–6临时寄存器调用者

函数返回时, 我们常使用一条伪指令 ret 跳转回调用之前的位置:, 它会被汇编器翻译为

jalr x0, 0(x1)

含义为跳转到寄存器 ra (即 x1)保存的物理地址, 由于 x0 是一个恒为 0 的寄存器, 任何写入到 x0 的值都会被直接丢弃, 在 rd 中, 保存这一步被省略, 即不需要保存返回地址.

进行函数调用的时候, 我们通过 jalr 指令 保存返回地址并实现跳转;而在函数即将返回的时候, 则通过 ret 指令跳转之前的下一条指令继续执行. 这两条指令实现了函数调用流程的核心机制.

编译器除了函数调用的相关指令之外, 基本不使用 ra 寄存器. 意即, 如果在函数中没有调用其他函数, 那 ra 的值不会变化, 函数调用流程能正常工作. 但是, 实际编写代码时, 我们常常会遇到函数多层嵌套调用的情形.

因此我们需要保证, 在一个函数调用子函数的前后, 包括 ra 寄存器在内的所有通用寄存器的值都不能发生变化. 我们将由于函数调用, 在控制流转移前后需要保持不变的寄存器集合称之为函数调用上下文(Context) 或称活动记录 (Activation Record).

在调用子函数之前, 我们需要在内存中的一个区域保存 (Save) 函数调用上下文中的寄存器;而之后我们会从内存中同样的区域读取并恢复 (Restore) 这些寄存器.

函数调用上下文中的寄存器被分为如下两类:

  • 被调用者保存 (Callee-Saved) 寄存器, 即被调用的函数保证调用它前后, 这些寄存器保持不变;
  • 调用者保存 (Caller-Saved) 寄存器, 被调用的函数可能会覆盖这些寄存器.

寄存器相关内容可见于RISC-V 架构的 C 语言调用规范Cornell.

函数调用上下文由调用者和被调用者分别保存, 其具体过程分别如下:

  • 调用者:首先保存不希望在函数调用过程中发生变化的调用者保存寄存器, 然后通过 jal/jalr 指令调用子函数, 返回回来之后恢复这些寄存器.
  • 被调用者:在函数开头保存函数执行过程中被用到的被调用者保存寄存器, 然后执行函数, 在退出之前恢复这些寄存器.

无论是调用者还是被调用者, 都会因调用行为而需要两段匹配的保存和恢复寄存器的汇编代码, 可以分别将其称为 开场白 (Prologue) 和 收场白 (Epilogue), 它们会由编译器帮我们自动插入.

函数调用上下文的保存/恢复的寄存器保存在栈 (Stack) 中. sp (即x2寄存器) 用来保存栈指针 (Stack Pointer), 它是一个指向了内存中已经用过的位置的一个地址.

在 RISC-V 架构中, 栈是从高地址到低地址增长的.在一个函数中, 作为起始的开场白负责分配一块新的栈空间, 其实它只需要知道需要空间的大小, 然后将 sp 的值减小相应的字节数即可. 于是物理地址区间 [新sp,旧sp) 对应的物理内存便可用于函数调用上下文的保存/恢复等, 这块物理内存被称为这个函数的栈帧 (Stack Frame).

同理, 函数中作为结尾的收场白负责将开场白分配的栈帧回收, 这也仅仅需要 将 sp 的值增加相同的字节数回到分配之前的状态.这也可以解释为什么 sp 是一个被调用者保存寄存器.

函数调用过程中, 栈帧分配与sp寄存器变化如图:

栈指针

一个函数的栈帧内容可能如下.

StackFrame

它的开头和结尾分别在 sp(x2)fp(s0) 所指向的地址.按照地址从高到低分别有以下内容, 它们都是通过 sp 加上一个偏移量来访问的:

  • ra 寄存器保存其返回之后的跳转地址, 是一个调用者保存寄存器;
  • 父亲栈帧的结束地址 fp, 是一个被调用者保存寄存器;
  • 其他被调用者保存寄存器 s1~s11
  • 函数所使用到的局部变量.

因此, 栈上实际上保存了一条完整的函数调用链, 通过适当的方式我们可以实现对它的跟踪.

当我们将源代码编译为可执行文件之后, 会得到一个看似充满杂乱无章字节的文件.这些字节至少可以分成代码和数据两部分, 代码部分由一条条可以被 CPU 解码并执行的指令组成, 而数据部分只被 CPU 视作可用的存储空间.

我们还可根据其功能, 进一步把两个部分划分为更小的单位: (Section) . 不同的段会被编译器放置在内存不同的位置上, 这构成了程序的 内存布局 (Memory Layout). 一种典型的程序相对内存布局如下:

内存布局

代码部分只有代码段 .text 一个段, 存放程序的所有汇编代码.

数据部分则还可以继续细化:

  • 已初始化数据段保存程序中那些已初始化的全局数据, 分为 .rodata.data 两部分.前者存放只读的全局数据, 通常是常数或常量字符串等;而后者存放可修改的全局数据.
  • 未初始化数据段 .bss 保存程序中那些未初始化的全局数据, 通常由程序的加载者代为进行零初始化, 也即将这块区域逐字节清零;
  • (heap) 区域用来存放程序运行时动态分配的数据, 如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域, 它向高地址增长;
  • 栈区域 stack 不仅用作函数调用上下文的保存与恢复, 每个函数作用域内的局部变量也被编译器放在它的栈帧内.它向低地址增长.

对于堆上的动态变量, 其本体被保存在堆上, 大小在运行时才能确定. 而我们只能直接访问栈上或者全局数据段中的编译期确定大小的变量. 因此, 我们需要通过一个(运行时分配内存得到的)指向堆上数据的指针来访问它. 指针的位宽确实在编译期就能够确定. 该指针即可以作为局部变量放在栈帧内, 也可以作为全局变量放在全局数据段中.