编译系统透视:图解编译原理
上QQ阅读APP看书,第一时间看更新

1.1 一个简单C程序的运行时结构

解决编程过程中的实际问题,需要透彻了解程序在内存中的运行时结构,而透彻的程度自然成为衡量计算机语言学习水平的重要标准,也成为衡量软件项目开发水平的重要标准。

C程序运行的核心是函数的执行和调用,它构成了整个C程序运行时结构的基础框架。这一运行过程主要是在程序指令的驱动以及数据压栈、清栈的支持下实现的。为了介绍这一过程,我们设计了一个简单C程序,如下所示:

int fun(int a,int b);
int m=10;
int main()
{
    inti=4;
    int j=5;
    m = fun(i,j);
    return 0;
}
int fun(int a,int b)
{
    int c=0;
    c=a+b;
    return c;
}

程序很简单,却凸现了函数调用和执行的最基本情况。我们把此情景展现在内存中,共有三个区域,分别是代码区、静态数据区和动态数据区。情景如图1-1所示。

图1-1 内存区域特性的总体介绍

代码区装载了这个程序所对应的机器指令,main函数和fun函数的机器指令装载位置如图1-2所示。

图1-2 main函数和fun函数在代码区的位置

全局变量m的数值装载在静态数据区中,情景如图1-3所示。

图1-3 全局变量m在静态数据区的位置

程序开始执行前,动态数据区中没有数据,情景如图1-4所示。

图1-4 动态数据区没有数据

这是因为,只有程序开始执行后,在指令的驱动下,这一区域才会产生数据,压栈和清栈的工作就是在这一区域完成的,情景如图1-5所示。

图1-5 建栈和清栈的情景

程序执行的本质就是代码区的指令不断执行,驱使动态数据区和静态数据区产生数据变化。这一过程需要计算机的管控。下面我们着重介绍对代码区和动态数据区的管控。CPU中有三个寄存器,分别是eip、ebp和esp,情景如图1-6所示。

图1-6 对代码区和动态数据区的管控

其中eip永远指向代码区将要执行的下一条指令,它的管控方式有两种,一种是“顺序执行”,即程序执行完一条指令后自动指向下一条执行;另一种是跳转,也就是执行完一条跳转指令后跳转到指定的位置。

ebp和esp用来管控栈空间,ebp指向栈底,esp指向栈顶,在代码区中,函数调用、返回和执行伴随着不断压栈和清栈,栈中数据存储和释放的原则是后进先出。

内存的划分及程序执行的总体情况先介绍到这里。下面详细介绍案例程序的运行时结构。初始情景是这样的,eip指向main函数的第一条指令,此时程序还没有运行,栈空间里还没有数据,ebp和esp指向的位置是程序加载时内核设置的(详情请看《Linux内核设计的艺术》一书),情景如图1-7所示。

图1-7 程序加载时esp和ebp的起始位置

程序开始执行main函数第一条指令,eip自动指向下一条指令。第一条指令的执行,致使ebp的地址值被保存在栈中,保存的目的是本程序执行完毕后,ebp还能返回现在的位置,复原现在的栈。随着ebp地址值的压栈,esp自动向栈顶方向移动,它将永远指向栈顶,情景如图1-8所示。

图1-8 保存ebp

程序继续执行,开始构建main函数自己的栈,ebp原来指向的地址值已经被保存了,它被腾出来了,用来看管main函数的栈底,此时它和esp是重叠的,情景如图1-9所示。

图1-9 准备构建main函数的栈

程序继续执行,eip指向下一条指令,此次执行的是局部变量i的初始化,初始值4被存储在栈中,esp自动向栈顶方向移动,情景如图1-10所示。

图1-10 局部变量i压栈并初始化

继续执行下一条指令,局部变量j的初始值5也被压栈,情景如图1-11所示。

图1-11 局部变量j压栈并初始化

这两个局部数据都是供main函数自己用的,接下来调用fun函数时压栈的数据虽然也保存在main函数的栈中,但它们都是供fun函数用的。可以说fun函数的数据,一半在fun函数中,一半在主调函数中,下面来看函数调用时留在main函数中的那一半数据。

先执行传参的指令,此时参数入栈的顺序和代码中传参的书写顺序正好相反,参数b先入栈,数值是main函数中局部变量j的数值5,情景如图1-12所示。

图1-12 j的数值作为参数被压栈

程序继续执行,参数a被压入栈中,数值是局部变量i的数值4,情景如图1-13所示。

图1-13 i的数值作为参数被压栈

程序继续执行,此次压入的是fun函数返回值,将来fun函数返回之后,这里的值会传递给m,情景如图1-14所示。

图1-14 设定fun函数返回值的位置

还剩最后一步,跳转到fun函数去执行,这一步分为两部分动作,一部分是把fun函数执行后的返回地址压入栈中,以便fun函数执行完毕后能返回到main函数中继续执行,情景如图1-15所示。

图1-15 fun函数执行后的返回地址被压栈

到这里,函数调用的数据准备工作就完成了。另一部分就是跳转到被调用的函数的第一条指令去执行,情景如图1-16所示。

图1-16 跳转到fun函数去执行

fun函数开始执行,第一件事就是保存ebp指向的地址值,此时ebp指向的是main函数的栈底,保存的目的是在返回时恢复main函数栈底的位置,这和前面main函数刚开始执行时第一步就保存ebp的地址值的目的是一样的,情景如图1-17所示。

图1-17 fun函数开始执行后先保存main函数栈底地址值

再往后就要构建fun函数的栈了。程序继续执行,仍然使用腾出来的ebp看管栈底,ebp和esp此时指向相同的位置,情景如图1-18所示。

图1-18 准备建立fun函数的栈空间

程序继续执行,局部变量c开始初始化,入栈,数值为0,这个c就是fun函数的数据,存在于fun函数的栈中,情景如图1-19所示。

图1-19 局部变量c被压栈

此时回顾fun函数的数据,可以发现一半在main函数中,情景如图1-20所示。

图1-20 fun函数的数据一半在main函数中

另一半在fun函数中,情景如图1-21所示。

图1-21 fun函数的数据的另一半在fun函数中

接下来会执行几个运算指令,展现对这些数据的应用。以ebp为基点,很容易找到main函数栈和fun函数栈中数据的位置,情景如图1-22所示。

图1-22 将加法运算结果赋值给局部变量c

程序继续执行,fun函数中局部变量c的数据当成返回值返回,情景如图1-23所示。

图1-23 c的数值返回

现在fun函数已经执行完毕,要恢复main函数调用fun函数的现场,这一现场包括两个部分,一部分是main函数的栈要恢复,包括栈顶和栈底,另一部分是要找到fun函数执行后的返回地址,然后再跳转到那里继续执行。

我们来看ebp的恢复。前面存储了ebp的地址值,现在可以把存储的地址值赋值给ebp,使之指向main函数的栈底,情景如图1-24所示。

图1-24 恢复main函数栈底地址值

ebp地址值出栈后,esp自动退栈,指向fun函数执行后的返回地址,之后执行ret指令,即返回指令,把地址值传给eip,使之指向fun函数执行后的返回地址,情景如图1-25所示。

图1-25 返回到main函数中执行

恢复现场以后,把fun函数返回值传递给m,情景如图1-26所示。

图1-26 将返回值赋值给m

该处理fun函数调用时的传参和返回值设置了,这两者已经没有存在的必要了,全部清栈,情景如图1-27所示。

图1-27 参数和返回值清栈

剩下就是main函数的内容了,main函数执行完毕以后,栈也全部清掉。清栈的方式与fun函数执行完后采用的清栈方式一致,情景如图1-28所示。

图1-28 main函数清栈

操作系统已经为整个程序执行完的善后工作做了准备,详情请看《Linux内核设计的艺术》一书。