# Lecture 07 Procedures 函数的调用

## Mechanisms 机制 / 目录

- [Passing Control 移交线程控制权](#passing-control-移交线程控制权)
  - [Stack 栈](#stack-栈)
  - [Call & Ret 函数调用](#call--ret-函数调用)
- [Passing Data 传递数据](#passing-data-传递数据)
  - [传入参数](#传入参数)
  - [返回值](#返回值)
- [Managing Local Data 管理其他变量](#managing-local-data-管理其他变量)
  - [Stack Frame 栈帧](#stack-frame-栈帧)
  - [Recursion 递归](#recursion-递归)
  - [`ret` 指令](#ret-指令)
- [Register Saving Conversions 寄存器保存约定](#register-saving-conversions-寄存器保存约定)
  - [x86-64 Linux System V ABI](#x86-64-linux-system-v-abi)
- [Illustration of Recursion 递归示例](#illustration-of-recursion-递归示例)

## Passing Control 移交线程控制权

### Stack 栈

在画图时，栈底通常画在上方。  
在内存地址视角中，栈底对应较高地址，因此按地址从低到高展示时，栈底会出现在下方。

![栈结构示意图](/CMU-CSAPP/images/L07%20Stack%20Picture.png)  

#### 表 1：栈底在上（高地址在上）

| 数据编号 | 数据    | 地址               |
|----------|---------|--------------------|
| #5       | 0x55    | 0x7fffffffe0a0     |
| #4       | 0x44    | 0x7fffffffe098     |
| #3       | 0x33    | 0x7fffffffe090     |
| #2       | 0x22    | 0x7fffffffe088     |
| #1       | 0x11    | 0x7fffffffe080     |

#### 表 2：低地址在上（地址升序）

| 地址               | 数据    | 数据编号 |
|--------------------|---------|----------|
| 0x7fffffffe080     | 0x11    | #1       |
| 0x7fffffffe088     | 0x22    | #2       |
| 0x7fffffffe090     | 0x33    | #3       |
| 0x7fffffffe098     | 0x44    | #4       |
| 0x7fffffffe0a0     | 0x55    | #5       |

寄存器```%rsp```用于存储栈顶的当前位置。

- ```push <source:data>``` : 将指定内容压入栈中。系统会自动更改```rsp```寄存器的值（-8）
- ```pop <dest:reg>``` : 将栈顶的内容出栈并放入```dest```（必须是寄存器）。系统会自动更改```rsp```寄存器的值（+8）。
- - 使用```pop```时，实际上不会删除内存中的某个值。它只会修改```rsp```寄存器的值。

### Call & Ret 函数调用

函数使用**栈**来进行调用、返回等操作。

- ```call <label>``` : 将**返回地址**压入栈，然后跳转到Label处的函数
  - 返回地址 : 调用函数处下一条的汇编指令地址。
- ```ret``` : 将栈中的返回地址出栈，然后跳转至该地址
  - *```rep;ret```* : *与ret作用相同，已弃用[^rep-ret]*
- ```rip```寄存器: 保存目前指令的地址

[^rep-ret]: 早期 AMD CPU 中，部分场景不写 ```rep``` 会导致分支预测相关优化失效；现代 CPU 上不再需要这样做。

```call```指令实际上做了三件事情: 把```rip```地址处的下一条指令的地址入栈、修改```rsp```，然后jump至指定地址。你不可以手动操作```rip```。

## Passing Data 传递数据

### 传入参数

一般来说，我们会将参数使用这六个寄存器传递：

- ```%rdi```
- ```%rsi```
- ```%rdx```
- ```%rcx```
- ```%r8```
- ```%r9```

特殊地，如果参数超过六个，额外参数会通过栈传递。参数在栈中的顺序（从栈顶到栈底）如下：

| 内容      | 位置   |
|-----------|--------|
| ...       | <-栈底 |
| 其他内容  |        |
| 第 n 参数 |        |
| ...       |        |
| 第 8 参数 |        |
| 第 7 参数 | <-栈顶 |

注: 仅适用于64位CPU。[^32-bit-cpu]

[^32-bit-cpu]: 在32位x86架构（包括x86-64 CPU运行32位代码的“兼容模式”）中，不存在像64位System V ABI那样统一、跨平台的寄存器参数传递标准。在绝大多数32位程序的默认场景下（Linux/Windows C/C++），所有参数通过栈传递，而非寄存器。

### 返回值

返回值一般使用```%rax```进行传递。

## Managing Local Data 管理其他变量

### Stack Frame 栈帧

一般情况下，由于诸多与函数相关的特点，我们会在栈上分配一些内存给局部变量。加上其他要分配的内存[^extra-mmr-allocate]，这部分被称作“栈帧”。

| 函数特点 | 栈特点 |
| ---------- | ---------- |
| 返回后，函数内分配的局部变量被丢弃 | 更改```rsp```指针即可丢弃数据 |
| 函数内可能会调用其他函数 | 直接将返回地址压栈 |
| 单线程调用其他函数时，原函数停止继续运行 | 保存原函数 的帧指针，在其之上可直接建立新栈帧 |

[^extra-mmr-allocate]: 上一个函数的寄存器内容、Windows下的影子空间、返回地址等等内容。

### Recursion 递归

由于“栈帧”的特性，它很适合用来做递归。系统会为每一层递归单独分配内存空间，彼此之间不会因为相同的代码导致变量的地址也相同（使用```%rbp + 数```管理）。

- 寄存器```%rbp``` : **几乎不用的**[^rbp-barely-use-explanation]栈帧指针，指向当前栈帧的底部（较大地址）。通过```%rbp```加上一个偏移量，可以访问当前函数的局部变量。

[^rbp-barely-use-explanation]: 现代编译器（如 GCC 或 Clang）默认采用帧指针省略（Frame Pointer Omission）。这是一种优化技术，允许编译器不使用```%rbp```寄存器来管理栈帧，而是直接使用```%rsp```寄存器来访问局部变量和参数。这使得 %rbp 可以被用作第 7 个通用寄存器，从而略微提升代码的执行速度。仅仅在调试或必须使用帧指针[^rbp-used-situations]的特殊场景下，编译器才会将帧指针放入```%rbp```。

[^rbp-used-situations]: 例如，如果一个函数使用了变长数组，或是使用alloca()在栈上分配了内存，编译器将无法确定运行时栈帧到底有多大。这时候，%rbp就必须被保留 ---- 即使 %rsp 来回变动以腾出空间容纳巨大的数组，编译器也总能根据相对于 %rbp 的固定偏移量找到你的其他局部变量。

Q: 既然```%rbp```不是必须的，程序怎么知道如何释放空间呢？程序怎么知道栈帧的底部在哪里？

A: 在编译时，编译器会根据函数预计内存的使用情况来生成相应的代码。无论是否使用```%rbp```，编译器都会确保在函数返回时正确地调整```%rsp```寄存器，以释放栈帧占用的空间。仅有少数例外。[^rbp-optional-exception]

[^rbp-optional-exception]: 有时我们甚至不需要栈帧。在某些情况下，例如叶子函数优化（Leaf Function Optimization）或帧指针省略（Frame Pointer Omission, FPO）优化，编译器可能会选择不使用栈帧，但这通常是基于对函数调用和内存使用的分析，以确保不会导致错误的内存访问或栈溢出。“叶子”函数是指不调用任何其他函数的函数。由于它从不将控制权让渡给其他例程，编译器通常可以将其所有局部变量和参数完全保留在寄存器中。（如果编译器确定所有局部数据都能放入可用寄存器，且无需将“被调用者保存”的寄存器压入栈中保存）。这将移除用于“压入”和“弹出”栈帧的指令，从而节省内存和执行时间。

### ret 指令

```ret```指令会直接使用```%rsp```寄存器的值作为返回地址，因此在使用```ret```之前，必须确保```%rsp```指向正确的返回地址。通常情况下，编译器会在函数结束前生成代码来调整```%rsp```，以确保它指向正确的位置。

## Register Saving Conversions 寄存器保存约定

当函数A调用函数B时：

- A 被称作 **调用者**（caller）
- B 被称作 **被调用者**（callee）

显然，通用寄存器只有16个，我们不能保证函数B不会修改函数A正在使用的寄存器。因此，我们有了两种约定：

- **调用者保存约定**（Caller-Save Convention）: 调用者负责保存它需要保留的寄存器值。A假设B会修改这些寄存器；A在调用函数B之前将它们压入栈中；待函数B返回后，A再将它们弹出。
  - 遵循这个约定的寄存器被称作**调用者保存寄存器**（Caller-Save Registers）。
- **被调用者保存约定**（Callee-Save Convention）: 被调用者负责保存它需要使用的寄存器值。A假设B在返回后，寄存器值保持不变；在函数B开始时，B将它们压入栈中，结束前再将它们弹出。
  - 遵循这个约定的寄存器被称作**被调用者保存寄存器**（Callee-Save Registers）。

### x86-64 Linux System V ABI

| 寄存器 | 约定类型 | 说明 |
| -------- | ---------- | ------ |
| ```%rax``` | Caller-Save | 返回值 |
| ```%rdi``` | Caller-Save | 传递参数 |
| ```%rsi``` | Caller-Save | 传递参数 |
| ```%rdx``` | Caller-Save | 传递参数 |
| ```%rcx``` | Caller-Save | 传递参数 |
| ```%r8``` | Caller-Save | 传递参数 |
| ```%r9``` | Caller-Save | 传递参数 |
| ```%r10``` | Caller-Save | 临时寄存器 |
| ```%r11``` | Caller-Save | 临时寄存器 |
| | | |
| **```%rbx```** | Callee-Save | 被调用者保存寄存器 |
| ```%r12``` | Callee-Save | 被调用者保存寄存器 |
| ```%r13``` | Callee-Save | 被调用者保存寄存器 |
| ```%r14``` | Callee-Save | 被调用者保存寄存器 |
| ```%rbp``` | Callee-Save[^rbp-linux-reg-saving] | 特殊[^rbp-linux-reg-saving] |
| ```%rsp``` | Callee-Save[^rsp-linux-reg-saving] | 特殊[^rsp-linux-reg-saving] |

[^rbp-linux-reg-saving]: 如果不使用栈帧，则```%rbp```可视作Callee-Save寄存器；如果使用栈帧，则```%rbp```被用作帧指针，编译器会自动处理它的保存和恢复。

[^rsp-linux-reg-saving]: ```%rsp```是栈指针，它在函数调用过程中需要保持不变，因此可以当作某种特殊的Callee-Save寄存器。编译器会确保在函数调用前后正确调整```%rsp```，以维护栈的完整性。（一般来说我们不会手动调整```%rsp``` -- 除非你知道你在做什么！）

## Illustration of Recursion 递归示例

递归并不是什么神奇的黑科技。C编译器不会对递归函数进行特殊处理(除非进行优化)。它只是按照正常的函数调用机制来处理递归函数。

```c
long pcount_recursive(unsigned long x){
    if (x == 0)
    {
        return 0;
    }
    else
    {
        return (x & 1)
            + pcount_recursive(x >> 1);
    }
}
```

这个函数被用作递归示例。它使用递归，计算一个无符号长整数中二进制表示中1的个数。

```asm
pcount_recursive:
.LFB24:
        testq   %rdi, %rdi  # if (x == 0)
        jne     .L9         # jump to .L9 if x != 0
        movl    $0, %eax    # set return value to 0
        ret             # return
.L9:                    # else...
            # (x & 1) ...
        pushq   %rbx        # save %rbx on the stack (callee-save)
        movq    %rdi, %rbx  # copy x to %rbx
        andl    $1, %ebx    # compute x & 1, result in %rbx and discard high 32 bits
            # + pcount_recursive(x >> 1) ...
        shrq    %rdi        # compute x >> 1, result in %rdi
        call    pcount_recursive    # recursive call
        addq    %rbx, %rax  # add (x & 1) to the result of the recursive call
        popq    %rbx        # restore %rbx from the stack
            # return ...
        ret             # return
```

可以看到，在递归调用之前，函数将```%rbx```寄存器的值压入栈中，以保存它的值（因为```%rbx```是Callee-Save寄存器）。在递归调用之后，函数将```%rbx```的值弹出，以恢复它的值。这样，每次递归调用都会正确地保存和恢复```%rbx```寄存器的值，确保递归函数能够正确地执行。
