想象你是一家餐厅的主厨,后厨只有一张操作台。台面上摆着一碟你精心调好的秘制酱料,等会儿要淋在烤鸭上。这时你喊帮厨小李:“帮我切点葱花!”小李手脚麻利地干活,可等他干完,你发现台面被葱末和汁水弄得一塌糊涂,你那碟酱料也被推到角落、洒了一半。你火冒三丈,但也只能怪自己:谁让你没在开口之前把酱料收进柜子呢? 在 CPU 的世界里,函数调用就是一场“借台面”的合作。寄存器是 CPU 里速度最快、数量最少的“操作台面”,x86-64 通用寄存器一共只有 16 个。当主调函数(调用者)调用另一个函数(被调用者)时,被调函数也要用这些台面干活,就可能把调用方还没用完的值冲掉。为了不吵架,大家定下规矩:一部分台面(RAX/RCX/RDX/RDI/RSI)谁用谁毁,调用方得自己提前把东西收好——这叫“调用者保存”;另一部分台面(RBX/RBP/R12-R15)被调方借用后必须原样擦干净放回——这叫“被调用者保存”。这套规矩在 Linux 世界有个正式名字:x86-64 System V ABI。学会它,你就拿到了读懂任何函数调用汇编代码的钥匙。

岗位关联

对 AI 从业者而言,理解寄存器保存约定是读懂深度学习算子底层汇编、定位性能热点、调试崩溃栈的底层能力——当模型推理慢到怀疑人生时,这份“厨房规矩”能帮你找到真正该优化的地方。

目录
  1. 可视化演示
  2. 问题起源:共享台面会被弄乱
  3. 两大阵营:公共区与私人柜
  4. 调用者保存:自己写好便签
  5. 被调用者保存:借用必还原
  6. 调用序列:完整的一顿饭
  7. 性能权衡:少压栈的智慧
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示寄存器分类总览、函数调用保存流程及压栈优化策略的三模态交互动画。

本节配有交互动效(进入视口后自动加载,约 3KB)。

寄存器分类总览 调用者 被调者 Caller: RAX,RCX,RDX,RDI,RSI,R8-R11 x86_64共16个通用寄存器System V ABI按保存责任分为调用者保存和被调用者保存两组

问题起源:共享台面会被弄乱

餐厅后厨只有一张操作台。主厨正在台面上备菜,手边一碟调好的酱汁等着出锅时用。他喊帮厨:“来,把这些葱切了!”帮厨埋头切葱,葱末、汁水洒了一台面,顺手把酱汁碟推到角落。主厨回来一看傻了眼——不是帮厨坏,是台面本来就那么大,谁都用,谁都会弄乱。

计算机里,函数调用就是一次次“借用台面”。寄存器的读写只要 1 个时钟周期,内存却要慢几十上百倍,所以函数里要紧的中间结果都爱往寄存器里放。可 x86-64 一共只有 16 个通用寄存器,被调函数一进来也要用,一用就可能覆盖调用方还没用完的值——酱汁被打翻,数据丢失。

覆盖发生在最普通的指令里。被调函数执行一句 movl $100, %eax,调用方存在 RAX 里的旧值就没了。如果没有事先约定,调用方根本不知道哪个寄存器会被动过,程序就会在诡异的地方出错。于是所有编译器都要遵守一套“调用约定”(calling convention),明确划分责任:哪些寄存器你动之前自己保护,哪些寄存器他用完必须还原。

台面只有那么一小条,谁都想用。不讲好“谁负责把东西收好”,后厨迟早要打架。正是这个朴素的道理,催生了今天要讲的两套规矩。

x86-64 通用寄存器: 16×64 bit=128 字节的操作台\text{x86-64 通用寄存器: } 16 \times 64\,\text{bit} = 128\,\text{字节的操作台}
通俗说:整个 CPU 的“快存台面”加起来只有 128 字节,珍贵又拥挤,所以必须立规矩。
  1. 通用寄存器数量x86-64 给程序提供了 16 个 64 位通用寄存器,从 RAX 排到 R15,它们是 CPU 里最快的存储位置。
    16 个有限位置16\ \text{个有限位置}
  2. 单个宽度每个寄存器 64 bit,也就是 8 字节,可以放一个内存地址或一个长整数。
    64 bit=8 字节64\,\text{bit} = 8\,\text{字节}
  3. 总容量很小16 个寄存器加起来只有 128 字节,连一行像素都存不满,所以寄存器必然反复复用;复用就带来覆写风险。
    16×8=128 字节16 \times 8 = 128\ \text{字节}

两大阵营:公共区与私人柜

后厨心里有本账:台面左侧是公共调料区,盐、糖、酱油谁都可以取用,放在那里的东西别人动了不许抱怨;台面右侧是帮厨的私人刀具柜,谁借了刀,用完必须洗干净放回原位,少一把都不行。

System V ABI(x86-64 Linux 世界的调用约定)把这 16 个通用寄存器也分成了两个阵营。“调用者保存”寄存器(也叫易失寄存器 volatile)相当于公共调料区:被调函数可以随意改写,如果调用方在调用之后还惦记着里面的值,就必须自己事先保存——责任在调用方。“被调用者保存”寄存器(也叫非易失寄存器 non-volatile)相当于私人刀具柜:被调函数如果借用,必须在返回前原样恢复——责任在被调方。

具体名单如下:调用者保存(caller-saved)的是 RAX、RCX、RDX、RDI、RSI 这 5 个;被调用者保存(callee-saved)的是 RBX、RBP、R12、R13、R14、R15 这 6 个。特别注意 RAX 是返回值寄存器——被调函数几乎必然改写它,所以调用方要及时取走返回值或提前备份。

公共调料区的酱汁自己收好,私人刀具柜的刀用毕归还。ABI 这份“厨房公约”把所有寄存器的保管责任分得明明白白,谁也不用猜对方会不会动手脚。

易失(调用者保存): {RAX,RCX,RDX,RDI,RSI},非易失(被调用者保存): {RBX,RBP,R12,R13,R14,R15}\text{易失(调用者保存): } \{RAX, RCX, RDX, RDI, RSI\}, \qquad\text{非易失(被调用者保存): } \{RBX, RBP, R12, R13, R14, R15\}
通俗说:五个是“公共调料”谁都能碰,六个是“私人刀具”借了要还。记住这份名单,就记住了整个 ABI 的骨架。
  1. 易失组五成员这 5 个寄存器在调用边界上可被随意改写,调用方若要保留旧值必须自己压栈。
    {RAX,RCX,RDX,RDI,RSI}\{RAX, RCX, RDX, RDI, RSI\}
  2. RAX 的特殊身份它是公认的“成品盘”,函数返回值固定放这里,所以它几乎每一次调用都会被覆盖。
    RAX←return valueRAX \gets \text{return value}
  3. 非易失组六成员这 6 个寄存器保证跨调用存活,调用方可以放心使用而不用自己备份。
    {RBX,RBP,R12,R13,R14,R15}\{RBX, RBP, R12, R13, R14, R15\}
  4. “保证不变”的含义不是禁止使用,而是“用了必须还原”,实现还原是每个被调函数自己的责任。
    rexit=rentryr^{\text{exit}} = r^{\text{entry}}

调用者保存:自己写好便签

主厨知道自己那碟酱汁放在公共调料区,喊帮厨之前先做一件事:拿一张便签把配方抄下来,塞进储物柜。帮厨忙完,主厨再掏便签,把酱汁重新调回来。麻烦是麻烦了点,但谁让酱汁放在公共区呢。

这就是“调用者保存”的标准动作:在 call 指令之前,把“调用后还要用”的易失寄存器压栈保存;call 返回之后,再弹栈恢复。RAX 是返回值,调用后一般不需要旧值;但 RCX、RDX、RDI、RSI 里可能存着循环计数、中间结果,调用后还要用,就得保护。

保护动作靠两条指令完成。push 把栈指针减 8,再把寄存器值写到新的栈顶;pop 反过来从栈顶读值写回寄存器,栈指针加 8。注意保存动作必须发生在 call 之前、恢复在 call 之后,并且一个 push 对应一个 pop,否则栈不平衡,ret 会跳到错误地址。每次压栈都是一次内存写入,调用越频繁、要保存的值越多,开销越大。

主厨的便签写得太晚(call 之后才想到保存)就来不及了;便签写太多(把所有调料都抄一遍)又是浪费。保存的关键是“及时”和“只存需要的”。

push r:SP←SP−8,[SP]←r;pop r:r←[SP],SP←SP+8\text{push } r: \quad SP \gets SP - 8, \quad [SP] \gets r; \qquad\text{pop } r: \quad r \gets [SP], \quad SP \gets SP + 8
通俗说:push 是“先腾地方再放东西”,pop 是“先拿东西再收地方”,成对出现才不把柜子弄乱。
  1. push 压栈先把栈指针向下挪 8 字节腾出位置,再把寄存器内容写入新栈顶,旧值就安全落袋。
    SP←SP−8;[SP]←rSP \gets SP - 8;\quad [SP] \gets r
  2. pop 弹栈从栈顶读出刚才存的值,写回寄存器,再把栈指针向上抬 8 字节,位置回收。
    r←[SP];SP←SP+8r \gets [SP];\quad SP \gets SP + 8
  3. 配对使用每条 push 最终要有一条 pop 来抵消,栈才能回到原位,返回地址才不会被顶乱。
    push r  ⇔  pop r\text{push } r \;\Leftrightarrow\; \text{pop } r
  4. 包裹调用边界push 必须发生在 call 之前、pop 必须在 call 之后,顺序颠倒等于白做。
    push r→call f→pop r\text{push } r \to \text{call } f \to \text{pop } r

被调用者保存:借用必还原

帮厨切葱要用主厨的专用菜刀,规矩是:用之前说一声“我借用”,用完必须把刀洗干净、擦干、挂回原处。这样一来,主厨任何时候回来看,刀都在老地方——相当于这把刀“从来没被借走过”。

被调用者保存寄存器就是这个“借了要还”的刀具柜。被调函数在入口处(prologue)把要用的非易失寄存器压栈,在出口处(epilogue,每条 ret 之前)弹栈恢复。于是调用方视角里,这些寄存器的值在 call 前后一模一样——仿佛被调函数根本没碰过它们。

典型的被调函数结构是:入口先 push RBX(要用 R12-R15 同理依次压栈),中间随便改写,返回前按相反顺序 pop 还原,最后 ret。RBP 常被用作栈帧基址指针,RBX 与 R12-R15 适合存放需要跨多个函数调用存活的变量(如循环计数器、对象指针)。最大的好处是:如果函数体从头到尾都没用这些寄存器,就一个 push/pop 都不需要,零开销。

刀借走再还回来,主厨从不知情。非易失寄存器的魅力正在于此:调用方可以把重要变量放心托付,完全不用自己操心备份——因为被调方已经把“物归原处”写进了自己的职责。

入口: push rj;函数体: 随意用 rj;出口: pop rj⇒rj返回后=rj进入前\text{入口: } push\ r_j;\quad \text{函数体: 随意用 } r_j;\quad \text{出口: } pop\ r_j \quad \Rightarrow \quad r_j^{\text{返回后}} = r_j^{\text{进入前}}
通俗说:刀借走再还回来,主厨从不知情——被调函数把“物归原处”写进了自己的职责。
  1. 入口保存函数刚开始就压栈备份要用的非易失寄存器,防患于未然。
    push RBX\text{push } RBX
  2. 自由使用函数体内 RBX 随便改写,反正栈里已有备份,不担心丢。
    mov RBX,new_value\text{mov } RBX, \text{new\_value}
  3. 出口恢复返回之前把备份弹回 RBX,现场完全还原。
    pop RBX\text{pop } RBX
  4. 不变式调用方观察到的非易失寄存器值在 call 前后严格相等,这是 ABI 的硬承诺。
    rjexit=rjentryr_j^{\text{exit}} = r_j^{\text{entry}}

调用序列:完整的一顿饭

把整整一顿饭从头走一遍:主厨下单前收好酱汁(保存易失寄存器)→ 把食材递到传菜口(设置参数)→ 喊“开始”(call)→ 帮厨借刀做饭、用完擦净挂回(保存并恢复非易失寄存器)→ 端菜上桌(ret 返回)→ 主厨掏出便签复原酱汁,继续出菜(恢复易失寄存器)。

一次函数调用在汇编层面就是这样一个有严格先后顺序的仪式。双方各管各的:调用方只负责自己的易失寄存器,被调方只负责自己的非易失寄存器,谁都不越权。这个顺序保证了无论嵌套多少层调用,每一层都能正确恢复。

栈的“后进先出”(LIFO)特性是整套仪式的地基。call 指令本身会把返回地址压栈,ret 则弹出返回地址跳回;再加上双方各自的 push/pop,栈变成了层层叠叠的“调用记录”。只要每一层都严格配对 push/pop,内层函数先把自己的记录收走,外层记录自然完好无损。

正因为每个角色都按这套流程来,几百层嵌套调用也不会乱套——这就是后厨多年磨合出的默契。

调用方: push(易失)⏟(1)→设参+call⏟(2)→push(非易失)→body→pop(非易失)⏟(3) 被调方→ret+pop(易失)⏟(4)\text{调用方: } \underbrace{push(\text{易失})}_{(1)} \to \underbrace{\text{设参} + call}_{(2)} \to \underbrace{push(\text{非易失}) \to body \to pop(\text{非易失})}_{(3)\ \text{被调方}} \to \underbrace{ret + pop(\text{易失})}_{(4)}
通俗说:一顿饭的动线清清楚楚——谁先收、谁后还、菜从哪个窗口递,全有定数。
  1. 调用方保护易失寄存器把 call 之后还要用的 RAX/RCX/RDX/RDI/RSI 逐个压栈。
    push ri,ri∈易失组\text{push } r_i,\quad r_i \in \text{易失组}
  2. 设置参数并执行 call把参数放进约定寄存器,call 指令把返回地址压栈并跳转到被调函数。
    call f:[SP]←返回地址\text{call } f:\quad [SP] \gets \text{返回地址}
  3. 被调方管理非易失寄存器入口压栈备份、函数体自由使用、返回前弹栈恢复。
    push rj→body→pop rj,rj∈非易失组\text{push } r_j \to \text{body} \to \text{pop } r_j,\quad r_j \in \text{非易失组}
  4. 返回并恢复现场ret 弹出返回地址跳回调用方,调用方再弹栈还原自己的易失寄存器。
    ret→pop ri\text{ret} \to \text{pop } r_i

性能权衡:少压栈的智慧

经验老道的后厨不会无脑抄便签。酱汁马上就用完的,不抄;明天还要用的老卤,锁进专门的柜子由专人看管。减少跑储物柜的次数,出菜速度就上来了。

压栈是写内存,比寄存器操作慢得多,所以两种保存策略的取舍本质是性能账。调用者保存的开销和“调用次数 × 存活值个数”成正比——同一个值只要跨过一个调用点就得压一次;被调用者保存的开销只和“函数体是否真的用到该寄存器”有关,函数可以一次也不碰它,成本为零。

现代编译器在寄存器分配阶段自动做这道算术:生命周期跨过调用边界的变量,优先放入被调用者保存寄存器(RBX、R12-R15);生命周期只在函数内部打转的临时值,放进易失寄存器,省去压栈。还有一个经典优化——叶子函数:它不再调用别人,里的易失值永远不会“活过调用”,所以可以放心大胆用易失寄存器而不做任何保存。合理分配能在热循环里省下成千上万次内存访问。

好厨师不靠蛮力跑腿,靠的是“什么东西该放哪”的判断力。寄存器分配也是同一门手艺:把每一份数据放在最省事的台面上,压栈次数自然降下来,程序就跑得快了。

C调用者保存=N调用次数×K存活值数,C被调用者保存=K函数体内使用数C_{\text{调用者保存}} = N_{\text{调用次数}} \times K_{\text{存活值数}}, \qquad C_{\text{被调用者保存}} = K_{\text{函数体内使用数}}
通俗说:易失账本按“次数”算,非易失账本按“需求”算;把跨调用的珍贵变量交给非易失,才是精明的算账。
  1. 调用者保存开销公式每多一次调用,只要还有存活值就要多压一次栈,成本随调用次数线性上涨。
    Ccaller=Ncalls×KliveC_{\text{caller}} = N_{\text{calls}} \times K_{\text{live}}
  2. 被调用者保存开销公式成本只取决于函数体内部真正借用了几个非易失寄存器,与外部调用次数无关。
    Ccallee=KusedC_{\text{callee}} = K_{\text{used}}
  3. 分配原则生命周期跨越调用边界的变量,应当交给非易失寄存器保管,把压栈负担甩给真正动手的人。
    live range∩call≠∅⇒非易失寄存器\text{live range} \cap \text{call} \neq \varnothing \Rightarrow \text{非易失寄存器}
  4. 叶子函数特例没有内层调用,易失值不可能被改写,连保存都可以免掉。
    Ncalls=0⇒免保存N_{\text{calls}} = 0 \Rightarrow \text{免保存}

AI 应用场景

用 perf 定位 AI 算子的压栈热点

阶段:模型推理性能优化

解决的问题:深度学习算子(如 LayerNorm、Softmax)被编译成机器码后,如果寄存器分配不当,热循环里到处都是 push/pop。传统方式靠直觉乱猜,理解 ABI 后可以打开热点函数汇编,按保存规则逐条检查每个压栈是否必要,把跨调用存活的变量换到非易失寄存器,减少内存访问。

具体案例:某推理引擎在 CPU 上比预期慢 3 倍。用 perf 查看热点函数汇编,发现内层循环调用数学库函数时,每次调用前都 push 了 3 个易失寄存器。把循环计数器挪到 RBX(非易失)后,压栈次数减少 60%,端到端延迟下降 22%。

崩溃栈回溯失败时检查寄存器

阶段:系统调试与稳定性

解决的问题:训练框架崩溃后,gdb 打印 backtrace 依赖栈帧信息。如果某个被调用者保存寄存器(尤其 RBP)被错误覆写,回溯会走到完全无关的地址。按 ABI 规则核对函数入口/出口是否配对 push/pop,就能定位到是哪个手写汇编或内联汇编破坏了现场。

具体案例:一个自定义算子导致段错误,backtrace 跳到无效地址。用 gdb 检查寄存器,发现 RBX 在函数返回后与调用前不一致——被调函数使用了 RBX 却没有恢复,违反被调用者保存规则。补上 push/pop 后崩溃消失。

在内联汇编里遵守 ABI 加速热点

阶段:底层算子编码

解决的问题:手写 SIMD 汇编加速 AI 推理的关键算子时,编译器默认你遵守 ABI。传统方式容易写坏寄存器导致上层神秘 bug;理解保存约定后,在汇编里明确哪些寄存器“借用必还”、哪些“自备自保”,配合编译器的 clobber 声明,写出既快又稳的代码。

具体案例:为一个 INT8 量化卷积手写内联汇编,最初用 RBX 存累加器但忘记恢复,模型偶发输出错误。在汇编中加入 push/pop RBX 并在 Extended Asm 中声明 clobber 后,输出稳定复现一致。

Python 演示

python65 行
class RegisterVM:    """极简寄存器虚拟机: 模拟 System V ABI 的保存规则"""     def __init__(self):        self.regs = {"RAX": 10, "RBX": 20, "RCX": 30,                     "RDI": 40, "RSI": 50, "R12": 60}        self.stack = []        self.push_count = 0     def push(self, value):        self.stack.append(value)        self.push_count += 1     def pop(self):        return self.stack.pop()     def call_callee(self, arg1, arg2):        # 调用方视角: RCX 和 RSI 调用后还要用, 先压栈自保        self.push(self.regs["RCX"])        self.push(self.regs["RSI"])        # 设置前两个参数: RDI, RSI        self.regs["RDI"] = arg1        self.regs["RSI"] = arg2        ret = self._callee()        # 返回后: 按 LIFO 顺序恢复        self.regs["RSI"] = self.pop()        self.regs["RCX"] = self.pop()        return ret     def _callee(self):        # 被调方视角: 要用非易失寄存器 R12, 先保存后恢复        self.push(self.regs["R12"])        self.regs["R12"] = self.regs["RDI"] * 2        result = self.regs["R12"] + self.regs["RSI"]        self.regs["R12"] = self.pop()        self.regs["RAX"] = result        return result  def compare_strategies(call_times, callee_touches_preserved):    """对比循环计数器的两种存放方案的压栈次数"""    cost_a = call_times                                         # 放 RAX: 每次调用都要压栈    cost_b = call_times if callee_touches_preserved else 0      # 放 RBX: 被调方借用才压    return cost_a, cost_b  def main():    vm = RegisterVM()    before = dict(vm.regs)    ret = vm.call_callee(7, 13)     print("调用前寄存器:", before)    print("返回结果:", ret)    print("调用后寄存器:", vm.regs)    print("非易失 R12 保持一致:", vm.regs["R12"] == before["R12"])    print("易失 RAX 已被覆盖:", vm.regs["RAX"] != before["RAX"])    print("压栈总次数:", vm.push_count)     a, b = compare_strategies(1000, False)    print("1000 次调用, 计数器放易失寄存器压栈:", a)    print("1000 次调用, 计数器放非易失寄存器压栈:", b)  if __name__ == "__main__":    main()
目的:用一台极简的“寄存器虚拟机”完整演示函数调用时调用者保存与被调用者保存的压栈/弹栈过程,并量化两种策略的性能差异。
  1. self.push(self.regs["RCX"])调用方把“调用后还要用”的易失寄存器压栈自保
  2. ret = self._callee()模拟 call 指令,进入被调函数
  3. self.regs["RSI"] = self.pop()返回后按 LIFO 逆序弹栈恢复
  4. self.push(self.regs["R12"])被调方使用非易失寄存器前先备份
  5. self.regs["R12"] = self.pop()返回前恢复非易失寄存器原值
  6. self.regs["RAX"] = resultRAX 永远是“成品盘”,覆盖无妨
  7. cost_b = call_times if callee_touches_preserved else 0非易失策略的成本取决于被调方是否真的借用

常见误区

误区刚学汇编时以为 RBX、R12 这些是只读寄存器,被调函数碰都不能碰,一碰就会报错。

正解完全不是。它们只是“动完必须还原”。被调函数可以像用 RAX 一样自由使用 RBX,只要在返回前把入口备份的旧值弹回去。ABI 只保证调用前后值一致,不限制中间怎么折腾。

为何常见:“被调用者保存”这个名称读起来像“被保存起来不许动”,实则是“由被调用者负责保存”,字面歧义害人。

误区把循环变量放在 RAX 里,调完 printf 回来接着用,结果循环乱跳、结果全错,还怀疑是编译器 bug。

正解RAX 是调用者保存寄存器,printf 返回时 RAX 已被改成返回值。如果调用后还要用 RAX 的旧值,调用前必须 push、调用后马上 pop。这是所有手写汇编者的“第一课”,也是最高频的事故。

为何常见:高级语言里编译器默默帮你做了保存,让人误以为寄存器的值天然安全;一旦手写汇编或读汇编,就踩坑。

误区有人把所有变量都塞进非易失寄存器,觉得这样最安全;也有人全用易失寄存器,图省事不恢复。

正解两种极端都不对。全放非易失寄存器,函数内部一旦使用就要压栈,且寄存器数量有限不够用;全放易失寄存器,每次调用都压栈,热循环会慢到肉眼可见。正确做法是按“变量是否跨调用存活 + 函数体是否使用”做成本核算,编译器就是干这个的。

为何常见:新手从“避免 bug”而非“性能与安全的平衡”角度想问题,忽略了压栈本身也是开销。

误区小心翼翼把所有寄存器都 push 了一遍,但把 push 写在 call 之后、pop 写在 call 之前,或者压栈顺序和弹栈顺序不一致。

正解保存必须在 call 指令之前完成才有意义,恢复必须在 call 返回之后;而且栈是 LIFO,谁后压谁先弹,顺序错了会恢复错乱。只保存“调用后仍需使用”的值即可,全部保存纯属浪费且容易被顺序坑。

为何常见:只记住了“要保存”这个动作,忽略了保存时机、恢复时机和栈序这三件事才是关键。

面试题

Q1. 在 x86-64 System V ABI 中,下列哪个寄存器属于调用者保存(易失)寄存器?

单选题 · easy

  • A.RBX
  • B.RBP
  • C.RDX← 正确答案
  • D.R15

答案:C。调用者保存寄存器包括 RAX、RCX、RDX、RDI、RSI 五个。RBX、RBP、R12-R15 属于被调用者保存(非易失)寄存器。就像公共调料区里 RDX 是“谁都能用”的调料,而 RBX 是“借用必还”的私人刀具。

Q2. RAX 寄存器在函数调用约定中承担什么特殊职责?

单选题 · easy

  • A.保存栈帧基址
  • B.存放函数返回值← 正确答案
  • C.传递第一个参数
  • D.保存循环计数器

答案:B。RAX 是约定的“成品盘”,函数返回值固定放在 RAX 中返回,因此被调函数几乎必然改写它,调用者要尽早取走返回值。栈帧基址是 RBP 的职责,第一个参数走 RDI,循环计数器放哪由编译器决定。

Q3. 被调用者在函数入口 push R12、返回前 pop R12,根本目的是什么?

单选题 · mid

  • A.提高函数执行速度
  • B.向栈中传递调用参数
  • C.保证调用前后值一致← 正确答案
  • D.清空该寄存器旧值

答案:C。R12 属于被调用者保存寄存器,ABI 承诺它在调用前后值不变。被调函数要用 R12,就必须入口备份、出口恢复,让调用方看到的值“从未变过”。这就像刀借走再洗干净挂回去,主厨从不知情。

Q4. 某函数内部要临时使用 RBX,正确的做法是?

单选题 · easy

  • A.调用前由调用方保存 RBX
  • B.入口 push RBX 返回前 pop← 正确答案
  • C.用 RAX 替换就无需保存
  • D.直接使用由硬件恢复

答案:B。RBX 是被调用者保存寄存器,保护责任在被调函数自己身上:入口 push 备份,函数体随意使用,返回前 pop 还原。调用方不需要也不应该替它保存;硬件不会自动恢复任何寄存器。

Q5. 下列哪些属于被调用者保存(非易失)寄存器?

多选题 · mid

  • A.RBP← 正确答案
  • B.RSI
  • C.R13← 正确答案
  • D.RCX

答案:AC。非易失寄存器名单是 RBX、RBP、R12-R15;RSI 和 RCX 属于调用者保存寄存器。记住两组名单是读懂一切调用汇编的基础,就像记住哪些调料在公共区、哪些刀具在私人柜。

Q6. 关于 push 指令对栈指针的影响,描述正确的是?

单选题 · hard

  • A.SP 加 8,值写入新栈顶
  • B.SP 减 8,值写入新栈顶← 正确答案
  • C.SP 不变,值写入原栈顶
  • D.SP 减 8,值写入原栈顶

答案:B。x86 栈向下生长,push 先把 SP 减 8 腾出位置,再把值写入新的栈顶地址;pop 则反过来先读再让 SP 加 8。栈像一摞往下堆的盘子,每压一个盘子,栈顶就往下挪一格。

Q7. 下列哪些情况会导致函数调用后寄存器值异常?

多选题 · hard

  • A.被调函数用 R12 未恢复← 正确答案
  • B.调用方未保存仍需的 RCX← 正确答案
  • C.被调函数用 RAX 存结果
  • D.调用方 push 与 pop 不配对← 正确答案

答案:ABD。A 违反被调用者保存规则,B 违反调用者保存责任,D 会让栈不平衡、返回地址错乱。C 是正常操作:RAX 本来就是返回值寄存器,被覆盖天经地义。三种“违规”正是后厨里三种典型的乱象:借刀不还、酱汁不收、储物柜记账对不上。

Q8. 内层循环频繁调用小函数时,把跨调用存活的变量放进哪种寄存器更省压栈?

单选题 · mid

  • A.调用者保存寄存器
  • B.被调用者保存寄存器← 正确答案
  • C.栈顶指针 RSP
  • D.无区别任意选择

答案:B。变量跨调用存活时,放易失寄存器会让“每次调用都压栈”;放非易失寄存器则把保存责任移给真正借用它的被调函数,若函数不碰它成本为零。相当于把酱汁从公共调料区挪进专人看管的私人柜,省去主厨反复抄便签。

Q9. 叶子函数(不调用其他函数)把变量放在易失寄存器且免保存,原因是什么?

单选题 · extreme

  • A.叶子函数不需要返回
  • B.值不会活过调用边界← 正确答案
  • C.易失寄存器自动失效
  • D.硬件自动保存易失值

答案:B。易失寄存器只在“被别的函数调用”时才有被覆盖的风险。叶子函数不再调用任何人,它的易失值不会活过任何调用边界,自然无人能改写,所以免保存。这是“没有帮厨来借台面,主厨的调料当然不会被打翻”的极端情形。

Q10. 关于 RSP 与压栈配对的描述,正确的有哪些?

多选题 · mid

  • A.push 和 pop 必须一一配对← 正确答案
  • B.ret 指令会弹出返回地址← 正确答案
  • C.栈不平衡导致返回地址错乱← 正确答案
  • D.RSP 被调函数可随意修改

答案:ABC。push/pop 配对是栈平衡的基石,ret 依赖栈顶的返回地址跳回,栈一旦不平衡程序就会跳飞。RSP 是栈指针,被调函数不能随意改动,而必须保证返回时栈结构与进入时一致(所以它也不在“可随意改写”的易失组里)。