call 比 jmp 多做一件事:把下一条指令的地址压在栈上,然后才跳过去——多这一步,是为了回来的时候知道回到哪儿。
10.6 讲清了栈的规矩,这一节看它第一个大用途:记住函数的来路。
一、call 和 jmp 的差别
jmp 只是跳过去——跳完就不知道原来在哪儿了。 call 跳之前多做一个动作:把下一条指令的地址压进栈。 多存的这个东西叫返回地址——它就是【回来时该走哪一条】的凭据。 所以函数调用比起普通跳转,多的就是【记住来路】这一件事。
二、ret 是配套动作
函数体最后一条指令是 ret,它只做一件事:把栈顶那个地址弹出来,跳回去。
call 压、ret 弹——一来一回刚好一对。
这也是为什么函数体里必须有一条 ret——没有它,栈上那个地址就永远没人取。
两件事合起来,就是函数的【进】和【出】。
三、实测看到那个数字
跑一串最简单的调用:先 movl 一个值,然后 call 一个子过程,再往下走。 执行 call 的那一步,栈上会多出一个数字。 那个数字不是数据,是【回来后该走第几条】这个信息。 等 ret 执行,它被弹出来,控制权就回到了原地。 从外面看,就像什么都没发生过——这就是抽象的功劳。
四、栈存在的真正理由
函数可以一层套一层地调:A 调 B,B 调 C,C 再调 D。 每层都把自己的返回地址押在栈上——于是回来时天然是【后调用的先返回】。 栈的规矩和调用的形状,正好是同一个形状。 顺带解释了一件常见的事:递归太深会把栈压满(1.5 提过),程序崩的常见原因就是这个。
五、动手 Demo:函数调用的工作原理
call 比 jmp 多做一件事:把下一条指令的地址压在栈上,然后才跳过去。 跑一遍,看栈上那个数字是怎么来、怎么没的。
prog = [
("movl", "$1", "%eax"),
("call", "sub1"),
("movl", "$9", "%ebx"),
("label", "done"),
("movl", "$0", "%ecx"),
("label", "sub1"),
("addl", "$10", "%eax"),
("ret",),
]
vm = VM(prog)
while vm.pc < len(prog):
shown, before, after = vm.step()
print(shown, "栈=", vm.stack, "eax=", after["eax"])
if shown == "ret":
break
实跑输出:
--------------------------------------------------------------------
函数调用的工作原理
--------------------------------------------------------------------
**call 不是普通的跳转**——它比 jmp 多做了一件事:
**把下一条指令的地址压在栈上,然后才跳过去。**
为什么要多这一步?**因为回来的时候得知道回到哪儿。**
函数里最后一条 ret,就是**把那个地址弹出来,跳回去。**
指令 它在第几条 下一条跳到 栈(底→顶) eax
---------------------------------------------------------------------
movl $1, %eax 0 1 (空) 1
call sub1 1 5 [2] 1
sub1: 5 6 [2] 1
addl $10, %eax 6 7 [2] 11
ret 7 2 (空) 11
movl $9, %ebx 2 3 (空) 11
done: 3 4 (空) 11
movl $0, %ecx 4 5 (空) 11
**看清那一步:执行 call 的时候,栈上多了一个数——2。**
那就是【回来后该走第 2 条】这件事被记下来了。
**等 ret 执行,它被弹出来,控制权就回到了原地。**
这也是**栈这种结构存在的真正理由**:
函数可以一层套一层地调,**每层都把返回地址押在栈上,天然就是【后调用的先返回】。**
实测:执行 call 的那一步,栈上多了一个数——2。 那就是【回来后该走第 2 条】这件事被记下来了;ret 执行后它被弹掉,控制权回到原地。
30 秒实验
- 在纸上画出 A 调 B、B 调 C 时栈里依次压进了什么。
- 想一想:如果 call 不压返回地址,函数执行完会跑到哪儿去。
常见误区
- 以为 call 和 jmp 是一回事。call 多存了一个返回地址。
- 以为函数返回靠的是【记住调用的位置】。靠的是栈上那个地址,不是记忆。
自测三问(点开对答案)
1. call 比 jmp 多做了什么? —— 把下一条指令的地址压进栈,作为返回地址,然后才跳转。 2. ret 做了什么? —— 把栈顶的返回地址弹出来,跳到那里继续执行。 3. 为什么嵌套调用天然是后进先出? —— 因为每层调用都把返回地址押在栈上,最晚压入的最先被弹出,正好对应最后调用的最先返回。- ☐ 下一节(10.8 被调用函数的工作原理)
相关笔记
- 函数调用的原理 — 这件事在硬件上的完整样子
- 决定程序流程的程序计数器 — 被存起来的到底是什么
- 被调用函数的工作原理 — 进来之后还要做什么
- 将数据存入栈中 — 栈的规矩
- ⚙️ 程序是怎样跑起来的 · 索引