编译器构造:内存回收/垃圾回收/寄存器分配

发布于 作者: Ethan

内存管理与垃圾回收 (Memory Management, Garbage Collection)

1 内存分配与重用背景

每种现代编程语言都允许程序动态分配新的存储空间,用于创建新的记录、数组、元组、对象、闭包等。随之而来的,每种现代语言都需要具备回收和循环利用这些存储空间的机制。这通常是任何现代语言运行时系统中最复杂的部分 。

如果用于某对象的内存成为“垃圾”,那么这块内存就可以被重用。所谓“垃圾”,是指该值在程序后续的任何计算中都不会再被使用的对象 。

2 识别垃圾与栈分配的局限

确定哪些对象是垃圾是一个复杂甚至在根本上是不可判定(undecidable)的问题 。

  • 栈分配 (Stack-allocation):当函数返回或发生尾调用时,栈帧中的所有对象都变成了垃圾,其内存可以被重用 。然而,这是一种低估(under-approximation)。在栈上分配的对象可能会在其不再被使用后很久仍保留在内存中 。

由于准确判断垃圾非常困难,业界发展出了多种不同的内存管理技术 :

  • 程序员负责:显式分配与释放 。
  • 自动技术:引用计数、追踪式垃圾回收(包括标记-清除、复制收集、分代 GC) 。

3 显式内存管理 (Explicit MM)

在显式内存管理中,用户库负责管理内存,程序员决定在何时何地分配和释放内存,例如使用 C 语言中的 mallocfree 函数 。

  • 工作原理:未使用的内存块存储在一个空闲链表(freelist)中。malloc 会在空闲链表中搜索可用大小的内存块;free 则将释放的内存块放回空闲链表的头部 。
  • 优点:如果程序员愿意付出努力,可以在对程序最有利的确切时间释放内存 。
  • 缺点
    • 人们通常不愿处理如此繁琐的细节 。
    • 极难写对,出错时非常危险 。
    • malloc 并非免费操作,可能需要进行大量搜索以寻找足够大的内存块 。
    • 随着程序运行,堆内存会发生碎片化 (fragmentation),留下许多无法使用的小碎片 。

缓解碎片化与优化方案

  • 使用多个空闲链表,针对不同大小(通常是 2 的幂)的内存块分别管理,使得 mallocfree 的时间复杂度趋近于 O(1) 。
  • 如果某个尺寸的块耗尽,可以将更大的块分割以适应所需大小;相邻的空闲块也可以合并为更大的块 。
  • 即便如此,仍然无法避免碎片化,通常会有 30% 的空间被浪费。内存管理总是需要付出代价的 。

带有不安全显式管理的语言正逐渐被淘汰,因为它们会带来沉重的软件工程负担,并常常导致安全漏洞。取而代之的是,像 Rust 这样保证内存安全的语言正被引入 Linux 内核和现代 Web 浏览器等高性能领域。拜登政府甚至发布了一项行政命令,建议使用内存安全语言编写新软件 。

4 自动内存管理基础

在自动内存管理中,通常采用保守的近似策略。常规的解决方案是:当一个对象从根节点 (roots) 变得不可达时,它就被视为垃圾 。

  • 根节点 包括寄存器、栈和全局静态数据 。
  • 如果不存在从根节点到某条对象的路径,说明该对象在随后的计算中不可能再被使用,因此可以安全地回收其内存 。

5 引用计数 (Reference Counting)

引用计数通过记录指向每个对象的指针数量(即引用计数)来追踪对象状态。当引用计数降为 0 时,说明该对象已成为不可达的垃圾 。

  • 缺点
    • 无法检测循环引用 (cycles)
    • 性能开销极大:代替单次指针赋值(如 x.f = p),系统需要执行一连串操作,包括读取旧对象计数、递减、判断释放、读取新对象计数、递增并写回 。
    • 尽管数据流分析可以消除部分冗余的增减操作,但仍有大量开销保留 。
  • 因此,引用计数通常只在某些特殊场景下使用,而不作为语言实现中的主要 GC 机制 。

6 标记-清除 (Mark-Sweep)

这是一种两阶段算法:

  1. 标记阶段 (Mark phase):从根节点开始对对象图进行深度优先遍历 (DFS),标记所有存活(可达)的数据 。
  2. 清除阶段 (Sweep phase):遍历整个堆,将所有未被标记的数据块添加回空闲链表。回收完毕后,程序恢复执行 。

成本分析

  • 每次收集的开销与整个堆的大小 (H) 和可达数据量 (R) 有关。每次收集返回 $H - R$ 个字的空闲空间 。
  • 为保证低 GC 分摊成本,可达数据与总堆大小的比例 ($R/H$) 必须足够小。例如,如果 $R/H > 0.5$,就应该增加堆大小 。

隐藏成本与 DSW 指针反转算法

  • 深度优先搜索通常作为递归算法实现,其消耗的栈空间与可达对象图中最长路径成正比 。如果堆退化为一条长链表,算法所使用的栈空间可能会超过堆空间本身 !
  • Deutsch-Schorr-Waite (DSW) 技巧:该算法不使用递归,而是在遍历图时重用图本身的组件来构建一个显式栈。这样只需在每个块中增加几个额外的标志位,而无需为每个节点分配整个活动记录栈帧 。

标记-清除算法的缺点是同样会遭遇碎片化问题,因为块不会像在复制收集算法中那样被移动和压实 。

7 复制收集 (Copying Collection)

基本思路是使用两个堆:一个是程序当前活跃使用的 (from-space),另一个在 GC 期间处于空闲状态 (to-space) 。

GC 过程 (Cheney 算法)

  • 从根节点开始,利用广度优先搜索遍历可达数据 。
  • 将存活的数据从 from-space 复制到 to-space 。
  • 死亡对象被留在 from-space 中。完成后,两个堆互换角色 。

优点

  • 算法简单,能够收集循环引用 。
  • 运行时间仅与存活对象的数量成正比 。
  • 自动进行内存压实 (compaction),消除了碎片化 。
  • 内存分配极快:只需通过简单的指针自增加上对象大小即可完成 。

缺点

  • 需要精确的类型信息(以区分指针和非指针数据),通常通过头部字或标签位占用额外空间来实现 。
  • 需要程序实际使用内存的两倍空间。通常在堆分配达到 70% 满时触发回收 。
  • 可能会导致较长的 GC 停顿时间,对交互式或实时应用程序不利 。

8 分代垃圾回收 (Generational GC)

分代垃圾回收建立在两个经验观察之上:

  1. 如果一个对象存活了很长时间,它可能继续存活 。
  2. 在很多语言(尤其是函数式语言)中,大多数对象“朝生夕死” 。 通过频繁扫描年轻对象,而不频繁扫描老对象,可以节省大量工作 。

机制与优化

  • 将对象分配到不同的世代(如 G0, G1...)。G0 包含最年轻的对象,被扫描的频率高于 G1 。
  • 对 G0 进行垃圾回收时,其根节点不仅包括寄存器和栈,还包括所有老年代 (G1) 中的对象 。
  • 记忆集 (Remembered set):老对象极少指向新对象。编译器会在对象字段写入时插入额外代码,捕获对旧对象的修改。这些记录被保存在记忆集中,GC 扫描时将其作为根节点的一部分,从而避免扫描整个老年代 。

不同算法的结合: 由于新生代和老年代具有不同的特征,通常结合不同的 GC 算法。新生代使用复制收集(因为通常不到 10% 的数据存活,复制成本低),而老年代更适合使用标记-清除 。

9 保守垃圾回收 (Conservative Collection)

像 C 语言这样缺乏语言级支持的环境也可以从 GC 中受益。

  • Boehm-Weiser-Demers 算法 使用启发式规则来判断内存中的数据是指针还是整数(例如,如果最后两位非零,或者该值不在堆的地址范围内,则不能是指针) 。
  • 在标记阶段,算法会遍历所有“可能”是指针的数据。它是保守的,因为可能会把整数误认为指针从而保留死数据。但由于它不复制对象从而不改变指针的值,这种误判不会带来危害 。

10 编译器与 GC 的接口

垃圾回收器和编译器之间的接口主要涉及分配代码和 GC 调用规范。

分配代码优化: 现代语言的分配操作极多(约每 7 条指令就可能有 1 次分配),因此分配代码必须极其快速,通常需要内联并优化以避免函数调用的开销 。通过编译器优化(例如消除无用的存储指令、组合移动指令等),每次内存分配的总开销可以降低至 3 条指令左右 。

调用 GC 代码规范

  • 识别根节点:程序必须能在控制流中的特定位置(即 GC 点)调用垃圾回收器 。对于任何 GC 点,编译器生成一个全局的指针映射表 (pointer map),指明当前栈帧哪些位置包含指针。当发生 GC 时,GC 沿着栈向下扫描并查找对应的映射表来定位根节点 。
  • 数据布局:程序必须允许 GC 确定对象的大小和包含的指针。每个记录通常有一个包含这些信息的头部;在 Java 等面向对象的语言中,每个对象有额外字段指向类定义,GC 依靠类定义来确定对象的布局 。

这是一份基于所提供文档整理的专业详细笔记。

寄存器分配第一部分:活跃度分析 (Register Allocation Part 1: Liveness Analysis)

1 代码生成的性能瓶颈与内存层次结构

在编译器开发的前期阶段,主要关注点在于如何正确地编译各种语言特性(即功能正确性),并保证代码的渐进复杂度不受破坏。然而,如果仅仅采用最简单的策略,生成的汇编代码在执行时会具有极高的常数级性能损耗。

这些导致性能低下的原因包括:

  • 为所有局部变量分配栈内存。
  • 大量冗余的动态类型检查。
  • 未在编译期对简单的算术运算进行常量折叠等优化。

其中,内存访问是最大的性能瓶颈之一。计算机科学中的内存层次结构(Memory Hierarchy)表明,不同存储介质的访问速度有着天壤之别:

  • 寄存器 (Registers):访问时间约为 0.25 - 1 纳秒,速度极快。
  • L1 / L2 缓存 (Cache):访问时间在 1 - 25 纳秒之间。
  • 主存 (Main Memory):访问时间在 25 - 100 纳秒之间。
  • 硬盘与网络 (Hard Disk & Network):访问时间则需要数毫秒甚至更久。

如果编译器将所有的 SSA(静态单赋值)局部变量都直接映射并存储在内存的栈空间中(如 [rsp - 8] 等),即使考虑了嵌套子块中栈空间的复用,依然需要频繁地将数据在内存和寄存器之间来回搬运,这会带来巨大的性能损失。

2 寄存器分配与图着色算法 (Graph Coloring)

为了解决栈分配带来的性能问题,编译器引入了寄存器分配 (Register Allocation) 机制。

  • 核心目标:尽可能将变量的值存储在寄存器中;只有在寄存器耗尽的情况下,才将变量“溢出 (Spill)”并分配到栈空间。
  • 收益与代价:这通常是编译器中最重要的优化,能使变量访问速度提升 3 到 10 倍以上,同时大幅减小栈帧的体积。然而,其代价是计算复杂度极高,往往是编译器中最耗时的阶段。

如果两个变量在程序的同一时刻都需要被使用,它们就不能被放入同一个寄存器中,这种现象被称为冲突 (Conflict)干扰 (Interference)

目前生成代码质量最高的寄存器分配算法采用的是图着色 (Graph Coloring) 方法:

  • 构建一个干扰图 (Interference Graph),其中的顶点 (Vertices) 代表程序中的变量,边 (Edges) 代表变量之间的冲突关系。
  • 将不同的颜色 (Colors) 视为不同的可用物理寄存器。
  • 寻找一个有效的寄存器分配方案,本质上就是解决图着色问题:为图中的每个顶点分配一种颜色,使得任意两个相邻的顶点颜色都不相同。

完整的寄存器分配可以拆分为三个主要任务:

  1. 活跃度分析 (Liveness Analysis):确定在程序的每个点上,哪些变量的值是必须被保留(即处于“活跃”状态)的。
  2. 冲突分析 (Conflict Analysis):利用活跃度信息构建干扰图。
  3. 图着色 (Graph Coloring):尝试对干扰图进行着色,若无法找到可行解,则将部分变量溢出到栈上。

3 活跃度分析 (Liveness Analysis) 及其不可计算性

活跃度分析是编译器最基础的数据流分析之一。

  • 语义定义:如果一个代码块(或表达式、操作)的可观测行为依赖于变量 $x$ 的值,那么变量 $x$ 在该块中就是活跃的 (Live)
  • 应用场景
    • Lambda 提升 (Lambda lifting) / 闭包转换:用于精确确定需要被闭包捕获的活跃变量。
    • 寄存器分配:只需要将活跃的变量保留在寄存器或栈中,一旦变量不再活跃,其占用的空间即可被重用。
    • 函数调用:在调用函数前,只需保存那些在当前作用域内处于活跃状态的“调用者保存 (Caller-save)”寄存器的值。

可计算性的局限 (Limitation: Computability)

根据莱斯定理 (Rice's Theorem),图灵完备语言中关于程序的任何非平凡语义属性都是不可判定的 (undecidable)。因为准确判断一个变量是否活跃,需要编译器能够精准预测任意函数在运行时的返回值及执行路径,这在数学上是不可能的。

既然无法获得完美的活跃度信息,编译器必须进行过度近似 (Overapproximation)

  • 我们的目标是包含所有真正活跃的变量,但允许混入一些实际上并不活跃的变量。
  • 假阳性 (False positives)(即误判变量活跃)是可以接受的:这最多会导致编译器浪费一些额外的物理寄存器或栈空间。
  • 假阴性 (False negatives)(即漏判了真正活跃的变量)是绝对不可接受的,会导致程序崩溃或计算出错误结果。

在未引入数据流分析之前,编译器仅仅依赖“作用域 (Scope)”来判断活跃度(不在作用域内必然不活跃),但这过于粗糙。改进的策略是:只在变量真正被使用时才认为其活跃,但保守地假设代码中所有的执行分支(如 if/else)都有可能被执行。

4 数据流分析与活跃度的计算 (Dataflow Analysis)

为了计算各个表达式和块中的活跃变量,我们可以为 LIVE 函数定义一系列规范(Specification)。例如:

  • 常量的活跃变量集为空:LIVE(n) = { }
  • 变量自身的活跃集就是它自己:LIVE(x) = { x }
  • 在赋值表达式 x = op in b 中,其活跃集是 (LIVE(b) - x) U LIVE(op)。因为 $x$ 被重新赋值了,所以在赋值点之前 $x$ 并不需要是活跃的,但 op 中的操作数必须是活跃的。
  • 条件分支 cbr 的活跃集是其条件变量以及真假两个分支(即 fg 的主体)活跃集的并集。

定点迭代 (Fixed-point Iteration): 由于控制流可能包含循环或相互递归的跳转块,这些规范定义在本质上是递归的。例如,块 $f$ 的活跃变量可能会依赖于块 $f$ 自身的活跃变量。

  • 解决这一问题的标准数据流分析方法是求取递归方程的最小解
  • 实现方式:将所有基本块的活跃变量集初始化为空集(或零个变量)。然后,进行多轮迭代,在每一轮中利用上一轮的信息更新当前块的活跃集。
  • 当某一次完整的迭代中,没有任何一个块的活跃集发生改变时,就说明达到了不动点 (Fixed point),分析结束(即 update_liveness(b) == b)。

5 冲突分析与干扰图构建 (Conflict Analysis)

在获取了程序每个点的活跃变量信息后,下一步是判断哪些变量之间存在冲突。 真正的冲突发生在两个变量满足以下条件时:

  1. 它们在同一时刻都是活跃的。
  2. 它们被赋予了不同的值。

同样地,对于冲突分析,编译器也会秉持过度近似的原则(宁愿判定为冲突,也不能漏掉真实的冲突)。

构建干扰图的简化策略

  1. 初始化:将程序中的所有变量作为干扰图的顶点加入图中。
  2. 赋值冲突:每当发生一次对变量的赋值操作时,就向该被赋值变量与当前所有活跃变量之间添加冲突边(即在图中连线)。
  3. 参数冲突:对于任意一个代码块,它的所有参数必须互为冲突关系。因为在跳转到该块时,所有的参数可以被视为是在同一时刻被赋值的,它们必须占据不同的寄存器。

通过上述步骤,编译器便生成了一个完整的变量冲突网络,为后续将变量分配到具体的物理寄存器做好了准备。

这是一份基于所提供文档整理的专业、详尽的笔记。

寄存器分配第二部分:图着色与代码生成

在编译器后端的优化中,内存层次结构对程序性能有着决定性的影响。为了解决栈分配带来的性能瓶颈,编译器必须进行复杂的寄存器分配。

1 寄存器分配的完整流程

寄存器分配的最终目标是将变量存储在有限的物理寄存器中,以最大化程序的执行速度,并在必要时合理使用内存栈。该过程在编译器中通常被分为三个核心步骤与一个后备步骤:

  1. 活跃度分析 (Liveness Analysis):分析并在代码的每个位置识别出哪些变量的值在后续程序中仍然会被使用(即处于“活跃”状态)。
  2. 冲突分析 (Conflict Analysis):明确哪些变量之间存在相互干扰,从而不能被放入同一个物理寄存器中。
  3. 图着色 (Graph Coloring):基于冲突信息,将变量分配给具体的寄存器,确保互相冲突的变量拥有不同的寄存器分配。
  • 溢出 (Spilling):在图着色阶段,如果发现物理寄存器数量不足以满足分配需求,则必须将部分变量“溢出”,即分配到内存的栈槽 (Stack slots) 中。

2 活跃度分析与定点迭代机制

活跃度分析是一种典型的数据流分析。由于控制流中可能存在循环,活跃度分析无法单次遍历完成。

  • 迭代计算:分析过程分为多个回合(Round)。在初始回合(Round 0)中,所有代码块的活跃变量集均被初始化为未知或空集。随后,编译器根据子表达式中变量的作用域、语法出现位置以及后续基本块的反馈,逆向推导并更新当前的活跃集合。
  • 实现细节:在实现上,可以在静态单赋值 (SSA) 的抽象语法树 (AST) 基本块中添加元数据注解,例如利用哈希集合 (HashSet<String>) 来存储活跃变量。
  • 达到不动点:编译器会重复调用计算函数,直到程序中没有任何一个代码块的活跃集合发生改变(即 update_liveness(b) == b),此时即达到了定点迭代的不动点 (Fixed point),分析宣告结束。

3 冲突分析与过度近似

当两个变量同时满足以下两个条件时,它们之间会发生真正的冲突:

  1. 它们在同一时刻处于活跃状态。
  2. 它们被赋予了不同的值。

由于精确计算动态条件下的变量冲突是不可判定的,冲突分析常常采用过度近似 (Overapproximation) 策略。这意味着编译器宁愿判断出比实际更多的冲突,也绝不容忍遗漏任何一个真实的冲突。

  • 简单构建策略
    • 使用程序中的所有变量初始化干扰图的节点。
    • 每当一个变量被赋值时,就向该变量与当前所有处于活跃状态的变量之间添加冲突连线。
    • 此外,一个代码块的所有参数之间必须互相添加冲突边,因为它们是在跳转到该块时被同时赋值的。

4 图着色算法与复杂性探讨

通过上述分析构建出寄存器干扰图后,寄存器分配任务就被等价转换为了数学中的图着色问题 (Graph Coloring)

  • 原理:将可用的物理寄存器(如 x86 架构下的 rax, rsi, rdi, rcx, rdx)视为不同的“颜色”。为干扰图中的每一个节点涂色,并保证任意两个相连的节点不会拥有相同的颜色,这就是一个成功的寄存器分配方案。
  • NP 完全性挑战:对于颜色数 $k > 2$ 的一般图,判断其是否可着色是一个 NP 完全问题。1981 年,Chaitin 等人的研究指出,带有赋值和任意控制流 (goto) 的命令式语言的寄存器分配与一般图着色问题完全等价,证明了寄存器分配问题在本质上是 NP 困难的。

5 Chaitin 算法与消除顺序

为了解决着色问题,业界开发了 Chaitin 算法,这是一种在时间复杂度上极其高效($O(|V| + |E|)$)、且易于实现的递归算法。

  • 算法步骤
    1. 如果图为空,则自然是可着色的。
    2. 从图中移除一个节点及其所有相关的边。
    3. 对剩余的图进行递归着色。
    4. 将该节点及其边重新加回图中,并为其挑选一个不同于所有相邻节点的颜色。
    5. 如果邻居节点已经穷尽了所有可用的颜色,则将该变量“溢出”。
  • 消除顺序 (Elimination Ordering):Chaitin 算法生成的着色质量高度依赖于节点被移除的先后顺序。定理表明,对于任何图,都存在一个最优的消除顺序,使得 Chaitin 算法能够生成完美的着色。但对于一般图而言,找到这个最优消除顺序依然是 NP 完全问题。

6 SSA 程序的完美弦图着色优化

现代编译器积极将代码转换为 SSA 形式的根本原因之一在于其卓越的数学性质。2006 年 Hack 等人的论文证明:SSA 程序的干扰图全部属于弦图 (Chordal Graphs)

  • 弦图的特性:在弦图中,任何大于等于 4 个节点的环都必然有一条跨越环的“弦”。
  • 完美消除顺序 (PEO):弦图不仅具有完美消除顺序,而且这个顺序可以通过多项式时间高效计算得出。定理指出,一个图是弦图当且仅当它拥有 PEO;同样,一个图是弦图当且仅当它是树结构中一系列子树的交集图。在 SSA 程序中,变量的活跃周期恰好构成了抽象语法树上的子树,从而保证了其干扰图必然是弦图。
  • 支配关系的应用:在 SSA 中计算 PEO 非常简单。依靠“作用域”或“支配 (Dominance)”关系:如果变量 $x$ 的定义域在变量 $y$ 被定义时有效(即 $x$ 距离语法树的根节点更近),则称 $x$ 支配 $y$。通过对抽象语法树节点进行简单的前序遍历 (Pre-order traversal),就可以提取出 PEO。按照这个顺序执行 Chaitin 算法,可以在 $O(|V|^2)$ 时间内计算出绝对最优的图着色方案。

7 代码生成中的挑战:指令重叠与溢出处理

成功完成寄存器分配后,下一步是修改代码生成器以生成最终汇编。

  • 输入输出重叠:编译器可以直接将计算结果存入输出寄存器(如 mov rx, ry; sub rx, rz)。然而,如果输出寄存器与某个输入寄存器恰好是同一个(例如 rx = rz),这套指令就会出错。解决方案是使用一个暂存寄存器,或者利用特定操作的代数性质(如 sub rx, ry; imul rx, -1)。
  • 处理内存溢出:对于那些被分配到内存栈中的“溢出”变量,每次写入时必须向内存发起 store 操作,读取时发起 load 操作。如果一个二元操作的三个变量(如 x = y + z)全部被溢出到内存,由于 x86 架构不允许在一条运算指令中访问多次内存,硬件将无法直接处理。简便的解决策略是:在全局范围内预留一个专门的暂存物理寄存器 (Scratch register),专门用于从内存中搬运这些溢出变量以完成单次算术操作。

8 带参数分支指令的实现难题

在处理诸如 br f(x, y, z) 的带参数跳转指令时,汇编层面上需要将局部变量的数据移动到参数对应的寄存器中(例如 mov r_a, r_x)再进行跳转。

  • 当这些寄存器出现互相重叠甚至是环形依赖时,简单的 mov 指令会覆盖掉尚未转移的数据。
  • 可以通过插入 xchg 指令来交换数据以打破依赖环,它不需要额外的暂存寄存器,且执行速度比逻辑异或交换 (xor swapping) 更快。
  • 尽管在 SSA 中完成寄存器分配是多项式时间的,但最小化代码生成阶段所需的 movxchg 指令总数,被证明是一个 NP 完全问题

9 寄存器分配与调用约定的整合

一旦代码开始广泛使用寄存器,就必须严格遵守底层系统的应用程序二进制接口 (ABI)。在 System V AMD 64 调用约定中,寄存器被严格分为两类,编译器必须对它们进行妥善处理:

  • 易失性 / 调用者保存寄存器 (Volatile / Caller-save):这类寄存器的值可能会在函数调用期间被被调用函数修改。
    • 编译策略:一种简单方案是在每次发起调用前,将所有处于活跃状态的易失性寄存器压栈保存并在调用后恢复;另一种更优的方案是,将易失性寄存器本身作为节点加入到干扰图中,并在所有非尾调用处人为添加冲突边,迫使着色算法尽量避开它们。
  • 非易失性 / 被调用者保存寄存器 (Non-volatile / Callee-save):这类寄存器保证在函数调用返回时,其值与调用前完全一致。
    • 编译策略:在每个全局函数的定义开头,编译器必须插入指令,将该函数内用到的所有非易失性寄存器存入栈中;并且在函数任何返回 (ret) 或外部尾调用发生之前,恢复这些寄存器的值。溢出的局部变量必须被放置在紧靠这些被保存寄存器之后的内存偏移处。

函数块与返回的完整实现: 在实现 ret x 时,完整的汇编序列需要经历三个步骤:首先将返回值放入规定的 rax 寄存器中,其次从内存中恢复所有之前保存的非易失性寄存器(如 rbx, rbp 等),最后才能执行返回指令。