Files
my-notes/CMU-CSAPP/L07 Procedures 函数的调用.md

222 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 栈帧
有时,我们会在栈上分配一些内存给局部变量。常见情况比如:
- 寄存器不够存放所有的局部变量
- 某些局部变量是个Array或者Struct
- 对一个局部变量取地址(&)。这个操作需要它在内存中有一个固定地址才能进行,因此会给它分配一个栈空间。
加上其他要分配的内存[^extra-mmr-allocate],这部分被称作“栈帧”。
| 函数特点 | 栈特点 |
| ---------- | ---------- |
| 返回后,函数内分配的局部变量被丢弃 | 更改```rsp```指针即可丢弃数据 |
| 函数内可能会调用其他函数 | 直接将返回地址压栈 |
| 单线程调用其他函数时,原函数停止继续运行 | 保存原函数 的帧指针,在其之上可直接建立新栈帧 |
| 原函数的数据保持不变 | 新分配一个栈即可避免数据被覆盖 |
[^extra-mmr-allocate]: 上一个函数的寄存器内容、Windows下的影子空间、返回地址等等内容。
需要注意的是在C语言中struct、union这种“庞大”的数据类型也会被留在栈上。如果处理不当很可能Stack Overflow。因此在处理大型数据结构时通常建议使用动态内存分配如malloc来避免栈空间的过度使用并使用free来手动释放。
### 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 | 传递参数 |
| ```%rcx``` | Caller-Save | 传递参数 |
| ```%rdx``` | Caller-Save | 传递参数 |
| ```%r8``` | Caller-Save | 传递参数 |
| ```%r9``` | Caller-Save | 传递参数 |
| ```%r10``` | Caller-Save | 临时寄存器 |
| ```%r11``` | Caller-Save | 临时寄存器 |
| | | |
| **```%rbx```** | Callee-Save | 被调用者保存寄存器 |
| ```%r12``` to ```%r15``` | 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```寄存器的值,确保递归函数能够正确地执行。