递归是程序员绕不开的话题。它优雅、简洁,但也常常伴随着性能与内存问题。尤其在处理大规模数据时,普通递归极易导致栈溢出(Stack Overflow)。而“尾递归”则是递归优化中的一把利器:在很多语言和编译器支持下,它能让递归和循环一样高效。
本文将逐步讲解尾递归的高效之处,从调用栈到汇编底层,带大家理解为什么它能“以递归之名,行循环之实”。
一、尾递归的定义
尾递归(Tail Recursion) 指的是递归函数在返回前的最后一步调用自身,并且不再有额外操作。
例子:
- 非尾递归(有额外乘法):
int fact(int n) {
if (n == 0) return 1;
return n * fact(n - 1); // 还要做乘法,不是尾递归
}
- 尾递归(直接返回递归调用结果):
int fact_tail(int n, int acc) {
if (n == 0) return acc;
return fact_tail(n - 1, n * acc); // 尾递归
}
二、普通递归的调用栈
调用 fact(3)
时,调用栈是这样逐层增长的:
┌───────────────┐
│ fact(0) │ ← 栈顶,返回 1
└───────────────┘
┌───────────────┐
│ fact(1) │ return 1 * fact(0)
└───────────────┘
┌───────────────┐
│ fact(2) │ return 2 * fact(1)
└───────────────┘
┌───────────────┐
│ fact(3) │ return 3 * fact(2)
└───────────────┘
执行完 fact(0)
之后,还需要一层层回溯,把结果乘上去。
👉 栈深度 = n,当 n 很大时容易溢出。
三、尾递归的调用栈
调用 fact_tail(3, 1)
时,编译器会优化为覆盖参数,复用栈帧:
fact_tail(3,1) → fact_tail(2,3) → fact_tail(1,6) → fact_tail(0,6)
整个过程中,调用栈始终只有 一层:
┌────────────────────┐
│ fact_tail(n, acc) │ ← 栈顶始终只有 1 层
└────────────────────┘
👉 无论递归多少次,栈不会变深,相当于循环。
四、底层原理:call vs jmp
准备test.c(尾递归版本)
// test.c
#include <stdio.h>
int fact_tail(int n, int acc) {
if (n == 0) return acc;
return fact_tail(n - 1, n * acc);
}
int main() {
printf("%d\n", fact_tail(3, 1));
return 0;
}
先看看未优化的版本:
0000000000001149 <fact_tail>:
1149: f3 0f 1e fa endbr64
; CET 入口,与逻辑无关
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 83 ec 10 sub $0x10,%rsp
; 标准prologue:建立帧指针 rbp,预留 16 字节栈空间给本地变量
; -O0 下 GCC/Clang 一般强制保留帧指针,便于调试
1155: 89 7d fc mov %edi,-0x4(%rbp)
1158: 89 75 f8 mov %esi,-0x8(%rbp)
; 把参数 n/acc 溢出到栈上的局部变量(-4、-8 偏移)
115b: 83 7d fc 00 cmpl $0x0,-0x4(%rbp)
115f: 75 05 jne 1166 <fact_tail+0x1d>
; if (n != 0) 跳到递归路径
1161: 8b 45 f8 mov -0x8(%rbp),%eax
1164: eb 16 jmp 117c <fact_tail+0x33>
; 出口:EAX = acc;跳到epilogue返回
1166: 8b 45 fc mov -0x4(%rbp),%eax
1169: 0f af 45 f8 imul -0x8(%rbp),%eax
; eax = n * acc 【把新的累积值先放 EAX】
116d: 8b 55 fc mov -0x4(%rbp),%edx
1170: 83 ea 01 sub $0x1,%edx
; edx = n - 1 【准备下一次的 n】
1173: 89 c6 mov %eax,%esi
1175: 89 d7 mov %edx,%edi
; 把 (n-1, n*acc) 放回参数寄存器 (EDI, ESI)
1177: e8 cd ff ff ff call 1149 <fact_tail>
; 递归调用(是真 call,不是 jmp) —— 没有做尾调用消除
117c: c9 leave
117d: c3 ret
; 标准尾声:恢复栈帧并返回
由于GCC的O1优化比较保守,需要开到O2才能看到尾递归优化的效果,所以,我们就使用O2优化来查看编译器是如何对尾递归进行优化的。
O2优化后的尾递归汇编代码:
0000000000001180 <fact_tail>:
1180: f3 0f 1e fa endbr64
; CET 入口(控制流强化),与逻辑无关
1184: 89 f0 mov %esi,%eax
; eax = acc 【先把累积器放进返回寄存器】
1186: 85 ff test %edi,%edi
1188: 74 0e je 1198 <fact_tail+0x18>
; if (n == 0) 直接返回;否则进入循环
118a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
; 6 字节 NOP(对齐/填充),常用于让下面的热循环入口落在更好的边界上,
; 有利于指令缓存/解码器对齐(微优化)
1190: 0f af c7 imul %edi,%eax
; eax = eax * n 【acc *= n】
1193: 83 ef 01 sub $0x1,%edi
; n-- 【等价于归纳变量递减】
1196: 75 f8 jne 1190 <fact_tail+0x10>
; if (n != 0) 跳回 1190,形成一个紧凑的 while 循环
1198: c3 ret
; 返回 eax(也就是最终的 acc)
由此可见,优化后的尾递归:
-
没有 call,只有 jne 回跳——尾递归已被彻底循环化。
-
没有栈调整(看不到 sub/add $imm, %rsp):O2 下不需要为对齐而额外开临时栈槽。
-
插入了一个 多字节 NOP(nopw)来对齐热路径,有助于取指/解码性能(这是 O2/O3 常见的“无害指令布局”优化)。
五、为什么尾递归更高效?
- 避免栈增长:尾递归不新增栈帧,避免了内存溢出。
- 减少函数调用开销:少了保存/恢复寄存器和返回地址的负担。
- 等价循环:编译器把尾递归转成
while
循环,性能相当。
六、小结
-
普通递归:一层层压栈 → 回溯计算。
-
尾递归:参数覆盖 → 跳转执行,相当于循环。
-
本质区别:
- 普通递归用的是 call/ret(函数调用指令)。
- 尾递归优化后用的是 jmp(跳转),栈帧复用。
一句话总结:
尾递归之所以高效,是因为编译器能把“再调用自己”优化为“在同一个栈帧里循环跳转”,从而避免栈增长,性能接近循环。
✍️ 这就是尾递归的完整故事:从调用栈的堆叠,到编译器如何把 call
变成 jmp
。