本文中提到的题目来自于这篇文章:《2012微软暑期实习笔试题及答案》 中的第五题。这道题目是这样的:
5. What is output if you compile and execute the following code?
void main()
{
int i = 11;
int const *p = &i;
p++;
printf("%d", *p);
}
(A) 11; (B) 12; (C) Garbage value; (D) Compile error; (E) None of above.
首先,很显然这道题目第一要考察 const 的写法,也就是这个代码中 const 修饰的是指针变量 p 还是被 p 指向的整数。简单的记忆的方法就是 const 修饰的是最靠近它的东西,左侧优先。因此这句话中 const 修饰的是左侧的 int,即相当于 const int* p; 所以通常采用后者这种写法,因为后者更符合阅读(pointer to const int),不容易引发认知错误。因此,上面的代码能通过编译,即答案 (D)首先可以被排除。
p++ 使 p 向高地址移动了一个 int 的距离(对于win32,4 bytes),移动后指向了什么位置呢?显然依然是一个栈上地址。但这道题目是不是就应该选(C)Gargage Value 呢?所以这道题目比较有趣,它涉及到了函数调用时 stack frame 的细节。因此我们还需要更明确的分析 stack 上的存储内容,明确 p 指向了什么,然后才能给出结论。
我们先给出一个基本结论,p 被初始化指向 i (因为 i 是第一个出现的临时变量,所以 i 是最靠近返回值地址的(savedPC)),这时继续让 p 的指向向 savedPC 的方向移动一个 int 元素跨度。【注意减小 sp 相当于在栈上开辟空间,增加 sp 相当于释放栈上空间,p++ 使得向栈内方向而不是栈外方向移动,正是这种移动方向导致这道题的答案带有了争议性。我们非常希望 int 不是第一个临时变量,假设它前面还有其他变量,那么我们就可以更明确的指出 p 会指向在 i 之前出现的临时变量。可惜 i 就是第一个临时变量。假如编译器分配空间时,让 i 和 savedPC 紧邻,则这会导致 p 指向 savedPC。但幸运的是,在后面的分析中可以看到编译器竟然会在 i 和 savedPC 可能会预留出垃圾数据。】
首先把上面的代码用 VC6.0 做一个实验,编译配置是 Win32 Debug,把最后一句替换成 printf("%08X\n", *p); 发现输出结果是:0012FFC0。
然后使用 IDA 反汇编分析可执行文件。
首先,main 函数的返回值类型和参数列表(本题目中为 void main ( void ); )不重要,不会影响生成的代码。生成的 main 函数实际上是三个参数,汇编代码对应的原型是:
int main(int argc, char* argv[], char* environ[]);
其中 environ 是一个废弃参数,类似 argv 它也是一个字符串数组,以 NULL 元素为截止标志,是一些"key = value"形式的系统环境变量之类的字符串。
使用 VC6 debug (注意:不同编译器,不同编译选项结果不同,参考补充部分),main 函数的汇编代码如下:
main proc near ; CODE XREF: j_mainj .text:00401010 .text:00401010 var_48 = dword ptr -48h .text:00401010 var_8 = dword ptr -8 .text:00401010 var_4 = dword ptr -4 .text:00401010 .text:00401010 push ebp .text:00401011 mov ebp, esp .text:00401013 sub esp, 48h .text:00401016 push ebx ;prolog 部分 .text:00401017 push esi .text:00401018 push edi .text:00401019 lea edi, [ebp+var_48] ; 初始化分配的栈上空间 .text:0040101C mov ecx, 12h .text:00401021 mov eax, 0CCCCCCCCh .text:00401026 rep stosd .text:00401028 mov [ebp+var_4], 0Bh ; int i = 11 .text:0040102F lea eax, [ebp+var_4] .text:00401032 mov [ebp+var_8], eax ; const int* p = &i .text:00401035 mov ecx, [ebp+var_8] .text:00401038 add ecx, 4 .text:0040103B mov [ebp+var_8], ecx ; p++ .text:0040103E mov edx, [ebp+var_8] .text:00401041 mov eax, [edx] .text:00401043 push eax .text:00401044 push offset ??_C@_05FMLN@?$CF08X?6?$AA@ ; "%08X\n" .text:00401049 call printf ; printf("%08X\n", *p) .text:0040104E add esp, 8 ; 调用方清理栈上的参数 .text:00401051 pop edi ; epilog 部分 .text:00401052 pop esi .text:00401053 pop ebx .text:00401054 add esp, 48h ; 释放栈上空间 .text:00401057 cmp ebp, esp .text:00401059 call __chkesp .text:0040105E mov esp, ebp .text:00401060 pop ebp .text:00401061 retn .text:00401061 main endp
stack 状态如下图所示(prolog 之后,执行到 0x0040 1028 时):
通常,在函数内的临时变量按照其声明顺序在栈上分配的(从栈底到栈顶方向),但在语言层面这应该是不被保证的(因为没有必要)。但这一题显然我们需要先默认这一点,即临时变量 p 和 i 的地址紧邻,且 p 的地址比 i 的地址小(因为 p 的声明晚,因此 p 更靠近栈顶)。
【注意】以下分析专指 VC debug 编译版本(关于 release 版本,请查看本文补充部分)。
进入函数 main 以后,第一步是在栈上保存 ebp,然后 ebp 指向当前栈顶,此后在函数内 ebp 作为访问参数和临时变量的一个依据(基址)。然后根据函数临时变量等数据的存储需要,在栈上分配空间(sub esp, XXh; 根据具体函数而定,本范例中为 48 h),并初始化为 0xCC。因此如果把 p 的指向栈顶方向移动一些距离(p -= 2),输出结果将是 0x CCCC CCCC。
在初始化栈上分配的空间之前,注意到将三个寄存器(ebx,esi,edi)保存到栈上,这是编译器默认生成的 prolog 代码。如果不希望编译器插入 prolog 和 epilog,可以在函数前面使用 __declspec ( naked ) 。
因此 p++ 使得 p 的指向向栈底方向移动,因此将指向 ebp 的保存值。注意,ebp 是一个作用明确的寄存器,其意义是刚进入当前函数时的栈指针。因此现在 p 指向了 ebp 的保存值,可以设想,ebp 的保存值是上一层函数中的栈指针,也就是 mainCRTStartup 函数中的栈指针。因此可以断定 ebp 的保存值比当前的 ebp 的值和函数内所有临时变量(int i 等等)的地址都要大(更靠近栈的底部),因为它来自上一层函数。可以通过简单的实验证实这一点。
我们可以调整上面的代码,让 p 继续向栈底移动,则将依次指向函数返回地址(位于 mainCRTStartup 中的地址),参数 argc 等。例如,通过下面的代码,将打印出 main 函数返回时跳转的地址,可以通过对照输出结果和反汇编代码证实这一点。
#include <stdio.h> int main(int argc, char* argv[], char** environ) { int i = 11; int const* p = &i; //int k; //for(k = 0; environ[k] != NULL; k++) //{ // printf("%s\n", environ[k]); //} printf("&i: %08X\n", &i); printf("&p: %08X\n", &p); p++; //指向ebp的保存值 p++; //指向返回地址 printf("---------------after p++\n"); printf("%08X\n", *p); return 0; }
如果执行的是 p--,将会产生一个有趣的在现实应用中罕见的现象,p 指向了自己。相当于 *p 和 p 的值是相同的,即下面两行代码的输出相同:
printf("%08X", *p);
printf("%p", p);
相当于:
int *p = (int*)&p;
如果不用强制类型转换是不能通过编译的。它指向自己这一点会引发一些理解上的有趣现象,一个变量的值就是它的地址,因此可以对他无限次数的解析引用,就像一个函数指针,使用时前面可以加 0 到多个随意数量的星号。例如下面的代码,产生的输出将相同:
int main(int argc, char* argv[]) { int i = 11; int const* p = &i; p--; // p 指向自己 printf("%08X\n", *p); printf("%p\n", p); int***** p2 = (int*****)p; printf("%08X\n", *****p2); return 0; }
【仅针对 VC6 Debug(其他情况请参考补充)的结论】
现在重新看这道题,则 (C)Garbage Value 的选项也是不正确的,因为 p 向栈底移动,这里的数据是在生存周期之内,而且通常是确定的,可能有明确的含义和作用。在本题中明确的是 ebp 的保存值(上层函数的栈指针)。
如果把 p 向栈顶移动(减小 p 的值),另 p 指向函数申请的栈上未利用空间(被初始化为 0x CCCC CCCC 的部分),则可以认为这里属于 garbage 数据,这里是函数未使用的数据空间(地址是有效的,读写都是安全的)。
当访问了栈顶以外的地址(比当前 esp 小,假设位于栈的空间范围之内)时,则属于 Garbage Value。或者访问具有相应权限的堆范围内的可访问地址(如已被释放的地址),也是垃圾数据。当然这两种情况都是危险的,应该严格禁止。前者更是比较经典的错误,在很多题目中出现。在现实应用里也很容易犯第一种错误。
以下结论主要针对 vc6 debug:
- 本题的本质是令 p 指向“程序员未知”但实际有效有用的栈上地址,因此应选(E)(以上皆否)。 (争议性请参考后面的补充)
- 选(C)(Garbage Value)也可以认为正确,但这是不严谨的。 (争议性请参考后面的补充)
- 如果题目中的 "p++" 修改为 "p -= 2" 则无争议应该选(C)(对于 debug 版本位于函数临时变量空间内,对于 release 可能位于栈顶之外)。
[补充] 最后从这个题目的题干(What is output if you compile and execute the following code ? ) 来分析,它说“如果你编译,执行下面的这段代码,其输出是什么(注意题干中的单词 you 比较关键,从语气上它偏重了程序员角度)”。 则从程序员的角度来说,(c)最有可能符合情况,即结果出乎程序员的意料,程序员不知道那是什么,以及为什么输出这样的结果。无论数据来自哪里,对程序员来说那看起来就是“垃圾数据”。本文并不满足于此,而是希望对输出结果给出更确切详细的分析。答案是否选择(c),主要取决于你对 "garbage value" 这个字眼如何理解和看待(这里的分歧主要是 garbage 是相对于程序员来说的,还是相对于程序本身来说的,对于前者,只要他不是故意这样做的,那么他会认为答案是 c )。所以这道题应该是有一定争议性。
(1) 一个语言层面上比较严谨的出题方法。
如果我们在 i 前面增加一个临时变量 x,就可以在 p++ 后让 p 指向 x。假设 x 没有初始化,它的初值是不确定的(在debug 版本中是 0x cccc cccc,在 release 版本中将是真正的垃圾数据),即 x 的值是垃圾数据(注意 garbage value 指得是这个变量的值,而不是说这个变量本身没有用),这样的话答案选(c)相对于原题目应该是更严谨一些 (因为它能够保证在 i 的高地址方向存在未使用的“空隙”):
void main()
{
int x, i = 11;
int const *p = &i;
p++;
printf("%d", *p); // x 的初值是不确定的。
}
(2) 关于 VC6 release 版本(采用原题代码):
本文主要是分析 debug 版本,因为 debug 版本的代码和 c++ 代码比较接近。而 release 版本代码有很多优化。两者有很多差别。上面的这段代码的 release 版本汇编代码如下:
.text:00401000 sub_401000 proc near
.text:00401000 .text:00401000 var_8 = dword ptr -8 .text:00401000 var_4 = dword ptr -4 .text:00401000 .text:00401000 sub esp, 8 .text:00401003 mov eax, [esp+8+var_4] ; 这是一个不确定的值(垃圾数据)
.text:00401007 mov [esp+8+var_8], 0Bh .text:0040100F push eax .text:00401010 push offset a08x ; "%08X\n" .text:00401015 call sub_401020 .text:0040101A add esp, 10h .text:0040101D retn .text:0040101D sub_401000 endp
栈上分配 8 bytes 临时空间,其中靠近栈底的 4 bytes 无用,另一个是 i,p++后指向了无用的 4 bytes,此处未初始化,因此将输出不确定值。
可以看到 release 版本和 debug 版本的差别非常大,release 版经过了优化,两者主要区别有:
(1)release 在栈上分配空间小,满足需求即可,而 debug 一次分配比较多(48h)。
(2)release 版本不使用 ebp 保存栈顶指针,依然使用 esp 访问参数和临时变量(因为 debug 版本中的 ebp 可用 esp + XXh 表示)。
(3)release 版本没有 prolog 和 epilog。
(4)release 对代码的优化是,没有为指针 p 分配栈上空间。 (p 在release 版本中不存在)
(5)调用 printf 函数后的复原堆栈和释放栈上临时变量空间合并成了一条指令(add esp,10h)。
release 版本的汇编代码基本就和生成它的 C++ 代码一样精简!因此生成的可执行文件 release 版本比 debug 版本小很多。从这个例子也可以看到 release 版本相比 debug 代码效率要高很多。
(3)关于 VS2005 debug 版本(采用原题代码):
本文中主要使用的都是 VC6 来试验。但是使用 VS2005 的 debug 版本输入原题目代码,会导致输出 0x cccc cccc; 可见针对原题代码,不同编译器还是会给出不同实现的。看来 VS2005 产生了更安全的代码(可能是基于对 p 的移动范围的分析)。
根据汇编代码,绘制其栈上空间分布如下所示:
此图和 VC6 debug 版本基本相同,不同的主要是 i 和 p 的存储位置,i 的保存位置距离 ebp 中间空出了一个 int 的空间。而且 p 到 i 之间也有两个 int 的“空隙”。这就导致 VC2005 debug 版的输出是 0xCCCCCCCC。因为 p++ 使 p 指向了 i 和 ebp 之间的无用数据。不知道这是编译器针对代码故意做的特殊处理还是巧合导致的。
(4)关于 VC2005 release 版本(采用原题代码):本质和 VC6 release 相同。
和 VC6 release 高度相似,本质相同(只是语句顺序稍有不同)。因此将输出不确定的值(垃圾数据)。
.text:00401000 sub_401000 proc near ; CODE XREF: start-14Ep .text:00401000 .text:00401000 var_8 = dword ptr -8 .text:00401000 var_4 = dword ptr -4 .text:00401000 .text:00401000 sub esp, 8 .text:00401003 mov eax, [esp+8+var_4] .text:00401007 push eax .text:00401008 push offset a08x ; "%08X\n" .text:0040100D mov [esp+10h+var_8], 0Bh .text:00401015 call ds:printf .text:0040101B xor eax, eax .text:0040101D add esp, 10h .text:00401020 retn .text:00401020 sub_401000 endp
(5)两种编译器和两种版本的结果总结:
win32 debug | win32 release | |
VC6.0 | ebp 的保存值 ( NOT garbage value ) | 不确定值 ( garbage value ) |
VS2005 | 0x CCCC CCCC ( garbage value ) | 不确定值 ( garbage value ) |
共同点是:p 都移向靠近栈底方向。除 VC6 debug (在 i 的高地址方向没有空隙的唯一情况)以外,其他都是指向了栈上临时空间的未使用数据区。因此再次给出补充部分的结论:此题目的输出结果和编译器/编译配置有关,除 VC6 debug 配置以外,其他三种情况皆属于垃圾值 ( garbage value ) 。
题目没有从语言上避免其歧义性,因此产生了依赖编译器的结果。在 debug 版本中 p 实际存在,在 release 版本中 p 因为没有存在价值而不存在(在栈上没有其存储位置)。
(6)结束语:
如果按照出题人的想法,要考察 const 写法,i 的声明就必须在 p 之前,但你又不能明确指明代码意图给答题者,否则就相当于透露了答案。所以既要把题目描述的严谨精确,又尽可能不给答题者透露任何暗示,这是不容易的。