做递归之前,我一直觉得汇编就是一堆指令流水账,循环写得明白就够了。直到第一次需要在汇编里实现递归,发现事情完全不一样:寄存器里的参数调一次子函数就面目全非,返回地址也莫名其妙地压进栈里,程序居然还能跑对。这篇文章就围绕“汇编语言里的递归函数”展开,用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 里,任何被调函数(包括递归调用的自己)都有义务替我保留,所以我可以在递归返回后继续使用它。
2.2 栈的镜像:每次递归调用到底发生了什么
假设调用 fact(3),入口时 %edi = 3。
第一次进入 fact,栈上压入返回地址、原 %rbp、原 %rbx,然后 %ebx = 3。基线判断发现 3 > 1,于是 %edi = 2,执行 call fact。此时 CPU 会把返回地址压栈,跳到函数开头继续执行。
第二次进入 fact,同样压入 %rbp、%rbx,%ebx = 2,%edi = 1,再次执行 call fact,进入第三层。
第三层进入 fact,判断 1 <= 1,进入 .Lbase,%eax = 1,然后恢复 %rbx、%rbp,ret 弹出返回地址,跳回第二层的 imull 指令。
第二层此时 %eax = 1,而自己的 %ebx 还稳稳地保存着 2,执行 imull %ebx, %eax 得到 2,恢复现场,ret 回到第一层。
第一层拿到 %eax = 2,自己的 %ebx 保存着 3,相乘得到 6,恢复现场,ret 回到最初的调用点。
整个过程在内存栈上的表现,就是一层层往低地址方向增长,每层帧大概占 32 字节左右。栈的增长方向是向下的,也就是 %rsp 的值在每次 push 时变小。当递归返回时,%rsp 又一点点恢复原状。只要递归深度可控,栈空间完全够用。
这里还要注意一个细节:call fact 之后我没有额外保存任何东西,为什么不会乱?因为 %ebx 是被调用者保存寄存器,调用约定保证了任何函数在返回时 %ebx 的值和进入函数时一致。靠的就是每个人写的代码遵守这个约定,或者编译器生成代码时遵守这个约定。这是整个递归能够正确工作的地下契约。
2.3 基线与递推:递归的两个分支怎么落成指令
任何递归函数都可以分成两个部分:基线条件和递推公式。汇编里的实现也是围绕这两个分支展开的。
基线条件是递归的出口,通常是最简单的可立即返回的情况。阶乘中 n <= 1 返回 1,这条路径没有递归调用,直接给 %eax 赋值然后返回。在汇编里它往往放在一个靠前的判断分支中。
递推公式则是递归的入口。阶乘中需要把问题规模缩小,先把 n-1 放进参数寄存器,然后 call 自己,拿到子问题的结果后再乘以原来的 n。
一个容易忽略的问题:jle .Lbase 才是保证递归能终止的护栏。如果这段代码漏掉基线判断,会发生什么?无限递归,栈上的帧一层层叠下去,直到栈空间耗尽,进程被系统杀死,表现形式就是段错误。这个问题我在后面单独聊。
3. 递归模式是相通的:斐波那契与二叉树遍历
阶乘是单路递归,每个栈帧只发起一次递归调用,逻辑最简单。实际工程里更多遇到的是多路递归和结构递归。学会了阶乘,剩下两种也不难,但有一些新东西要处理。
3.1 斐波那契:同一个函数多次分支,临时值的保存套路
斐波那契数列的递归定义是 fib(n) = fib(n-1) + fib(n-2),基线条件是 n <= 2 时返回 1。这比阶乘复杂的地方在于:一次递归展开会分叉成两个子调用,第一个子调用的返回值必须跨越第二个子调用继续存活。
直接看代码:
asm复制# int fib(int n)
# 参数 n 在 %edi,返回值放在 %eax
fib:
pushq %rbp
movq %rsp, %rbp
pushq %rbx
pushq %r12
movl %edi, %ebx
cmpl $2, %edi
jle .Lbase
leal -1(%rdi), %edi
call fib
movl %eax, %r12d
leal -2(%rbx), %edi
call fib
addl %r12d, %eax
jmp .Ldone
.Lbase:
movl $1, %eax
.Ldone:
popq %r12
popq %rbx
popq %rbp
ret
和阶乘版本对比,多出来的核心是 %r12。第一次 call fib 返回后,我没有当场把 %eax 相加,因为还要计算 fib(n-2)。如果我把第一个子调用的结果留在 %eax 里,第二次 call fib 会直接覆盖它。所以我把 fib(n-1) 的结果保存到 %r12d 中,它是一个被调用者保存寄存器,第二个子调用有义务帮我看住它,返回后还能继续使用。
这里有一个讲理论的点:为什么用 %r12 而不用栈保存?因为访问寄存器比访问内存快得多,只要能放下,寄存器是首选。只有在临时值太多、寄存器不够用的情况下,才会把数据压到栈里。如果你需要保存十几个中间值,那就是另一番光景了,不过一般递归里没这么夸张。
多路递归在指令层面其实只是多了一次调用,难点完全在于“跨调用幸存的数据往哪里放”这个规划问题。每次写之前先在草稿纸上标清楚:哪些值在 call 之后还需要用,然后决定它们的栖身之所。
3.2 二叉树遍历:指针参数与结构体字段的取用
树形结构是递归最典型的应用场景之一,因为树的定义本身就是递归的:一个节点包含左右两棵子树。先用 C 语言定义节点结构:
c复制struct node {
int val;
struct node *left;
struct node *right;
};
目标:写一个 sum_tree,计算整棵树所有节点的和。空节点返回 0,非空节点返回“当前值加左子树和加右子树和”。
x86-64 汇编实现:
asm复制# int sum_tree(struct node *root)
# 参数在 %rdi,返回值放在 %eax
sum_tree:
testq %rdi, %rdi
je .Lzero
pushq %rbp
movq %rsp, %rbp
pushq %rbx
pushq %r12
movq %rdi, %rbx
movl (%rbx), %r12d
movq 8(%rbx), %rdi
call sum_tree
addl %eax, %r12d
movq 16(%rbx), %rdi
call sum_tree
addl %eax, %r12d
movl %r12d, %eax
jmp .Ldone
.Lzero:
xorl %eax, %eax
ret
.Ldone:
popq %r12
popq %rbx
popq %rbp
ret
重点看几个地方。
testq %rdi, %rdi 是判断指针是否为空的常用指令。test 做按位与但不保存结果,只影响标志位,所以立刻可以跟 je 判断零。空节点返回 0,这是树的递归出口。
movq %rdi, %rbx 和阶乘里的 movl %edi, %ebx 作用一致:把参数搬到被调用者保存寄存器。但这次是指针,所以是完整的 64 位。
movl (%rbx), %r12d 是把 root->val 读出来,(%rbx) 是取 %rbx 指向的内存地址处的数据。左子树指针在偏移 8 字节处,右子树指针在偏移 16 字节处,对应结构体的内存布局。
第一次 call sum_tree 返回后,左子树的和保存在 %eax 中,我把它累加到 %r12d 里。然后第二次 call sum_tree 计算右子树,返回后同样累加。最后把 %r12d 移到 %eax 作为返回值。
整个树的递归过程,无论树长什么样,最终都归结为每个节点做同样的事:存当前值,递归左子,递归右子,相加返回。栈的增长只在树的深度方向发生,宽度大的树不会同时占用太多栈空间。
3.3 递归形态对照
我把常见递归形态整理成一张对照表,方便你在设计时快速定位:
| 形态 | 典型场景 | 汇编实现特点 | 跨调用保存需求 |
|---|---|---|---|
| 单路递归 | 阶乘、求和 | 一条 call 链,返回值直接计算 | 只需保存原始参数 |
| 多路递归 | 斐波那契、回溯搜索 | 多次 call,结果需要合并 | 除原始参数外,还需保存首次结果 |
| 结构递归 | 二叉树遍历、链表反转 | 递归发生在数据结构自身结构上 | 需要保存当前节点指针和累计值 |
| 尾递归 | 累加器式求和 | 递归调用是最后一条指令 | 无需保存任何额外值 |
这张表的价值在于:每当你写一个新的递归,先判断它是哪一类,然后立刻能确定栈帧里需要压入哪些寄存器。尾递归是列表里最特殊的一个,它在汇编层面甚至可以不增长栈,这是下一章在 ARM 环境里重点展开的话题。
4. ARM 架构下的递归:不一样但更接近实战的选择
x86-64 的调用约定是“参数寄存器 + 栈压返回地址”,ARM 里实现递归的思路有明显不同。两种架构都值得掌握,因为嵌入式、移动端、以及最近很火的各类开发板上,ARM 反而更常见。
4.1 寄存器参数传递与栈帧的建立方式
以 AArch64(ARM 64 位)为例。函数调用时前 8 个整数参数依次放在 x0 到 x7 寄存器,32 位模式则使用对应的 w0 到 w7。返回值放在 x0。这比 x86-64 的参数寄存器多了两个,对于参数较多的递归,ARM 的寄存器压力稍微小一点。
最核心的差异在返回地址的处理上。x86-64 的 call 指令会自动将返回地址压栈,而 ARM 里调用指令 bl(branch with link)会把返回地址写到单独的链接寄存器 x30(即 LR,Link Register)里,并不动栈。这个设计的直接结果是:ARM 的递归函数在调用别人之前,必须先把 x30 保存起来,否则再执行一次 bl,原来的返回地址就被覆盖了。
一个典型的 ARM 阶乘函数长这样:
asm复制# int fact(int n)
# n 在 w0,返回值放在 w0
fact:
stp x29, x30, [sp, #-16]!
mov x29, sp
str x19, [sp, #-16]!
mov w19, w0
cmp w0, #1
b.le .Lbase
sub w0, w0, #1
bl fact
mul w0, w19, w0
b .Ldone
.Lbase:
mov w0, #1
.Ldone:
ldr x19, [sp], #16
ldp x29, x30, [sp], #16
ret
stp x29, x30, [sp, #-16]! 是 ARM 里很常见的开场白:一次把帧指针 x29 和返回地址 x30 压入栈,栈指针先减 16 再存,那个感叹号表示“先更新地址再操作”。ldp 是它的逆操作。这种一次性压两个寄存器的指令,在 x86-64 里没有完全对应的东西,需要用两条 push 指令实现同样效果。
函数内部保存 x19 是因为接下来要使用这个被调用者保存寄存器来保存 n。在递归调用 bl fact 之前,w0 里的 n-1 已经备好了,调用返回时 w0 是子调用的结果,w19 仍然是原始 n,二者相乘就是本层返回值。
4.2 叶子调用与尾递归:两种手写优化的常见思路
ARM 里有两个递归相关的优化思路特别值得单独讲。
第一个是叶子调用优化。如果一个函数内部不再调用任何其他函数,它就不需要修改链接寄存器,因此可以省掉保存和恢复 x30 的开销。在递归函数里,基线分支恰恰就是这样的叶子调用:它不再递归,直接计算返回值。手写汇编时可以对基线分支做特殊处理,省去 stp x29, x30 那两条指令,函数体积更小,执行也更快。
第二个是尾递归优化。尾递归的递归调用是整个函数最后一条需要执行的指令,调用完直接返回结果,不再做任何额外计算。这时候根本没有必要保存任何现场,因为本层的“现场”已经没用了。优化方式是直接把参数准备好,然后跳回函数开头,而不是用 call 调用。
用尾递归实现阶乘,经典写法是引入一个累加器参数:
asm复制# int fact_tail(int n, int acc)
# 入口:w0 = n, w1 = acc
# 调用方式:fact_tail(n, 1)
fact_tail:
cmp w0, #1
b.le .Ldone
mul w1, w1, w0
sub w0, w0, #1
b fact_tail
.Ldone:
mov w0, w1
ret
注意最后一步用的是 b fact_tail,而不是 bl fact_tail。b 是无条件跳转,不修改 x30,不压栈,不增长栈帧。从效果上看,这段代码和 while 循环几乎没有区别。编译器在处理尾递归时也做同样的事,这也是为什么你经常听说“尾递归效率高”的真正原因:它压根不产生新的栈帧,只是在原地改写参数寄存器然后回头重新执行函数体。
顺带一提,在 x86-64 里尾递归也可以这样优化,用 jmp 代替 call 即可。原理相通,只是 x86 里返回地址本来就是压栈的,尾递归调用直接 jmp 等于根本不在栈上产生新返回地址。
4.3 手写优化的边界:什么时候该交给编译器
看到这里你可能会想,既然尾递归这么香,是不是所有递归都应该改成尾递归形式?这个观念要打个折。尾递归的前提是问题可以改写成累加器风格,但有相当多递归场景做不到这一点。斐波那契需要合并两个结果,二叉树遍历需要把左右子树的和加在一起,这些都不是“最后一条指令直接返回递归调用结果”的形态,硬改成尾递归反而可读性很低。
另外一点,现代编译器对尾递归优化的识别已经很成熟了。如果业务代码不是对你的性能瓶颈所在,不值得为手工尾递归付出额外的心智成本。更好的做法是:直接用高级语言写清晰的递归,然后用 -O2 编译,再反汇编看编译器是否做了尾调用优化。把编译器生成的结果当作标准答案来学习,比自己闭门造车效率高得多。
5. 调试与排坑:递归程序最容易挂掉的几个瞬间
5.1 栈溢出的定界与观测
递归程序崩溃的原因排第一的就是栈溢出。现象很统一:程序跑着跑着,突然一个段错误,gdb 里 backtrace 显示几百上千层相同的函数帧。
原因在于每次递归调用都会在栈上开一个新帧,而栈的大小是有限的。Linux 下默认栈大小通常是 8MB,你可以用 ulimit -s 查看。如果每层帧占用 32 字节,8MB 栈大约足够 26 万个调用。看着不少,但如果你在调试时不带优化编译的斐波那契,每层消耗甚至可能超过 64 字节,深度一上来很快就爆。
定位办法很简单:gdb 直接 run,崩溃后用 bt 看调用栈,栈帧最上层的数量就能判断是不是递归深度过大。也可以用 info proc mappings 查进程的栈映射范围,对比 $rsp 是否被推到了栈底部附近。
真正棘手的是那种基线分支写错导致的无限递归:跑起来不立即崩溃,但会持续消耗内存和 CPU。这种程序在 gdb 里单步几下就会发现 call 的地址一直重复,函数参数不见收敛,那基本就可以断定是基线条件的问题。
5.2 寄存器被覆盖:递归里最隐蔽的错
如果说栈溢出是显性的坑,寄存器覆盖就是隐性的坑。这类 bug 不报错、不崩溃,只是结果悄悄变错。最常见的写法规格如下:
asm复制# 错误示例:保存 n 到 %esi,然后递归
myfunc:
movl %edi, %esi
call myfunc
addl %esi, %eax
ret
看起来没毛病:把 n 保存到 %esi,递归返回后相加。但 %esi 是调用者保存寄存器,函数 myfunc 在被调用时完全可以自由使用 %esi 做临时运算。递归调用返回后,%esi 里的值必须假设已经被破坏了。
解决办法有两个方向。一是把临时值放到被调用者保存寄存器,比如 %ebx、%r12d,但前提是该函数要负责保存它们原来的值,也就是在入口 push、在出口 pop。二是直接把值压栈,用栈当作后备存储。前者更快,后者更直白,工程上通常优先用前者。
写完递归代码后,养成一个检查习惯:找代码里的所有 call 指令,逐一确认在跨调用后还需要哪些寄存器保持原值,然后对照调用约定,看它们是否被安排在了合适的位置。这个习惯能筛掉大量头疼的 bug。
5.3 用 gdb 把栈帧一层层撕开
调试汇编递归,gdb 是最好的工具,关键是知道看什么。建议在递归函数入口打断点,然后用条件断点跳过前面的层数,直接定位到某一层。比如阶乘函数入口在 fact,想在第 3 层调用时停下来:
code复制(gdb) break fact if $edi == 3
在 x86-64 下 $edi 对应参数寄存器,ARM 下则是 $w0。
进入断点后,bt 可以列出当前所有栈帧。每帧对应一次尚未返回的调用,这是理解递归最好的方式。frame 2 切换到指定层,info registers rbp rsp rip 查看当时的帧指针、栈指针和指令位置。还可以 x/16gx $rsp 直接查看栈内存,你会亲眼看到一层一层的返回地址和保存的寄存器值。
调试时有个技巧:如果只关心本层逻辑,不想一路 step 进递归子调用,用 next 跨过 call 指令比 step 更合适。想直接返回到上一层,可以用 finish,执行到当前函数返回并打印返回值。想手动提前结束一层递归,可以直接修改寄存器或内存里的值,然后继续,这在验证算法逻辑时有奇效。
5.4 编译器优化带来的“迷惑行为”
一个容易让人骂街的场景:用 gcc -O2 编译递归程序,然后 gdb 单步跟踪,发现代码跳来跳去,根本看不到递归结构,有时候甚至整个函数都被内联了。这不是你的汇编写错了,而是编译器做了激进优化。
学汇编递归时,建议先用 -O0 编译,这样得到的代码和源程序结构一一对应,方便理解。等逻辑完全跑通后,再看 -O2 产生的汇编,把它当作免费的优化教程。差别最大的通常就是尾递归:-O0 下老老实实 call 自己,-O2 下直接跳回函数开头,栈帧一点不增长。
6. 一个老汇编人的观点:递归的性能真相与使用界限
6.1 递归的真实开销拆解
每次递归调用的固定成本大致由三部分组成:控制流开销、寄存器保存开销、栈读写开销。call/ret 本身就是两条指令,配合 push/pop 保存寄存器,一进一出大概 6 到 10 条指令。对于现代 CPU 的分支预测器来说,这种固定模式通常预测得很准,成本不高。
真正的差异来自栈的读写。每一层递归至少两次写栈(保存返回地址和寄存器),返回时再读栈。虽然现代 CPU 有缓存,但 cache miss 时的代价仍然可观。递归深度越大,访问跨度越大,栈深处越可能被逐出缓存,性能下降就越明显。
用阶乘做基准测试时,递归版本和循环版本在 n 比较小的情况下差距很小,但 n 到几十万时,递归版本的栈压力会带来可感知的延迟增长。循环只维护一个变量,递归则每次都要访问内存栈。这个结论不绝对,取决于具体代码,但方向可以参考:递归在可读性上有巨大优势,性能上通常弱于迭代,但弱多少要看具体实现。
6.2 值得用递归的场景与不值得用递归的场景
我的判断标准很简单:数据结构本身的定义是否是递归的。如果答案是肯定的,用递归几乎是必然的。树的遍历、表达式求值、JSON 解析、目录递归扫描,这些数据结构天然按递归方式展开,用迭代反而需要手写栈,容易出错,维护成本也高。此时递归的性能劣势可以接受,因为栈的深度通常是树高或数据层级数,不会大到失控。
反之,一些本质上可以归纳为线性推进的问题,就不值得递归。最简单的累加求和,改用循环只需要一条跳到开头的指令。斐波那契这种大量重叠子问题的递归,性能是指数级的,改成动态规划或循环后有质的飞跃。这些场景里执着于手写递归,甚至手写尾递归,属于用牛刀杀鸡。
6.3 如果你想练好汇编递归,照着这个顺序来
写到这里,我想给出一条比较靠谱的练习路径,这条路径我走过,也带过不少人走过。
第一步,找一个简单的 C 递归函数,用 gcc -S -O0 生成汇编,对照源码逐行理解。不用从零手写,先读懂编译器的回答。
第二步,自己手写阶乘、斐波那契、二叉树遍历,推荐在 x86-64 和 ARM 上各写一遍。重点不只在跑通,而是要能讲清楚每次 call 前后哪些寄存器是活的、为什么。
第三步,用 gdb 观察栈帧。打断点后 bt 数帧数,切到中间某一帧看寄存器内容,再用 x/16gx $rsp 亲自看一眼返回地址在栈上的排列。
第四步,写一个尾递归版本的阶乘,用 -O2 编译,反汇编观察编译器是怎么把递归优化成循环的。
做完这四步,你对汇编递归的掌控就不是背代码,而是真正理解了它背后的机制。以后再看到“递归”这两个字,脑子里浮现的不再是抽象的“自己调用自己”,而是栈上的一层层帧、寄存器里的一句句约定。
写递归这么多年,我的体会是:汇编里的递归并不难,难的是建立起“跨调用数据存活”的思维习惯。你不需要记住每条指令的每一个细节,但必须时刻清楚一个问题——调用返回后,哪些数据还必须活着,它们又在谁的管辖范围内。想明白这一点,递归在汇编里就再也不是什么神秘的东西了。
