距离上次更新本文已经过去了 695 天,文章部分内容可能已经过时,请注意甄别

[TOC]

前言

在早期的学习中,我们已经了解到当函数传值调用参数的时候,用的是形参。

形参是实参的一份临时拷贝,对形参的改变不会影响实参里的值。

传值调用和传址调用👉点我

今天让我们以汇编语言来了解函数调用的参数压栈这一知识点

所用编译器:VS2019

不同编译器的实现可能略有不同,以实际为准


1. 什么是栈区?

栈,是一种数据结构。

在学习 C 语言的过程中,我们一般只关注内存中的 3 个区域,分别是栈区、堆区和静态区。

其中堆区主要用于动态内存管理,在之前的博客中已经和大家介绍过。

详解动态内存管理👉点我

image-20220305104153482

而栈区就是编译器给函数运行分配的空间了。

和堆区空间需要手动分配不同,这一部分空间是编译器自动管理的,函数的栈帧会自动创建,自动销毁。

1.1 栈区小知识点

  • 栈区的使用是从高地址到低地址
  • 栈区的使用遵循先进后出,后进先出
  • 栈区的放置是从高地址往低地址放置:push 压栈
  • 删除是从低往高删除:pop 出栈

2. 知识点

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//本次使用的代码
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}

int main()
{
int a = 10;
int b = 20;

int c = Add(a, b);
printf("%d\n", c);

return 0;
}

2.1 寄存器

常见寄存器有 eax、ebx、ecx、edx,其中 ebp 和 esp 较为特殊

ebp、esp 这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的

  • eax/ebx/ecx/edx:通用寄存器,保留临时数据
  • ebp:栈低指针
  • esp:栈顶指针
  • eip:指令寄存器,保存当前指令的下一条指令的地址

2.2 主函数调用

每一个函数调用,都要在栈区创建一个空间

我们知道 main 函数是程序的入口

实际上,main 函数也是被其他函数调用的

  • mainCRTStartup 函数调用__tmainCRTStartup
  • __tmainCRTStartup 函数调用 main 函数

编译器会先在内存高地址处开辟一部分空间给 mainCRTStartup__tmainCRTStartup 函数,它们进行调用 main 函数的操作。

image-20220304174750130

在 VS2019 中,按 F10 进行调试,出现黄色小箭头后,右键-转到反汇编,即可打开调试中汇编语言的显示界面

image-20220304175501357

3. 逐条解释

3.1 从 main 开始

先来看第一部分代码,逐条语句进行解释

c
1
2
3
4
5
6
push  ebp//在栈顶开辟存放ebp这一寄存器对应值的空间
mov ebp,esp//将esp的值传入ebp中(即将ebp指针移动到原本esp指向的位置)
sub esp,0E4h//将esp的内容减去0E4h(将esp移动到原esp-0E4h的位置)
push ebx//在栈顶放入ebx
push esi//在栈顶放入esi
push edi//在栈顶放入edi

image-20220304175318995

  • lea:load effecticve address 加载有效地址
  • dword:double word – 4 个字节
c
1
2
3
4
lea  edi,[ebp-24h]//将ebp-24h的地址放入edi
mov ecx,9//将9放入ecx,对应十进制36
mov eax,0CCCCCCCCh//将0CCCCCCCCh放入eax
rep stos dword ptr es:[edi]//将edi往下ecx个地址的数据全部初始化为0CCCCCCCCh

按 F10 往下运行,过 rep 那一步后,可以看到 36 个字节的数据都被初始化为 0CCCCCCCCh

image-20220304222600365

image-20220304223523703

继续往下运行,可以看到编译器初始化 a、b 变量的过程

image-20220304224413226

image-20220304224455204

  • VS2019 下是小端存储

image-20220304224457833


c
1
2
3
4
5
6
	int c =Add(a,b);
mov eax,dword ptr [b]//把b的内容放入eax
push eax //在栈顶放入eax
mov ecx,dword ptr [a]//把a的内容放入ecx
push ecx //在栈顶放入ecx
call _Add (01A10B4h) //在栈顶放入该地址(call指令下一条指令的地址)

最后这一步 call 很关键,后续会用到

image-20220304225430774

3.2 调用 Add

按 F11,进入 Add 函数

image-20220304225730042

image-20220304225830983

c
1
2
3
4
5
6
push    ebp//将ebp上移
mov ebp,esp//将esp内容放入ebp(移动ebp)
sub esp,0CCh//esp+0CCh(为Add开辟空间)
push ebx//在栈顶放入ebx
push esi//在栈顶放入esi
push edi//在栈顶放入edi
c
1
2
3
4
5
6
lea      edi,[ebp-0Ch]//ebp-0Ch的空间  
mov ecx,3//3存入ecx
mov eax,0CCCCCCCCh//存入eax
rep stos dword ptr es:[edi]//esp往下0ch的空间初始化
mov ecx,offset _6A27082D_test@c (024C003h)
call @__CheckForDebuggerJustMyCode@4 (024131Bh)

image-20220304232641792

image-20220304233757748

继续往下,寄存器初始化了 Z 地址处的数据 为 0

image-20220304232945700

c
1
2
3
mov    eax,dword ptr [x]//将x的值放入eax
add eax,dword ptr [y]//将y的值加道eax中,即x+y
mov dword ptr [z],eax//将eax的值放入变量z

image-20220304233409810

c
1
2
	return z;
mov eax,dword ptr [z]//将形参z的值放入eax

image-20220304233609053

c
1
2
3
4
5
6
pop     edi//出栈,删除为edi创建的栈区
pop esi//pop指令会将esi的值放入esi(等于没变)
pop ebx//每pop一次,esp就往高位移动一次

add esp,0CCh//为esp地址+0CCh,即退出Add程序的栈区空间
cmp ebp,esp//将esp的值与ebp进行比较

image-20220304234213355

c
1
2
call        __RTC_CheckEsp (0241244h)  
mov esp,ebp//ebp的值赋给esp,此时esp和ebp相同

image-20220304234829970

c
1
pop         ebp//弹出ebp  

这里执行弹出指令时

  • 将 ebp 所指向的 main 函数的起始地址赋值给了 ebp 指针
  • esp 指针向高位移动一位

最后的结果如下图所示,esp 和 ebp 重新开始维护 main 函数的栈区空间

image-20220305004304926

3.3 回到 main 函数

c
1
ret  

前面提到 call _Add (01A10B4h) 这条指令非常重要,实际上,在执行 ret 指令时,esp 指针就指向了栈顶存放的 call 指令的下一条指令的地址,同时,这个地址也被 pop 掉了

image-20220304235511090

回到调试界面,可以看到黄色小箭头的确指向了 call 指令的下一条

image-20220304235537358

而这一条指令的意思,是往 esp 里加 8,即向高位移动 8 个字节。

实际上这条指令就是在销毁我们的形参

image-20220304235715237

c
1
mov      dword ptr [c],eax//将eax中的值放入变量c  

此时 eax 中存放的就是 Add 函数的返回值

这里我们可以得出一个结论:

自定义函数的返回值是通过寄存器这一中间 “变量”,返回主函数中的。

  • 先把返回值放入寄存器 A
  • 主函数从寄存器 A 中取出返回值,放入接受返回值的变量

继续往下,可以看到 printf("%d\n", c); 语句后,编译器又一次将变量 c 的值放回了 eax

实际上这里是 printf 函数的运行

  • 先把待打印变量放入 eax
  • 在栈顶压入 eax
  • offset string "%d\n"(猜测是数据类型检查)
  • _printf :执行 printf 函数

image-20220305000343063

c
1
add  esp,8//给esp+8个字节

我对这一部分产生了疑惑,重新调试发现

  • push eax 这一指令让 esp 往低地址处走了 4 个字节
  • offset string "%d\n" 这一指令也让 esp 往低走了 4 个字节

image-20220305002121882

执行完这一指令后,esp 回到了执行 printf 之前的地址处

image-20220305002448651

3.4 结束程序

c
1
2
3
4
5
6
	return 0;
xor eax,eax//xor指令是异或--在这里的作用不清楚
}
pop edi//出栈--esp对应移动
pop esi//
pop ebx//

image-20220305004452316

c
1
2
add    esp,0E4h//esp+0E4h(退出为main函数开辟的空间) 
cmp ebp,esp//比较ebp和esp

image-20220305004546525

image-20220305003713343

c
1
2
3
call   __RTC_CheckEsp (0241244h)  
mov esp,ebp//将ebp的值复制给esp
//此时esp和ebp的值依旧相同

c
1
2
pop    ebp//ebp出栈--esp和ebp分离
ret//main函数结束

image-20220305003921287


4. 本篇博客中的汇编语言总结

  • mov:数据转移指令

  • push:数据入栈,同时 esp 栈顶寄存器往低位走

  • pop:数据弹出至指定位置,同时 esp 栈顶寄存器往高位走

  • sub:减法

  • add:加法

  • call:函数调用。1. 压入返回地址;2. 转入目标函数

  • jump:通过修改 eip,转入目标函数,进行调用

  • ret:回复返回地址,压入 eip,类似 pop eip 指令

  • cmp(比较):执行从目的操作数中减去源操作数的隐含减法操作,并且不修改任何操作数

  • xor:在两个操作数的对应位之间进行(按位)逻辑异或(XOR)操作,并将结果存放在目标操作数中

部分汇编指令参考:C 语言中文网


5. 结语

完成这篇博客的时候,已经是周六的 00:54🌛

从周五的 19:20 开始,不知不觉中写了这么久

函数调用堆栈这一部分的知识有些晦涩难懂,写下这篇博客也算是理清了一些思路吧。

加油!

顶不住了,睡觉去了

说来讽刺,几个舍友还在打某 3 亿鼠标的枪战梦想……