想象你住进一个物业管理非常严格的小区:快递柜、门禁卡、搬家通道都有明文规定,谁不按约定办事,货物就可能会丢、会撞、甚至会卡在楼道里。x86_64 的世界也有这样一份“全小区公告”,名字叫 System V ABI。它规定函数之间怎么传参数、怎么保存现场、又怎么把一个结构体当作“包裹”交还给调用方。你会发现,有的小包裹可以随手塞进路过的两个行李箱(寄存器),而大件家具则必须先让对方腾空一个仓库,再由你告诉对方仓库地址。

岗位关联

理解这份约定,是 AI 工程师调试推理引擎段错误、读懂反汇编、以及用 Python 调 C/C++ 底层库时绕不开的底层功。学完本文,你能一眼认出结构体返回模式,不再被“随机崩溃”折磨。

目录
  1. 可视化演示
  2. ABI与系统契约
  3. 返回寄存器规则
  4. 八字节分类合并
  5. 隐藏指针返回
  6. 对齐与递归展开
  7. 反汇编与FFI
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示System V ABI结构体返回分类体系、INTEGER/SSE寄存器返回、MEMORY栈返回机制及字段排列对分类的影响,交互切换深入理解ABI底层约定。

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

分类体系:INTEGER/SSE/MEMORY三类返回 INTEGER类通过rax/rdx寄存器返回,≤16字节 SSE类通过xmm0/xmm1寄存器返回浮点数据 Class = INTEGER | SSE | MEMORY 结构体大小和字段类型决定返回分类。

ABI与系统契约

你搬进新小区时,物业会发一本《货物交接手册》:钥匙放门口左脚垫下、大件物品先到保安室登记、生鲜放在 2 号冷柜。如果每家都按自己的心情乱放,快递员迟早会崩溃。System V ABI 就是 x86_64 世界那本“交接手册”,编译器、操作系统、编程语言都默认照它执行。

这本手册不是某个编译器私藏的规矩,而是 Unix/Linux 生态共同认可的边界。我们用 C 写一个函数,再用 Rust、Python 或汇编去调用它,双方必须知道:参数从哪里进、返回值从哪里出、谁负责清理栈。于是 ABI 定义了参数传递、寄存器保存、栈对齐和返回值规则四大类,缺一不可。

具体到结构体返回,编译器会先看结构体“货箱”有多大、里面装的是什么类别的数据,再决定走两个随身行李箱还是走大件仓库。这个决定不是碰运气,而是由一套可复现的分类算法得出,所以反汇编时你能看到一致的规律。

所以,当你看到一段反汇编里有 mov rax, ... 随后 ret,不要只认为“这是普通返回值”。要先去查“手册”:结构体大还是小、有没有隐藏指针参与。正如搬家前先翻物业手册,你才知道该把钥匙放哪儿。

ABI={参数传递, 寄存器保存, 栈对齐, 返回值规则}\displaystyle \text{ABI} = \{\text{参数传递},\ \text{寄存器保存},\ \text{栈对齐},\ \text{返回值规则}\}
ABI 是大括号里的四份子契约,本文只拆开“返回值规则”这一格细看。
  1. ABI 符号\displaystyle \text{ABI} 指“应用二进制接口”,是机器码层面的接口协议,不是源代码层面的函数签名。
  2. 大括号集合\displaystyle \{\cdots\} 表示 ABI 由多项规则并列组成,编译器必须同时遵守,不能只挑一部分。
  3. 返回值规则元素\displaystyle \text{返回值规则} \in \text{ABI} 说明返回值规则是整个契约中的关键子项。
  4. 子规则地位\displaystyle \text{返回值规则} \subseteq \text{ABI} 提醒我们,返回值只是 ABI 拼图的一块,后续章节都围绕它展开。

返回寄存器规则

小区快递站门口固定停着两辆“随身行李车”,一辆叫 rax,一辆叫 rdx。只要货物能塞进这两个车斗,快递员就不会动用大型仓库。对结构体而言,规则同样简单:结构体总大小不超过 16 字节,并且分类结果允许,就用 rax 和 rdx 返回。

为什么是 16 字节?因为 x86_64 的通用寄存器每个是 64 位,也就是 8 字节;两个随身行李车加起来刚好 16 字节。比如 struct Point { long x; long y; }; 占了两个八字节块,编译器把 x.y 中的第一块挪进 rax,第二块挪进 rdx,函数末尾一个 ret 就完事。

但如果结构体有 24 字节,两辆车装不下;或者虽然有 20 字节但其中有字段被判成“必须走仓库”,编译器就不会硬塞。小型结构体走寄存器,不只是快,还避免了对内存的读改写,调用方拿到返回值时不用再去栈上取第二趟。

回到比喻:小包裹直接塞进行李车,收件人拎着就走,不需要仓库钥匙。这也是为什么 C 代码里 8 字节的小结构体和单个整数返回时的汇编几乎一样,都优先用 rax。

regs=min⁡(2, ⌈sizeof(Struct)8⌉)\displaystyle \text{regs} = \min\left(2,\ \left\lceil \frac{\text{sizeof}(\text{Struct})}{8} \right\rceil\right)
结构体占用几个八字节容器,就用几个寄存器;最多只有两个,超出部分必须改走内存。
  1. 结构体大小\displaystyle \text{sizeof}(\text{Struct}) 先算出整个结构体占多少字节,这是判断能否进寄存器的第一步。
  2. 八字节容器数\displaystyle S/8 用总字节数除以 8,看看理论上占用几个容器。
  3. 向上取整\displaystyle \lceil S/8 \rceil 表示只要还剩 1 个字节也要占满一个容器,比如 9 字节的结构体需要两个容器。
  4. 上限裁剪\displaystyle \min(2, \cdot) 因为只有 rax 和 rdx 两个随身行李车,超过 2 个容器就装不下。

八字节分类合并

随身行李车不是把所有东西一视同仁地乱塞,货物要按“材质”分类:普通机械件走整数传送带,易碎液体走浮点传送带,还有一类“超大/特殊件”直接禁止上随身车。ABI 把每个 8 字节容器里的字段分成 INTEGER、SSE、MEMORY 等类别,然后按优先级合并出整个容器最终属于哪一类。

分类的优先级有个暗规则:MEMORY 最霸道,只要某个容器里出现了它,整个结构体就被判定为“大件走内存”;INTEGER 比 SSE 优先。例如同一个八字节块里既有整数又有浮点字段,这一块会整体按 INTEGER 处理,因为整数类别的“搬运通道”更通用。

注意,这里的“合并”发生在同一个 8 字节块内部,而不是把不同八字节块强行压成一个类。两个不同的八字节块可以分别映射到不同寄存器:两个整数块走 rax/rdx,两个浮点块走 xmm0/xmm1,一整数一浮点则各走各的通道。只有合并出 MEMORY 时才会彻底放弃寄存器。

类比分拣线:一个快递箱里一半机械零件一半液体,安检员不会把它拆成两半带走,而是统一贴上“液体优先”标签。ABI 的分类合并,就是给每个八字节容器贴一张最终类别标签。

merged=pick({c1,c2,… }),MEMORY≻INTEGER≻SSE\displaystyle \text{merged} = \text{pick}(\{c_1, c_2, \dots\}),\quad \text{MEMORY} \succ \text{INTEGER} \succ \text{SSE}
pick 会从同一个八字节容器里的多个字段类别中,按右边这条优先级链选出唯一类别。
  1. 字段类别集合\displaystyle \{c_1, c_2, \dots\} 列出同一八字节块里每个字段的原始类别,比如 INTEGER、SSE。
  2. 合并函数\displaystyle \text{pick}(\cdots) 表示把这一组类别压缩成单个类别,以便决定使用哪条传送带。
  3. 优先级链\displaystyle \text{MEMORY} \succ \text{INTEGER} \succ \text{SSE} 表示一旦出现 MEMORY,排序最高;没有 MEMORY 时 INTEGER 压过 SSE。
  4. 最终裁决\displaystyle \text{merged} = \text{MEMORY} 如果合并结果是 MEMORY,结构体不能再进寄存器,必须走隐藏指针路线。

隐藏指针返回

当结构体被判成 MEMORY,比如大小超过 16 字节,或者分类合并出了 MEMORY,编译器会切换到“大件家具”模式:调用者必须先在家里腾出一个仓库,把仓库地址告诉搬家公司;搬家完成后,回执上写的是这个仓库地址,而不是家具本身。

这个仓库在 C 代码里是调用者栈帧上的一块临时缓冲区。表面上你写 struct Big r = f();,实际编译器会改写成 f(&r);,把缓冲区地址作为隐藏参数传给被调函数。被调函数把结构体内容写进 r,最后把 r 的地址放进 rax。所以调用者拿到的是“仓库钥匙”,而不是货物本体。

为什么要这样做?因为大结构体体积不稳定,如果都往寄存器里塞,CPU 的寄存器数量远远不够。更重要的是,寄存器里的数据只是副本,调用者必须有一个真实的内存位置来长期保存结果。隐藏指针方案把分配责任放在调用者身上,被调函数只负责往里写,所有权清晰,不会泄漏内存。

所以下次读汇编时看到 lea rax, [rsp+16] 或类似的取地址指令,再配一个 call,你要立刻意识到:这不是普通参数地址,而是编译器在为 MEMORY 类结构体准备返回仓库。就像搬家回执上只写“已送入 3 号仓库”,而不是把整张床塞进快递员的口袋。

rax=addr(ret_buf)\displaystyle \text{rax} = \text{addr}(\text{ret\_buf})
大结构体的返回值不是数据本身,而是调用方预先准备的返回缓冲区地址。
  1. 调用方分配\displaystyle \text{ret\_buf} = \text{alloca}(\text{sizeof}(S)) 调用者在自己的栈上开辟足够大的临时仓库。
  2. 传入隐藏参数\displaystyle \text{rdi} \leftarrow \text{ret\_buf} 编译器把仓库地址当作第一个参数悄悄传给被调函数。
  3. 被调方写入\displaystyle \text{store}(S, \text{ret\_buf}) 被调函数把结构体逐字节复制进这块缓冲区。
  4. 回传地址\displaystyle \text{rax} = \text{addr}(\text{ret\_buf}) 最后让 rax 携带仓库地址,调用方用它访问结果。

对齐与递归展开

仓库里的货架按固定尺寸分格,货物不能超出格子边缘。结构体也一样:每个成员在内存里都有对齐要求,char 可以随便放,long 却必须落在 8 的倍数地址上,否则 CPU 访问效率会下降甚至触发异常。这些看不见的“填充物”会让结构体实际大小大于字段字节数之和。

ABI 在处理结构体返回时,会先按成员对齐规则展开每个字段,再把它切进八字节块。嵌套结构体不是被当成一个黑盒,而是被递归展开:里面的成员一层层拆开,直到成为基本数据类型,再继续参与分类。数组也类似,每个元素按同样的规则递归处理,而不是把整个数组当作一个巨型字段。

对齐还会影响“这个字段落在哪个八字节块”的判断。比如一个 struct { char a; long b; },a 占 1 字节后会有 7 字节填充,b 从偏移 8 开始;如果不理解填充,你会误以为 b 和 a 挤在同一个八字节块里,分类结果就会算错。真正按 ABI 切分时,它们被分到两个不同的八字节块。

这就像搬家公司的打包师傅:先把每个小物件放进合适的隔层,再按货架尺寸封箱,最后还要在箱外写上总尺寸。递归展开保证无论结构体嵌套多深,最终都能拆成一排标准八字节格子,寄存器或仓库才知道怎么接纳它。

align(S)=max⁡ialign(fi),size(S)=⌈offset(flast)+size(flast)align(S)⌉×align(S)\displaystyle \text{align}(S) = \max_i \text{align}(f_i),\quad \text{size}(S) = \left\lceil \frac{\text{offset}(f_{\text{last}}) + \text{size}(f_{\text{last}})}{\text{align}(S)} \right\rceil \times \text{align}(S)
结构体的整体对齐取所有成员对齐的最大值,最终大小按这个对齐值向上取整。
  1. 成员对齐遍历\displaystyle \max_i \text{align}(f_i) 遍历所有成员,找出最大的对齐字节数,比如成员里有 long 就是 8。
  2. 尾部结束偏移\displaystyle \text{offset}(f_{\text{last}}) + \text{size}(f_{\text{last}}) 计算最后一个字段结束的位置,这是填充后的“真实终点”。
  3. 向上取整\displaystyle \lceil \cdot / \text{align}(S)\rceil 让最终大小成为对齐值的整数倍,多出的字节留给尾部填充。
  4. 递归语义\displaystyle \text{expand}(\text{nest}) = \bigcup \text{expand}(f_i) 嵌套结构体展开后,里面每个字段仍然按同一套规则切块。

反汇编与FFI

理论讲完,落地要看汇编。一个 8 字节结构体返回时,函数结尾通常只有 mov rax, ... 加 ret;16 字节结构体则可能出现 mov rax, ...; mov rdx, ...; ret。而当你看到一个以隐藏指针返回的函数,反汇编里会先看到 lea rdi, [rsp+offset] 这类“准备仓库”指令,末尾再加 mov rax, rdi。

读反汇编不是让你手写汇编,而是用来验证编译器是否遵守 ABI。尤其在 Python 用 ctypes 或 cffi 调用 C 库时,如果两边对结构体布局的理解不一致,轻则拿到垃圾数据,重则栈被写坏、进程段错误。AI 推理引擎里,C++ 写的 tokenizer、张量编解码器经常返回自定义结构体,Python 侧必须用 ctypes.Structure 精确复刻字段和对齐。

跨语言边界上最常见的错误是:只关心字段顺序,不关心填充和对齐。比如 C 结构体里有 char 和 long,Python 侧如果用了紧凑打包,两边八字节块切分就错位,最终分类结果可能从寄存器方案变成内存方案,而调用方却没有准备隐藏缓冲区。理解 ABI 后,你能直接对症下药,而不是靠“加个随机 sleep”碰运气。

到这里,比喻闭环了:小区手册不仅约束搬家公司,也约束所有业主。当你用 Python 调 C 库、用 Rust 写扩展、或者分析崩溃 core dump,其实都是在同一份“交接手册”下协同。掌握了这套规则,你就不再靠猜,而是像老练的物业管家一样,看一眼回执就知道货去了哪个仓库。

asm_pattern={rax←v0, rdx←v1S≤16rax←ret_bufmerged=MEMORY\displaystyle \text{asm\_pattern} =\begin{cases}\text{rax} \leftarrow v_0,\ \text{rdx} \leftarrow v_1 & S \le 16 \\\text{rax} \leftarrow \text{ret\_buf} & \text{merged} = \text{MEMORY}\end{cases}
反汇编里观察到的返回模式不外乎两种:寄存器直返或 rax 携带隐藏缓冲区地址。
  1. 第一块装载\displaystyle \text{rax} \leftarrow v_0 结构体第一个八字节块被写入 rax,这是最常见的返回特征。
  2. 第二块装载\displaystyle \text{rdx} \leftarrow v_1 第二个八字节块进入 rdx,两辆车都出动。
  3. 内存返回分支\displaystyle \text{rax} \leftarrow \text{ret\_buf} 当分类结果是 MEMORY,rax 里放的是缓冲区地址。
  4. 与预测比较\displaystyle \text{observed} = \text{predict}(\text{C},\text{Rust},\text{Python}) 把反汇编观察结果与 ABI 预测对比,不一致就说明至少有一方没守约。

AI 应用场景

推理引擎 FFI 绑定

阶段:模型服务工程落地

解决的问题:Python 调用 C++ 推理内核时,返回 tokenizer 结果结构体。传统做法是手写 ctypes.Structure,一旦字段对齐与 C 不一致,就会出现乱码或段错误;理解 ABI 后,能按八字节切分规则精确复刻布局。

具体案例:某开源 LLM 服务的 Python 层调用本地 C 库返回 struct TokenPair { uint64_t token_id; uint8_t ok; }。若只把字段按紧凑方式拼成 9 字节,分类会误判成 MEMORY,但正确 ABI 会先补齐到 16 字节并按 rax/rdx 返回。修正后服务崩溃率归零。

大模型服务崩溃排查

阶段:线上稳定性运维

解决的问题:模型返回的 embedding 大结构体超过 16 字节,调用方如果没有分配隐藏缓冲区,被调函数会把数据写到非法栈区。会看反汇编的工程师能迅速定位是“谁没给仓库”而不是盲目重编。

具体案例:服务偶发段错误,gdb 里看到被调函数先 lea rdi, [rsp+...] 再写大块数据,但调用方函数没有为返回值预留栈空间。对照 ABI 后确认是旧编译器版本用了不同返回约定,统一编译选项后问题消失。

JIT 编译器结果码生成

阶段:模型编译优化

解决的问题:会把自定义算子编译成机器码的框架,必须按 ABI 生成返回指令;否则函数返回结构体后,调用侧寄存器却读不到值。理解返回约定能让 JIT 生成的代码与 C ABI 无缝对接。

具体案例:在类 TVM 的算子编译器中,一个小算子要返回 16 字节的 float2:按 ABI 应写入 xmm0/xmm1 而不是 rax/rdx。修正后跨模块调用稳定性显著提升。

Python 演示

python44 行
INTEGER = "INTEGER"SSE = "SSE"MEMORY = "MEMORY"  def classify_return(fields):    """按简化版 System V 规则判断结构体返回方式。     fields 是三元组列表: (name, size, kind)    """    total = sum(f[1] for f in fields)    if total > 16:        return MEMORY, "隐藏指针, rax=缓冲区地址"     kinds = [f[2] for f in fields]    merged = kinds[0] if kinds else INTEGER    for k in kinds[1:]:        if MEMORY in (merged, k):            merged = MEMORY        elif merged != k:            merged = INTEGER     if merged == MEMORY:        return MEMORY, "隐藏指针, rax=缓冲区地址"     regs = (total + 7) // 8    placement = "rax" if regs == 1 else "rax, rdx"    return merged, placement  if __name__ == "__main__":    small_int = [("x", 8, INTEGER), ("y", 8, INTEGER)]    float_pair = [("a", 8, SSE), ("b", 8, SSE)]    big_struct = [("data", 32, INTEGER)]     samples = [        ("small_int", small_int),        ("float_pair", float_pair),        ("big_struct", big_struct),    ]     for name, fields in samples:        cls, placement = classify_return(fields)        print(name, "->", cls, placement)
目的:用纯 Python 模拟 System V ABI 的结构体返回分类器,输入字段大小和类别,输出走寄存器还是隐藏指针。
  1. total = sum(f[1] for f in fields)先算出结构体总字节数,这是判断是否超过 16 字节上限的第一关。
  2. if total > 16:超过 16 字节直接判 MEMORY,因为两个八字节随身箱装不下。
  3. merged = kinds[0] if kinds else INTEGER用第一个字段的类别作为初始类别,后续逐个合并。
  4. if MEMORY in (merged, k):只要合并双方中有一方是 MEMORY,最终类别就是 MEMORY,对应优先级最高。
  5. elif merged != k:两个不同类别相遇时,按简化规则统一成 INTEGER,对应 INTEGER 压过 SSE。
  6. regs = (total + 7) // 8向上取整计算需要的八字节寄存器数,9 字节也会算出 2 个寄存器。
  7. placement = "rax" if regs == 1 else "rax, rdx"一个容器用 rax,两个容器用 rax 和 rdx,这就是寄存器直返的落点。
  8. samples = [... ]准备三个典型结构体:纯整数、纯浮点、超大结构体,覆盖三种返回路线。

常见误区

误区只要结构体稍微大一点,比如 20 字节,就必须立刻改成指针传递,否则函数一定慢到爆炸。

正解在 System V ABI 下,20 字节已经超过 16 字节上限,编译器本来就会自动切换成隐藏指针返回。真正影响性能的不是“用了结构体”,而是你手工改指针后破坏了 ABI 预期,导致调用方忘记分配缓冲区或所有权混乱。先按 ABI 规则核对,再做优化。

为何常见:很多教程强调“大结构体用指针”,但没说清编译器其实也会悄悄做同样的事,因此大家误以为 ABI 不处理大结构体。

误区看到反汇编里 `rax` 有值,就以为整个结构体的所有数据都被塞进了 `rax`,`rdx` 是函数自己临时用的。

正解rax 只携带一个八字节容器。16 字节结构体需要 rax 和 rdx 两个寄存器各装一块;浮点版本则可能使用 xmm0/xmm1。只有 MEMORY 类返回时,rax 装的才是缓冲区地址而非数据。

为何常见:初学汇编时接触的返回值几乎都是单值,习惯性把 rax 当成唯一的“答案位”,忽略了 ABI 为多容器场景预留了 rdx。

误区大结构体返回时,被调函数内部 `malloc` 一块空间,然后把地址放进 `rax`,由调用方负责 `free`。

正解System V ABI 规定隐藏缓冲区的分配者是调用方。调用方在栈上开辟返回缓冲区,把地址作为隐藏参数传给被调函数;被调函数只负责往里写数据,再把地址复制进 rax。这样所有权清晰,不会产生谁来释放的扯皮。

为何常见:很多语言里“返回对象”天然是堆分配的,大家把这种语言习惯投射到 C ABI 上,以为底层也一定在堆上建对象。

误区结构体里加不加 `padding` 只是内存占用多一点,反正函数返回值照样能拿。

正解对齐直接决定字段落在哪个八字节块。struct { char a; long b; } 如果不算填充,a 和 b 看似 9 字节,会被误判成跨类结构;按真实布局,a 和 b 分别落在两个八字节块,返回路线完全不同。错误对齐甚至会触发 MEMORY 分类。

为何常见:普通应用里很少直接感知 offsetof 和 padding,于是误以为这只是编译器内部的小优化。

误区Python `ctypes.Structure` 里字段类型和顺序都写对了,C 函数返回值就应该正常。

正解除了字段顺序,还要保证字段类型的内存对齐和 C 端一致。ctypes 默认会按自然对齐排布,但如果你用 _pack_ 强行紧凑打包,八字节切分就会偏移,ABI 分类结果随之改变,可能导致返回值取错寄存器或意外走隐藏指针。

为何常见:大家平时看到的高层语言自动序列化工具弱化了内存布局概念,让人误以为“顺序对=布局对”。

面试题

Q1. 一个 8 字节结构体 { int64_t x; } 在 x86_64 System V ABI 下如何返回?

单选题 · easy

  • A.通过 rax 返回数值← 正确答案
  • B.通过 rdx 返回数值
  • C.通过栈临时区返回
  • D.必须用隐藏指针返回

答案:A。8 字节结构体只占一个八字节容器,系统 V ABI 规定小结构体按 INTEGER 类放入 rax。rdx 是第二个返回寄存器,只有需要第二个容器时才使用;栈和隐藏指针是 MEMORY 类才会采用的方案。

Q2. 16 字节结构体 { long a; long b; } 返回时会用到哪些寄存器?

单选题 · easy

  • A.只用 rax 寄存器
  • B.rax 和 rdx 两者← 正确答案
  • C.只使用栈空间存放
  • D.必须用隐藏指针返回

答案:B。16 字节恰好是两个八字节容器,且都是 INTEGER 类。第一个容器进 rax,第二个进 rdx,不需要栈或隐藏指针。记住两个随身行李箱 rax 和 rdx 是默认配置。

Q3. 以下哪种分类结果最终会按 MEMORY 返回?

单选题 · mid

  • A.全字段为 INTEGER 类
  • B.全字段为 SSE 类
  • C.合并后含 MEMORY 类← 正确答案
  • D.每字段都属 INTEGER 类

答案:C。分类优先级是 MEMORY > INTEGER > SSE,只要某个八字节块合并结果出现 MEMORY,整个结构体就不能再进寄存器。全 INTEGER 或全 SSE 的结构体只要不超 16 字节都可以走寄存器直返。

Q4. 结构体总大小超过 16 字节时,函数返回后 rax 中保存的是什么?

单选题 · mid

  • A.结构体中第一个八字节
  • B.调用方返回缓冲区地址← 正确答案
  • C.结构体在栈上的偏移量
  • D.结构体总大小字节数

答案:B。超过 16 字节会触发 MEMORY 返回,调用方预先准备缓冲区,被调函数把数据写入缓冲区,再把缓冲区地址放进 rax。rax 里不是数据本身,而是仓库地址,即隐藏指针。

Q5. 嵌套结构体参与返回分类时,ABI 如何处理其中的字段?

单选题 · hard

  • A.不作为字段参与寄存器传递
  • B.一律递归展开到八字节块← 正确答案
  • C.按对齐边界单独成组传递
  • D.超出寄存器数量则进入栈

答案:B。System V ABI 要求把嵌套结构体递归展开成基本数据字段,再按顺序合并进八字节容器。不是把嵌套结构体当成一个整体黑盒,递归展开才能正确处理字段类别和对齐边界。

Q6. x86_64 System V ABI 中,第二个返回寄存器是哪个?

单选题 · easy

  • A.rbx 专用寄存器
  • B.rdx 专用寄存器← 正确答案
  • C.rcx 专用寄存器
  • D.rsi 专用寄存器

答案:B。返回值序列中,第一个返回寄存器是 rax,第二个是 rdx。rbx、rcx、rsi 各有参数传递或调用者保存的职责,不属于标准返回寄存器序列。

Q7. 同一个八字节块内同时出现 INTEGER 和 SSE 字段时,最终类别是什么?

单选题 · mid

  • A.合并成 MEMORY 类
  • B.合并成 INTEGER 类← 正确答案
  • C.合并成 SSE 类
  • D.编译器直接报错

答案:B。在单个八字节块内部,类别合并遵循优先级:MEMORY > INTEGER > SSE。没有 MEMORY 时,INTEGER 会压过 SSE,所以最终按 INTEGER 处理。两个类不会共存,也不会自动升级成 MEMORY。

Q8. 哪些情况会迫使结构体采用隐藏指针返回?

多选题 · mid

  • A.结构体总大小超过十六字节← 正确答案
  • B.分类合并结果包含一个 MEMORY← 正确答案
  • C.返回类型使用 C 基本四字节整数
  • D.结构体成员全部是双精度浮点

答案:AB。两个触发条件分别是大小超限和分类出 MEMORY。四字节整数是小结构体,直接进寄存器;全双精度浮点如果不超过 16 字节也走 SSE 寄存器,不会强制 MEMORY。

Q9. 以下哪些反汇编特征表明结构体在按寄存器返回?

多选题 · mid

  • A.mov rax 保存第一八字节结果← 正确答案
  • B.mov rax 保存缓冲区地址
  • C.存在 rdx 写入第二个八字节结果← 正确答案
  • D.ret 指令前面必定出现 call 指令

答案:AC。寄存器直返的标志是 rax 写入第一块、rdx 写入第二块。若 rax 保存缓冲区地址,那是隐藏指针返回。ret 前不一定有 call,这个选项与返回方式无关。

Q10. 跨语言 FFI 返回结构体时,常见的失败原因包含哪些?

多选题 · hard

  • A.双方编译器使用了不同 ABI 约定← 正确答案
  • B.结构体字段对齐被显式地改变← 正确答案
  • C.返回值寄存器数量判断不一致← 正确答案
  • D.隐藏指针缓冲区由被调用方分配

答案:ABC。不同编译器或不同平台若启用不同 ABI,寄存器分配会错位;显式改变对齐会让八字节切分偏移;寄存器数量判断错会导致调用方读错位置。D 恰好相反,System V ABI 规定隐藏缓冲区由调用方分配,被调用方只负责写入。