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






4.97/5 (31投票s)
检查 C++ 编译器生成的汇编列表
引言
VC++ 编译器可以创建一个文本文件,其中显示为 C/C++ 文件生成的汇编代码。我经常使用这个文件来查看编译器生成的代码类型。这个文件对异常处理、虚表、虚基表等概念提供了很好的深入理解。只需具备非常基础的汇编语言知识就足以理解清单文件的输出。(有关汇编语言的简要介绍,请参阅 Matt Pietrek 的文章,了解基本汇编语言)。本文(本系列两篇文章之一)的目的是说明清单文件如何帮助我们理解 C++ 编译器的内部工作原理。
设置清单文件
您可以在 VC6 的项目设置对话框中设置 C/C++ 编译器选项以生成清单文件,如下图所示。
在 VC++.NET 中,您可以在项目属性对话框中设置相同的选项。
编译器生成的不同类型的清单包括:-
- 仅汇编代码 (.asm)
- 汇编代码和机器码 (.cod)
- 带源代码的汇编代码 (.asm)
- 带机器码和源代码的汇编代码 (.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";
这次应用程序成功运行。检查清单可以告诉我们原因。
- 在第一种情况下,数据 "Hello World" 放置在
CONST
段中,这是一个只读段。 - 在第二种情况下,数据放置在
_DATA
段中,这是一个读写段。
CONST SEGMENT
?szHelloWorld@@3PADA DB 'Hello World', 00H ; szHelloWorld
CONST ENDS
_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)。