函数调用(Procedure Call),被调用函数执行完毕,程序是如何返回调用函数继续执行的?

考察一个简单的程序,main()调用func(),func()执行完毕后,返回main()继续执行:

void 
func() { ... }

int 
main() {
  ...
  func();
  ...
  return 0;
}

其中main()和func()负责函数调用相关的指令如下:

(gdb) disas main
Dump of assembler code for function main:
   ...
   0x00000616 <+36>:	call   0x57d <func>
   0x0000061b <+41>:	movl   $0x1,-0xc(%ebp)
   0x00000622 <+48>:	sub    $0x8,%esp
   ...
End of assembler dump.
(gdb) disas func
Dump of assembler code for function func:
   0x0000057d <+0>:	push   %ebp
   0x0000057e <+1>:	mov    %esp,%ebp
   0x00000580 <+3>:	push   %ebx
   0x00000581 <+4>:	sub    $0x14,%esp
   ...
   0x000005f0 <+115>:	leave  
   0x000005f1 <+116>:	ret    
End of assembler dump.

程序的执行流程如下图:

Fig.1 a simple procedure call
Fig.1 a simple procedure call

这时候产生一个问题:func()执行完毕,程序怎么知道回到caller1函数中的哪里继续执行的呢?

一个自然的想法:在即将执行callee函数之时,在caller函数当前处打上标记;待callee函数执行完成,返回标记指令继续执行,这个标记的作用类似于书签。

我们知道,CPU总是执行EIP/RIP指向的指令,EIP/RIP保存了指令的地址。所以这个标记也是通过地址来实现的,一般称为返回地址(return address)。

ra的作用就是待callee函数执行完毕,修改EIP/RIP为ra的值,而ra的值就是caller函数需要继续执行的指令地址。这个机制是通过指令callret来实现。

call Instruction

执行跳转函数的指令是call

  616:  e8 62 ff ff ff call 57d <func>

call(near)指令做了两个动作:

  1. 保存执行完func()函数后,返回main()需要继续执行的下一条指令地址,即return address(通过把ra push到stack的方式)
  2. 跳转到func()第一条指令开始执行

通过gdb验证,在执行call <func>之前:

(gdb) disas main  
Dump of assembler code for function main:  
   ...  
   0x5655560f <+29>:  movl   $0x0,-0xc(%ebp)  
=> 0x56555616 <+36>:  call   0x5655557d <func>  
   0x5655561b <+41>:  movl   $0x1,-0xc(%ebp)  
   ...

可以看出,待func()执行完成,返回main(),需要继续执行的第一条指令是:

   0x5655561b <+41>:  movl   $0x1,-0xc(%ebp)  

所以return address为0x5655561b

此时的栈上的值,还没有保存ra:

(gdb) i r esp
esp 0xffffcfb0 0xffffcfb0
(gdb) x/16x $esp
0xffffcfb0: 0x00000001 0xffffd074 0xffffd07c 0x00000000
0xffffcfc0: …

在执行call指令之后,程序跳转到func()第一条指令开始执行:

(gdb) si  
0x5655557d in func ()  
(gdb) disas func  
Dump of assembler code for function func:  
=> 0x5655557d <+0>: push   %ebp  
   0x5655557e <+1>: mov    %esp,%ebp  
   0x56555580 <+3>: push   %ebx  
   ...

此时的栈上esp的值,已经保存了ra:

(gdb) i r esp
esp 0xffffcfac 0xffffcfac
(gdb) x/8x $esp
0xffffcfac: 0x5655561b 0x00000001 0xffffd074 0xffffd07c
0xffffcfbc: …

如下图:

Fig.2 executing call instruction
Fig.2 executing call instruction

可以看出,在执行完指令:

0x56555616 <+36>: call 0x5655557d <func>

返回时执行的指令,

0x5655561b <+41>: movl $0x1,-0xc(%ebp)的地址0x5655561b被保存在栈上。

ret Instruction

此时return address已经被保存在了栈上,下面看如何通过ret指令,利用保存的ra来返回main()继续执行。

ret指令做了一件事:

  1. pop当前栈上esp指向的值(return address),到EIP/RIP

同样用gdb比较ret执行之前和之后。

在执行ret指令之前:

(gdb) disas func
Dump of assembler code for function func:
   ...
   0x565555ed <+112>: mov    -0x4(%ebp),%ebx
   0x565555f0 <+115>: leave  
=> 0x565555f1 <+116>: ret

此时eip及栈上esp保存的值:

(gdb) i r eip
eip            0x565555f1 0x565555f1 <func+116>
(gdb) i r esp
esp            0xffffcfac 0xffffcfac
(gdb) x/8x $esp
0xffffcfac: 0x5655561b  0x00000001  0xffffd074  0xffffd07c
0xffffcfbc: ...

执行ret指令之后:

(gdb) disas main
Dump of assembler code for function main:
   ...
   0x56555616 <+36>:  call   0x5655557d <func>
=> 0x5655561b <+41>:  movl   $0x1,-0xc(%ebp)
   0x56555622 <+48>:  sub    $0x8,%esp
   ...

此时eip及栈上esp保存的值:

(gdb) i r eip
eip            0x5655561b 0x5655561b <main+41>
(gdb) i r esp
esp            0xffffcfb0 0xffffcfb0
(gdb) x/8x $esp
0xffffcfb0: 0x00000001  0xffffd074  0xffffd07c  0x00000000
0xffffcfc0: ...

如下图:

Fig.3 executing ret instruction
Fig.3 executing ret instruction

可以看出,执行了ret指令后,return address被pop到eip,所以回到main()继续执行:

0x5655561b <+41>:  movl   $0x1,-0xc(%ebp)  

总结

这个idea是感觉是比较自然的,唯一需要注意的是必须保证callee函数执行完毕后,栈顶ESP/RSP一定要指向return address,而这是通过所谓function prologue/epilogue来实现的。

但是溢出攻击也同样利用了这种实现机制,将在其它系列中介绍。

  1. 调用函数称为caller函数,被调用函数称为callee函数。