5.2 AAPCS调用约定与栈帧手推


5.2 AAPCS 调用约定与栈帧手推

本节摘要:AAPCS 是 ARM 世界的函数调用合同:参数前八个走 x0x7、返回值走 x0x19x28 由被调方保存、sp 时刻十六字节对齐。本节把一次调用的序言与尾声逐条指令手推一遍,再进调试器逐帧核对,最后用一次"栈对齐翻车"的现场演示合同被违反时的样子。

上一节的分支是"小结构",本节的函数调用是"大结构"。要讲的东西听起来枯燥——一堆寄存器名单与对齐规则——但它是全书含金量最高的一节:调试器怎么还原调用栈、C 与汇编怎么互调、中断进来时谁的状态必须保存,答案全在这一节。我们的办法照旧:不背名单,手推一遍,现场验证。

一次踩穿栈的现场

先看一个真实事故当引子。某项目在链接进一段第三方汇编后,程序偶发崩溃,崩溃点在 printf 深处,调用栈回溯出来却是乱的。排查半天发现问题根源:那段手写汇编的函数序言只把 sp 减了 8 字节——而 AAPCS 规定任何时刻公开接口处 sp 必须十六字节对齐printf 内部用 stp 存一对寄存器时,对齐检查失败,直接 faults。这个案例值得记取的不是"要遵守约定"这句空话,而是:调用合同是跨模块的,违反它不一定当场炸,而是在别人的代码深处炸——这也是本节标题里"手推"二字的价值:能推,就能在 review 里当场抓住这类错误。

合同正文:寄存器的保存责任

AAPCS 的寄存器条款按"谁用谁保存"划线,一张表说完:

寄存器 角色 跨调用保证 谁负责保存
x0x7 参数与返回值 无保证 调用方自己有需要就先存
x8x15 临时工 无保证(x16x17 还可能被平台征用) 调用方
x18 平台保留 按平台约定,别碰 不适用
x19x28 长寿命变量 保证原样 被调方用前压栈
x29 / x30 帧指针 / 返回地址 保证 被调方序言压栈
sp 栈指针 恢复原值且保持对齐 被调方

记法:前十六个(x0x15)是"公共便签纸",调用方知道调用后它们不可信;后段(x19 起)是"私人抽屉",被调方想用必须先把自己原来的内容存好、退出前还回去。返回值条款:基本类型从 x0 返回(浮点走 d0、SIMD 结构体走 v0v3),大于十六字节的结构体改走内存——调用方预留空间、地址当隐形第一参数传进 x8。栈条款:sp 十六字节对齐、向下生长,参数超过八个的部分依次压栈。

手推:一次调用的完整旅程

把合同落到指令。设调用方某处执行 bl calleecallee 用到 x19x20 与两个局部变量。逐条推演:

callee: // 进入时:x0 是参数,x30 是返回地址,sp 对齐 stp x29, x30, [sp, #-32]! // ① sp 减 32,压入旧帧指针与返回地址 mov x29, sp // ② 帧锚定在栈帧顶部 stp x19, x20, [sp, #16] // ③ 被保存寄存器放上半区剩余槽位 ... // 函数体:随便用 x19 x20,改 sp 无妨 ldp x19, x20, [sp, #16] // ④ 归还被保存寄存器 ldp x29, x30, [sp], #32 // ⑤ 弹回帧指针与返回地址,sp 复位 ret // ⑥ 按 x30 回家

六步之外还剩两问。第一问:栈帧到底多大?①里那个 32 怎么算的——返回地址对 8、旧帧指针对 8、x19x20 共 16,合计 32,恰是十六的倍数,对齐条款自动满足;局部变量再往上叠,总尺寸凑齐十六的倍数即可。第二问:没有 stp 的纯叶子函数(不调别人、不用保存寄存器)能不能一条 stp 都不写?能——x30 没人动,直接 ret,编译器对短叶子函数生成的正是这种"零帧函数"。手推练习的变式:把函数改成带八个以上参数的版本,推一推多出来的参数落在栈的哪个位置(调用方压在自己的帧顶,被调方按正偏移取)。

图 5-2:手推对应的栈帧剖面与指令对照

图 5-2:手推对应的栈帧剖面与指令对照

调试器逐帧核对

手推完要见真章。在 callee 函数体中部断点,核对三件事:

(gdb) p $sp $1 = (void *) 0x7ffffff060 // 比入口处小 32:栈帧占位吻合 (gdb) x/2gx $sp 0x7ffffff060: 0x0000007ffffff0b0 0x00000000004007c4 // 前者是旧帧指针,后者是返回地址——手推的 ① 号步骤实锤 (gdb) p $x29 $2 = 0x7ffffff060 // 帧锚与栈顶重合,② 号步骤实锤 (gdb) bt #0 callee () at sum.c:12 #1 0x00000000004007c4 in main () // backtrace 能串出链条,靠的正是每帧里 x29 串成的一条链

bt 那两行最值得咀嚼:调试器并不知道函数边界,它只是沿 x29 链一路向上爬、用每个帧里的 x30 报告"谁调的谁"。第三方那段汇编之所以把回溯搅乱,正是因为它没把帧指针压好,链条从那里断线。变式实验:故意把 ⑤ 的偏移写成 24(少了 8),运行看 sp 复位错位、bt 全乱——亲手制造一次本节开头的事故,比读十遍规则都有效。

深挖:省掉帧指针的代价与变参函数的栈

调试器能顺藤摸瓜,前提是藤在——x29 链。但编译开关 -fomit-frame-pointer 会让编译器省掉帧指针的设置(把 x29 释放出来当普通寄存器),栈帧从此没有锚点,回溯退化成"猜"。现代工具链有备份方案:把栈帧信息编码进专门的调试节(.eh_frame 类格式),回溯器按表追查,不必依赖 x29。两套机制并存的结果是:出问题的二进制若两样都没带(既无帧指针又无调试节),调用栈就成了玄学。发布版加 -fno-omit-frame-pointer 是性能团队常见的选择——用一个寄存器换永远能用的回溯,多数团队觉得值。

另一类特殊乘客是变参函数。printf 这类函数的参数个数不定,前八个照旧走寄存器,超出部分压栈之外,调用方还必须把"后面还有多少个参数落在寄存器"的信息留给被调方(AArch64 的做法是把通用寄存器整块压栈并传一块栈记录的指针)。对汇编作者的实操含义只有一条:手写汇编调用变参函数,参数放不进寄存器的老老实实按 ABI 压栈,别自作聪明——变参的栈布局是 ABI 精确定义的,猜错一个偏移就是栈损坏。

本节要点回顾

  • 责任划线x0x15 调用方自理,x19x28x29x30 被调方保存,x18 别碰。
  • 参数与返回:前八个参数走 x0x7,返回值 x0,超大结构体改走内存加隐形指针。
  • 对齐铁律sp 公开接口处十六字节对齐,帧尺寸凑十六的倍数。
  • 帧锚链条x29 逐帧相链,调试器的调用栈回溯全靠它。
  • 手推价值:违反合同的崩溃炸在别人深处,能手推就能在源头抓住。

调用合同推完了。有时你还是想亲手写几句汇编插进 C 里——下一节做内联汇编与编译器输出的人机对照。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U