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

ATL 内部机制 第四部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (26投票s)

2002年6月5日

CPOL

8分钟阅读

viewsIcon

124467

downloadIcon

1

继续 ATL 内部机制系列,解释 ATL 的内部工作原理。

引言

到目前为止,我们还没有讨论任何关于汇编语言的内容。但是,如果我们真的想了解 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++ 编译器会修饰函数名以实现函数重载。让我们稍微修改一下程序,重载函数,以便查看代码的行为。

程序 56
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" 来声明函数。让我们看看程序中的一些小改动。

程序 57
extern "C" void fun(int, int) {
}

int main() {
	fun(5, 10);
	return 0;
}

这个函数的汇编语言代码是:

	push	10					; 0000000aH
	push	5
	call	_fun

这意味着你现在不能用 C 链接来重载函数。请看下面的程序:

程序 58
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?它能是其他值吗?正如我们所见,传递给函数的所有参数实际上都被压入了栈中。当你或编译器将某些东西压入栈时,寄存器会受到什么影响?请看下面的简单程序,以了解其行为。我使用了 `printf` 而不是 `cout`,以避免 `cout` 的开销。

程序 59
#include <cstdio>

int g_iTemp;

int main() {

	fun(5, 10);

	_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 寄存器在压入一些值到栈中之前和之后的值。这清楚地表明,当你将某些东西压入栈时,栈在内存中会向下增长。

现在有一个问题,当我们向函数传递参数时,谁负责恢复栈指针?是函数本身还是函数的调用者?实际上,这两种情况都有可能,这也是标准调用约定和 C 调用约定之间的区别。请看调用函数之后的下一条语句。

	push	10					; 0000000aH
	push	5
	call	_fun
	add	esp, 8

这里向函数传递了两个参数,所以将两个值压入栈后,栈指针会减去 8 字节。那么在这个程序中,设置栈指针的责任在于函数的调用者。这被称为 C 调用约定。在这种调用约定下,你可以传递可变数量的参数,因为调用者知道传递了多少参数,所以它可以自己设置栈指针。

但是,如果选择了标准调用约定,那么清理栈的责任就在被调用者身上。在这种情况下,不能向函数传递可变数量的参数,因为函数无法知道传递了多少参数,因此它无法适当地设置栈指针。

请看下面的程序,以了解标准调用约定的行为。

程序 60
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` 指令自己设置栈指针。

现在我们来探索编译器为我们生成的代码。编译器插入这段代码是为了创建栈帧,以便它可以以标准方式访问参数和局部变量。栈帧是为函数保留的内存区域,用于存储关于参数、局部变量和返回地址的信息。当调用新函数时,栈帧总是被创建;当函数返回时,栈帧被销毁。在 8086 架构上,`EBP` 寄存器用于存储栈帧的地址,有时也称为栈指针。

因此,编译器首先保存前一个栈帧的地址,然后使用 `ESP` 的值创建新的栈帧。在函数返回之前,旧栈帧的值会被保留。

现在看看栈帧里有什么。栈帧在 `EBP` 的正向偏移量处包含所有参数,在 `EBP` 的负向偏移量处包含所有局部变量。

所以函数的返回地址存储在 `EBP`,前一个栈帧的值存储在 `EBP + 4`。现在看看这个例子,它有两个参数和三个局部变量。

程序 61
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` 处。

掌握了这些知识后,让我们来玩一个关于函数参数的游戏。看看这个简单的程序。

程序 62
#include <cstdio>

extern "C" int fun(int a, int b) {
	return a + b;
}

int main() {

	printf("%d\n", fun(4, 5));
	return 0;
}

这个程序的输出是符合预期的。输出是“9”。现在稍微修改一下程序。

程序 63
#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。

VC 有 `naked` 属性用于函数。如果你为任何函数指定 `naked` 属性,它就不会为该函数生成序言(prolog)和尾声(epilog)代码。那么什么是序言和尾声代码呢?Prolog 是一个英语单词,意思是“开头”,是的,它也是一种用于人工智能的编程语言的名称,但那种编程语言与编译器生成的 prolog 代码没有关系。这是编译器在函数调用开始时自动插入的代码,用于设置栈帧。看看程序 61 生成的汇编语言代码。在函数开头,编译器会自动插入以下代码来设置栈帧:

	push	ebp
	mov	ebp, esp
	sub	esp, 12					; 0000000cH

这段代码称为序言代码。同样,在函数末尾插入的代码称为尾声代码。在同一个程序中,编译器生成的尾声代码是:

	mov	esp, ebp
	pop	ebp
	ret	0

现在看看带有 `naked` 属性的函数:

程序 64
extern "C" void _declspec(naked) fun() {
	_asm ret
}

int main() {

	fun();
	return 0;
}

编译器生成的函数 fun 的代码大致如下:

	_asm ret

这意味着这个函数没有序言和尾声代码。事实上,`naked` 函数有规则,也就是说,你不能在 `naked` 函数中声明自动变量,因为编译器必须为你生成代码,而在 `naked` 函数中,编译器不会为你生成任何代码。实际上,你必须自己写 `ret` 语句,否则程序会崩溃。你甚至不能在 `naked` 函数中写 `return` 语句。为什么?因为当你从函数返回一个值时,编译器会将其值放入 `eax` 寄存器。所以这意味着编译器必须为你返回语句的代码。

程序 64
#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` 的值复制到调用函数后立即声明的变量中。

现在我们把整个函数变成 `naked`,并加入序言和尾声代码,使其返回两个变量相加的值。

程序 65
#include <cstdio>

extern "C" int _declspec(naked) sum(int a, int b) {

	// prolog code
	_asm push ebp
	_asm mov ebp, esp

	// code for add two variables and return
	_asm mov eax, dword ptr [ebp + 8]
	_asm add eax, dword ptr [ebp + 12]
	
	// epilog code
	_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 的和。

这个属性在 ATLBASE.H 文件中用于实现 `_QIThunk` 结构体的成员。当定义了 `_ATL_DEBUG_INTERFACES` 时,这个结构体用于调试 ATL 程序的引用计数。

我希望在下一篇文章中能探索 ATL 的更多神秘之处。

© . All rights reserved.