做递归之前,我一直觉得汇编就是一堆指令流水账,循环写得明白就够了。直到第一次需要在汇编里实现递归,发现事情完全不一样:寄存器里的参数调一次子函数就面目全非,返回地址也莫名其妙地压进栈里,程序居然还能跑对。这篇文章就围绕“汇编语言里的递归函数”展开,用x86-64和ARM两条线把栈帧、调用约定、调试和性能全部拆开讲清楚。适合刚学完寻址方式和条件跳转、想在汇编里写出真正递归程序的读者,也适合那些在高语言里写惯了递归、想看看底层发生了什么的人。
递归这个概念在高级语言里很自然:函数自己调用自己,每次调用都有自己的局部变量,互不干扰。但在汇编层面,这种“互不干扰”并不是凭空存在的,它完全依赖内存里的栈。如果你还没想明白栈和递归的关系,直接写汇编递归一定会踩坑。读完这篇文章,你可以自己用汇编写出阶乘、斐波那契甚至二叉树遍历,并且能清楚地解释每次 call 之后栈上发生了什么。
1. 递归在汇编语言里到底意味着什么
1.1 从高级语言到机器指令,递归的本质是“栈”这个动作
在 C 语言里写 fact(n-1) * n,编译器替你完成了所有细节:计算参数、保存现场、调用函数、恢复现场、取回返回值。你根本不需要关心 n 在递归调用期间被放在了哪里。但如果你打开汇编代码看一眼,会发现这段逻辑其实非常“机械化”。
一条 call 指令做了两件事:把 call 下一条指令的地址压入栈,然后跳转到目标地址。一条 ret 指令则恰好相反:从栈顶弹出一个地址,跳回那个地址继续执行。合起来看,这就是“函数调用”的全部底层机制。每一层递归,就是压入一次返回地址;每一层返回,就是弹出一个返回地址。
递归之所以成立,是因为每一次调用都会“复制”一份执行上下文。所谓上下文,就是那些在当前这层函数里还需要继续使用的数据,最主要的就是参数值和局部变量。这些数据必须被保存到内存栈里,因为下一层递归调用会复用同一组寄存器,会把上一层还没算完的中间结果全部覆盖。
这就是递归在汇编里的本质:一次对栈的操作。栈上的每个帧,代表着一次尚未返回的函数调用。递归展得越深,栈上积累的帧就越多。等基线条件满足后返回,一层层弹出帧,不断计算中间结果,最终回到最初的调用点。
1.2 调用约定:谁保存什么,谁在哪里返回
写汇编递归逃不开一件事:同一组寄存器既被调用者使用,又被被调用者使用,双方都得遵守约定。x86-64 Linux 下最常见的 System V ABI 有几个关键规则:
- 整数参数依次放在
%rdi、%rsi、%rdx、%rcx、%r8、%r9,返回值放在%rax。 %rbx、%rbp、%r12到%r15属于被调用者保存寄存器。函数如果改动它们,必须在返回前恢复原值。%rax、%rcx、%rdx、%rsi、%rdi、%r8到%r11属于调用者保存寄存器。调用者如果在 call 之后还需要这些寄存器里的数据,必须自己想办法保存。
递归中最容易出问题的就是这个“调用者保存”约定。假设你在递归里把参数 n 放在 %edi 里,然后 call 自己。子调用内部同样会使用 %edi 作为参数寄存器,等你从子调用返回时,%edi 里的内容早就不是原来的 n 了。如果后面还要用到 n,就必须在调用之前把它的值搬到某个安全的地方。
所以递归的实质在寄存器层面就是一个“记账”的过程:哪些值跨过 call 仍然需要活着,它们必须放在栈或被调用者保存寄存器里;哪些值 call 之后就用不到了,可以放心丢给子调用随便折腾。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一个阶乘:栈帧的完整生命周期
2.1 完整代码与逐行拆解
纸上谈兵没什么意思,直接上一个完整的 x86-64 汇编阶乘。使用 Linux 系统调用汇编,AT&T 语法,编译环境是 gcc。
asm复制# int fact(int n)
# 参数 n 在 %edi,返回值放在 %eax
fact:
pushq %rbp
movq %rsp, %rbp
pushq %rbx
movl %edi, %ebx
cmpl $1, %edi
jle .Lbase
leal -1(%rdi), %edi
call fact
imull %ebx, %eax
jmp .Ldone
.Lbase:
movl $1, %eax
.Ldone:
popq %rbx
popq %rbp
ret
第一眼看上去可能觉得突兀,为什么有 %rbp、%rbx 这些寄存器出现。我逐行解释。
code复制pushq %rbp
movq %rsp, %rbp
这是建立帧指针的标准动作。%rbp 保存当前栈帧的起始地址,方便调试器根据栈回溯调用链,也方便在函数内通过 %rbp 加偏移来访问局部变量。在这个简单的例子中没有局部变量需要这样访问,但保留这个惯例对调试很有帮助。
code复制pushq %rbx
movl %edi, %ebx
这行的逻辑很关键。%rbx 是被调用者保存寄存器,我必须先保存它的原值,才能放心使用。然后我把参数 n 从 %edi 挪到 %ebx,因为 %edi 属于调用者保存寄存器,下一层递归 call self 时这个寄存器一定会被覆盖。放在 %ebx 里,任何被调函数(包括递归调用的自己
