7.2 局部静态变量的工作方式
静态变量分为全局静态变量和局部静态变量,全局静态变量和全局变量类似,只是全局静态变量只能在本文件内使用。但这只是在编译之前的语法检查过程中,对访问外部的全局静态变量做出的限制。全局静态变量的生命周期和全局变量也是一样的,而且在反汇编代码中它们也无二样。也就是说,全局静态变量和全局变量在内存结构和访问原理上都是一样的,相当于全局静态变量等价于编译器限制外部源码文件访问的全局变量。有鉴于此,笔者不再重复讲解了。
局部静态变量比较特殊,它不会随作用域的结束而消失,并且在未进入作用域之前就已经存在,其生命周期也和全局变量相同。那么编译器是如何做到使局部静态变量与全局变量的生命周期相同的,但作用域不同的呢?实际上,局部静态变量和全局变量都保存在执行文件中的数据区中,但由于局部静态变量被定义在某一作用域内,让我们产生了错觉,误认为此处为它的生命起始点。实则不然,局部静态变量会预先被作为全局变量处理,而它的初始化部分只是在做赋值操作而已。
既然是赋值操作,与之伴随的另一个问题就出现了。当某函数被频繁调用时,C++语法中规定局部静态变量只被初始化一次,那么编译器如何确保每次进入函数体时,赋值操作只被执行一次呢?通过代码清单7-3的分析,让我们揭开这个谜底。
代码清单7-3 局部静态变量的工作方式—Debug版
//C++源码说明:全局变量的访问
void ShowStatic(int nNumber){
static int g_snNumber=nNumber;//定义局部静态变量,赋值为参数
printf("%d\r\n",g_snNumber);//显示静态变量
}
void main(){
for(int i=0;i<5;i++){
ShowStatic(i);//循环调用显示局部静态变量的函数,每次传入不同值
}
}
//C++源码与对应汇编代码讲解
//for循环调用过程讲解略
//ShowStatic函数内实现过程
void ShowStatic(int nNumber){
;在Debug版下保存环境、开辟栈、初始化部分略
static int g_snNumber=nNumber;//定义局部静态变量
0040D9D8 xor eax, eax;清空eax
;取地址0x004257CC处1字节数据到al中
0040D9DA mov al,['ShowStatic':'2':$S1(004257cc)]
;将eax与数值1做位与运算,eax最终结果只能是0或1
0040D9DF and eax,1
0040D9E2 test eax, eax
;比较eax,不等于0则执行跳转,跳转到地址0x0040D9FE处
0040D9E4 jne ShowStatic+3Eh(0040d9fe)
;将之前比较是否为0值的地址取出数据到cl中
0040D9E6 mov cl, byte ptr['ShowStatic':'2':$S1(004257cc)]
;将cl与数值1做位或运算,cl的最低位将被置1,其他位不变
0040D9EC or cl,1
;再将置位后的cl存回地址0x004257CC处
0040D9EF mov byte ptr['ShowStatic':'2':$S1(004257cc)],cl
;取出参数信息放入edx中
0040D9F5 mov edx, dword ptr[ebp+8]
;将edx赋值到地址0x004257C8处,即将局部静态变量赋值为edx中保存的数据
0040D9F8 mov dword ptr[___sbh_sizeHeaderList+4(004257c8)],edx
printf("%d\r\n",g_snNumber);//显示局部静态变量中的数据
;局部静态变量的访问,和全局变量的访问方式一样
0040D9FE mov eax,[___sbh_sizeHeaderList+4(004257c8)]
;printf函数调用过程分析略
在代码清单7-3中,地址0x004257CC中保存了局部静态变量的一个标志,这个标志占位1个字节。通过位运算,将标志中的一位数据置1,以此判断局部静态变量是否已经被初始化过。由于一个静态变量只使用了1位,而1个字节数据占8位,因此这个标志可以同时表示8个局部静态变量的初始状态。通常,在VC++6.0中,标志所在的内存地址在最先定义的局部静态变量地址的附近,如最先定义的整型局部静态变量在地址0x004257C0处,那么标记位通常在地址0x004257C4或0x004257BC处。当同一作用域内超过8个局部静态变量时,下一个标记位将会在第9个定义的局部静态变量地址附近。识别局部静态变量的标志位地址并不是目的,主要是根据这个标志位来区分全局变量与局部静态变量。多个局部静态变量的定义如图7-3所示。
图 7-3 多个局部静态变量的定义
图7-3中定义了两个局部静态变量,分别为g_snNumber1和g_snNumber2,它们所在的地址分别为0x004257CC和0x004257C8。它们都使用了地址0x004257D0为标志位。g_snNumber1使用了1个字节中的最低位作为初始化标志,而g_snNumber2则使用了g_snNumber1标记位的高一位作为标志位。
以此类推,直至将1个字节中的8位用完为止。图7-3中的标志位为3,因为将两个局部静态变量的标志位分离为:“g_snNumber1=0000 0001”和g_snNumber2=0000 0010”,组合后为00000011,所以转换为十六进制数后就变成了0x03。
当局部静态变量被初始化为一个常量值时,这个局部静态变量在初始化过程中不会产生任何代码,如图7-4所示。
图 7-4 初始化为常量的局部静态变量
由于初始化的数值为常量,即多次初始化不会产生变化。这样无需再做初始化标志,编译器采用了直接以全局变量方式处理,优化了代码,提升了效率。虽然转换为了全局变量,但仍然不可以超出作用域访问。那么编译器是如何让其他作用域对局部静态变量不可见的呢?通过名称粉碎法,在编译期将静态变量重新命名。对图7-4中的静态变量g_snOne进行名称粉碎后,结果如图7-5所示。
图 7-5 名称粉碎后的静态变量名称
通过名称粉碎后,在原有名称中加入了其所在的作用域,以及类型等信息。如何查找粉碎后的名称呢?查找编译后对应的obj文件,使用“WinHex”将该文件打开后,使用快捷键“Ctrl+F”快速查找字符串,输入原静态变量名称便能快速定位到该静态变量粉碎后的名称处,如图7-5所示。(粉碎规则不必重点学习,读者只需知道是通过名称粉碎来完成作用域的识别过程即可,C++的函数重载也是如此,同样是先粉碎函数名称再组合出新名称。)
obj文件中粉碎后的组合名称从何而来呢?通过修改VC++6.0的编译选项,生成ListingFile文件,该文件为包含名称粉碎处理后汇编代码。依次修改编译选项:Project→Settings→C\C++→Category→Listing Files,如图7-6所示。
图 7-6 ListingFile编译选项设置
设置好编译选项后再次编译,针对工程中的CPP文件生成对应的同名称的ASM文件,如图7-7所示。
图 7-7 ListingFile选项生成的汇编文件
obj文件就是由图7-7中的汇编文件编译而成的。“VariableType.asm”汇编文件中保存了粉碎后的变量、函数名称等信息,如图7-8所示。
图 7-8 汇编文件信息
图7-8中的汇编代码对应代码清单7-3中的函数ShowStatic。从图中注释可以发现,此汇编代码完成的功能是对静态变量g_snNumber1的定义及初始化操作。变量名称在汇编代码中找不到,因为它被加工过的名称所代替。由于obj是由此汇编文件所生成的,因此在obj文件中会出现粉碎后重新组合的变量名称。
总结:
;reg_flag表示存放初始化标志的寄存器r8,通常使用寄存器中的低位,如al等
;INIT_FLAG表示初始化标记
mov reg_flag, INIT_FLAG
;reg_data表示存放静态变量初值的寄存器
mov reg_data, mem;reg_data值为初值,其来源可能因程序不同而不同
test reg_flag,1\2\8……0x80;测试标志位
jxx INIT_END;跳转成功,表示已经被初始化过
or reg_flag,1\2\8……0x80;修改标志寄存器中的数据
;STATIC_DATA表示静态变量
mov STATIC_DATA, reg_data;初始化静态变量
mov INIT_FLAG, reg_flag;修改该静态变量初始化标志位
INIT_END:
在分析过程中,如果遇到以上代码块,表示符合局部静态变量的基本特征,可判定为局部静态变量的初始化过程。在分析的过程中应注意对测试标志位的操作,其立即数只能为1、2、8这样的2的幂。