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内核设计的艺术》一书。