12 KiB
Lecture 07 Procedures 过程 (函数的调用)
Mechanisms 机制 / 目录
- Passing Control 移交线程控制权
- Passing Data 传递数据
- Managing Local Data 管理其他变量与内存
- Register Saving Conversions 寄存器保存约定
- Illustration of Recursion 递归示例
Passing Control 移交线程控制权
Stack 栈
在画图时,栈底通常画在上方。
在内存地址视角中,栈底对应较高地址,因此按地址从低到高展示时,栈底会出现在下方。
表 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作用相同,已弃用1
rip寄存器: 保存目前指令的地址
call指令实际上做了三件事情: 把rip地址处的下一条指令的地址入栈、修改rsp,然后jump至指定地址。你不可以手动操作rip。
Passing Data 传递数据
传入参数
一般来说,我们会将参数使用这六个寄存器传递:
%rdi%rsi%rdx%rcx%r8%r9
特殊地,如果参数超过六个,额外参数会通过栈传递。参数在栈中的顺序(从栈顶到栈底)如下:
| 内容 | 位置 |
|---|---|
| ... | <-栈底 |
| 其他内容 | |
| 第 n 参数 | |
| ... | |
| 第 8 参数 | |
| 第 7 参数 | <-栈顶 |
注: 仅适用于64位CPU。3
返回值
返回值一般使用%rax进行传递。
Managing Local Data 管理其他变量与内存
Stack Frame 栈帧
有时,我们会在栈上分配一些内存给局部变量。常见情况比如:
- 寄存器不够存放所有的局部变量
- 某些局部变量是个Array或者Struct
- 对一个局部变量取地址(&)。这个操作需要它在内存中有一个固定地址才能进行,因此会给它分配一个栈空间。
加上其他要分配的内存4 ,这部分被称作“栈帧”。
| 函数特点 | 栈特点 |
|---|---|
| 返回后,函数内分配的局部变量被丢弃 | 更改rsp指针即可丢弃数据 |
| 函数内可能会调用其他函数 | 直接将返回地址压栈 |
| 单线程调用其他函数时,原函数停止继续运行 | 保存原函数 的帧指针,在其之上可直接建立新栈帧 |
| 原函数的数据保持不变 | 新分配一个栈即可避免数据被覆盖 |
需要注意的是,在C语言中,struct、union这种“庞大”的数据类型也会被留在栈上。如果处理不当,很可能Stack Overflow。因此,在处理大型数据结构时,通常建议使用动态内存分配(如malloc)来避免栈空间的过度使用,并使用free来手动释放。
Recursion 递归
由于“栈帧”的特性,它很适合用来做递归。系统会为每一层递归单独分配内存空间,彼此之间不会因为相同的代码导致变量的地址也相同(使用%rbp + 数管理)。
- 寄存器
%rbp: 几乎不用的5 栈帧指针,指向当前栈帧的底部(较大地址)。通过%rbp加上一个偏移量,可以访问当前函数的局部变量。
Q: 既然%rbp不是必须的,程序怎么知道如何释放空间呢?程序怎么知道栈帧的底部在哪里?
A: 在编译时,编译器会根据函数预计内存的使用情况来生成相应的代码。无论是否使用%rbp,编译器都会确保在函数返回时正确地调整%rsp寄存器,以释放栈帧占用的空间。仅有少数例外。6
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-Save7 | 特殊7 |
%rsp |
Callee-Save8 | 特殊8 |
Illustration of Recursion 递归示例
递归并不是什么神奇的黑科技。C编译器不会对递归函数进行特殊处理(除非进行优化)。它只是按照正常的函数调用机制来处理递归函数。
long pcount_recursive(unsigned long x){
if (x == 0)
{
return 0;
}
else
{
return (x & 1)
+ pcount_recursive(x >> 1);
}
}
这个函数被用作递归示例。它使用递归,计算一个无符号长整数中二进制表示中1的个数。
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寄存器的值,确保递归函数能够正确地执行。
-
早期 AMD CPU 中,部分场景不写
rep会导致分支预测相关优化失效;现代 CPU 上不再需要这样做。 ↩︎ -
例如,如果一个函数使用了变长数组,或是使用alloca()在栈上分配了内存,编译器将无法确定运行时栈帧到底有多大。这时候,%rbp就必须被保留 ---- 即使 %rsp 来回变动以腾出空间容纳巨大的数组,编译器也总能根据相对于 %rbp 的固定偏移量找到你的其他局部变量。 ↩︎
-
在32位x86架构(包括x86-64 CPU运行32位代码的“兼容模式”)中,不存在像64位System V ABI那样统一、跨平台的寄存器参数传递标准。在绝大多数32位程序的默认场景下(Linux/Windows C/C++),所有参数通过栈传递,而非寄存器。 ↩︎
-
上一个函数的寄存器内容、Windows下的影子空间、返回地址等等内容。 ↩︎
-
现代编译器(如 GCC 或 Clang)默认采用帧指针省略(Frame Pointer Omission)。这是一种优化技术,允许编译器不使用
%rbp寄存器来管理栈帧,而是直接使用%rsp寄存器来访问局部变量和参数。这使得 %rbp 可以被用作第 7 个通用寄存器,从而略微提升代码的执行速度。仅仅在调试或必须使用帧指针2 的特殊场景下,编译器才会将帧指针放入%rbp。 ↩︎ -
有时我们甚至不需要栈帧。在某些情况下,例如叶子函数优化(Leaf Function Optimization)或帧指针省略(Frame Pointer Omission, FPO)优化,编译器可能会选择不使用栈帧,但这通常是基于对函数调用和内存使用的分析,以确保不会导致错误的内存访问或栈溢出。“叶子”函数是指不调用任何其他函数的函数。由于它从不将控制权让渡给其他例程,编译器通常可以将其所有局部变量和参数完全保留在寄存器中。(如果编译器确定所有局部数据都能放入可用寄存器,且无需将“被调用者保存”的寄存器压入栈中保存)。这将移除用于“压入”和“弹出”栈帧的指令,从而节省内存和执行时间。 ↩︎
-
如果不使用栈帧,则
%rbp可视作Callee-Save寄存器;如果使用栈帧,则%rbp被用作帧指针,编译器会自动处理它的保存和恢复。 ↩︎ -
%rsp是栈指针,它在函数调用过程中需要保持不变,因此可以当作某种特殊的Callee-Save寄存器。编译器会确保在函数调用前后正确调整%rsp,以维护栈的完整性。(一般来说我们不会手动调整%rsp-- 除非你知道你在做什么!) ↩︎
