Files
my-notes/CSAPP/L07 Procedures 函数的调用.md
2026-03-30 16:09:38 +08:00

12 KiB
Raw Blame History

Lecture 07 Procedures 函数的调用

Mechanisms 机制 / 目录

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 栈帧

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

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

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 传递参数
%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-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寄存器的值,确保递归函数能够正确地执行。


  1. 早期 AMD CPU 中,部分场景不写 rep 会导致分支预测相关优化失效;现代 CPU 上不再需要这样做。 ↩︎

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

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

  4. 上一个函数的寄存器内容、Windows下的影子空间、返回地址等等内容。 ↩︎

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

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

  7. 如果不使用栈帧,则%rbp可视作Callee-Save寄存器如果使用栈帧%rbp被用作帧指针,编译器会自动处理它的保存和恢复。 ↩︎

  8. %rsp是栈指针它在函数调用过程中需要保持不变因此可以当作某种特殊的Callee-Save寄存器。编译器会确保在函数调用前后正确调整%rsp,以维护栈的完整性。(一般来说我们不会手动调整%rsp -- 除非你知道你在做什么!) ↩︎