callret 是汇编里最重要的「接力棒」。没有它们,代码只能从上到下一条道走到黑。这一篇我们拆开函数调用的完整机制,并用递归计算 5 的阶乘——在汇编里亲眼看到栈是怎么一层层长高的。

这是「x86 汇编入门」系列的第 5 篇。上一篇用跳转实现了循环。这一篇通过 05_function.asm,理解 call / ret、栈帧和 x86_64 调用约定。

一、call 和 ret 做了什么?

1
2
3
call    factorial     ; ① 把「下一条指令地址」压栈  ② 跳到 factorial
...
ret ; 从栈弹出地址,跳回去

可以把它想成:call 留下回城坐标,ret 按坐标回去。栈就是存放这些坐标的地方。

二、栈帧:函数自己的「工作台」

每次进入函数,标准开场是:

1
2
3
4
5
6
7
8
factorial:
push rbp ; 保存调用者的帧指针
mov rbp, rsp ; 建立当前帧
push rbx ; 保存要用的 callee-saved 寄存器
...
pop rbx ; 恢复
pop rbp
ret

这叫栈帧(Stack Frame)rbp 指向当前帧底部,局部数据和保存的寄存器都相对它定位。

三、x86_64 调用约定(精简版)

Linux x86_64 下,整数/指针参数按顺序放:

参数位置 寄存器
第 1 个 rdi
第 2 个 rsi
第 3 个 rdx
第 4 个 rcx
第 5 个 r8
第 6 个 r9

返回值放在 rax

05_function.asm 调用 factorial(5)

1
2
3
mov     rdi, 5
call factorial ; 返回后 rax = 120
call print_number

四、递归阶乘走读

数学定义:

1
2
factorial(0) = 1
factorial(n) = n × factorial(n-1)

汇编实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
factorial:
push rbp
mov rbp, rsp
push rbx
mov rbx, rdi ; rbx 保存 n

cmp rdi, 0
jne .recurse
mov rax, 1 ; 基础情况:返回 1
jmp .done

.recurse:
dec rdi ; n - 1
call factorial ; 递归
imul rax, rbx ; n * factorial(n-1)

.done:
pop rbx
pop rbp
ret

关键点:

  • rbx 保存 n,因为递归 call 会改掉 rdi
  • 基础情况必须能停下来,否则无限递归直到栈溢出
  • 每次 call 多一层栈帧,5! 最多嵌 6 层,完全可控

五、caller-saved vs callee-saved

类型 寄存器 谁负责保存
caller-saved rax, rcx, rdx, rsi, rdi, r8r11 调用者
callee-saved rbx, rbp, r12r15 被调用函数

调用函数前,如果 caller-saved 寄存器里还有重要数据,调用者要自己 push;被调用函数若使用 callee-saved 寄存器,进入时保存、返回前恢复。

六、运行输出

1
2
make
./build/05_function
1
5! = 120

用 gdb 单步跟进去,能看到栈指针 rsp 随每次 call 下降,随每次 ret 回升——比任何图示都直观。

七、小结

本篇要点:

  • call 压入返回地址,ret 弹出并跳转
  • 栈帧用 push rbp / mov rbp, rsp 建立
  • 第一个整型参数用 rdi,返回值用 rax
  • 递归 = 同样的函数 + 更小的参数 + 可终止的基础情况

x86 汇编入门系列第 5 篇完。下一篇是系列收官——用文件系统调用读写磁盘上的文件,把前面所有技能串起来。