作者:Zeeshan Amjad
译者:李马 (home.nuc.edu.cn/~titilima )
原文出处: http://www.codeproject.com/atl/atl_underthehood_4.asp
介绍
到现在为止,我们还没有讨论过任何有关汇编语言的东西。但是如果我们真的要了解ATL底层内幕的话,就不能回避这一话题,因为ATL使用了一些底层的技术以及一些内联汇编语言来使它更小巧快速。在这里,我假设读者已经拥有了汇编语言的基础知识,所以我只会集中于我的主题,而不会再另外写一份汇编语言的教程。如果你尚未足够了解汇编语言,那么我建议你看一看Matt Pietrek于1998年2月发表在Microsoft System Journal的文章《Under The Hood》,这篇文章会给予你关于汇编语言足够的信息的。
现在就要开始我们的旅行了,那么先以这个简单的程序作为热身吧:
程序55.
void fun(int, int) { } int main() { fun(5, 10); return 0; }现在在命令行模式下,使用命令行编译器cl.exe来编译它。在编译的时候,使用-FAs开关,例如,如果程序的名字是prog55的话:
Cl -FAs prog55.cpp这就会生成一个带有相同文件名,扩展名为.asm的文件,这个文件中包含有以下程序的汇编语言代码。现在看看生成的输出文件,让我们首先来讨论函数的调用吧。调用函数的汇编代码是类似这个样子:
push 10 ; 0000000aH push 5 call ?fun@@YAXHH@Z ; fun首先,函数的参数以自右而左的顺序入栈,然后再调用函数。但是,函数的名称和我们给定的有所不同,这是由于C++编译器会对函数的名称作一些修饰已完成函数的重载。让我们稍微修改一下程序,重载这个函数,再来看看代码的行为吧。
void fun(int, int) { } void fun(int, int, int) { } int main() { fun(5, 10); fun(5, 10, 15); return 0; }现在调用这两个函数的汇编代码是类似这个样子:
push 10 ; 0000000aH push 5 call ?fun@@YAXHH@Z ; fun push 15 ; 0000000fH push 10 ; 0000000aH push 5 call ?fun@@YAXHHH@Z ; fun请看函数的名字,我们编写了两个名称相同的函数,但是编译器将函数名做了修饰完成了函数重载的工作。
extern "C" void fun(int, int) { } int main() { fun(5, 10); return 0; }调用函数的汇编代码为
push 10 ; 0000000aH push 5 call _fun这就意味着现在你就不能对这个带有C链接方式的函数进行重载了。请看以下的程序
extern "C" void fun(int, int) { } extern "C" void fun(int, int, int) { } int main() { fun(5, 10); return 0; }这个程序会给出一个编译错误,因为函数的重载在C语言中是不支持的,并且你给两个函数起同样的名称的同时还告诉编译器不要修饰它的名字,也就是使用C的链接方式,而不是C++的链接方式。
push ebp mov ebp, esp pop ebp ret 0在我们进行详细地讲解之前,请看以下函数的最后一条语句,也就是ret 0。为什么是0?或者可以是别的非0数吗?正如我们所见,我们向函数传递的所有参数事实上都被压入了堆栈。在你或者编译器向堆栈中压入数据的时候,会对寄存器有什么影响吗?请看以下这个简单的程序来观察这一行为吧。我使用了printf而不是cout,这是为了避免cout的开销。
#include <cstdio> int g_iTemp; int main() { fun(5, 10); // 译注:这里的fun,应该是上文中的void fun(int, int) _asm mov g_iTemp, esp printf("Before push %d\n", g_iTemp); _asm push eax _asm mov g_iTemp, esp printf("After push %d\n", g_iTemp); _asm pop eax return 0; }程序的输出为:
Before push 1244980 After push 1244976这个程序显示了压栈前后ESP寄存器中的值。下图清楚地说明了在你向堆栈中压入数据后,ESP的值会减少。
push 10 ; 0000000aH push 5 call _fun add esp, 8在这里有两个参数传递给了函数,所以堆栈指针在两个参数入栈后会减去8个字节。现在在这个程序中,设置堆栈指针就是函数调用者的职责了。这就称作C调用约定。在这种调用约定中,你可以传递可变数目的参数,因为调用者知道有多少参数传递给了函数,所以它可以来设置堆栈指针。
extern "C" void _stdcall fun(int, int) { } int main() { fun(5, 10); return 0; }现在来看看函数的调用。
push 10 ; 0000000aH push 5 call _fun@8在这里,函数名称中的@表示这是一个标准调用约定,8则表示被压入堆栈的字节数。所以,参数的数目可以由这个数目除以4得知。
push ebp mov ebp, esp pop ebp ret 8这个函数通过“ret 8”指令在返回之前设置了堆栈指针。
extern "C" void fun(int a, int b) { int x = a; int y = b; int z = x + y; return; } int main() { fun(5, 10); return 0; }现在来看看编译器产生的函数代码。
push ebp mov ebp, esp sub esp, 12 ; 0000000cH ; int x = a; mov eax, DWORD PTR _a$[ebp] mov DWORD PTR _x$[ebp], eax ; int y = b; mov ecx, DWORD PTR _b$[ebp] mov DWORD PTR _y$[ebp], ecx ; int z = x + y; mov edx, DWORD PTR _x$[ebp] add edx, DWORD PTR _y$[ebp] mov DWORD PTR _z$[ebp], edx mov esp, ebp pop ebp ret 0现在来看看_x、_y这些东西都是什么。也就是定义在函数定义上方的这些东西:
_a$ = 8 _b$ = 12 _x$ = -4 _y$ = -8 _z$ = -12这就意味着你可以像这样阅读代码:
; int x = a; mov eax, DWORD PTR [ebp + 8] mov DWORD PTR [ebp - 4], eax ; int y = b; mov ecx, DWORD PTR [ebp + 12] mov DWORD PTR [ebp - 8], ecx ; int z = x + y; mov edx, DWORD PTR [ebp - 4] add edx, DWORD PTR [ebp - 8] mov DWORD PTR [ebp - 12], edx这也就意味着参数a和b的地址分别为EBP + 8和EBP + 12。并且,x、y和z的值分别存储在内存中EBP - 4、EBP - 8、EBP - 12的位置上。
#include <cstdio> extern "C" int fun(int a, int b) { return a + b; } int main() { printf("%d\n", fun(4, 5)); return 0; }就像我们所期望的那样,程序的输出为9。现在让我们来对程序作少许修改。
#include <cstdio> extern "C" int fun(int a, int b) { _asm mov dword ptr[ebp+12], 15 _asm mov dword ptr[ebp+8], 14 return a + b; } int main() { printf("%d\n", fun(4, 5)); return 0; }程序的输出为29。我们知道参数的地址,并且在程序中我们改变了参数的值。因而,在我们将两个变量相加时,新的变量值15和14就被加起来了。
push ebp mov ebp, esp sub esp, 12 ; 0000000cH这段代码就称作prolog代码。同样,插入在函数末尾的代码就称作epilog代码。在程序61中,编译器生成的epilog代码为:
mov esp, ebp pop ebp ret 0现在来看看带有naked属性的函数。
extern "C" void _declspec(naked) fun() { _asm ret } int main() { fun(); return 0; }编译器生成的fun函数代码是类似于这个样子:
_asm ret这就意味着在这个函数中没有prolog代码和epilog代码。事实上,naked函数有一些规则,也就是你不能在naked函数中定义自动变量。因为如果你这么做的话,编译器就需要为你产生代码,而naked函数中编译器是不会产生任何代码的。其实,你还需要自己编写ret语句,否则程序就会崩溃。你甚至不能在naked函数中编写return语句。为什么呢?因为当你从函数中返回一些东西的时候,编译器就会把它的值放在eax寄存器之中。所以这就意味着编译器会为你的return语句产生代码。让我们通过下面的简单程序来弄懂函数返回值的工作过程吧。
#include <cstdio> extern "C" int sum(int a, int b) { return a + b; } int main() { int iRetVal; sum(3, 7); _asm mov iRetVal, eax printf("%d\n", iRetVal); return 0; }程序的输出为10。在这里我们并没有直接使用函数的返回值,而是在函数调用结束后将eax的值复制了一份。
#include <cstdio> extern "C" int _declspec(naked) sum(int a, int b) { // prolog代码 _asm push ebp _asm mov ebp, esp // 用于相加变量和返回的代码 _asm mov eax, dword ptr [ebp + 8] _asm add eax, dword ptr [ebp + 12] // epilog代码 _asm pop ebp _asm ret } int main() { int iRetVal; sum(3, 7); _asm mov iRetVal, eax printf("%d\n", iRetVal); return 0; }程序的输出为10,也就是两个参数3和7的和。