C++程序员光速入门汇编(二):函数调用与函数调用栈

前言

上一篇文章中,我们了解到了x86-32 MASM的关键寄存器,常用的汇编指令,以及给出简单的C++程序编译后的汇编代码,并对照源C++代码进行了逐行解析。本文想要讨论的主题,集中于C++和MASM汇编中函数调用的过程,尤其是如何利用调用栈去进行函数调用。

程序分区与调用栈

先复习一下C++程序的分区。C++程序的分区主要有以下几类:

  1. 代码区(Text Segment):存储程序的可执行机器代码。
  2. 数据区:包括初始化的全局变量和静态变量。数据区又分为以下几部分:
    2.1 已初始化数据段(Initialized Data Segment):存储初始化的全局变量和静态变量。
    2.2 未初始化数据段(Uninitialized Data Segment,又称BSS Segment):存储未初始化的全局变量和静态变量。在程序启动前,系统会将这些变量初始化为零。
  3. 堆(Heap):用于存储程序在运行时动态分配的内存。堆由程序员负责管理,需要手动申请和释放内存。
  4. 栈(Stack):用于存储程序运行时的局部变量、函数参数和返回地址等。栈的内存分配和释放由编译器自动管理。

幸运的是,当我们讨论C++编译为MASM的场景时,两者的内存分区可以在逻辑上统一。MASM汇编程序的内存分区也可以分为同样的四个区域,并且完全对照C/C++程序的四个分区。只是相对而言,C/C++的堆区管理和栈区管理更加简化,在汇编程序中,程序员需要更直接地处理底层细节。

对大部分C++程序员而言,可能知道栈区的存在,也听过“调用栈”之类的概念,但未必有足够深入的理解。再复习一下栈这个数据结构的性质。栈是一种后进先出(LIFO,Last In First Out)数据结构,允许在顶部(称为栈顶)进行数据的添加(压栈)和移除(弹栈)操作。为了不混淆数据结构意义上的“栈”,以及C++和MASM汇编中进行函数调用时需要作为媒介的“栈”,建议将后者称之为“调用栈”(call stack)或者“函数调用栈”(function call stack)。不过为了方便,后文提到的“栈”一律指代调用栈。

调用栈在程序中有多种作用,包括:

  1. 存储局部变量:当一个函数被调用时,它的局部变量和临时数据将被分配在栈上,函数返回时会自动释放这些空间。
  2. 参数传递:在函数调用时,通常会将参数压入栈中,供被调用函数访问和使用。
  3. 返回地址存储:当一个函数被调用时,程序需要知道从何处返回执行。因此,当前执行点(通常是下一条指令)的地址会被压入栈中。当函数执行完毕并返回时,该地址将从栈顶弹出,程序会继续从该地址执行。
  4. 嵌套函数调用:栈使得程序可以处理嵌套的函数调用,每次函数调用都会在栈上分配新的空间,以保持当前上下文环境。这里说到的“保持上下文环境”,从汇编角度理解,其实就是保存主调函数的寄存器状态(最关键的是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。因此出于篇幅原因,我们的汇编代码示例和讲解只讨论这两种函数调用约定方式。它们的异同如下:

  1. __cdecl(C调用约定):
  • 参数传递:从右到左将参数压入栈。
  • 返回值:EAX寄存器存放整数和指针类型的返回值,浮点类型的返回值存放在ST0寄存器中。
  • 调用者需要保存的寄存器:EAX、ECX、EDX。
  • 被调用者需要保存的寄存器:EBX、ESI、EDI、EBP。
  • 栈清理:由调用者清理栈。在MASM中,实际指的是将ESP寄存器值调整回函数调用之前的值。
  1. __stdcall(标准调用约定,Windows API通常使用此约定):
  • 参数传递:从右到左将参数压入栈。
  • 返回值:与__cdecl相同。
  • 调用者需要保存的寄存器:与__cdecl相同。
  • 被调用者需要保存的寄存器:与__cdecl相同。
  • 栈清理:由被调用者清理栈。同样,在MASM中,实际指的是将ESP寄存器值调整回函数调用之前的值。

在Windows平台和MSVC编译器下,__stdcall声明和WINAPI(或者APIENTRYCALLBACK等)是等效的。它们都是宏定义,指定了使用__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中,pushpop指令用于操作栈。push指令将数据压入栈,而pop指令从栈顶弹出数据。在调用函数时,参数会被压入栈中,以便被调用的函数可以访问这些参数。。在调用add函数之前,先通过eax寄存器作为媒介,使用mov指令将变量值传送到eax寄存器,再将eax寄存器值压入栈,从而将参数xy的值压入栈中。然后,我们使用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 ; 将栈指针的值复制到基指针这行指令的作用是什么呢?首先我们要明确的是,在我们调用poppush指令时,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处理器》第五章的讲解,或许对读者有所帮助。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容