从汇编角度深入理解函数的工作原理
😗
计算机里面存储的都是二进制数,也就是0和1,它并不理解高级语言,必须通过编译器翻译成二进制的代码,计算机才能够理解并进一步执行命令。它真正能够理解的是低级语言
机器语言虽然比汇编语言更加低级,但是机器语言对于我们来说十分难以理解,根本看不懂机器要做什么。因此我们将学习汇编语言以便于更好地从计算机角度思考。
汇编语言是第二代计算机语言,它用一些容易让人理解和记忆的字母或者单词来代替一个特定的指令,比如:用“ADD”代表数字逻辑上的加法
因此它也比机器语言多出了一个步骤,就是把这些指令翻译成二进制,这个步骤被称为 assembling(贴标签),完成这个步骤的程序就叫做 assembler。它处理的文本自然就叫做 aseembly code。标准化之后称为 assembly language、中文译为汇编语言、可简称为ASM
寄存器
在了解汇编语言之前需要先简单地了解寄存器
寄存器是什么?
寄存器是CPU中的一块存储区域,拥有着非常高的读写速度。我们平常所看到的32位CPU\64位CPU指的就是寄存器的大小。CPU上有很多个寄存器,在x86处理器上有9个寄存器:
eax
ebx
ecx
edx
edi
esi
eip
ebp
esp
这里要特别提到的是ESP和EBP
(1)ESP:栈指针寄存器(extended stack pointer)(栈顶指针),保存Stack的地址(指针),该指针永远指向最上面一个栈帧的栈顶。
(2)EBP:基址指针寄存器(extended base pointer)(栈底指针),该指针永远指向最上面一个栈帧的底部。
这两个寄存器用来维护栈帧
什么是栈
栈是每个程序预留的空间、 是一块内存空间 ,存储的是函数调用过程中的局部变量、临时变量、参数以及返回地址。
都说栈帧、栈帧, “帧”是个什么情况呢?
其实main函数也是被其他函数调用的。 系统开始调用main函数时, 会为它在内存里面建立一个帧(frame)
, main函数中的局部变量都保存在这个帧
里面。 main函数调用结束后, 该帧就会被回收, 释放所有的内部变量, 目前就不再拥有这块空间的使用权。(栈帧随着函数调用而产生、随着函数调用结束而释放)
此时只是调用了main函数, 如果在main函数里再调用别的函数呢?
# include <stdio.h>
int main(void)
{
int a = 2;
int b = 5;
int c = 0;
c = Add(a, b);
return 0;
}
上面代码中,main函数的内部调用了 Add 函数。执行到这一行的时候,系统也同样会为 Add 函数再建立一个帧
,用来储存它的局部变量、临时变量、返回地址以及参数等。也就意味着 , 这个时候同时有两个帧:main 和 Add 函数的栈帧。
一般来说,调用栈有多少层,就有多少帧。
栈是由高地址向低地址增长的,也就是所谓的向下增长,且栈有栈顶和栈底之分。在 i386 标准下,栈顶由寄存器 esp 进行定位,因此我们又称它为栈顶指针。同理,栈底由栈底指针进行定位。
所有的帧
都存放在 Stack
,由于帧
是一层层叠加的,所以 Stack 叫做栈。
生成新的帧,称为 push
, 也就是入栈,很多人也叫压栈, 我认为读做压栈更容易去体会到上面的图
它的回收,称为pop
,也就是出栈。Stack 的特点就是,最晚入栈的帧最早出栈(因为最内层的函数调用,最先结束运行),通常说成是“先进后出”。
就好比生活中叠放起来的书
每一次函数执行结束后,就会自动地释放一个帧,当所有的函数执行结束之后,那么整个 Stack 就都释放了。
eg:
这里的栈底的地址为0xaaaaaaf,esp寄存器表明了栈顶的位置。在栈中push(压栈),即压入数据,会导致esp减小。而pop(出栈),则会导致esp增大。按照这个道理,那么esp减小意味着在栈上开辟空间,增大esp就意味着在栈上回收空间。而ebp寄存器指向的是一个栈中的固定位置(某个帧),ebp寄存器又被称作“帧指针”。
在前面讲到,栈保存了一个函数调用所需要的维护信息,而且是连续存储的。那么这些数据在栈中的位置是怎么样子的呢?(i386标准下)
上面是被调用者的函数栈帧布局示意图。
这张比较详细的示意图来源于网络
上面函数的各种信息在栈中的大致位置也被人们称为当前函数的活动记录。
这里的ebp所指向的数据为调用这个函数之前ebp的值,在函数进行返回的时候,就可以让esp恢复为调用之前的值。
old ebp则指的是把ebp压入栈中,保存了ebp(old)旧值
。那么这里为什么要把ebp压入栈中呢?其实是为了在函数要返回时方便把ebp恢复为调用之前的值。这里你也许又会产生疑惑:为什么要恢复呢?因为我们的计算机不是只产生一个函数呀,我们还需要调用其他的函数,其实有点像初始化,方便其他函数使用。
函数工作原理
讲到这里我们来看看常见的汇编指令吧。虽然我这篇博客并不要求将汇编学习地透彻,但是常见的C指令我们还是需要认真对待。以便于理解函数的工作原理
指令
其实一般程序的开头必定是这三句指令,看它们的作用就可想而知。
第三条指令是因为我们前面提到的
esp减小意味着在栈上开辟空间,增大esp就意味着在栈上回收空间。
那么根据前面出现的这几条指令,我们也可以大致理解结尾必定会出现的指令
在掌握开头和结尾的几条指令后,我们不妨来写一个Add函数作为例子看看
# include <stdio.h>
int Add()
{
return 2 + 5;
}
int main(void)
{
Add();
return 0;
}
这是在vs2019编译器,i386标准,Debug版本之下所进行反汇编得到的部分汇编指令:
# include <stdio.h>
int Add()
{
push ebp
mov ebp,esp
sub esp,0C0h
push ebx
push esi
push edi
mov edi,ebp
xor ecx,ecx
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
mov ecx,offset _FEAD0967_栈帧@c (024C003h)
call @__CheckForDebuggerJustMyCode@4 (024130Ch)
return 2 + 5;
mov eax,7
}
pop edi
pop esi
pop ebx
add esp,0C0h
cmp ebp,esp
call __RTC_CheckEsp (0241235h)
mov esp,ebp
pop ebp
ret
现在我们来对上面的汇编指令进行分析
第四步中有两个我们还没讲到的指令
xor ecx,ecx
异或运算,操作数两数相反为1;两数相同为0。由于这两个数相同,异或后等于清0
要比mov ecx,0效率高
rep stos dword ptr es:[edi]
重复其上面的指令。上面 ecx 中的值是重复的次数。并将eax中的值拷贝到es:[edi]指向的地址.
rep指令的目的是重复其上面的指令.ecx的值是重复的次数.
STOS指令的作用是将eax中的值拷贝到ES:EDI指向的地址.
call xxxxx
调用xxxx函数
rep stos dword ptr es:[edi]
dword 是double word的缩写,翻译为双字。 就是四个字节。ptr 是pointer的缩写 ,即指针
[ ]里的数据是一个地址值,这个地址指向一个双字型数据。
在没有寄存器参与的内存单元访问指令中,用word prt 或byte ptr 指明所要访问的内存单元的长度。
说到这里,我们来看一下下面这个代码
# include <stdio.h>
int main(void)
{
char arr[20];
return 0;
}
这里面定义了一个没有初始化的字符数组,元素有20个。
在监视之下我们可以看到这个未初始化的数组后后面出现了很多“烫”字。你有想过为什么吗?
其实在栈的调试信息里,系统将分配出来的栈帧的每一个字节都初始化为了0xcc.而两个连续排列的0xcc(0xcccc)的汉字编码就是“烫”,如果0xcccc被当作文本就是“烫”字。这样有助于我们判断是否某个变量没有初始化。但是有时候我们有可能会看到“屯”字,是因为有时候编译器也可能使用0xCDCDCDCD来初始化,而0xCDCDCDCD的汉字编码为“屯”。
函数的调用惯例
我们来看这样的一个函数
# include <stdio.h>
int Add(int n, int m)
{
return n + m;
}
int main(void)
{
int n = 2;
int m = 5;
int ret = Add(n, m);
return 0;
}
在前面我们讲过函数的各种数据在栈中的大致位置,但是此时Add有两个参数,那么我们先将哪一个参数压入栈呢?
如果函数的调用方(main)在传递参数的时候先压入参数n,而Add函数却认为应该先压入参数m,那么Add函数内的n就被它当作m了,那就真的乱套了。因此,函数调用方与被调用方之前存在一个约定,它们都遵循这个约定,以致于不乱套,函数才能被正确地调用,这个约定就被称之为 “调用惯例”
调用惯例包含一下几个内容:
(1)函数参数是由什么方式传递的
(2)函数参数传递的顺序
(3)负责出栈的是函数调用方还是被调用方
(4)怎么修饰函数名的
C语言中存在着许多调用惯例,现在来看看常见的调用惯例有哪些
那么也就可以知道为什么 call后面的函数名为什么这么奇怪了。
函数的返回值传递
刚才简述了函数的参数传递,函数和调用方之间的联系还有返回值,那我们一起来探讨函数的返回值是如何传递的吧
在上面的反汇编代码中,我们不难看出返回的值是通过存储在eax寄存器中,在由调用方读取eax中存储的数据。
但是这个时候我们就该思考一个问题了:eax其本身只有四个字节
,如果遇到大于4个字节的返回值,该如何进行传递呢?
对于返回5~8字节的返回值的情况来说,调用惯例采用的方式都是:eax 和 edx 寄存器联合返回(eax存储的返回值<4字节、edx存储的返回值 >4到8字节)
不到万不得已不要随意返回大字节的对象……