现在的位置: 首页 > 综合 > 正文

关于微软 2012 暑期实习题第 5 题

2012年04月19日 ⁄ 综合 ⁄ 共 7657字 ⁄ 字号 评论关闭

    本文中提到的题目来自于这篇文章:《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 之前,但你又不能明确指明代码意图给答题者,否则就相当于透露了答案。所以既要把题目描述的严谨精确,又尽可能不给答题者透露任何暗示,这是不容易的。

抱歉!评论已关闭.