高级 #assembly#recursion#stack

递归的汇编实现

函数调用自身看起来像"自我循环"——栈帧机制让它成为可能。每调用一次自己,栈上就多一层帧,返回时再从最内层逐层弹出

函数能调用自己吗?

在 C 语言里写递归很简单:int fact(int n) { return n <= 1 ? 1 : n * fact(n - 1); }

但汇编里没有”递归”这个语法——只有 CALLRET。那递归是怎么工作的?

答案就在栈里。 每次调用自己,栈上就多一层栈帧。每层帧保存了不同的参数值和局部变量——它们互不干扰。

类比:俄罗斯套娃

俄罗斯套娃一层套一层,每一层的长相都一样,但大小不同

  • 最外层最大(n = 3)
  • 往里一层小一点(n = 2)
  • 最里层最小(n = 1)
  • 打开最里层后,按原路一层层合回去

递归函数调用正是这样——每层调用有不同的参数,最深的那层返回后,结果一层层往回传。

从数学定义说起

阶乘(Factorial)是经典的递归例子:

n! = n × (n-1) × (n-2) × ... × 1

数学定义:
0! = 1
n! = n × (n-1)!   (当 n > 0)

看,n! 的定义用到了 (n-1)!——函数用到了自己。这就是递归。

int fact(int n) {
    if (n <= 1) return 1;     // 基本情况(base case)
    return n * fact(n - 1);   // 递归情况(recursive case)
}

汇编实现:fact(3)

; 计算 fact(3) = 3 × 2 × 1 = 6
; 按照 cdecl 调用约定

_main:
    PUSH #3          ; 参数 n = 3
    CALL _fact
    ADD  SP, #4      ; 清理参数
    ; R0 = 6(返回值)

_fact:
    ; 序言
    PUSH BP
    MOV  BP, SP
    ; [BP + 8] = n
    
    ; 判断基本情况:if (n <= 1) return 1
    LOAD R0, [BP + 8]    ; R0 = n
    CMP  R0, #1          ; n <= 1 ?
    JLE  _fact_base      ; 是 → 返回 1
    
    ; 递归情况:return n * fact(n - 1)
    LOAD R0, [BP + 8]    ; R0 = n
    SUB  R0, #1          ; R0 = n - 1
    PUSH R0              ; 参数:n - 1
    CALL _fact           ; 调用 fact(n - 1)
    ADD  SP, #4          ; 清理参数
    ; 此时 R0 = fact(n - 1)
    
    LOAD R1, [BP + 8]    ; R1 = n
    MUL  R0, R1          ; R0 = n * fact(n - 1)
    JMP  _fact_end       ; 跳转到尾声
    
_fact_base:
    MOV  R0, #1          ; 基本情况:返回 1
    
_fact_end:
    ; 尾声
    POP  BP
    RET

追踪执行过程:fact(3)

这是理解递归最关键的练习——让我们一步步跟踪栈的变化。

初始调用:_main 调用 _fact(3)

第 1 步:PUSH #3
栈:              ┌──────────┐
                  │  n = 3   │ ← SP
                  └──────────┘

第 2 步:CALL _fact(压入返回地址)
栈:              ┌──────────┐
                  │  n = 3   │
                  ├──────────┤
                  │  返回地址 │ ← SP
                  └──────────┘

第 3 步:进入 _fact,序言(PUSH BP, MOV BP, SP)
栈:              ┌──────────┐
                  │  n = 3   │
                  ├──────────┤
                  │  返回地址 │
                  ├──────────┤  ← BP(帧底)
                  │  旧 BP   │
                  └──────────┘  ← SP(帧顶)

第 4 步:n > 1,递归调用 _fact(2)

栈(此时有两层):
                  ┌──────────┐
                  │  n = 3   │  ← 第一层的参数
                  ├──────────┤
                  │  返回地址 │  ← 回 _main
                  ├──────────┤  ← 第一层的 BP
                  │  旧 BP   │
                  ├──────────┤  ← SP(第一层的栈顶)
                  │  n = 2   │  ← 第二层的参数(PUSH #2)
                  ├──────────┤
                  │  返回地址 │  ← 回第一层的 _fact
                  ├──────────┤  ← 第二层的 BP
                  │  第一层BP│  ← PUSH BP 保存的
                  └──────────┘  ← 第二层的 SP

第 5 步:n = 2 也大于 1,继续递归 _fact(1)

栈(三层了):
                  ┌──────────┐
                  │  n = 3   │
                  ├──────────┤
                  │  返回地址 │
                  ├──────────┤
                  │  old BP  │
                  ├──────────┤
                  │  n = 2   │
                  ├──────────┤
                  │  返回地址 │
                  ├──────────┤
                  │  old BP  │
                  ├──────────┤
                  │  n = 1   │  ← 第三层参数
                  ├──────────┤
                  │  返回地址 │  ← 回第二层
                  ├──────────┤  ← 第三层 BP
                  │  old BP  │
                  └──────────┘  ← 第三层 SP

第 6 步:n = 1,满足基本情况,返回 1

_fact(1) 执行:
    R0 = 1          ← 基本情况
    POP BP
    RET             ← 栈弹出,回到第二层

第 7 步:回到第二层(n = 2),计算 2 × fact(1)

恢复第二层上下文:
    BP 恢复为第二层的帧底
    从 [BP + 8] 读取 n = 2
    
    R0 = fact(1) = 1
    R1 = n = 2
    R0 = 1 × 2 = 2
    
    所以 fact(2) = 2
    
    POP BP
    RET             ← 回到第一层

第 8 步:回到第一层(n = 3),计算 3 × fact(2)

    从 [BP + 8] 读取 n = 3
    R0 = fact(2) = 2
    R1 = n = 3
    R0 = 2 × 3 = 6
    
    所以 fact(3) = 6
    
    POP BP
    RET             ← 回到 _main
完整调用栈变化:

_main → fact(3) → fact(2) → fact(1) → return 1 → return 2 → return 6 → _main
        ┌──────┐  ┌──────┐  ┌──────┐
        │ n=3  │  │ n=2  │  │ n=1  │
        │ ...  │  │ ...  │  │ ...  │
        └──────┘  └──────┘  └──────┘

       栈深度 1    深度 2    深度 3    深度 2    深度 1
                              ← 展开(回归)←

🔑 递归的精髓:每一层调用创建新的栈帧,参数不同但代码相同。最深的那层触发基本情况,开始逐层返回。后调用的先返回——栈的 LIFO 特性与递归天然匹配。

递归 vs 迭代

同样是计算阶乘,也可以用循环实现:

int fact_iter(int n) {
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

汇编对比

递归版本(我们已经写了):

; 递归计算 fact(3)
; 需要 3 层栈帧,多次 CALL/RET
_fact_recursive:
    PUSH BP
    MOV  BP, SP
    LOAD R0, [BP + 8]
    CMP  R0, #1
    JLE  base_case
    ; ... 递归调用 ...

迭代版本

; 迭代计算 fact(3)
; 只用有限的栈空间,一条直线执行
_fact_iter:
    MOV  R0, #1          ; result = 1
    MOV  R1, #2          ; i = 2

loop:
    CMP  R1, [BP + 8]   ; i <= n?
    JG   done
    MUL  R0, R1          ; result *= i
    ADD  R1, #1          ; i++
    JMP  loop

done:
    RET                  ; 返回 result

对比

方面递归迭代
空间🐢 O(n) 栈空间⚡ O(1) 固定空间
速度🐢 较慢(CALL/RET 开销)⚡ 较快
可读性✅ 数学定义直译有时不够直观
适用范围树、图、分治线性问题

💡 编译器有时会自动把尾递归(tail recursion)优化成迭代——这种情况下递归既保留了可读性,又有迭代的性能。

尾递归(Tail Recursion)

尾递归是指递归调用是函数的最后一步操作,且返回值直接传递,不做额外计算:

// 非尾递归:返回后还要乘以 n
int fact(int n) {
    if (n <= 1) return 1;
    return n * fact(n - 1);  // ← 还要做乘法,不是尾递归
}

// 尾递归:用累加器
int fact_tail(int n, int acc) {
    if (n <= 1) return acc;
    return fact_tail(n - 1, n * acc);  // ← 直接返回,没有额外操作
}

为什么尾递归可以优化?

在尾递归中,当前栈帧在递归调用之后不再需要了——没有额外的计算,不需要返回后继续执行。所以编译器可以:

  1. 不创建新栈帧——直接在当前帧中更新参数
  2. 跳转到函数开头——而不是 CALL
  3. 效果 = 迭代,写法 = 递归
; 尾递归优化后的汇编(编译器生成)
; 不再有嵌套的 CALL,变成了循环!

_fact_tail:
    ; 参数:R0 = n, R1 = acc

loop:
    CMP  R0, #1       ; n <= 1 ?
    JLE  done          ; 是 → 返回 acc
    MUL  R1, R0        ; acc = n * acc
    SUB  R0, #1        ; n = n - 1
    JMP  loop          ; ← 直接跳回,不是 CALL!

done:
    MOV  R0, R1        ; 返回值 = acc
    RET

尾递归优化(TCO,Tail Call Optimization) 让递归的代码具有迭代的性能。写递归时尽量写成尾递归的形式——好编译器会帮你优化它。

有尾递归和没尾递归的区别

非尾递归 fact(5) 的栈:                   尾递归 fact_tail(5, 1) 的栈:
┌──────────┐    (深度 5)                 ┌──────────┐    (深度 1)
│ n=5      │                               │ n=5, acc │
│ ret→main │                               │ → main   │
├──────────┤                               ├──────────┤
│ n=4      │                               │ 只复用这一层 │
│ ret→fact │                               │          │
├──────────┤                               └──────────┘
│ n=3      │
│ ...      │    ← 5 层栈帧      
└──────────┘

另一个经典递归:斐波那契数列

int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

汇编实现:

_fib:
    ; 序言
    PUSH BP
    MOV  BP, SP
    ; [BP + 8] = n
    
    LOAD R0, [BP + 8]    ; R0 = n
    CMP  R0, #1          ; n <= 1 ?
    JLE  _fib_base       ; 是 → 返回 n
    
    ; fib(n - 1)
    LOAD R0, [BP + 8]
    SUB  R0, #1          ; R0 = n - 1
    PUSH R0
    CALL _fib
    ADD  SP, #4          ; 清理参数
    ; 此时 R0 = fib(n - 1)
    ; ⚠️ 马上要调用 fib(n - 2),R0 会被覆盖!
    PUSH R0              ; 保存 fib(n - 1) 到栈上
    
    ; fib(n - 2)
    LOAD R0, [BP + 8]
    SUB  R0, #2          ; R0 = n - 2
    PUSH R0
    CALL _fib
    ADD  SP, #4          ; 清理参数
    ; 此时 R0 = fib(n - 2)
    
    POP R1               ; R1 = fib(n - 1)(之前保存的)
    ADD R0, R1           ; R0 = fib(n - 1) + fib(n - 2)
    JMP _fib_end

_fib_base:
    LOAD R0, [BP + 8]    ; 返回 n 本身

_fib_end:
    POP BP
    RET

⚠️ fib 的效率问题fib(40) 的递归调用次数是天文数字(约 3.3 亿次)。这是因为没有记忆化——fib(3) 被重复计算了无数次。这就是为什么纯递归的 fib 在实践中基本不用,需要用动态规划或记忆化来优化。

fib(5) 的调用树:

                fib(5)
               /      \
          fib(4)      fib(3)
         /      \     /     \
     fib(3)   fib(2) fib(2) fib(1)
    /    \    /   \   /   \
 fib(2) fib(1) ... ...  ...
 /   \
...  ...

可以看到 fib(3) 被重复计算了 2 次,fib(2) 被重复计算了 3 次!
这个递归的时间复杂度是 O(2ⁿ),n=100 时宇宙毁灭都算不完。

递归的常见错误

错误 1:缺少基本情况(无限递归)

_fact_bad:
    PUSH BP
    MOV  BP, SP
    LOAD R0, [BP + 8]
    ; ❌ 没有基本情况检查!
    SUB  R0, #1
    PUSH R0
    CALL _fact_bad    ; 永远递归下去,直到栈溢出
    ADD  SP, #4
    ; ...

效果:程序运行时,栈不断增长,最终超出内存限制——栈溢出(Stack Overflow)

错误 2:栈溢出(Stack Overflow)

递归深度太深时,栈会耗尽:

; 计算 fact(10000) → 递归深度 10000 层
; 每层栈帧至少 8-16 字节
; 总栈空间 ≈ 10000 × 16 = 160 KB
; 如果不巧栈上限只有 128 KB……

错误 3:寄存器冲突(最常见!)

_fib_bug:
    ; ...
    PUSH R0          ; 保存 n?不,这是 R0,调用者保存的
    CALL _fib
    ; R0 现在存了返回值
    POP R1           ; 想恢复之前的值? ❌ 栈顶现在是别的!
    ; ...

正确做法:在递归调用之前把需要保留的值 PUSH 到栈上,调用完成后 POP 回来。

错误 4:尾递归但调用者忘了传累加器

// 这是尾递归的正确调用方式:
// 调用者需要提供初始的累加器值
int result = fact_tail(5, 1);  // ✅ acc 从 1 开始

// 如果调用者不传或传错:
int result = fact_tail(5, 0);  // ❌ 0 × 任何数 = 0,永远返回 0!

观察:编译器生成的递归代码

写一段 C 代码,看看编译器会生成什么样的汇编:

int fact(int n) {
    if (n <= 1) return 1;
    return n * fact(n - 1);
}

编译器优化级别 -O0(无优化)生成的代码会和你手写的非常像——完整的序言/尾声,标准的栈帧操作。但加上 -O2(优化)后,编译器可能:

  1. 检测到这是尾递归 → 转换为迭代
  2. 或者直接内联展开小规模的递归 → 完全没有 CALL 指令
; -O0 版本(手写风格):              ; -O2 版本(优化后):
_fact:                               _fact:
    PUSH BP                               MOV  R1, #1
    MOV  BP, SP                      loop:
    LOAD R0, [BP + 8]                     CMP  R0, #1
    CMP  R0, #1                           JLE  done
    JLE  base                              MUL  R1, R0
    ; ...递归调用...                        SUB  R0, #1
base:                                      JMP  loop
    MOV  R0, #1                      done:
    POP  BP                               MOV  R0, R1
    RET                                   RET

💡 这就是为什么现代 C 程序员可以写递归而不用担心性能——只要编译器足够聪明,递归代码在优化后和执行迭代几乎一样快(前提是尾递归)。

小结

递归和栈是一枚硬币的两面——没有栈机制,递归就不可能实现

概念要点
递归的本质函数调用自身,每层调用有独立的栈帧
基本情况必须有一个终止条件,否则无限递归
栈的使用每层递归 = 一层新栈帧,返回时逐层弹出
栈溢出递归太深或没有基本情况
尾递归递归调用是最后一步,可被优化为迭代
性能考量非尾递归通常比迭代慢,但代码更接近数学定义

递归 vs 迭代的取舍:

  • 递归写起来更接近问题的数学定义(尤其是树、图、分治算法)
  • 迭代通常更快、更省内存
  • 尾递归 + 好编译器 = 两者兼得

为什么这很重要? 递归是许多高级算法的基础——树的遍历、快速排序、图的深度优先搜索、回溯算法……理解递归在底层的栈实现,让你既能在高级语言中自如地写递归,又能理解什么时候该换成迭代来避免栈溢出。

接下来,你将进入汇编语言的进阶应用——如何把汇编和 C 语言混合编程,发挥两种语言各自的优势(此内容将在后续章节详细讲解)。