本节摘要:AAPCS 是 ARM 世界的函数调用合同:参数前八个走
x0至x7、返回值走x0、x19至x28由被调方保存、sp时刻十六字节对齐。本节把一次调用的序言与尾声逐条指令手推一遍,再进调试器逐帧核对,最后用一次"栈对齐翻车"的现场演示合同被违反时的样子。
上一节的分支是"小结构",本节的函数调用是"大结构"。要讲的东西听起来枯燥——一堆寄存器名单与对齐规则——但它是全书含金量最高的一节:调试器怎么还原调用栈、C 与汇编怎么互调、中断进来时谁的状态必须保存,答案全在这一节。我们的办法照旧:不背名单,手推一遍,现场验证。
先看一个真实事故当引子。某项目在链接进一段第三方汇编后,程序偶发崩溃,崩溃点在 printf 深处,调用栈回溯出来却是乱的。排查半天发现问题根源:那段手写汇编的函数序言只把 sp 减了 8 字节——而 AAPCS 规定任何时刻公开接口处 sp 必须十六字节对齐。printf 内部用 stp 存一对寄存器时,对齐检查失败,直接 faults。这个案例值得记取的不是"要遵守约定"这句空话,而是:调用合同是跨模块的,违反它不一定当场炸,而是在别人的代码深处炸——这也是本节标题里"手推"二字的价值:能推,就能在 review 里当场抓住这类错误。
AAPCS 的寄存器条款按"谁用谁保存"划线,一张表说完:
| 寄存器 | 角色 | 跨调用保证 | 谁负责保存 |
|---|---|---|---|
x0 至 x7 |
参数与返回值 | 无保证 | 调用方自己有需要就先存 |
x8 至 x15 |
临时工 | 无保证(x16、x17 还可能被平台征用) |
调用方 |
x18 |
平台保留 | 按平台约定,别碰 | 不适用 |
x19 至 x28 |
长寿命变量 | 保证原样 | 被调方用前压栈 |
x29 / x30 |
帧指针 / 返回地址 | 保证 | 被调方序言压栈 |
sp |
栈指针 | 恢复原值且保持对齐 | 被调方 |
记法:前十六个(x0 至 x15)是"公共便签纸",调用方知道调用后它们不可信;后段(x19 起)是"私人抽屉",被调方想用必须先把自己原来的内容存好、退出前还回去。返回值条款:基本类型从 x0 返回(浮点走 d0、SIMD 结构体走 v0 至 v3),大于十六字节的结构体改走内存——调用方预留空间、地址当隐形第一参数传进 x8。栈条款:sp 十六字节对齐、向下生长,参数超过八个的部分依次压栈。
把合同落到指令。设调用方某处执行 bl callee,callee 用到 x19、x20 与两个局部变量。逐条推演:
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、x19 对 x20 共 16,合计 32,恰是十六的倍数,对齐条款自动满足;局部变量再往上叠,总尺寸凑齐十六的倍数即可。第二问:没有 stp 的纯叶子函数(不调别人、不用保存寄存器)能不能一条 stp 都不写?能——x30 没人动,直接 ret,编译器对短叶子函数生成的正是这种"零帧函数"。手推练习的变式:把函数改成带八个以上参数的版本,推一推多出来的参数落在栈的哪个位置(调用方压在自己的帧顶,被调方按正偏移取)。

手推完要见真章。在 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 精确定义的,猜错一个偏移就是栈损坏。
x0 至 x15 调用方自理,x19 至 x28 加 x29、x30 被调方保存,x18 别碰。x0 至 x7,返回值 x0,超大结构体改走内存加隐形指针。sp 公开接口处十六字节对齐,帧尺寸凑十六的倍数。x29 逐帧相链,调试器的调用栈回溯全靠它。调用合同推完了。有时你还是想亲手写几句汇编插进 C 里——下一节做内联汇编与编译器输出的人机对照。