65.9K
CodeProject 正在变化。 阅读更多。
Home

检查 C++ 编译器生成的汇编列表 - I

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (31投票s)

2002 年 5 月 27 日

CPOL

9分钟阅读

viewsIcon

144987

downloadIcon

1063

检查 C++ 编译器生成的汇编列表

引言

VC++ 编译器可以创建一个文本文件,其中显示为 C/C++ 文件生成的汇编代码。我经常使用这个文件来查看编译器生成的代码类型。这个文件对异常处理、虚表、虚基表等概念提供了很好的深入理解。只需具备非常基础的汇编语言知识就足以理解清单文件的输出。(有关汇编语言的简要介绍,请参阅 Matt Pietrek 的文章,了解基本汇编语言)。本文(本系列两篇文章之一)的目的是说明清单文件如何帮助我们理解 C++ 编译器的内部工作原理。

设置清单文件

您可以在 VC6 的项目设置对话框中设置 C/C++ 编译器选项以生成清单文件,如下图所示。

在 VC++.NET 中,您可以在项目属性对话框中设置相同的选项。

编译器生成的不同类型的清单包括:-

  1. 仅汇编代码 (.asm)
  2. 汇编代码和机器码 (.cod)
  3. 带源代码的汇编代码 (.asm)
  4. 带机器码和源代码的汇编代码 (.cod)

查看清单文件

让我们检查以下应用程序生成的清单。

#include <stdio.h>

int main(int argc, char* argv[])
{
    printf("Hello World!");
    return 0;
}		

1. 仅汇编清单 (/FA)

汇编清单被放置在中间目录中扩展名为 .asm 的文件中。例如,如果文件名为 main.cpp,则中间目录中会有一个 main.asm 文件。以下是清单文件中 main 函数的代码片段:-

PUBLIC  _main
PUBLIC  ??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@ ; `string'
EXTRN   _printf:NEAR
; COMDAT ??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@
; File g:\wksrc\compout\main.cpp
CONST   SEGMENT
??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@ DB 'Hello World!', 00H ; `string'
; Function compile flags: /Ogty
CONST   ENDS
;   COMDAT _main
_TEXT   SEGMENT
_argc$ = 8
_argv$ = 12
_main   PROC NEAR ; COMDAT
; Line 5
    push    OFFSET FLAT:??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@
    call    _printf
    add esp, 4
; Line 6
    xor eax, eax
; Line 7
    ret 0
_main   ENDP
END     

让我们尝试检查清单。

  • 以分号 (;) 开头的行是注释。
  • PUBLIC _main 表示 _main 函数与其他文件共享(与静态函数相反)。对于静态函数,没有前缀。
  • CONST SEGMENT 表示 CONST 数据段的开始。VC++ 编译器将字符串等常量数据放置在此部分。因此,我们看到字符串 "Hello World" 被放置在 CONST 段中。修改常量段中的任何数据都会导致抛出访问冲突异常。稍后将详细介绍。
  • _TEXT SEGMENT 标记另一个段的开始。编译器将所有代码放置在此段中。
  • _argc$ = 8 和 _argv$ = 12 表示参数 argc 和 argv 的堆栈位置。在这种情况下,它意味着如果将 8 添加到堆栈指针(CPU 寄存器 ESP),您将获得 argc 参数的地址。偏移量 4 将用于返回地址。
  • _main PROC NEAR 表示 _main 函数的开始。请注意,对于 C 函数(用 extern "C" 声明的函数),名称前缀为 _;对于 C++ 函数,名称经过修饰。
  • 接下来,我们看到编译器将字符串 "Hello World" 的地址推入堆栈并调用 printf 函数。函数调用结束后,堆栈指针增加 4(因为 printf 采用 C 调用约定)。
  • EAX 是保存函数返回值的寄存器。我们看到 EAX 与自身进行异或运算。(这是一种快速将寄存器设置为零的方法。)这是因为我们的原始代码从 main 函数返回 0。
  • 最后,ret 0 是从函数返回的指令。ret 指令后面的数字参数 0 表示堆栈指针应增加的量。

所以这是仅汇编清单。让我们看看其他三个清单是什么样子。

2. 带源代码的汇编 (/FAs)

这个清单比第一个提供了更清晰的视图。它显示了源代码和汇编代码。

_TEXT   SEGMENT
_argc$ = 8
_argv$ = 12
_main   PROC NEAR ; COMDAT

; 5    :    printf("Hello World");

    push    OFFSET FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
    call    _printf
    add esp, 4

; 6    :    return 0;

    xor eax, eax

; 7    : }

3. 带机器码的汇编 (/FAc)

清单显示了指令代码和指令助记符。此清单通常生成在 .cod 文件中。因此,对于此示例,清单可以在 main.cod 文件中看到。

;   COMDAT 
_main   _TEXT SEGMENT
_argc$  = 8 
_argv$ = 12 
_main PROC
NEAR  ; COMDAT        
; Line 5 
00000 68 00 00 00 00 push OFFSET     
            FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
                 
00005 e8 00 00 00 00 call _printf    
0000a   83 c4 04 add esp,  4   

; Line 6 
0000d 33  c0    xor eax, eax     

; Line 7 
0000f c3  ret   0        
_main   ENDP            
    

4. 汇编、机器码和源代码 (/FAsc)

此清单也生成在 .cod 文件中。正如预期,它显示了源代码以及带有汇编的机器码。

;   COMDAT _main
_TEXT   SEGMENT
_argc$ = 8
_argv$ = 12
_main   PROC NEAR  ; COMDAT

; 5    :    printf("Hello World");

  00000 68 00 00 00 00   push    
      OFFSET FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
  00005 e8 00 00 00 00   call    _printf
  0000a 83 c4 04     add     esp, 4

; 6    :    return 0;

  0000d 33 c0        xor     eax, eax

; 7    : }

  0000f c3       ret     0
_main   ENDP
        

所以我们看到了编译器生成的四种类型的清单。一般来说,没有必要查看机器码。在大多数情况下,带源代码的汇编 (/FAs) 是最有用的清单。

在了解了不同类型的清单以及如何生成清单后,让我们看看我们可以从清单中收集到哪些有用信息。

常量段

我们看到编译器将常量字符串 "Hello World" 放置在 CONST 段中。让我们通过以下示例应用程序来研究其含义。

#include <stdio.h> <stdio.h>

char* szHelloWorld = "Hello World";

int main(int argc, char* argv[])
{
    printf(szHelloWorld);

    szHelloWorld[1] = 'o';
    szHelloWorld[2] = 'l';
    szHelloWorld[3] = 'a';
    szHelloWorld[4] = '\'';

    printf(szHelloWorld);

    return 0;
}

这个示例应用程序首先打印 "Hello World",然后尝试将字符串 "Hello" 转换为 "Hola'",最后打印修改后的字符串。让我们构建并运行这个应用程序。令我们惊讶的是,应用程序在 szHelloWorld[2] = 'l'; 这一行崩溃并抛出访问冲突异常。

让我们修改这一行

char* szHelloWorld = "Hello World"; 

to

char szHelloWorld[] = "Hello World"; 

这次应用程序成功运行。检查清单可以告诉我们原因。

  1. 在第一种情况下,数据 "Hello World" 放置在 CONST 段中,这是一个只读段。
  2. CONST	SEGMENT
    ?szHelloWorld@@3PADA DB 'Hello World', 00H ; szHelloWorld
    CONST	ENDS				
  3. 在第二种情况下,数据放置在 _DATA 段中,这是一个读写段。
  4. _DATA	SEGMENT
    ?szHelloWorld@@3PADA DB 'Hello World', 00H ; szHelloWorld
    _DATA	ENDS
    

函数内联

从汇编清单中可以看到的最有用的事情之一是函数是否被内联。inline 指令或 _declspec(inline) 并不能强制编译器将函数内联。有各种因素(大部分未知)决定函数是否被内联。同样,不在函数前添加 inline 指令并不意味着它不会被内联。汇编清单在找出函数是否被内联方面非常有价值。让我们以下面的例子为例:-

void ConvertStr(char* argv)
{
    szHelloWorld[1] = 'o';
    szHelloWorld[2] = 'l';
    szHelloWorld[3] = 'a';
    szHelloWorld[4] = '\'';
}

int main(int argc, char* argv[])
{
    printf(szHelloWorld);
    ConvertStr(szHelloWorld);
    printf(szHelloWorld);
    return 0;
}

让我们检查 main 函数的清单。

_main   PROC NEAR       ; COMDAT

; 15   :    printf(szHelloWorld);

push OFFSET FLAT:?szHelloWorld@@3PADA    ; szHelloWorld
call  _printf

; 16   :    
; 17   :    ConvertStr(szHelloWorld);
; 18   : 
; 19   :    printf(szHelloWorld);

push OFFSET FLAT:?szHelloWorld@@3PADA    ; szHelloWorld
mov BYTE PTR ?szHelloWorld@@3PADA+1, 111    ; 0000006fH
mov BYTE PTR ?szHelloWorld@@3PADA+2, 108    ; 0000006cH
mov BYTE PTR ?szHelloWorld@@3PADA+3, 97 ; 00000061H
mov BYTE PTR ?szHelloWorld@@3PADA+4, 39 ; 00000027H
call    _printf
add esp, 8

; 20   : 
; 21   :    return 0;

    xor eax, eax

; 22   : }

    ret 0

请注意,我们没有看到 ConvertStr 的任何调用指令,而是看到了许多 move BYTE PTR 指令,它们修改了字符串中的字符(由 ConvertStr 函数完成)。这表明 ConvertStr 实际上是内联展开的。

让我们通过使用 _declspec(noinline) 来禁用 ConvertStr 的内联函数展开。

_main   PROC NEAR  ; COMDAT

; 15   :    printf(szHelloWorld);

    push    OFFSET FLAT:?szHelloWorld@@3PADA    
        ; szHelloWorld

    call    _printf

; 16   :    ConvertStr(szHelloWorld);

    push    OFFSET FLAT:?szHelloWorld@@3PADA    
        ; szHelloWorld

    call    ?ConvertStr@@YAXPAD@Z   ; ConvertStr

; 17   :    printf(szHelloWorld);

    push    OFFSET FLAT:?szHelloWorld@@3PADA            
        ; szHelloWorld

    call    _printf
    add esp, 12  ; 0000000cH

; 18   :    return 0;

    xor eax, eax

; 19   : }

    ret 0
_main   ENDP

正如预期,我们看到了对 ConvertStr 函数的调用指令。函数是否被内联取决于编译器的判断。在某些情况下,当一个内联函数调用另一个内联函数时,只有一个函数被内联。使用 #pragma inline_depth()#pragma inline_recursion() 有时似乎有帮助。同样,清单文件在显示函数是否被内联方面非常有用。

析构函数

为了检查析构函数的行为,让我们以下面的例子为例。

class SmartString
{
private:
    char* m_sz;

public:
    SmartString(char* sz)
    {
        m_sz = new char[strlen(sz) + 1];
        strcpy(m_sz, sz);
    }
	
    char* ToStr()
    {
        return m_sz;
    }

    _declspec(noinline) ~SmartString()
    {
        delete[] m_sz;
    }
};

int main(int argc, char* argv[])
{
    SmartString sz1("Hello World");
    printf(sz1.ToStr());

    return 0;
}

生成的代码如下所示。

; 36   : {

    push    ecx

; 37   :    SmartString sz1("Hello World");

push    OFFSET FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
lea ecx, DWORD PTR _sz1$[esp+8]
call    ??0SmartString@@QAE@PAD@Z       
    ; SmartString::SmartString

; 38   :    printf(sz1.ToStr());

    mov eax, DWORD PTR _sz1$[esp+4]
    push    eax
    call    _printf
    add esp, 4

; 39   : 
; 40   :    return 0;

    lea ecx, DWORD PTR _sz1$[esp+4]

    call    ??1SmartString@@QAE@XZ 
        ; SmartString::~SmartString

    xor eax, eax

; 41   : }

    pop ecx
    ret 0
_main   ENDP

这非常直接,我们清楚地看到函数退出之前调用了析构函数。有趣的是看看析构函数如何为对象数组工作。所以我们稍微修改一下应用程序。

int main(int argc, char* argv[])
{
    SmartString arr[2];

    arr[0] = ("Hello World");
    arr[1] = ("Hola' World");

    printf(arr[0].ToStr());
    printf(arr[1].ToStr());
	
    return 0;
}

main 函数的最后几行现在看起来像下面这样。

push    OFFSET FLAT:??1SmartString@@QAE@XZ  
; SmartString::~SmartString

push    2
push    4
lea eax, DWORD PTR _arr$[ebp]
push    eax
call    ??_I@YGXPAXIHP6EX0@Z@Z
xor eax, eax
leave
ret 0   

这段代码是对函数 ??_I@YGXPAXIHP6EX0@Z@Z 的函数调用,它在英语中代表“向量析构函数迭代器”(可以在清单中看到)。这个函数是由编译器自动生成的。上述汇编代码用 C++ 翻译将是:-

vector_destructor_iterator(arr, 2, 4, 
        &SmartString::SmartString);

这个“向量析构函数迭代器”到底是什么?我们知道当一个对象数组超出作用域时,数组中每个对象的析构函数都会被调用。这就是向量析构函数迭代器所做的。让我们检查向量析构函数迭代器的代码并尝试进行逆向工程。

PUBLIC  ??_I@YGXPAXIHP6EX0@Z@Z              
; `vector destructor iterator'

; Function compile flags: /Ogsy
;   COMDAT ??_I@YGXPAXIHP6EX0@Z@Z
_TEXT   SEGMENT
___t$ = 8
___s$ = 12
___n$ = 16
___f$ = 20
??_I@YGXPAXIHP6EX0@Z@Z PROC NEAR            
; `vector destructor iterator', COMDAT

    push    ebp
    mov ebp, esp
    mov eax, DWORD PTR ___n$[ebp]
    mov ecx, DWORD PTR ___s$[ebp]
    imul    ecx, eax
    push    edi
    mov edi, DWORD PTR ___t$[ebp]
    add edi, ecx
    dec eax
    js  SHORT $L912
    push    esi
    lea esi, DWORD PTR [eax+1]
$L911:
    sub edi, DWORD PTR ___s$[ebp]
    mov ecx, edi
    call    DWORD PTR ___f$[ebp]
    dec esi
    jne SHORT $L911
    pop esi
$L912:
    pop edi
    pop ebp
    ret 16          ; 00000010H
??_I@YGXPAXIHP6EX0@Z@Z ENDP             
; `vector destructor iterator'

从我们之前的讨论中我们知道 __t$=8 等表示函数的参数。我们已经知道从 _main 函数的汇编代码中调用函数的方式。基于此,我们可以找出函数的签名:-

typedef void (*DestructorPtr)(void* object);
void vector_destructor_iterator(void* _t, 
        int _n, int _s, DestructorPtr _f)		
函数实现可以逆向工程为类似以下内容:
void vector_destructor_iterator(void* _t, int _n, int _s, DestructorPtr _f)
{
    unsigned char* ptr = _t + _s*_n;

    while(_n--)
    {
        ptr -= size;
        _f(ptr);
    }					
}

基本上,它调用数组中每个对象的析构函数。现在我们可以弄清楚各个参数的含义。

vector_destructor_iterator(arr, 2, 4, 
    &SmartString::SmartString);
  • 第一个参数显然是指向数组的指针。
  • 第二个参数是数组中的元素数量。
  • 第三个参数是单个元素的大小。在我们的例子中,是 </li> sizeof(SmartString) (= 4)。
  • 第四个参数是析构函数的地址。

在这种情况下,编译器准确地知道数组中的元素数量。所以它将 2 作为数组大小传递给向量析构函数迭代器。问题是使用 new 分配的动态数组会发生什么。在这种情况下,编译器无法找出数组的确切大小,因为数组是在运行时分配的。为了找出这一点,我们再次修改应用程序。

int main(int argc, char* argv[])
{
    SmartString arr = new SmartString[2];
    arr[0] = "Hello World";
    arr[1] = "Hola' World";

    printf(arr[0].ToStr());
    printf(arr[1].ToStr());
		
    delete [] arr;

    return 0;
}

汇编清单现在看起来像:-

; 30   :    
; 31   :    delete [] arr;

    push    3
    mov ecx, esi
    call    ??_ESmartString@@QAEPAXI@Z
    pop edi

; 32   : 
; 33   :    return 0;

    xor eax, eax
    pop esi

我们看到出现了一个新函数 - ??_ESmartString@@QAEPAXI@Z。这个函数在英语中被称为“向量删除析构函数”。向量删除析构函数的汇编代码:-

PUBLIC  ??_ESmartString@@QAEPAXI@Z          
; SmartString::`vector deleting destructor'

; Function compile flags: /Ogsy
;   COMDAT ??_ESmartString@@QAEPAXI@Z
_TEXT   SEGMENT
___flags$ = 8
??_ESmartString@@QAEPAXI@Z PROC NEAR            
; SmartString::`vector deleting destructor', COMDAT

; _this$ = ecx
    push    ebx
    mov bl, BYTE PTR ___flags$[esp]
    test    bl, 2
    push    esi
    mov esi, ecx
    je  SHORT $L896
    push    edi
    push    OFFSET FLAT:??1SmartString@@QAE@XZ  
    ; SmartString::~SmartString

    lea edi, DWORD PTR [esi-4]
    push    DWORD PTR [edi]
    push    4
    push    esi
    call    ??_I@YGXPAXIHP6EX0@Z@Z
    test    bl, 1
    je  SHORT $L897
    push    edi
    call    ??3@YAXPAX@Z ; operator delete
    pop ecx
$L897:
    mov eax, edi
    pop edi
    jmp SHORT $L895
$L896:
    mov ecx, esi
    call    ??1SmartString@@QAE@XZ          
    ; SmartString::~SmartString

    test    bl, 1
    je  SHORT $L899
    push    esi
    call    ??3@YAXPAX@Z ; operator delete
    pop ecx
$L899:
    mov eax, esi
$L895:
    pop esi
    pop ebx
    ret 4
??_ESmartString@@QAEPAXI@Z ENDP             
; SmartString::`vector deleting destructor'

_TEXT   ENDS

这个函数有一个 __thiscall 调用约定,意味着第一个参数 this 被放置在 ECX 中。这是用于调用成员函数的伪调用约定。这个的 C++ 伪代码是:-

void SmartString::vector_deleting_destructor(int flags)
{
    if (flags & 2)
    {
        int numElems = *((unsigned char*)this - 4);
        vector_destructor_iterator(this, numElems, 
            4, &SmartString::SmartString);
    }
    else
    {
        this->~SmartString();
    }

    if (flags & 1)
        delete ((unsigned char*)this - 4);
}

所以我们看到元素数量存储在数组第一个元素之前。这意味着 new[] 运算符应该额外分配 4 字节。查看为 new[] 调用生成的汇编代码证实了这一点。

; 23   : SmartString* arr = new SmartString[2];

    push    12      ; 0000000cH
    call    ??2@YAPAXI@Z    ; operator new      

new 运算符将要分配的大小作为参数。我们看到数字 12 被推入堆栈。SmartString 的大小只有 4 字节,两个元素的总大小是 8 字节。所以 new 运算符确实额外分配了 4 字节来标记数组中的元素数量。为了进一步证实这一点,重载 SmartString 中的 new[] 运算符。可以看到,请求的内存量总是比存储数组所需的实际内存多 4 字节。

构造函数

让我们看看调用 new 运算符的段的汇编代码。

; 23   : SmartString* arr = new SmartString[2];

    push    12  ; 0000000cH
    call    ??2@YAPAXI@Z    ; operator new
    test    eax, eax
    pop ecx
    je  SHORT $L980
    push    2
    pop ecx
    push    OFFSET FLAT:??0SmartString@@QAE@XZ  
    ; SmartString::SmartString

    push    ecx
    lea esi, DWORD PTR [eax+4]
    push    4
    push    esi
    mov DWORD PTR [eax], ecx
    call    ??_H@YGXPAXIHP6EPAX0@Z@Z
    jmp SHORT $L981
$L980:
    xor esi, esi
$L981:

??_H@YGXPAXIHP6EPAX0@Z@Z 函数是“向量构造函数迭代器”,类似于向量析构函数迭代器。

翻译成伪 C++ 代码,它会看起来像

unsigned char* allocated = 
    new unsigned char[12]; //Allocate 12 bytes 4 for the size

if (allocated != NULL)
{
    //Put the size in the first four bytes
    //The actual array starts at allocated + 4
    *(int*)allocated = 4; 
	
    vector_constructor_iterator(allocated + 4, 
        4, 2, &SmartString::SmartString);
}

向量构造函数迭代器的工作方式与向量析构函数迭代器相同。它为数组中的所有元素调用构造函数。

异常

在我所有的例子中,我在编译应用程序之前禁用了异常处理。启用异常处理会导致编译器发出大量额外的代码。Vishal Kocchar 在 他的文章中对此进行了详细描述。

调用约定

Nemja Trifunjovic 在 他的文章中详细描述了调用约定。

结论

我尝试使用汇编清单检查 C++ 编译器内部工作的一些方面。检查汇编清单可以清楚地了解编译器在 C++ 代码的幕后所做的事情。这将帮助我们编写更好、更高效的 C++ 代码。从汇编清单中还可以发现许多其他事情。在下一篇文章中,我将讨论编译器如何实现虚函数表 (vftables)、虚基表 (vbtables) 和运行时类型信息 (RTTI)。

© . All rights reserved.