前言
上一篇文章中,我们了解到了x86-32 MASM的关键寄存器,常用的汇编指令,以及给出简单的C++程序编译后的汇编代码,并对照源C++代码进行了逐行解析。本文想要讨论的主题,集中于C++和MASM汇编中函数调用的过程,尤其是如何利用调用栈去进行函数调用。
程序分区与调用栈
先复习一下C++程序的分区。C++程序的分区主要有以下几类:
- 代码区(Text Segment):存储程序的可执行机器代码。
- 数据区:包括初始化的全局变量和静态变量。数据区又分为以下几部分:
2.1 已初始化数据段(Initialized Data Segment):存储初始化的全局变量和静态变量。
2.2 未初始化数据段(Uninitialized Data Segment,又称BSS Segment):存储未初始化的全局变量和静态变量。在程序启动前,系统会将这些变量初始化为零。 - 堆(Heap):用于存储程序在运行时动态分配的内存。堆由程序员负责管理,需要手动申请和释放内存。
- 栈(Stack):用于存储程序运行时的局部变量、函数参数和返回地址等。栈的内存分配和释放由编译器自动管理。
幸运的是,当我们讨论C++编译为MASM的场景时,两者的内存分区可以在逻辑上统一。MASM汇编程序的内存分区也可以分为同样的四个区域,并且完全对照C/C++程序的四个分区。只是相对而言,C/C++的堆区管理和栈区管理更加简化,在汇编程序中,程序员需要更直接地处理底层细节。
对大部分C++程序员而言,可能知道栈区的存在,也听过“调用栈”之类的概念,但未必有足够深入的理解。再复习一下栈这个数据结构的性质。栈是一种后进先出(LIFO,Last In First Out)数据结构,允许在顶部(称为栈顶)进行数据的添加(压栈)和移除(弹栈)操作。为了不混淆数据结构意义上的“栈”,以及C++和MASM汇编中进行函数调用时需要作为媒介的“栈”,建议将后者称之为“调用栈”(call stack)或者“函数调用栈”(function call stack)。不过为了方便,后文提到的“栈”一律指代调用栈。
调用栈在程序中有多种作用,包括:
- 存储局部变量:当一个函数被调用时,它的局部变量和临时数据将被分配在栈上,函数返回时会自动释放这些空间。
- 参数传递:在函数调用时,通常会将参数压入栈中,供被调用函数访问和使用。
- 返回地址存储:当一个函数被调用时,程序需要知道从何处返回执行。因此,当前执行点(通常是下一条指令)的地址会被压入栈中。当函数执行完毕并返回时,该地址将从栈顶弹出,程序会继续从该地址执行。
- 嵌套函数调用:栈使得程序可以处理嵌套的函数调用,每次函数调用都会在栈上分配新的空间,以保持当前上下文环境。这里说到的“保持上下文环境”,从汇编角度理解,其实就是保存主调函数的寄存器状态(最关键的是EBP基指针寄存器),并将ESP寄存器的值更新到EBP寄存器中。
这里又说到了上一章有所强调的EBP(基指针寄存器)ESP(栈指针寄存器)。可以由下图看到,EBP指向的是栈的底部,ESP指向的是栈的顶部(当栈中没有任何数据时,EBP和ESP都指向栈底)。当进行入栈操作时(调用PUSH汇编指令),ESP值自减;当进行出栈操作时(调用POP汇指),ESP自增。
入栈操作时,EBP寄存器和ESP寄存器的指向
出栈操作时,EBP寄存器和ESP寄存器的指向
图源:《汇编语言:基于x86处理器》——基普·欧文
调用栈在程序执行过程中起着至关重要的作用,有效地管理了程序的局部变量、函数调用和返回地址等关键信息。这么讲可能读者还是没办法理解,没关系,后文会给出具体的汇编代码,去解释调用栈是如何在函数调用中起作用的。
函数调用约定
相信C++程序员也都了解过函数调用约定,but again,理解未必足够到位。我们在Windows下的C++开发中,常用的调用约定就两:__cdecl
和__stdcall
。因此出于篇幅原因,我们的汇编代码示例和讲解只讨论这两种函数调用约定方式。它们的异同如下:
- __cdecl(C调用约定):
- 参数传递:从右到左将参数压入栈。
- 返回值:EAX寄存器存放整数和指针类型的返回值,浮点类型的返回值存放在ST0寄存器中。
- 调用者需要保存的寄存器:EAX、ECX、EDX。
- 被调用者需要保存的寄存器:EBX、ESI、EDI、EBP。
- 栈清理:由调用者清理栈。在MASM中,实际指的是将ESP寄存器值调整回函数调用之前的值。
- __stdcall(标准调用约定,Windows API通常使用此约定):
- 参数传递:从右到左将参数压入栈。
- 返回值:与__cdecl相同。
- 调用者需要保存的寄存器:与__cdecl相同。
- 被调用者需要保存的寄存器:与__cdecl相同。
- 栈清理:由被调用者清理栈。同样,在MASM中,实际指的是将ESP寄存器值调整回函数调用之前的值。
在Windows平台和MSVC编译器下,__stdcall
声明和WINAPI
(或者APIENTRY
、CALLBACK
等)是等效的。它们都是宏定义,指定了使用__stdcall
函数调用约定。这些不同的名称在实际使用中只是为了提高代码的可读性和表达意图。
- __stdcall:通常用于C++代码中,显式地指定函数调用约定。
- WINAPI:主要用于Windows API函数声明,强调函数是Windows API的一部分。
- APIENTRY:通常用于Windows驱动程序开发中的函数声明。
- CALLBACK:用于指定回调函数的调用约定。这些函数通常作为参数传递给其他函数或API,以便在特定事件或条件下由系统调用。
程序解析
先给出C++程序。其中包含一个函数,用于做简单的加法。注意这里没有指定函数调用约定,所以使用的是默认的__cdecl
。
#include <iostream>
int add(int a, int b) {
return a + b;
}
int main() {
int x = 3;
int y = 4;
int result = add(x, y);
std::cout << "The sum is: " << result << std::endl;
return 0;
}
以下是C++程序编译后可能对应的MASM汇编代码。同样,这里的MASM代码只是一种可能的情况,具体编译出的汇编代码取决于编译器版本等因素
; 导入外部库
extrn _printf:proc
extrn _scanf:proc
extrn _exit:proc
; 数据段定义
.data
sumMsg db 'The sum is: %d', 10, 0 ; 10为换行符,0为字符串结束标志
x dd 3
y dd 4
result dd 0
; 代码段定义
.code
_main PROC
; 调用add函数
mov eax, [x] ; 将x的值放入eax寄存器
push eax ; 将eax压入栈
mov eax, [y] ; 将y的值放入eax寄存器
push eax ; 将eax压入栈
call _add ; 调用add函数
add esp, 8 ; 清理栈
mov [result], eax ; 将返回值存入result
; 输出结果
push eax ; 将结果压入栈
push OFFSET sumMsg; 将sumMsg的地址压入栈
call _printf ; 调用printf输出结果
add esp, 8 ; 清理栈
; 退出程序
push 0 ; 将0压入栈
call _exit ; 调用exit结束程序
_main ENDP
; add函数定义
_add PROC
push ebp ; 保存基指针
mov ebp, esp ; 将栈指针的值复制到基指针
mov eax, [ebp+8] ; 将参数a的值(位于[ebp+8])复制到eax寄存器
add eax, [ebp+12] ; 将参数b的值(位于[ebp+12])加到eax寄存器
pop ebp ; 恢复基指针
ret ; 返回调用者
_add ENDP
END
先讨论函数被调用之前的准备工作。在MASM中,push
和pop
指令用于操作栈。push
指令将数据压入栈,而pop
指令从栈顶弹出数据。在调用函数时,参数会被压入栈中,以便被调用的函数可以访问这些参数。。在调用add
函数之前,先通过eax寄存器作为媒介,使用mov指令将变量值传送到eax寄存器,再将eax寄存器值压入栈,从而将参数x
和y
的值压入栈中。然后,我们使用call
指令调用add
函数。注意在调用call
指令时,会自动将主调函数,也就是main
函数的值压入栈中。
这时候就来到add
函数的内部实现了,注意到add
函数需要的参数已经被压入栈中。首先,我们将当前的基指针ebp
的值压入栈中,并将当前ebp
寄存器值设置为当前esp
栈指针的值。
push ebp ; 保存基指针
mov ebp, esp ; 将栈指针的值复制到基指针
这两行汇编指令,当初让我产生了不小的疑惑,相信读者也有可能有疑惑。我知道你很急,但是你先别急。首先,我们需要明确ebp
寄存器的作用是什么。ebp
寄存器的作用是用来访问已经在栈上的函数参数,后面可以看到[ebp+8]
和[ebp+12]
这两个源操作数,正是使用ebp
寄存器值再配合一定的偏移,进行函数参数的访问。
mov eax, [ebp+8] ; 将参数a的值(位于[ebp+8])复制到eax寄存器
add eax, [ebp+12] ; 将参数b的值(位于[ebp+12])加到eax寄存器
而我们在add函数的开头,保存栈基址寄存器值ebp
的作用,正是为了保证主调函数,也就是main
函数的上下文环境能够恢复。add
函数调用完成之后,需要将ebp
寄存器恢复为函数调用之前的状态,以保证主调函数能够正常访问其函数参数。后面我们可以看到,在add
函数的末尾,会将保存在栈上的主调函数的ebp
寄存器值,通过pop
指令弹回给ebp
寄存器值,并且返回到主调函数的位置。
pop ebp ; 恢复基指针
ret ; 返回调用者
通过这样的操作,就能够保证主调函数的ebp
寄存器值能够正常恢复,从而实现了通过调用栈进行函数嵌套调用的机制。假设add
函数中再嵌套调用了其他的函数,依旧会通过栈进行函数入参,保存主调函数地址,保存主调函数的ebp
值,将当前ebp
寄存器值设置为当前esp
寄存器值。当嵌套调用的函数的主题逻辑被执行完成后,会将保存在栈上的主调函数的ebp
值恢复到当前的ebp
寄存器中,并返回主调函数调用处。需要再次强调的是,ebp寄存器的主要作用是用来访问已经在栈上的函数参数。理解了这一点,自然就可以理解为什么汇编中要如此折腾ebp
寄存器了。
那么mov ebp, esp ; 将栈指针的值复制到基指针
这行指令的作用是什么呢?首先我们要明确的是,在我们调用pop
和push
指令时,esp
寄存器值是会自动增加和减小的。当我们即将开始add
函数的主体逻辑,也就是a+b的过程时,我们需要将ebp
寄存器值设置为当前的esp
寄存器值,以更新add
函数可操作的栈区基地址。另外一个角度讲,add
函数没有往栈压入任何数据(add
函数本身的参数不算在内),自然ebp
寄存器值和esp
寄存器值是相等的。这里需要结合前文中贴出的图进行理解。
在调用add
函数之后,我们使用add esp, 8
指令清理栈。这是因为我们之前压入了两个参数,每个参数占用4个字节(32位系统下),所以需要清理8个字节。我们之前说到,函数若没有特别的函数调用约定声明,使用的是__cdecl
调用约定。而__cdecl
调用约定是需要调用者清理调用栈的,这是相当关键的一步(是的,只要调整esp
寄存器值就相当于清理了调用栈,至于栈顶以外的数据, 我们是不会再访问的,也没有必要将其清零,增加额外的步骤)。接着,我们将add
函数的返回值(存储在eax
寄存器中,此时eax
寄存器值正是a+b
的结果!)保存到result
变量中。
接下来,我们调用printf
函数以输出结果。我们首先将result
的值(和)压入栈,然后将sumMsg
的地址压入栈。这样,printf
函数就可以访问这些参数并将结果打印到屏幕上。这里虽然没有展示出printf
的内部实现,但函数调用的过程是一致的,我们需要将计算结果和一段字符串的地址压入栈中,作为printf
函数的函数参数,并在函数调用完毕之后清理栈,即将esp
寄存器值调整回最初的状态。最后,我们调用exit
函数来结束程序,这里调用完exit
函数程序就结束了,所以也没必要恢复esp
寄存器的值了,程序就此完事~
我们再来分析一下__stdcall
调用约定声明的函数的调用过程。先给出C++程序,注意add
函数需要使用__stdcall
关键字指定调用约定。
#include <iostream>
int __stdcall add(int a, int b) {
return a + b;
}
int main() {
int x = 3;
int y = 4;
int result = add(x, y);
std::cout << "The sum is: " << result << std::endl;
return 0;
}
对应的MASM汇编代码:
; 导入外部库
extrn _printf:proc
extrn _scanf:proc
extrn _exit:proc
; 数据段定义
.data
sumMsg db 'The sum is: %d', 10, 0 ; 10为换行符,0为字符串结束标志
x dd 3
y dd 4
result dd 0
; 代码段定义
.code
_main PROC
; 调用add函数
mov eax, [x] ; 将x的值放入eax寄存器
push eax ; 将eax压入栈
mov eax, [y] ; 将y的值放入eax寄存器
push eax ; 将eax压入栈
call _add ; 调用add函数
mov [result], eax ; 将返回值存入result
; 输出结果
push eax ; 将结果压入栈
push OFFSET sumMsg; 将sumMsg的地址压入栈
call _printf ; 调用printf输出结果
add esp, 8 ; 清理栈
; 退出程序
push 0 ; 将0压入栈
call _exit ; 调用exit结束程序
_main ENDP
; add函数定义
_add PROC STDCALL
push ebp ; 保存基指针
mov ebp, esp ; 将栈指针的值复制到基指针
mov eax, [ebp+8] ; 将参数a的值(位于[ebp+8])复制到eax寄存器
add eax, [ebp+12] ; 将参数b的值(位于[ebp+12])加到eax寄存器
pop ebp ; 恢复基指针
ret 8 ; 返回调用者并清理栈(8字节)
_add ENDP
END
在这个示例中,我们将add
函数的调用约定指定为__stdcall
。在MASM汇编代码中,我们使用PROC STDCALL
修饰符来指定_add
函数使用__stdcall
约定。
由于__stdcall
约定要求被调用者清理栈,因此在_add
函数的末尾,我们使用ret 8
指令返回调用者并清理栈。这里的8表示我们清理了两个4字节的参数(共8字节)。前一个示例的ret
指令和这里的ret 8
指令的主要区别在于它们如何清理栈。ret
指令仅从函数返回,而不清理栈中的参数;而ret 8
指令在从函数返回的同时,还负责清理栈中的参数。这两个指令在不同的函数调用约定中具有不同的行为。
ret
指令用于从一个函数返回到调用者。在执行ret
指令时,它会将栈顶的值(即返回地址)弹出并将其放入指令指针寄存器(在x86架构中为EIP),从而实现函数返回。在这种情况下,ret
指令不会清理栈中的任何参数。当使用__cdecl
调用约定时,调用者负责在函数返回后清理栈。ret 8
指令在从函数返回时,除了将栈顶的返回地址弹出并放入指令指针寄存器外,还会调整栈指针(在x86架构中为ESP),以清理栈中的参数。在这个例子中,8
表示要清理的字节数,即我们需要清理两个4字节的参数(共8字节)。当使用__stdcall
调用约定时,被调用函数负责清理栈。
需要注意,在调用_printf
和_exit
函数时,我们仍然需要手动清理栈。这是因为这些函数遵循__cdecl
约定,它要求调用者负责清理栈。
总结
学习MASM中函数调用的过程确实可能是一个相对较难的部分,特别是对于初学者来说。理解函数调用的过程需要熟悉寄存器、栈、参数传递等概念。此外,还需要了解不同的函数调用约定,如__cdecl和__stdcall,以及它们对栈清理和寄存器使用的影响。但这是学习MASM汇编语言中十分重要的一步,希望读者能够仔细体会其中的原理。鉴于笔者蹩脚的技术水平和表达能力,建议读者也可以参考《汇编语言:基于x86处理器》第五章的讲解,或许对读者有所帮助。
网友评论