⚙️ 第十章 通过汇编语言认识程序的真面目
📄 一句话概括

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 秒实验

  1. 在纸上画出 A 调 B、B 调 C 时栈里依次压进了什么。
  2. 想一想:如果 call 不压返回地址,函数执行完会跑到哪儿去。

常见误区

  1. 以为 call 和 jmp 是一回事。call 多存了一个返回地址。
  2. 以为函数返回靠的是【记住调用的位置】。靠的是栈上那个地址,不是记忆。
自测三问(点开对答案) 1. call 比 jmp 多做了什么? —— 把下一条指令的地址压进栈,作为返回地址,然后才跳转。 2. ret 做了什么? —— 把栈顶的返回地址弹出来,跳到那里继续执行。 3. 为什么嵌套调用天然是后进先出? —— 因为每层调用都把返回地址押在栈上,最晚压入的最先被弹出,正好对应最后调用的最先返回。

相关笔记