
Essay2026 / 03 / 19
汇编入门-运行时栈与函数调用
本文以C与x64为基底,讲述函数(过程)的底层汇编实现。内容包括:运行时栈这个函数调用的关键特性,以及函数的调用、参数传递的汇编实现,以及递归的汇编实现
1-运行时栈
一个函数的要素就是,接受一系列参数的传入,然后返回一个可选的结果,实现某种功能。 在这个过程中,涉及到
- 控制传递 从一个函数的执行到另一个函数的执行
- 参数传递 函数能够接受传入的参数,也能返回结果
- 内存分配与释放 在函数开始时为其不够存在寄存器得局部变量分配空间,以及在执行完成后释放
现代大多数语言会使用一块称为 运行时栈 的内存区域来管理函数的调用.
这个过程用大白话来理解就是,函数A调用函数B的时候
- 1.调用函数时,系统先为函数A在栈上创建一块”快照”,称为 栈帧(stack frame) 这个栈帧会分配给被调用函数B。栈帧内主要记录三样东西: 返回地址,调用者的寄存器值,局部变量。其中返回地址将告诉被调用的函数B执行完毕后该从哪里继续执行调用者函数A。而局部变量将在执行函数时被用到。
- 2.执行函数,如果CPU的寄存器不够存函数的局部变量,那么将会在自己的栈帧内向下开辟空间(压栈)存放局部变量。这称为 寄存器溢出
- 3.函数执行完毕,系统将先恢复之前保存的寄存器的值,然后根据返回地址跳回到调用者函数A继续执行,交回控制权。最后,这个属于被调用函数B的栈帧将会被释放(移动栈指针)。
2-控制传递
函数调用过程中,需要将控制权进行转移。 控制转移的操作很简单,PC(Program Counter/程序计数器) 是CPU内的一个专用寄存器,其始终存储着下一条将要执行的指令的内存地址。控制的转移就是将PC的值设置为接受控制权的函数的位置
上升到汇编指令层面,涉及这一操作的指令为call与ret.
call,ret指令的操作与jmp类似,修改PC,然后跳转执行。但是会涉及到对运行时栈的操作
- call指令 即函数调用指令。call指令在修改PC、跳转到相应位置这个操作前,将当前PC的值压入运行时栈中
- ret指令 即函数返回指令。ret指令会在修改PC,跳转到相应位置这个操作前,从运行时栈中弹出返回地址作为PC的值
3-参数传递
函数之间调用很有可能涉及到数据的传递。这包括参数的传递,返回值的传递 这里面涉及到很多东西,我会一点点讲。
3-1 数据靠谁传输
我们的数据在参数传递与返回值的过程中需要靠存储设备来进行缓存。而在整个系统下,寄存器速度最快,再其次就是内存。所以在参数传递与返回值上,优先使用寄存器来进行传输,不够存就放到内存(运行时栈)中
3-2 调用竟然要拍”快照”
既然我们优先用寄存器,那么就要知道一个点 寄存器是公用资源。在函数A调用函数B之后,函数B就会修改寄存器。也就是说,如果不做任何措施,A叫来B帮自己完成一部分工作后,A的草稿纸可能被B改的面目全非而无法继续工作。那么A在叫B过来工作前,就需要先把自己的当前草稿拍个快照,然后再叫B过来工作。 在汇编层面,函数调用过程中,寄存器的原值会被压栈保存,在调用完成后用于恢复寄存器的值
3-3 谁来拍快照,拍哪些部分
那么调用的时候,寄存器要拍快照,那么谁来拍?拍哪些东西?毕竟寄存器有很多,函数调用后不一定会修改全部寄存器的值,有些寄存器调用者也还用不上。每次给全部寄存器压栈保存是很浪费资源的。而且,函数调用过程中,这可以是调用者开始调用前保存,也可以是被调用者来保存。
恰好,现在就有一套约定来,规范了这件事。就是ABI(Application Binary Interface,应用程序二进制接口) 以System V ABI(UNIX系统系的ABI)为例,将15个通用寄存器(除去%rsp)分为下面两种类型
- 调用者保存类型 这些寄存器代表着”易失性”,在调用函数前后会发生变化。所以这些寄存器往往是存储临时计算结果,函数参数传递,返回值这些生命周期短的数据。所以调用者在调用开始前,如果有要保留的内容在这里,需要自己先压栈保存。
- 被调用者保存类型 这些寄存器代表着”稳定性”,他们会在调用前后保持一致。所以这些寄存器往往是存放全局指针,循环计数器,基地址等跨越多个函数调用的数据。所以被调用者如果要使用这些寄存器,应当将原值压栈保存,并在返回前恢复。 下面为具体的寄存器,功能与类型划分表格
| 寄存器 | 功能 | 类型 |
|---|---|---|
| %rax | 返回值 | 调用者保存 |
| %rdi | 第一个参数 | 调用者保存 |
| %rsi | 第二个参数 | 调用者保存 |
| %rdx | 第三个参数 | 调用者保存 |
| %rcx | 第四个参数 | 调用者保存 |
| %r8 | 第五个参数 | 调用者保存 |
| %r9 | 第六个参数 | 调用者保存 |
| %rsp | 栈指针(指向栈顶) | 被调用者保存 |
| %rbp | 帧指针(指向当前帧的底部)/通用 | 被调用者保存 |
| %rbx | 通用存储 | 被调用者保存 |
| %r10,%r11 | 临时/辅助 | 调用者保存 |
| %r12~%r15 | 通用存储 | 被调用者保存 |
3-4 汇编怎么做
铺垫了这么多,我们是时候看看汇编层面如何按照我们刚才说的实现函数调用间的参数传递
这里为C代码
long Second(long x){
x--;
return x;
}
int First(long x){
long y = x*x;
y =y + Second(x);
return y;
}
而这是执行-Og级别优化得到的汇编代码
Second: 注释
leaq -1(%rdi),%rax x-1
ret
First: y in %rbx
pushq %rbx 将%rbx入栈
movq %rdi,%rbx %rdi --> %rbx
imulq %rdi,%rbx %rbx = %rbx * %rdi,即x*x
call Second 调用Second,此时%rdi为x
addq %rbx,%rax %rax = %rax + %rbx,
popq %rbx 恢复%rbx
ret
可见,在First调用Second的过程中,需要将参数传入%rdi。而此时%rdi仍有需要的值,此时First需要将%rdi的数据迁移到 “被调用者保存”的寄存器,即%rbx。那么First使用%rbx,就要先将其压栈保存,然后在迁移数据。
做完这一步,就可以call second来干活了。 second在执行完成后,修改了%rax,此时First通过%rax得到second的返回值,然后在进行运算。,最后返回。
4-递归
汇编层面的递归实现非常简单,就是函数call自身。 下面是一段C代码与编译后的汇编代码,优化级别-Og
long rfact(long x)
{
long result;
if(x <=1) result =1;
else result = result * rfact(x-1);
return result;
}
rfact:
cmpq $1,$rdi; 比较x与1
jg .L8 x>1跳转
movl $1,$eax 令result = 1;
ret
.L8:
pushq %rbx 使用%rbx前压栈保存
movq %rdi,%rbx 复制x到%rbx
leaq -1(%rdi),%rdi x-1
call rfact 调用fract(自身)
imulq %rbx,%rax x * fract(x-1)
popq %rbx 恢复%rbx
ret