数字营销 · Web开发 · 基础设施

深入了解函数调用与栈

从 IA32 体系结构出发,结合汇编和 C 语言分析函数调用过程,进一步理解寄存器、栈帧、返回地址以及函数调用过程中栈空间的变化。

有人把程序员比作魔术师,几行简单的代码就能指示电脑完成各种各样的操作。作为一个合格的魔术师,只有一步一步掌握魔法背后的底层运行原理,才能不断创新,创造出更新、更高效的“魔法”。

在计算机科学中,函数是一个非常重要的概念,而与函数调用密切相关的就是栈。栈如此重要,以至于 CPU 在硬件层面也对它提供了支持。下面我尝试分析一下函数调用和数据栈,水平有限,如有疏漏和错误,欢迎指出。

系统的硬件组成

现代计算机基本遵守冯·诺依曼体系结构,一般来说由 CPU(中央处理器)、主存储器(内存)、I/O 总线以及各种外设组成,如下:

IA32体系结构

一个程序要运行,主要涉及 CPU 和内存两个设备,而 CPU 通过寄存器(Register)对内存进行操作,也就是进行数据的读取和写入。

Intel IA32 架构下,主要寄存器如下:

IA32主要寄存器

寄存器不仅可以存储数据,还可以通过寄存器中的地址访问内存,例如:

movl $0x7c00, %eax    # 在寄存器中存储十六进制的 0x7c00
movl $45, (%eax) # 把 45 存放到 %eax 中地址所指向的内存中

内存中的栈

对于现代分时操作系统来说,每个进程都会拥有自己独立的地址空间。对于 x86 架构来说,可以简单理解为每个进程都有一套独立的虚拟地址空间:

Linux进程的地址空间

上图中从上往下第四项为进程栈空间。

栈以**栈帧(Stack Frame)**为基本单位,每进行一次函数调用,通常都会创建一个新的栈帧。在 IA32 架构下,ebpesp 分别可以用来指向当前栈帧的位置。

用汇编语言编写一个 add 函数:

.global add
.type add,@function

add:
push %ebp
movl %esp,%ebp

movl $0,%eax
addl 8(%ebp),%eax
addl 12(%ebp),%eax

pop %ebp
ret

这里的函数非常简单,它接收两个整数参数,将它们相加后把结果放入 eax 中作为返回值。

编译成 libadd.so

gcc add.s -fPIC-shared-o libadd.so

然后用 C 语言编写调用程序:

#include <stdio.h>

int add(int a, int b);

int main(){
int a, b;

printf("Please Input two numbers:");
scanf("%d %d", &a, &b);

int c = add(a, b);

printf("a+b=%d\n", c);

return 0;
}

编译并连接:

gcc main.c -o main -ladd -L.

运行:

[root@vultr ~]# ./main
Please Input two numbers:25 100
a+b=125

如果动态链接器没有配置当前目录,则运行时还需要让系统能够找到 libadd.so,例如:

LD_LIBRARY_PATH=. ./main

add 函数执行前后,数据栈会发生变化:

栈的变化动态图

可以看到,函数调用本质上就是一个栈帧不断建立和销毁的过程。

在典型的 IA32 调用约定下,执行函数序言之后,ebp 可以作为当前栈帧的基准位置,而函数参数、返回地址、保存的旧 ebp 以及局部变量等数据,都可以通过相对于 ebpesp 的偏移进行访问。

因此,所谓的“函数调用”,并不是一个抽象到无法理解的过程。CPU 实际上是在执行一系列指令,同时配合寄存器和内存中的栈结构,保存当前函数运行所需要的信息,并在函数执行结束之后恢复到调用者继续执行。

参考资料

  1. Randal E. Bryant,《深入理解计算机系统》
  2. 深入理解C语言的函数调用过程

评论0

欢迎分享你的看法,也欢迎补充不同的实践经验。