CSAPP - 03.Machine-Level Programming III
机器级编程 III: 过程 (Procedures)
主要内容:
本章关注计算机如何实现逻辑控制。包括:
- 条件码 (Condition Codes):CPU 如何通过
ZF,SF等标志位记住上一次计算的结果。 - 跳转指令 (Jumps):汇编中没有
if和while,只有goto(即jmp/je)。 - 翻译控制结构:详细讲解了 C 语言中的
if-else、while、for和switch是如何被“降维打击”成汇编中的条件跳转和跳转表的。
Motivation:
高级语言有优雅的结构(如循环、分支),但底层硬件非常“笨”,只会按顺序执行或跳转。理解这一章,你就能明白编译器是如何“欺骗”硬件来模拟复杂逻辑的,也能理解为什么某些写法(如 switch 跳转表)会比其他写法更快。
3.1 过程 (Procedures) 的机制
一个过程(或函数)的调用需要一套完整的机制来支持:
- 传递控制 (Passing Control):
- 在调用时,程序必须能跳转到过程的起始地址。
- 在过程返回时,程序必须能跳转回调用指令的下一条指令。
- 传递数据 (Passing Data):
- 必须能够将参数 (Arguments) 传递给过程。
- 过程必须能够返回一个返回值 (Return Value)。
- 内存管理 (Memory Management):
- 过程在执行期间需要能够分配 (Allocate) 内存来存储局部变量。
- 在过程返回时,必须释放 (Deallocate) 这部分内存。
这些机制都是通过机器指令集和一套固定的策略来实现的。这些策略和选择共同构成了 应用二进制接口 (Application Binary Interface, ABI)。
3.2 x86-64 栈 (Stack) 结构
栈 (Stack) 是实现过程机制的核心数据结构。
- 栈区域: 内存中一块特殊的区域,遵循
栈 (Stack)规则(后进先出, LIFO)来管理。 - 增长方向: 在 x86-64 架构中,栈从高地址向低地址增长,每次有压栈操作时候
%rsp减8。(在64位计算机上,存储地址使用的是64 bits即8 bytes的大小) - 栈指针 (Stack Pointer, %rsp):
%rsp是一个特殊的寄存器栈指针寄存器,它始终指向栈顶 (Stack Top)。- “栈顶”指的是当前栈上最低的内存地址(即最后压入的数据所在的位置)。
栈操作 (Stack Operations)
pushq Src(压栈):- 从
Src获取操作数。 - 将
%rsp减 8 (因为栈向低地址增长)。 - 将操作数写入到
%rsp指向的新地址。
- 从
popq Dest(出栈):- 从
%rsp指向的地址读取值。 - 将
%rsp加 8。 - 将读取的值写入到
Dest(通常是一个寄存器)。
- 从
3.3 调用约定 (Calling Conventions)
调用约定 (Calling Conventions) 是一套必须遵守的规则,它详细规定了过程调用如何实现。
1. 传递控制 (Passing Control)
控制流的传递依赖于 栈 (Stack) 和两条关键指令:
call labellabel为地址 (过程调用):- 将返回地址 (Return Address) 压入栈中。(返回地址是
call指令之后的那条指令的地址)。 - 无条件跳转 (Jump) 到
label处。
- 将返回地址 (Return Address) 压入栈中。(返回地址是
ret(过程返回):- 从栈顶弹出一个地址(这个地址就是之前
call压入的返回地址)。 - 无条件跳转到这个弹出的地址。
- 从栈顶弹出一个地址(这个地址就是之前
这个例子逐步展示了在一次函数调用中,call 和 ret 指令如何利用栈 (Stack) 来精确地转移程序控制权。
这其中涉及两个关键寄存器:
- %rsp (栈指针): 始终指向栈顶。
- %rip (程序计数器): 始终指向下一条要执行的指令。
场景: 程序即将从 multstore 函数内部调用 mult2 函数。
call 0x400550 <mult2> 指令执行时,会发生两件核心事情:
- 压入返回地址:
call指令会查找它后面紧跟着的那条指令的地址 (在这个例子中是0x400549)。- 它将这个 “返回地址” 压入 (push) 栈顶。
- 因此,栈指针
%rsp的值会减少 8 字节 (例如,从0x120变为0x118),指向这个新压入的返回地址。
- 跳转:
- 程序计数器
%rip的值被更新为call指令的目标地址,即mult2函数的起始地址0x400550。
- 程序计数器
结果: 程序控制权跳转到了 mult2 函数,并且栈上保存了"返回票根"(即返回地址),以便稍后能正确返回。
场景: mult2 函数已经执行完毕,即将执行其最后一条 ret (返回) 指令。
此时的状态:
- 程序计数器
%rip指向ret指令本身的地址 (0x400557)。 - 栈指针
%rsp仍然是0x118,栈顶仍然保存之前存入的返回地址0x400549。
场景: ret 指令被执行。
ret 指令会执行与 call 相反的操作:
- 弹出返回地址:
ret指令从栈顶弹出 (pop) 一个值。在这个例子中,它弹出了0x400549。- 因此,栈指针
%rsp的值会增加 8 字节 (例如,从0x118恢复为0x120),栈被"清理"干净。
- 跳转:
- 程序计数器
%rip的值被更新为刚刚从栈中弹出的那个地址,即0x400549。
- 程序计数器
结果: 程序控制权无缝地返回到了 multstore 函数中,并从 call 指令的下一行 (0x400549 处) 继续执行。
2. 传递数据 (Passing Data)
- 传递参数 (Arguments):
- 前 6 个 整数或指针参数按顺序使用寄存器传递:
%rdi%rsi%rdx%rcx%r8%r9
- 第 7 个及以后的参数通过
栈 (Stack)传递。
- 前 6 个 整数或指针参数按顺序使用寄存器传递:
- 返回数据 (Return Value):
- 返回值(如果是整数或指针)总是通过
%rax寄存器返回。
- 返回值(如果是整数或指针)总是通过
3. 管理本地数据 (Managing Local Data)
支持递归 (Recursion) 的语言(如C, Java)要求过程的代码是可重入的 (Reentrant)。这意味着一个过程可以调用它自己,而不会破坏上一次调用的状态。
这是通过为每一次过程调用(每一个实例)分配一个私有的内存区域来实现的,而不是为每一个函数分配一个内存空间,这个区域称为栈帧 (Stack Frame)。
一个“栈帧”就是当一个函数(过程)被调用时,在栈上为它分配的一块专属内存区域,用来存放它运行所需的所有本地信息。
这张图解从上到下(从高内存地址到低内存地址)展示了栈的结构,分为两个部分:“Caller Frame” (调用者栈帧) 和 “Current Stack Frame” (当前栈帧)。
下面是 “当前栈帧” 各部分的详细讲解(按压栈顺序,从高地址到低地址):
1 | |
- Arguments 7+ (第7个及以上的参数)
- 这是由调用者 (Caller) 放入的。
- 根据调用约定,前6个参数通过寄存器传递,而第7个、第8个等后续参数则在调用
call指令之前被压入栈中,存放在这里。
- Return Addr (返回地址)
- 这是由
call指令自动压入栈中的。 - 它保存了
call指令的下一条指令的地址。当当前函数执行ret指令时,CPU会从这里弹出地址,并跳转回去,从而实现函数返回。
- 这是由
- Old
%rbp(旧的%rbp, 可选)- 这是当前栈帧的开始。
- 如果当前函数需要使用
%rbp作为帧指针(基址指针),它执行的第一件事就是pushq %rbp,将调用者的%rbp值保存在这里。
- Saved Registers + Local Variables (保存的寄存器 + 局部变量)
- 这是栈帧的主体部分。
- Saved Registers: 如果当前函数需要使用任何“被调用者保存 (Callee-Saved)”的寄存器(如
%rbx,%r12-%r15),它必须在修改它们之前,先把它们的原始值保存在这里。 - Local Variables: 当函数的局部变量太多,寄存器不够用时,或者当有数组、结构体等大型局部变量时,它们会被存放在这里。
- Argument Build (参数构造区, 可选)
- 这是当前栈帧的最顶部(即最低的内存地址)。
- 如果当前函数自己需要调用另一个函数,它就会在这里准备传递给那个新函数的参数。
- 例如,如果它要调用的函数需要8个参数,它会把第7个和第8个参数放在这里,然后再执行
call。
- %rsp (栈指针)
- 始终指向栈顶(即栈上已使用内存的最低地址)。在图中,它指向 “Argument Build” 区域的底部。
- 它会随着
pushq(减小) 和popq(增大) 或subq/addq指令而动态变化。
- %rbp (帧指针, 可选)
- 如果使用,它会在函数开始时被设置为指向一个固定位置(即 “Old %rbp” 的存放位置)。
- 它的好处是:即使
%rsp因为构造参数等操作而移动,%rbp仍然保持不变,使得编译器可以用固定的偏移量(如-8(%rbp)访问局部变量,+16(%rbp)访问参数)来访问栈帧中的数据。 %rbp寄存器随后会更新,指向这个位置,为访问局部变量和参数提供一个固定的基准点。- 逻辑上,利用保存的
Old %rbp构建了一个“链表”,让我们能回溯函数的调用历史。当前函数的%rbp指向当前栈帧的基准点(就是存放Old %rbp的那个地址)。这个地址里存的值(即Old %rbp的内容)是指向上一个栈帧基准点的指针。
在现代 x86-64 架构中,%rbp 不再强制作为栈帧基准,主要出于以下优化考虑:
1. 现代编译器的静态分析能力 (通过 %rsp 寻址)
- 旧机制:过去因为
%rsp会随压栈/出栈频繁变动,必须用%rbp作为一个固定锚点来定位局部变量(如-8(%rbp))。 - 新机制:现代编译器非常智能,能够在编译时静态计算出任意时刻
%rsp相对于局部变量的偏移量。因此,直接使用%rsp寻址(如8(%rsp))即可,不再需要固定的%rbp。
2. 节省宝贵的寄存器资源
- x86-64 只有 16 个通用寄存器。如果不强制
%rbp做帧指针,它可以被当作第 17 个通用寄存器使用,用于存储普通变量,从而提升性能。 - 这种优化通常通过编译器选项
-fomit-frame-pointer开启。
3. 必须使用 %rbp 的特殊情况
虽然通常是可选的,但在以下场景中仍必须或建议使用:
- 变长数组 (VLA) /
alloca():当栈帧大小在编译期无法确定(动态变化)时,编译器无法计算相对于%rsp的固定偏移量,必须依赖%rbp记录栈帧起始位置。只有C99标准支持,标准C++不支持 - 调试与回溯:保留
%rbp链表结构能让调试器(如 GDB)更轻松、直观地解析函数调用栈(Backtrace)。
4. 寄存器保存约定 (Register Saving Conventions)
问题: 当一个过程(Caller, 调用者)调用另一个过程(Callee, 被调用者)时,Callee 可能会修改 Caller 稍后还需要使用的寄存器。
解决方案: x86-64 规定了一套寄存器保存约定:
-
调用者保存 (Caller-Saved):
- 寄存器:
%rax,%rdi,%rsi,%rdx,%rcx,%r8,%r9,%r10,%r11。 - 规则:
Caller(调用者) 必须假设Callee(被调用者) 会破坏这些寄存器的内容。 - 行动: 如果
Caller在call之后还需要这些寄存器中的值,Caller必须自己在call指令之前将它们保存(例如压入栈中),并在call之后恢复它们。
- 寄存器:
-
被调用者保存 (Callee-Saved):
- 寄存器:
%rbx,%rbp,%r12,%r13,%r14,%r15。 - 规则:
Caller(调用者) 可以假设Callee(被调用者) 在返回时会保持这些寄存器的值不变。 - 行动: 如果
Callee需要使用这些寄存器,它必须在使用前保存它们的原始值(通常是压入栈中),并在ret指令之前将它们恢复。
- 寄存器:
-
%rsp是特殊的,它必须在Callee返回时被正确恢复。
3.4 递归 (Recursion) 的实现
在 Y86-64 与 x86-64 体系结构中,递归函数的实现不需要任何特殊的硬件指令支持。它完全依赖于标准的函数调用机制,即利用栈 (Stack) 的特性和软件层面的调用约定。
1. 核心机制 (Core Mechanisms)
递归能够正常工作,主要得益于以下三个基石:
- 栈帧 (Stack Frames):
- 每次函数调用(即使是调用自身)都会在栈上分配一段私有的存储空间。
- 这保证了不同层级的递归(例如
pcount_r(2)和pcount_r(1))虽然运行同一段代码,但拥有独立的局部变量和返回地址,互不干扰。
- 寄存器保存约定 (Register Saving Conventions):
- 这是递归中数据“存活”的关键。
- 被调用者保存 (Callee-Saved) 寄存器(如
%rbx)确保了当内层递归返回时,外层函数保存在其中的临时数据(如当前的计算结果)依然保持原样。
- 栈的 LIFO 特性:
- 栈的“后进先出”规则与函数调用的
call / ret模式(P调用Q,Q必须在P之前返回)完美契合。
- 栈的“后进先出”规则与函数调用的
2. 实例分析:统计置位位数 (pcount_r)
这个例子展示了如何利用 Callee-Saved 寄存器 (%rbx) 在递归调用之间保存局部状态。
2.1 C 语言源代码
1 | |
3. 为什么必须保存寄存器?(Deep Dive)
- 问题: 为什么不能把
(x & 1)的结果存在%rdx或%rcx这种 Caller-Saved 寄存器里? - 原因:
Caller-Saved寄存器属于“临时工”。当你执行call pcount_r时,下一层函数有权随意修改%rdx的值。等你从call回来时,%rdx里的数据可能已经变了。 - 解决: 必须使用
Callee-Saved寄存器(如%rbx)。约定强制要求:不管下一层函数怎么运行,它在返回时必须保证%rbx的值与调用前一模一样(通过它自己的 push/pop 实现)。