第7章 变量在内存中的位置和访问方式

通过第6章对函数的介绍,读者已经预先接触了局部变量的定义及使用过程。除了局部变量外,还有全局变量和静态变量等。本章以VC++6.0为例,讲解各类作用域的底层机制和分析要点。在本章开始之前,我们先约定一下用语:

变量的作用域

指的是变量在源码中可以被访问到的范围。全局变量属于进程作用域,也就是说,在整个进程中都能够访问到这个全局变量;静态变量属于文件作用域,在当前源码文件内可以访问到;局部变量属于函数作用域,在函数内可以访问到;在“{}”语句块内定义的变量,属于块作用域,只能在定义变量的“{}”块内访问到。

变量的生命周期

指的是变量所在的内存从分配到释放的这段时间。变量所在的内存被分配后,我们可以形象地将这比喻为变量的生命开始;变量所在的内存被释放后,我们可以将这比喻为变量的消亡。

读者可以在阅读本章前,思考一下:以上谈到的作用域和生命周期,有区别吗?有什么区别呢?

7.1 全局变量和局部变量的区别

全局变量是如何形成的呢?2.6节中讲解了常量的识别和分析方法。常量与全局变量有着相似的特征,都是在程序执行前就存在了。在大多数情况下,在PE文件中的只读数据节中,常量的节属性被修饰为不可写;而全局变量和静态变量则在属性为可读写的数据节中。下面来定义全局变量:


int g_nVariableType=117713190;


然后获取全局变量的内存地址,如图7-1所示。

图 7-1 全局变量所在的内存地址

如图7-1所示,通过调试获取全局变量所在地址0x00421A30,地址中的数据为0x07042926,转换为十进制数为117717190。全局变量在文件中的地址定位和常量相同,也需要减去基地址,然后查阅节表得到文件地址。本示例中的基地址为0x00400000,它的全局变量对应在文件0x00021A30的偏移地址处,如图7-2所示。

图 7-2 全局变量所在的文件地址

由图7-2可见,具有初始值的全局变量,其值在链接时被写入所创建的PE文件中,当用户执行该文件时,操作系统先分析这个PE中的数据,将各个节中的数据填入对应的虚拟内存地址中,这时全局变量就已经存在了,等PE的分析和加载工作完成以后,才开始执行入口点的代码。因此全局变量可以不受作用域的影响,在程序中的任何位置都可以被访问和使用。全局变量和局部变量都是变量,它们都可以被赋值和修改。它们之间存在哪些区别呢?在反汇编代码中又该如何区分呢?下面将进一步讲解全局变量,分析其与局部变量的差别。

通过对全局变量的初步分析得出,它和常量类似,被写入文件中,因此其生命周期与所在模块相同。全局变量和局部变量的最大区别就是生命周期不同。全局变量诞生于所在执行文件被操作系统加载后,执行第一条代码前,这个时候已经具有内存地址了。当程序结束运行退出后,全局变量将被销毁。因此,全局变量可以在程序中的任何位置使用;而局部变量的生命周期则局限于函数作用域内,超出作用域后,由栈平衡操作来释放局部变量的空间。对于由“{}”划分的块作用域,其内部的局部变量的生命周期和函数作用域一致,但是编译器会在编译前检查语法,限制块外代码对其访问。

在访问方式上,局部变量的访问是通过栈指针相对间接访问,而全局变量的内存地址在全局数据区中,通过栈指针无法访问到。那么全局变量又是如何访问寻址的呢?我们先来看一个例子,如代码清单7-1所示。

代码清单7-1 全局变量的访问—Debug版


//C++源码说明:全局变量的访问

int g_nVariableType=117713190;//定义整型全局变量

void main(){

//从标准输入设备获取数据到g_nVariableType

scanf("%d",&g_nVariableType);

//将g_nVariableType输出到标准输出设备

printf("%d\r\n",g_nVariableType);

}

//C++源码与对应汇编代码讲解

void main(){

;Debug保存环境、栈空间申请初始化略

scanf("%d",&g_nVariableType);

;将全局变量的地址作为参数压入栈,与常量的处理方法相同

00401028 push offset g_nVariableType(00424a30)

0040102D push offset string"%d"(00422024)

00401032 call scanf(00401100);调用scanf函数

00401037 add esp,8;平衡scanf函数的两个参数

printf("%d\r\n",g_nVariableType);

;取全局变量内容传入eax

0040103A mov eax,[g_nVariableType(00424a30)]

0040103F push eax

00401040 push offset string"%d\r\n"(0042201c)

00401045 call printf(00401080);调用printf函数

0040104A add esp,8;平衡printf函数的两个参数

}

;Debug还原环境、栈空间略


通过对代码清单7-1的分析可知,访问全局变量与访问常量类似—都是通过立即数来访问。由于全局变量在编译期就已经确定了具体的地址,因此编译器在编译的过程中可以计算出一个固定的地址值。而局部变量需要进入作用域内,通过申请栈空间存放,利用栈指针ebp或esp间接访问,其地址是一个未知可变值,编译器无法预先计算。

上面讲解了全局变量与局部变量在指令中的寻址方式,以及生命周期的差别。不仅如此,在同时连续定义多个全局变量时,这些全局变量在内存中的地址顺序与局部变量也不一定相同。我们来看一个例子,如代码清单7-2所示。

代码清单7-2 全局变量的定义顺序


//C++源码说明:全局变量的访问

int g_nVariableType=117713190;//定义整型全局变量

int g_nVariableType1=117713191;//定义整型全局变量

void main(){

int nOne=1;int nTwo=2;//局部变量定义

//scanf与printf的使用避免常量传播优化

scanf("%d,%d",&nOne,&nTwo);

printf("%d%d\r\n",nOne, nTwo);

scanf("%d,%d",&g_nVariableType,&g_nVariableType1);

printf("%d%d\r\n",g_nVariableType, g_nVariableType1);

//C++源码与对应汇编代码讲解

void main(){

;Debug保存环境、栈空间申请初始化略

int nOne=1;//假设ebp为0x0012FF10

0040D9D8 mov dword ptr[ebp-4],1;nOne所在地址0x0012FF0C

int nTwo=2;

0040D9DF mov dword ptr[ebp-8],2;nTwo所在地址0x0012FF08

;scanf与printf讲解略

scanf("%d,%d",&g_nVariableType,&g_nVariableType1);

;g_nVariableType1所在地址为0x00424E78

0040DA10 push offset g_nVariableType1(00424e78)

;g_nVariableType所在地址为0x00424E4

0040DA15 push offset g_nVariableType(00424e74)

0040DA1A push offset string"%d,%d"(00422fe0)

;其他分析讲解略


通过对代码清单7-2的分析发现,全局变量在内存中的地址顺序是先定义的变量在低地址,后定义变量在高地址。有此特性即可根据反汇编代码中全局变量的所在地址,还原出其高级代码中被定义的先后顺序,更进一步接近源码。

下面对全局变量和局部变量的特征进行一下总结。

全局变量的特征如下:

所在地址为数据区,生命周期与所在模块一致;

使用立即数间接访问。

局部变量的特征如下:

所在地址为栈区,生命周期与所在的函数作用域一致;

使用ebp或esp间接访问。

通过上述对比,相信读者在分析反汇编代码时能轻松地将它们识别并分类。