在前一篇文章函数部分,接触到了局部变量的定义及使用过程。除了局部变量外,还有全局变量和静态变量等。
在开始之前,首先了解一下两个概念:
- 变量的作用域
指的是变量在源码中可以被访问到的范围。全局变量属于进程作用域,也就是说,在整个进程中都能够访问到这个全局变量;静态变量属于文件作用域,在当前源码文件内可以访问到;局部变量属于函数作用域,在函数内可以访问到;在”{}”语句块内定义的变量,属于块作用域,只能在定义变量的”{}”块内访问。 - 变量的生命周期
指的是变量所在的内存从分配到释放的这段时间。变量所在的内存被分配后,我们可以形象地将这比喻为变量地生命开始;变量所在地内存被释放后,我们可以将这比喻为变量的消亡。
全局变量和局部变量的区别
常量与全局变量有着相似的特征,都是在程序执行前就存在了。在大多数情况下,在PE文件中的只读数据字节中,常量的节属性被修饰为不可写;而全局变量和静态变量则在属性为可读写的数据节中。
具有初始值的全局变量,其值在链接时被写入所创建的PE文件中,当用户执行该文件时,操作系统先分析这个PE中的数据,将各个节中的数据填入对应的虚拟内存地址中,这时全局变量就已经存在了,等PE的分析和加载工作完成以后,才开始执行入口点的代码。因此全局变量可以不受作用域的影响,在程序的任何位置都可以被访问和使用。
访问全局变量与访问常量相似——都是通过立即数来访问。由于全局变量在编译期就已经确定了具体的地址,因此编译器在编译的过程中可以计算出一个固定的地址值。而局部变量需要进入作用域内,通过申请栈空间存放,利用栈指针ebp或esp间接访问,其地址是一个未知可变值,编译器无法预先计算。
全局变量的特征如下:
- 所在地址为数据区,生命周期与所在模块一致
- 使用立即数间接访问
局部变量的特征如下:
- 所在地址为栈区,生命周期与所在的函数作用域一致
- 使用ebp或esp间接访问
局部静态变量的工作方式
静态变量分为全局静态变量和局部静态变量,全局静态变量和全局变量类似,只是全局静态变量只能在本文件内使用。但这只是在编译之前的语法检查过程中,对访问外部的全局静态变量做出的限制。全局静态变量的生命周期和全局变量也是一样的,而且在反汇编代码中它们也无二样。也就是说,全局静态变量和全局变量在内存结构和访问原理上都是一样的,相当于全局静态变量等价于编译器限制外部源码文件访问的全局变量
局部静态变量比较特殊,它不会随作用域的结束而消失,并且在未进入作用域之前就已经存在,其生命周期也和全局变量相同。那么编译器是如何做到使局部静态变量与全局变量的生命周期相同的,但作用域不同的呢?实际上,局部静态变量和全局变量都保存在执行文件中的数据区中,但由于局部静态变量被定义在某一作用域内,让我们产生了错觉,误认为此处为它的生命起始点。实则不然,局部静态变量会预先被作为全局变量处理,而它的初始化部分只是在做赋值操作而已。
既然是赋值操作,那么另一个问题又来了。当某个函数被频繁调用时,C++语法中规定局部静态变量只被初始化一次,那么编译器如何确保每次进入函数体时,赋值操作只被执行一次呢?
1
2
3
4
5
6
7
8
9
10
11
12
void ShowStatic(int nNumber)
{
static int g_number=nNumber;
printf("%d\n",g_number);
}
int main(int argc, char* argv[])
{
for(int i=0;i<5;i++)
ShowStatic(i);//循环调用显示局部静态变量的函数,每次传入不同值
return 0;
}
1 | 0040D70A |. A0 387E4200 mov al,byte ptr ds:[0x427E38] ; 取地址0x00427E38处1字节数据到al中 |
以上代码中,地址0x427E38保存了局部静态变量的一个标志,这个标志站位1字节。通过位运算,将标志中的一个数据置1,以此判断局部静态变量是否已经被初始化过。另,当局部静态变量被初始化为一个常值时,这个局部静态变量在初始化过程中不会产生任何代码,编译器采用了直接以全局变量方式处理。
堆变量
堆变量是所有变量表现形式最容易识别的。在C/C++中,使用malloc与new实现堆空间变量的申请,返回的数据便是申请的堆空间的地址。相对应的,使用free与delete完成堆空间释放,但需要申请堆空间时得到的首地址。如果这个首地址丢失将无法释放堆空间,从而导致内存泄漏。
保存堆空间首地址的变量大小为4字节的指针类型,其访问方式按作用域来分,和之前所介绍过的全局、局部以及静态的表现形式相同。
C++中的new域delete属于运算符,在没有定义重载的情况下,它们的执行过程与malloc、free类似。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main(int argc, char* argv[])
{
char * pCharMalloc = (char \*) malloc(10);
char * pCharNew = new char[10];
if(pCharNew != NULL)
{
delete [] pCharNew;
pCharNew = NULL;
}
if(pCharMalloc != NULL)
{
free(pCharMalloc);
pCharMalloc = NULL;
}
return 0;
}
1 | 0040D7D8 |. 6A 0A push 0xA |
了解了堆空间的产生与销毁,那么堆空间中都存储哪些信息呢?
通过反汇编可以看见申请堆空间的过程中调用了函数_heap_alloc_dbg,其中使用_CrtMemBlockHeader结构描述了堆空间中的各个成员。在内存中,堆结构的每个节点都是使用双向链表的形式存储的,在_CrtMemBlockHeader结构中定义了前指针pBlockHeaderPrev和后指针pBlockHeaderNext,通过这两个指针就可遍历整个程序中申请的所有堆空间。成员lRequest记录了当前堆是第几次申请的;成员gap为保存堆数据的数组,在debug版下,这个数据的前后4个字节被初始化为0xFD,用于检测堆数据访问过程中是否有越界访问。_CtrMemBlockHeader结构的原型如下:1
2
3
4
5
6
7
8
9
10
11typedef struct _CtrMemBlockHeader
{
struct _CtrMemBlockHeader * pBlockHeaderNext; //下一块堆空间首地址(实际上指向的是前一次申请的堆信息)
struct _CtrMemBlockHeader * pBlockHeaderPrev; //上一块堆空间首地址(实际上指向的是后一次申请的堆信息)
char * szFileName;
int nLine;
size_t nDataSize; //堆空间数据大小
int nBlockUse;
long lRequest; //堆申请的次数
unsigned char gap[nNoMansLandSize]; //堆空间数据
} _CtrMemBlockHeader;
CtrMemBlockHeader便是调试版堆空间的每一项数据,有了次结构,就可以管理所申请的堆空间。在释放过程中,根据堆数据的首地址将所释放的堆从链表中脱链,完成堆释放操作。
运行刚刚的程序,申请到的首地址0x21F0E18,对应的数据为
堆数据地址减4后,其数据为0xFDFDFDFD,这是往上越界的标志。堆数据地址减8后数据为0x32,表示此堆空间为第0x32次申请堆操作。堆数据空间的容量存储在地址0x021F0E08处,该堆空间占10字节大小。地址0x21F0DF8处为上次申请的堆首地址,地址0x21F0DFC处的数据为0即NULL,表示没有下一个堆空间。在堆数据的结尾也加入了0xFDFDFDFD,这是往下越界的检查标志。
当某个堆空间被释放后,再次申请堆空间时会检查这个被释放的堆空间是否能满足用户要求。如果能满足则再次申请的对空间将会是刚刚释放过的堆空间地址,这就形成了回收空间的再次利用。