对const声明变量的奇异行为的探讨
Article last modified on 2002-7-25
----------------------------------------------------------------
The information in this article applies to:
- C/C++
----------------------------------------------------------------
奇异的现象:
我把这个试验的源代码列出来:
int main(int argc, char* argv[])
{
const int x=10000;
int *y=0;
y=(int*)&x;
*y=10;
printf("%d\n", x);
printf("%d\n", *y);
return 0;
}
首先我们声明了一个const变量x,初始化为10000。然后让一个int指针y指向x。通过给*y赋值,从而改变了x的实际值!
虽然在Watch窗口中你明明看到x的值确实是10,但是printf出来的x的值却偏偏是10000!!
可是,这个已经被彻底抹去的10000,又是从哪里被找回来的呢?
我的解释:
这样的代码经过VC编译器的Debug版本的编译,最后生成的完整的汇编代码为(我做了注释,可以参考一下):
11: int main(int argc, char* argv[])
12: {
00401250 push ebp
\\ 第一步,将基址寄存器(EBP) 压入堆栈
00401251 mov ebp,esp
\\ 第二步,把当前的栈顶指针(ESP)拷贝到EBP,做为新的基地址
00401253 sub esp,48h
\\ 第三步,把ESP减去一个数值,用来为本地变量留出一定空间。这里减去48h,也就是
\\ 72 .
\\ 这里对前面的三步说明一下:ESP和EBP寄存器是堆栈专用的。堆栈基址指针(EBP)寄
\\ 存器确定堆栈帧的起始位置,而堆栈指针(ESP)寄存器执行当前堆栈顶。在函数的入口处,
\\ 当前堆栈基址指针被压到了堆栈中,并且当前堆栈指针成为新的堆栈基址指针。局部变
\\ 量的存储空间、函数使用的各种需要保存的寄存器的存储空间在函数入口处也被预留出
\\ 来。
\\ 所以也就有了下面的三个压栈行为。
\\ 下面是连续三个压栈,第4步:
00401256 push ebx
\\ 将ebx寄存器压栈;EBX寄存器是段寄存器的一种,为基址 DS 数据段;
00401257 push esi
\\ 将esi寄存器压栈;ESI寄存器是指针寄存器的一种。是内存移动和比较操作的源地址寄
\\ 存器;
00401258 push edi
\\ 将edi寄存器压栈;EDI寄存器是指针寄存器的一种。是内存移动和比较操作的目标地址
\\ 寄存器
\\ 以上四步执行完之后,函数入口处的堆栈帧结构如下所示:
\\ 值得注意的是,上面所说的对于Debug版本才是正确的,对于Release版本可不一定对。
\\ Release 版本也许已经把堆栈基址指针优化掉了。
00401259 lea edi,[ebp-48h]
\\ 第5步,lea指令装入有效地址,用来得到局部变量和函数参数的指针。这里[ebp-48h]就是基地址再向下偏移48h,就是前面说的为本地变量留出的空间的起始地址;将这个值装载入edi寄存器,从而得到局部变量的地址;
\\ 下面的这第六步可是非常的重要,请记住:
\\ 第六步,给段寄存器预先赋值:
0040125C mov ecx,12h
\\ ECX寄存器是段寄存器的一种,为计数器 SS 堆栈段。设为12h。
00401261 mov eax,0CCCCCCCCh
\\ EAX寄存器是段寄存器的一种,为累加器 CS 代码段;设为0CCCCCCCCh。
00401266 rep stos dword ptr [edi]
\\ 这句话是干吗的?
\\ 下面开始我们的代码了:
13: const int x=10000;
00401268 mov dword ptr [ebp-4], 2710h
\\ 第一步,在基地址向下偏移4个字节所指向的地址,将10000这个DWORD数值放进去;
\\ 可以看出的是,对于一个普通的int z = 10000;汇编代码依然是这个样子。说明从这句话
\\ 是无法分清楚局部const变量的初始化和普通变量的初始化的!这一点很重要!就是说编译器
\\ 从表面上是无法分清楚一个局部const变量和一个普通变量的。
14: int *y=0;
0040126F mov dword ptr [ebp-8],0
15: y=(int*)&x;
00401276 lea eax,[ebp-4]
00401279 mov dword ptr [ebp-8],eax
\\ 第2步,将x的地址装载到EAX寄存器;
\\ 第3步,再把这个地址作为一个数值导到y的地址,这样y就指向了x!
\\ 这是局部const变量声明的情况!
\\ 而对于全局const变量声明的情况,这句y=(int*)&x;的汇编却是:
\\ 00401276 mov dword ptr [ebp-8],offset x (0043101c)
\\ 一个很显著的区别!
16: *y=10;
0040127C mov ecx,dword ptr [ebp-8]
0040127F mov dword ptr [ecx],0Ah
\\ 第4步,通过ECX寄存器倒手,将y所指向的地址的数值修改为0Ah,也就是10!
\\ 编译器之所以允许这种修改const变量值的非法情况,是因为编译器并不知道这是一个
\\ const变量,它实在是和普通的变量太像了!
17:
18: printf("%d\n", x);
00401285 push 2710h
\\ 第5步,将10000数值压栈!按照惯例,这个2710h会被存在当前栈顶指针前4个字节
\\ 处。原来ESP指向0012FF2C,所以现在指向0012FF28了。
\\ 编译器为什么会直接push一个常量入栈呢?
\\ 我觉得可能是这样:制定C++编译器规则的人想反正都是const变量了,它的值肯定不
\\ 能变。printf一个普通变量是倒手两个寄存器后把EAX寄存器的内容压栈,多影响效率呀。
\\ 还不如直接将这个const变量的值压栈呢。
0040128A push offset string "%d\n" (0042f01c)
\\ 再把格式化压栈;
\\ 这样,printf函数将取栈顶的内容打印,当然是按照%d\n来打印的,所以只会再取栈顶的
\\ 0x0012FF28指向的内容;所以打印出来的就是上面压栈的常量2710h!
\\ 这就是我给出的解释。请高手们指正。
0040128F call printf (004082f0)
00401294 add esp,8
19:
20: printf("%d\n", *y);
00401297 mov edx,dword ptr [ebp-8]
0040129A mov eax,dword ptr [edx]
0040129C push eax
\\ 看,对于一个普通变量的printf,就不一样了!
0040129D push offset string "%d\n" (0042f01c)
004012A2 call printf (004082f0)
004012A7 add esp,8
21:
22:
23: return 0;
004012AA xor eax,eax
24: }
004012AC pop edi
004012AD pop esi
004012AE pop ebx
\\ 函数入口处连着三个把寄存器压栈,这里一个一个地弹出来;
004012AF add esp,48h
\\ ESP寄存器再加回去;
004012B2 cmp ebp,esp
\\ 比较EBP和ESP
004012B4 call __chkesp (00408190)
004012B9 mov esp,ebp
\\ 将EBP中保存的栈顶指针再拷回ESP寄存器;
004012BB pop ebp
\\ 将EBP弹出堆栈;
004012BC ret
还有一个问题,为什么将const int x=100;这句代码放在全局声明会发生访问禁止呢?
访问禁止是发生在:
16: *y=10;
00401276 mov eax,dword ptr [ebp-4]
00401279 mov dword ptr [eax],0Ah
时。
为什么呢?
原因是,这里将0Ah存入EAX所指向的地址时,发生了0xC0000005: Access Violation。
那为什么局部const声明时没有这个问题呢?
局部const声明时,则这句*y = 10;的汇编为:
16: *y=10;
0040127C mov ecx,dword ptr [ebp-8]
0040127F mov dword ptr [ecx],0Ah
,我们可以看出寄存器用的不一样!
前者为EAX,
后者为ECX。
有什么区别吗?
是这样子:
EAX寄存器在函数入口处,她被预先设为0CCCCCCCCh。这一点很关键!
而
ECX寄存器却被预先设为12h。
这样,当全局声明const变量时,我们企图将一个DWORD值写入一个0CCCCCCCCh指向的未分配的内存中,所以遭遇到访问禁止!!
而局部声明const变量时,是将一个DWORD值写入一个00000012h指向的内存中,这是可以的!!
Written by zhengyun@tomosoft.com