想象一家自助餐厅的后厨:洗碗机旁立着一台弹簧餐盘塔,新洗净的餐盘一个个叠上去,取用时也从最上面拿——后进先出。你每点一道菜,厨师就放一个餐盘;这盘子上装什么菜、划几个格子,都写在当次配餐单上。程序里的函数调用,就是这台餐盘塔的日常:调用一个函数,往塔上放一个“餐盘”(专业叫栈帧);函数返回,餐盘被取走。而局部变量,就是盘子里用记号笔划出的格子——菜放进格子,盘子一撤,菜和格子一起消失。
这台餐盘塔有个反直觉的秘密:它是向下生长的。盘子越叠越多,弹簧托盘被压得越来越低,塔的最上层(栈顶指针 rsp)反而一路靠近地面。格子怎么划、每个格子多大,由“配餐单”(编译器的栈帧布局)决定;若想临时划一趟数量可变的格子(变长数组 VLA),厨师当场在盘子上调整隔板。压得太低、撞到底部的感应玻璃(守护页),就会触发著名的“栈溢出”。而仓库里的货架(堆内存)则是另一套完全不同的储物逻辑——东西随便放,但得记得登记与归还。
这篇文章会带你逛一遍这台餐盘塔的里里外外:盘子的结构、格子的划法、对齐的规矩、爆栈的成因与防线,以及它和仓库货架的根本差异。
理解栈与局部变量分配,是读懂调用栈、定位段错误(Segfault)、评估深递归与 VLA 风险的基础;对排查线上崩溃、编写高性能 C/Rust/汇编代码,是最直接的地基技能。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
栈帧结构
自助餐厅的每个餐盘都不是一只光溜溜的圆盘:盘沿贴着订单小票(参数),盘底贴着一张标签(帧指针),盘身还印着“送回地址”(返回地址)。当厨师从塔上取盘、装菜、再叠回塔上,这一整套关于“这个盘子属于谁、菜往哪放、用完了送回哪”的信息,必须跟着盘子走。程序里的函数调用也是这样——一次调用需要的所有“盘子信息”打包在一起,就是栈帧(Stack Frame),它对应自助餐厅里一个完整的餐盘。
把思路从餐厅拉回计算机:CPU 执行 call 指令时,会先把返回地址(相当于“这道菜做完送回哪儿”)压进栈;进入新函数后,再把旧帧指针压栈保存,并把帧指针指向新帧的底部。最后用 sub rsp, N 一次性为局部变量区腾出 N 字节。这里的 rsp(栈顶指针)就是弹簧托盘的最上沿,rbp(帧指针)则是盘底那张标签——整帧的锚点。注意栈是向低地址方向生长的,所以“压栈”在数值上做的是减法:
一个完整栈帧从上到下大致是:参数区、返回地址、旧 rbp、局部变量区。旧 rbp 串联起一长串帧,形成帧指针链——调试器打印调用栈(backtrace)时,靠的就是这条链一路“翻旧账”。就像厨师能顺着盘底标签找出这盘菜是哪位客人点的一样。
rsp(弹簧顶)永远指向最新盘子的上沿。rsp(栈顶指针)它保存着当前栈顶的地址,相当于弹簧托盘最上沿的位置。餐厅里取盘、放盘都从这一层操作,程序里读写局部变量也围绕它附近的格子进行。frame\_size(帧大小)本次函数调用需要占用的总字节数,包括参数区、返回地址、旧 rbp 和局部变量区。厨师把盘子的“直径与厚度”算好,才能一次腾出整块空间。- 减法(向下生长)栈区从高地址向低地址延伸,压入一帧就是把栈顶往低地址方向挪一个帧大小。这是所有栈操作与“堆往上长”最直观的分野。
- 整体赋值减法算出的新地址一次性写回
rsp,随后函数体内的指令就可以用rbp +/- 偏移来访问盘子里每个格子了。
局部变量布局
现在看盘子里最要紧的部分:划格子。局部变量 int x; long y; char z; 就像点的三道小菜,配餐员(编译器)会在盘子上划出三个格子,并把每道菜的“坐标”记在配餐单上。划格子有两条铁律:格子的位置在函数编译时就算死(静态确定),格子的寿命和盘子一样长——盘子一撤,格子作废。这就是局部变量生命周期的真相:它不属于“创建者”,而属于“这一帧存在的时间段”。
在汇编层面,编译器给每个局部变量分配一个槽位偏移(offset),用 rbp - offset 来寻址。为什么用减?因为局部变量区在帧指针(盘底标签)之下的低地址侧。假设函数声明了三个变量,编译器可能把 x 放在 rbp-4、y 放在 rbp-16、z 放在 rbp-17——注意它不会按你敲代码的顺序紧挨着排,而是按对齐和访问效率自由编排。源码里 int x = 1; 只是在告诉配餐员“我要一道 4 字节的菜”,至于菜放哪个格子,配餐员说了算。
作用域则是另一回事:{ } 代码块可以提前结束某个变量的“可视期”,但编译器早就把格子划好了,作用域只影响“你能不能写出这个变量名”,不影响格子本身占不占地方。就像餐厅圈了块用餐区,客人走了区还在,下一批客人还能用同一块地。
rbp(帧指针)当前帧底部的高地址端锚点。函数体内它保持不变,这是它能当“尺子”的前提——盘底标签不会在炒菜途中自己挪窝。offset(槽位偏移)编译器根据变量类型、对齐要求算出的固定距离,相当于配餐单上写明的“第几格”。它通常按声明顺序累加,但会插入对齐空格。- 减号(方向规则)栈向下生长,局部变量区位于帧指针下方(低地址侧),所以统一用减法寻址;而参数在帧指针上方,用加法——两个方向各管一摊。
- 拼装成完整地址汇编里常见的
mov eax, [rbp-8]本质就是这条公式的一次执行,把“盘底标签减偏移”翻译成实际内存地址,取出格子里的菜。
对齐与填充
自助餐的餐盘格子是按规格生产的:4 格、8 格、16 格,放 8 字节的“大排档”绝不会硬塞进 5 格的区域。程序里的自然对齐(Natural Alignment)是同一回事:CPU 访问一个 4 字节 int 时,它的地址最好落在 4 的倍数上;访问 8 字节 double,地址最好落在 8 的倍数上。如果格子划分得歪七扭八,CPU 就得拆两次访问再拼起来——好比厨师在 5 格的位置塞 8 格的大菜,盘子都得变形。
以 struct { char c1; int i; char c2; } 为例:若三个成员紧挨着排,c1 占 0 号、i 从 1 号开始——一个奇数地址,违反 4 字节对齐。所以编译器会在 c1 和 i 之间插入 3 个填充字节(padding),让 i 从 4 号开始。结构体的总大小也会向上取整成最大对齐的倍数,保证数组里第二个元素依然对齐。这段“塞海绵”的过程,用公式表达就是:
对齐不只是“省事”,更是性能的隐形杠杆:未对齐访问在某些架构上直接崩溃,在 x86 上也会带来 2 倍以上的访问延迟。结构体字段顺序写得聪明,能把填充从 12 字节压到 4 字节——厨师合理安排大菜小菜的摆放顺序,同一块盘子能多装好几道菜。
offset 向上取整到 align 的倍数,得到下一个格子的合法起点。整数除法自动丢弃余数,+align-1 就是“进位补丁”。offset(当前游标)已经分配的字节数终点,相当于盘子已经被划掉的区域末尾。align(对齐粒度)新成员要求的对齐边界,常见为 1、2、4、8。CPU 一次能读多宽,就喜欢地址对齐到多宽。+ align - 1(进位补丁)给游标加上一个小于对齐粒度的余量,制造“向上取整”的效果——好比 8.01 个格子算成 9 格。- 整除
align(折成块数)先除以对齐粒度得到“几个对齐块”,丢弃零头。 - 再乘
align(还原字节)把块数乘回字节单位,得到最终对齐后的起点。
变长数组VLA
普通的餐盘格子是出厂时固定死的;但有一种活动隔板餐盘——客人说“我要 N 份小菜”,厨师当场把隔板推到对应位置划出 N 格。这就是变长数组(VLA,Variable Length Array):int a[n]; 里的 n 在运行时才知道,所以格子的总数只能在运行时现算。它依然在栈上,不是堆——只不过它的“盘子”是特制的。
编译器处理 VLA 的方式很直接:在进入函数(或代码块)时,用运行时算出的数组大小把 rsp 再往下推一段,相当于临时加长这一帧;函数返回时 rsp 恢复原值,VLA 自动消失,不需要也无法手动释放。假设数组元素是 4 字节的 int,元素个数为 n,分配动作就是:
VLA 的安全限制很现实:n 如果来自用户输入且巨大如一千万,rsp 会直接扎穿栈底触发崩溃。C23 标准已把 VLA 降级为“可选特性”,许多项目改用显式堆分配或固定上限数组。活动隔板虽方便,但别让客人一声“来一万份”就真的划一万格。
n 个 int 的空间。注意它与普通帧分配的唯一区别是——n 这个数字运行时才揭晓。n(元素个数)一个运行时值,来自变量、函数参数或计算表达式。编译器的汇编代码里没有字面量,只有“读变量”。4(元素大小)sizeof(int)在主流平台上是 4 字节,一个格子一道 4 字节小菜。n \times 4(总字节数)数组整体占用的栈空间,相当于活动隔板推开后多出来的 N 格区域。rsp -(动态下移)把栈顶往下推一个数组大小的距离,为数组开出地盘。这一步发生在函数入口处,由编译器插入的指令完成。- 返回恢复函数返回时
rsp回到调用前的值,特制餐盘连同隔板一起被取走,不需要任何清理指令。
栈溢出与防护
弹簧餐盘的行程终究有限:盘子叠到一定程度,弹簧压到底,再放就要撞上底部感应玻璃。程序里这层“感应玻璃”叫守护页(Guard Page)——操作系统在栈区最低地址端映射了一个特殊内存页,标记为不可读不可写。任何访问一旦越过栈底,CPU 立刻触发缺页异常并升级为段错误(Segfault),程序当场崩溃。栈溢出的成因并不只有教科书爱讲的“无限递归”:超大局部数组、VLA 尺寸失控、每帧过大、调用链过深,本质都是同一件事——总消耗超过了总容量。
在 Linux 下主线程栈默认约 8MB,每调用一层函数平均消耗一个 frame_size,能嵌套的深度有个粗略上限:
还有一个专业细节:栈探测(Stack Probing)。如果某函数一次性 sub rsp, 4MB,一个大跨度跳跃可能“跳过”守护页,直接落到相邻的非法或合法内存上,崩溃变得不可控。于是编译器在这种大帧函数入口插入探测循环,逐页(通常 4KB)触碰栈空间,确保每一步都经过感应玻璃的哨位。就好比餐厅规定:特大号餐盘必须分段试压,一毫米一毫米往下压,而不是“哐当”一下砸穿地板。
stack\_size(栈总容量)系统为线程栈划出的地址空间大小,默认约 8MB。它决定了弹簧托盘的“总行程”。frame\_size(每帧消耗)平均每次调用压入的盘子厚度,等于各帧大小加上对齐填充。局部变量越多、越胖,这一项越大。- 除法(最大嵌套深度)总容量除以单帧消耗,得到不爆栈的理论最大调用层数。这就是为什么深递归和大数组往往会同时背锅。
- 越界判定当
rsp下移后的地址小于守护页顶边界时,下一次访问就会撞上感应玻璃。rsp本身“悄悄”越过不算数,碰触才算。
栈堆对比
栈是后厨的餐盘塔,堆是隔壁仓库的货架——两者都存东西,但哲学完全不同。餐盘塔讲究规矩:只能从顶上放、顶上取,位置天然有序,放盘取盘都在眨眼之间。仓库货架讲究自由:想放哪格放哪格,用一个地址记录位置,但管理员(分配器)得翻空闲清单、处理释放与合并,耗时不可同日而语。
栈分配的本质只是移动一个指针——rsp 减几下,空间就到手,复杂度 O(1),且没有碎片问题;堆分配(malloc)需要在空闲链表里找合适的块,必要时还会触发系统调用,慢一个数量级不止。代价是容量与寿命:栈只有 MB 级,且生命周期死死绑定函数调用;堆可达 GB 级,生命周期完全由程序员掌控。用区间记号表达两者的生命周期差异:
使用场景随之分明:小尺寸、短寿命、随函数走的临时缓冲,优先栈;大尺寸、长寿命、要在函数之间共享的数据,交给堆。后厨的餐盘塔装不了整只烤全羊,仓库货架又没必要为一只杯子跑一趟手续——各司其职,才是内存管理的智慧。
malloc 开始、free 结束。忘了 free 是泄漏,提前 free 是悬垂,全靠人肉记账。L(生命周期)一块内存“从生到死”的有效区间,即它从被创建到被回收之间的时间段。任何越界访问生命周期之外的内存都是未定义行为。- 区间记号
[起点, 终点]表示内存在这两个事件之间有效。栈和堆的区别,本质就是“谁来写这组起止点”。 - 栈区间(编译器自动)起点是函数调用、终点是函数返回,无法提前销毁也无法延后保留——盘子被取走时,里面的菜注定消失。
- 堆区间(程序员手动)起点是
malloc、终点是free。你可以让一块堆内存活 1 微秒,也可以让它活到程序结束,自由度与风险并存。
调用约定ABI
传菜也有规矩:后厨和传菜口之间约定,前 6 道菜直接从传菜口递出(速度快),第 7 道起放进托盘上层指定位置(走栈)。这套“谁走口、谁走盘、谁负责收拾”的店规,在程序世界里叫调用约定(Calling Convention / ABI)。不同平台店规不同:Linux 上用的是 SysV x86-64 约定,Windows x64 又是另一套。规则不统一,跨语言调用就会鸡同鸭讲。
在 SysV 约定下,函数的前 6 个整型参数依次放入寄存器 rdi, rsi, rdx, rcx, r8, r9,第 7 个参数开始才压到栈上。返回值放 rax。寄存器传参比访存快得多,所以编译器会想尽办法把参数塞进“传菜口”。第 7 个以后的参数就没有这种优待了:
这就不难理解为什么 -fomit-frame-pointer 优化会删掉 rbp:既然 ABI 已经把参数和返回地址的摆放定死,某些函数用 rsp + 偏移 也能算清一切,省出一个通用寄存器。店规写得越细,后厨运行就越顺畅——前提是每个进来的厨师都背得滚瓜烂熟。
\displaystyle arg_i =\begin{cases}R_i & i \le 6 \\[rsp + k_i] & i > 6\end{cases}k_i 是编译器预先算好的栈上偏移。arg_i(第 i 个参数)函数声明里的参数序号,从 1 开始数。它决定这道菜走哪条路。- 分段大括号按序号将参数分流。前半段是“前 6 道直递传菜口”,后半段是“其余放托盘指定层”。
R_i(寄存器列表)SysV 规定的寄存器序列,依次是rdi, rsi, rdx, rcx, r8, r9。编译器将前 6 个参数装入这些寄存器,调用时原样带到被调函数。[rsp + k_i](栈上参数)第 7 个起,参数压入调用栈,访问方式与局部变量类似,只是方向相反(在栈顶之上)。k_i由编译器静态算出。- 保存规则(谁负责收拾)ABI 还规定哪些寄存器由调用者保存(caller-saved)、哪些由被调用者保存(callee-saved),以及结束后由谁清理栈上参数。规则一旦错位,函数返回时栈就会失衡。
AI 应用场景
线上服务“爆栈”崩溃诊断
阶段:可观测性与故障定位
解决的问题:传统排查靠人肉翻日志、猜测是不是递归太深,效率低。基于调用栈追踪(backtrace)与帧大小统计,工程师能自动算出每一帧消耗的字节数,并定位是哪个函数把 8MB 栈空间耗尽的。
具体案例:某推荐服务解析深度嵌套的 JSON 配置时偶发 Segfault。工程师抓取崩溃时刻的调用栈,发现解析函数每层帧占 2KB+,配置稍深就突破 depth_max。把递归解析改为显式栈 + 堆内存后,崩溃清零。
运行时与容器的“感应玻璃”防线
阶段:安全加固与系统防护
解决的问题:守护页 + 栈探测让栈越界在第一时间变成可捕获异常,而不是静默写坏相邻内存,导致难以定位的数据损坏。对涉及外部输入解析的系统尤其关键。
具体案例:某 AI 推理框架的 C++ 算子一次性在栈上声明 4MB 中间缓冲,编译器自动插入逐页探测代码,配合 mprotect 设置的守护页,越界瞬间触发 SIGSEGV 并精确报告到所在函数。
AI 框架扩展的性能取舍
阶段:高性能计算优化
解决的问题:小张量计算若每次都走 malloc 分配堆内存,开销远超计算本身。利用栈分配的零开销优势,对小尺寸临时缓冲走栈、对大尺寸走内存池,是推理引擎的常见优化手段。
具体案例:优化 Python 调 C++ 的逐元素算子时,对 shape ≤ 64 的临时缓冲使用栈上定长数组,大 shape 走堆内存池,实测整体推理延迟下降约 15%。
Python 演示
# -*- coding: utf-8 -*-"""栈帧模拟器: 演示局部变量的栈分配, 对齐与 VLA"""ALIGN = 8STACK_CAP = 1024 def align_up(size, align=ALIGN): """把 size 向上取整到 align 的倍数, 模拟编译器对齐""" return (size + align - 1) // align * align class StackSim: def __init__(self): self.free = STACK_CAP # 剩余栈容量(字节) self.frames = [] # 已压入的栈帧 def push(self, name, var_sizes): """压入一个栈帧, 为局部变量分配槽位""" need = 24 + sum(align_up(s, min(s, ALIGN)) for s in var_sizes) if self.free < need: raise OverflowError("stack overflow: 还需 %d 字节" % need) self.free -= need offset = 0 layout = [] for i, size in enumerate(var_sizes): offset = align_up(offset, min(size, ALIGN)) layout.append((name + "_v%d" % i, offset, size)) offset += size self.frames.append((name, layout, need)) return layout def pop(self): """函数返回: 弹出栈顶帧并归还容量""" if not self.frames: return None name, layout, need = self.frames.pop() self.free += need return name def depth(self): return len(self.frames) def main(): sim = StackSim() sim.push("main", [8, 4, 4]) sim.push("render", [16, 1, 1]) n = 5 sim.push("calc", [4 * n]) # VLA: 运行时才知道长度 print("栈深:", sim.depth(), "剩余容量:", sim.free) for name, layout, need in sim.frames: print("帧:", name, "槽位:", layout) while sim.pop(): pass print("全部返回, 栈容量复原:", sim.free) if __name__ == "__main__": main()
need = 24 + sum(align_up(s, min(s, ALIGN)) for s in var_sizes)帧大小 = 固定 24 字节开销(返回地址+旧帧指针+留白)+每个变量对齐后的尺寸。if self.free < need: raise OverflowError(...)容量不足立刻抛异常,相当于守护页拦截越界访问。offset = align_up(offset, min(size, ALIGN))对齐核心逻辑:小变量按自身尺寸对齐,大变量不超过 8 字节粒度。self.frames.append((name, layout, need))压栈:新帧被推到“盘子塔”顶部,记录槽位布局。self.free -= need分配即扣减容量,对应汇编里sub rsp, frame_size。self.free += need函数返回归还容量,对应rsp复位。sim.push("calc", [4 * n])VLA 演示:数组大小n在运行时才确定,照样在栈上现划空间。while sim.pop(): pass依次返回直到栈空,栈容量完全复原,验证“栈自动回收”的特性。
常见误区
误区我写了 `int a; long b; char c;`,那内存里一定是 a 在最前、b 在中间、c 在最后,三个变量地址连续紧挨。
正解编译器会重排变量、插入填充,甚至把变量优化进寄存器或直接优化掉。真实布局是编译器按对齐与访问效率自由编排的,唯一保证是“用编译期算好的偏移一定能访问到它”。
为何常见:源码顺序给了“先来后到”的直觉,但只有看到汇编才会发现配餐员(编译器)有完全的自由裁量权。
误区栈从高地址向低地址生长,那我函数里先写的变量一定在高地址,后写的在低地址。
正解栈的生长方向只决定 rsp/rbp 怎么移动,不决定帧内单个槽位的布局。变量放哪一格由编译器的偏移计算决定,与声明次序没有必然关系。
为何常见:把“栈的方向”错当成“帧内的排布规则”,其实是两件不同尺度的事。
误区数组长度要运行时才算出来,那 VLA 肯定在堆上分配,用完还得 free。
正解VLA 依然分配在栈上,只是大小在运行时通过 rsp -= n * size 动态划定,函数返回时自动回收,既不需要也不能手动 free。
为何常见:“动态”二字让人直接联想到 malloc 和堆,忽略了“动态”的真正含义只是“运行时计算大小”。
误区我平时不写递归,所以永远不用担心栈溢出。
正解超大局部数组、失控的 VLA、每帧过大、层层调用的深链,都会让总消耗超过栈容量。栈溢出的本质是“每帧大小 × 深度 > 总容量”,任何一项超标都会爆。
为何常见:教科书总用无限递归做例子,掩盖了“总用量 = 帧大小 × 深度”这个乘积关系。
误区我拿到函数里局部变量的地址,函数返回后这个地址上偶尔还能读到一样的值,说明它还能用。
正解返回后该栈空间已“归还”给下一个调用者,下一次调用会立刻覆盖。读取或写入这块内存是未定义行为(悬垂指针),在编译器优化下可能表现得完全不符合预期。
为何常见:内存没有被主动清空,侥幸读了几次相同值就误以为安全,实则在雷区上跳舞。
面试题
Q1. 函数被调用时,它的局部变量空间在哪里分配?
- A.堆内存中动态分配
- B.寄存器中直接存储
- C.调用栈的栈帧中← 正确答案
- D.全局数据区中分配
答案:C。局部变量随函数调用在栈帧中分配,帧压栈、返回弹出,好比餐盘塔加盘与取盘。堆是 malloc 手动管理,寄存器数量太少放不下数组,全局区属于整个程序生命周期。
Q2. 压入一个栈帧分配局部变量空间时,栈顶指针 rsp 的变化是?
- A.数值增大向上移动
- B.数值减小向下移动← 正确答案
- C.保持数值完全不变
- D.先增大再立即减小
答案:B。栈从高地址向低地址生长,压入一帧执行 rsp -= frame_size 的减法,如同弹簧托盘被压下去。其他选项与栈的生长方向矛盾。
Q3. 关于帧指针 rbp 的作用,下列说法正确的有?
- A.指向当前栈帧的固定底部← 正确答案
- B.函数执行期间保持不变← 正确答案
- C.局部变量通过 rbp 减偏移访问← 正确答案
- D.指向堆内存中分配的空间
答案:ABC。rbp 是帧内锚点(盘底标签),函数执行中不变,局部变量用 rbp - offset 访问。它服务于栈帧,与堆内存无直接关系,D 错。
Q4. 结构体成员对齐时,编译器插入填充字节的主要目的是?
- A.让结构体占用更多内存
- B.让每个成员落在对齐边界上← 正确答案
- C.方便程序员肉眼对齐代码
- D.提高结构体的可读性
答案:B。CPU 访问地址落在对齐倍数上最快,填充字节是“塞海绵”,把成员起点推到自然对齐位置,代价是多占内存、收益是访问性能。
Q5. 下列哪些情况可能导致栈溢出?
- A.无限递归的函数调用← 正确答案
- B.在栈上声明超大数组← 正确答案
- C.函数互相嵌套调用过深← 正确答案
- D.在堆上 malloc 大量内存
答案:ABC。栈溢出的本质是“每帧大小 × 深度 > 总容量”,递归无限、数组超大、调用过深都会突破。堆内存不占栈空间,D 不会导致栈溢出。
Q6. 变长数组 VLA 的空间在哪里分配、何时确定大小?
- A.堆上分配,编译期确定
- B.栈上分配,运行期确定← 正确答案
- C.堆上分配,运行期确定
- D.栈上分配,编译期确定
答案:B。VLA 大小是运行时表达式的值,编译器在函数入口用 rsp -= n*4 现划空间,返回自动回收,是“活动隔板餐盘”,不是仓库货架。
Q7. 操作系统设置守护页(Guard Page)的主要目的是?
- A.加快局部变量访问速度
- B.在栈越界时尽早触发异常← 正确答案
- C.压缩程序占用内存空间
- D.把栈数据自动备份到磁盘
答案:B。守护页是不可读写的哨兵页,栈顶一旦触及立即触发缺页异常并升级为段错误,防止静默写坏相邻内存,如同底部感应玻璃报警。
Q8. 关于 SysV ABI 的整型参数传递,下列说法正确的有?
- A.前六个参数依次放入 rdi rsi rdx rcx r8 r9← 正确答案
- B.第七个参数起压入调用栈← 正确答案
- C.返回地址由被调函数负责保存
- D.栈上参数由调用方负责清理← 正确答案
答案:ABD。SysV 约定前 6 参数走寄存器、第 7 个起压栈;返回地址由 call 指令自动压栈,不是被调函数保存;带栈参数时由调用方负责栈平衡,D 正确。
Q9. 编译器用 rbp 减偏移访问局部变量的主要原因是?
- A.rbp 在函数执行期间保持不变← 正确答案
- B.rbp 存储的是局部变量的值
- C.rbp 指向栈顶方便计算
- D.rbp 指向全局数据区
答案:A。rsp 在函数执行中因压栈、传参频繁变化,而 rbp 固定,用固定锚点减编译期算好的偏移,才能得到稳定的局部变量地址。
Q10. 单次分配非常大的栈帧时,编译器常插入栈探测代码,目的是?
- A.提高汇编代码的可读性
- B.逐页触碰确保不跨过守护页← 正确答案
- C.把大帧自动迁移到堆上
- D.为局部变量预填充零值
答案:B。大帧一次性 sub rsp 可能跳过守护页直接落到其他区域,导致崩溃不可控。探测代码按页触碰栈空间,保证越界瞬间触发异常,相当于分段试压。