1. 从"函数调函数"到"函数调自己":递归对汇编意味着什么
很多人在高级语言里第一次接触递归时,会觉得这东西挺巧妙的——一个函数自己调用自己,像两面镜子对着放,一眼看不到底。可一旦切换到汇编语言,递归就不再是"巧妙"那么简单了,它直接牵扯到计算机体系结构里最核心的一个机制:栈。
先说清楚一个问题:为什么递归在汇编里是个值得单独拿出来讲的话题?因为在高级语言里,编译器替你处理了函数调用的所有脏活累活——参数怎么传、局部变量放哪、返回地址存哪、调用完怎么跳回来。这些在高级语言里是隐形的,你写一个 return fib(n-1) + fib(n-2) 就完事了。但到了汇编层面,这一切都要你自己安排。而递归的特殊之处在于:同一个函数会被多次调用,每一次调用都必须有自己独立的一套"现场",否则数据就全乱套了。
什么是"独立的现场"?我用一个生活化的例子来解释。假设你是一个柜台办事员,手头有一叠单据要处理。每张单据上都写着一个电话号码,你需要打电话核实信息。当你拿起一张单据拨出电话,对方告诉你"请稍等,我再给你一个号码,你核实完这个再回来继续处理我这张"——这时候你就相当于在一个函数里调用了另一个函数。你得先把当前这张单据压在一边,记住它放在哪,然后去处理新号码。处理完新号码之后,你还得能回到刚才那张单据继续操作。
递归就是特殊情况:新号码那张单据上,写的是同一个柜台的处理流程。每张单据都对应一个"待办状态",你总是先处理最新的那张,处理完回收它,再回头处理上一张。这种"后到先处理"的顺序,天然和栈的后进先出(LIFO)结构完美对应。所以递归在汇编里的本质,就是用栈来保存每一层函数调用的完整状态。
那么每一层的"完整状态"具体包括什么?最少得有三样东西:
- 返回地址:当前这层函数执行完后,该回到哪个地址继续执行。
- 参数和局部变量:每一层调用都有自己的参数值和中间计算结果,它们不能被其他层干扰。
- 调用者保存的寄存器:如果汇编代码里用了寄存器来存临时数据,而下一层调用也会用这些寄存器,那就必须先把当前值保存好。
这三个东西合在一起,就是计算机体系结构里常说的栈帧(Stack Frame)。理解递归在汇编里的实现,核心就是理解栈帧的建立和销毁。本篇文章我们就以ARM64(AArch64)架构为例,把递归函数的汇编实现从头到尾拆干净,顺便给出x86_64的对比说明。选ARM64做主线的理由有三个:一是当前嵌入式、移动端、Mac全系都在用ARM架构,实用价值高;二是ARM64的寄存器数量多、规则清晰,非常适合教学;三是你随便拿一台树莓派或者用QEMU模拟器就能上手实践,门槛不高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的关键准备:寄存器约定与栈布局
2.1 ARM64寄存器调用约定:谁负责什么
写汇编程序,第一步不是写代码,而是搞清楚你所在平台的"函数调用规则"。这个规则在计算机行业里叫 ABI(Application Binary Interface),也就是"二进制层面的函数调用契约"。ARM64的ABI规则比较清爽,专门给函数调用划拨了一批寄存器:
- x0-x7:用于传递参数。第一个参数放x0,第二个放x1,依此类推。函数返回值也放在x0里。
- x8:间接结果寄存器,某些系统调用和返回大结构体时会用到,入门阶段可以先不深究。
- x19-x28:被调用者保存寄存器。意思是你调用的函数内部可以用这些寄存器,但返回之前必须把它们恢复原状。如果你在自己的函数里用了它们,你有责任保存和恢复。
- x29:帧指针(Frame Pointer),通常用来指向当前栈帧的底部。
- x30:链接寄存器(Link Register),专门保存函数调用的返回地址。
这些约定里最需要注意的就是 x30。在ARM64里,BL(Branch with Link)指令会做两件事:先把当前指令下一条的地址写入x30,然后跳转到目标地址去执行。也就是说,当你调用一个函数时,返回地址自动落到了x30里。被调用的函数如果需要再调用别人(或者递归调用自己),就必须先把x30里的值保存到栈上,否则一次新的BL指令就会覆盖掉上一个返回地址。
这就是为什么递归在汇编里必须依托栈:你的函数里要再一次调用自己,x30就会被覆盖。如果你不保存上一层调用的返回地址,那么当你的递归到达最深层开始"归"的时候,就根本找不到回家的路了。
2.2 栈的生长方向与栈帧布局
ARM64的栈和绝大多数架构一样,是向下生长的,也就是栈地址从高到低。SP(栈指针)永远指向当前栈的顶部——也就是最低地址处。当你需要占用栈空间时,用一条SUB SP, SP, #N把栈指针往下挪N字节;用完再ADD SP, SP, #N还回去。
一个典型的栈帧长这样:
| 偏移方向 | 内容 |
|---|---|
| SP → (低地址) | 局部变量区、临时保存区 |
| 栈帧中部 | 保存的x29、x30 |
| 高地址方向 | 调用者的栈帧 |
在ARM64里有个不成文的习惯:每个栈帧的开头(即低地址端)先保存x29和x30,这样栈就形成了一条"链表"——通过x29指向上一层的x29,就能在调试器里一眼看出完整的调用链。这也是为什么调试时你能在GDB里输入bt(backtrace)看到"main 调用了 factorial,factorial 又调用了 factorial……"的完整回溯,本质就是顺着x29链遍历栈。
很多新手学递归的汇编实现,上来就看代码,结果被那一堆STP、LDP、SUB SP搞晕了。我建议你先在纸上画一画栈布局:一个长方形代表栈空间,栈顶在下,栈底在上,SP指针从下往上移动。每一层递归调用压入一个新的栈帧,就像一个盘子摞一个盘子,最上面(最低地址)永远是最新那层。等递归到最深层开始逐层返回时,栈帧按相反顺序一个个弹出销毁。这个"摞盘子"的图景一旦建立起来,后面读汇编代码会顺畅很多。
2.3 为什么不能用寄存器一传到底
有一个很自然的疑问:递归函数每层的参数和局部变量,能不能就用普通寄存器来存?比如算阶乘,每一层调用的n值不同,我每次进来先把n放到x19寄存器里,下一层来了再覆盖,行不行?
答案是不行。原因很简单:当你在第3层递归里计算到一半,程序要跳去执行第4层调用时,第3层的数据只是"暂时不用",并没有"彻底消失"。等第4层返回后,第3层还要接着用之前那些值继续计算。如果第3层的数据被第4层直接写进了同一个寄存器,那么第4层返回之后,第3层就什么都不剩了。这就好比你工作到一半把文件放在了办公桌上,结果来了个同事,直接拿你的文件擦桌子了。
所以每一层递归的局部数据必须放在各自的栈帧里,这就是"每一层有自己的独立现场"的物理含义。理解这点之后,我们再来看具体的递归函数实现——你将会看到,整个实现的骨架无非就是"进入函数时保存现场,调用自己之前保存该保存的,返回之前恢复现场"这三件事的循环。
3. 实战案例一:阶乘的递归实现(ARM64)
3.1 从高级语言到汇编:翻译思路
阶乘是最经典的递归
