前言
🎈大家好,我是何小侠🎈
🍃大家可以叫我**小何或者小侠🍃**
💐希望能通过写博客加深自己对于学习内容的理解💐
🌸也能帮助更多人理解和学习🌸
花不尽,柳无穷。应与我情同。觥船一棹百分空。何处不相逢。
朱弦悄。知音少。天若有情应老。劝君看取利名场。今古梦茫茫。
— 宋代·晏殊《喜迁莺·花不尽》
这篇博客我们一起来简单的了解一下,在函数传参,调用过程中的种种细节,由于是汇编语言,我自己会用查找的资料和一些讲解或图片展示来
帮助大家理解,由于这个过程真的很难用博客来讲解,所以我可能讲的不是很好,希望大家谅解。
目录
🍊
前言🍊
我们先让大家了解一下汇编与反汇编的区别:
词性 | 汇编 | 反汇编 |
---|---|---|
名词 | 就是指汇编语言 | 指的是由机器语言经过反汇编过程生成的汇编语言。 |
动词 | 指的是将汇编语言指令转换为机器语言指令的过程。 | 指的是由已生成的机器语言(二进制语言)转化为汇编语言的过程,也可以说是汇编的逆向过程 |
那么我们再介绍一下什么是机器语言:机器语言是一种由计算机硬件直接执行的编程语言。
它使用二进制代码表示指令和数据,与计算机的底层硬件架构紧密相关
。
机器语言是计算机能够理解和执行的唯一语言,它包括了一系列的机器指令,每个指令对应着计算机硬件中的一个操作。
由于机器语言使用的是二进制代码,因此对人类来说很难直接阅读和编写,通常需要使用汇编语言或高级编程语言来进行编写和转换。
但是啊,重点来了:
反汇编可以将机器语言指令转换为汇编语言指令,也可以将高级语言程序转换为汇编语言代码。这取决于我们想要分析的程序是已经编译成机器语言的可执行文件,还是高级语言的源代码。
在调试过程中,高级语言通常会被编译成汇编语言代码,然后再被转换成机器语言指令执行。当我们有一个已编译的可执行文件时,反汇编可以将其中的机器语言指令转换为对应的汇编语言指令,使其更具可读性和可理解性。这对于逆向工程、代码审计和漏洞分析等任务非常有用。
另一方面,当我们有高级语言的源代码时,反汇编可以将其转换为汇编语言代码,以便更深入地理解程序的执行过程和指令的细节。这对于调试、性能优化和代码分析等任务非常有帮助。
总之,反汇编可以用于将机器语言转换为汇编语言,也可以将高级语言转换为汇编语言,具体取决于我们想要分析的程序的形式和需求。
而我们今天要介绍的,就是我们调试的过程中的反汇编。
越高级的编译器就越不容易观察,希望大家不要选择太新的编译器,VS2022是不行的.
寄存器与函数栈帧🍊
要了解函数栈帧就必须理解寄存器
由于32位和64位的寄存器差别很大,而且我也没有时间去了解64位的寄存器所以今天我们只讨论32位的。
EAX(Extended Accumulator)是累加器寄存器。它在计算过程中常用于存储临时数据、计算结果和函数返回值。
EBX(Extended Base)是基址寄存器。它通常用于存储指向数据段的指针,或者作为通用寄存器来存储数据。
ECX(Extended Counter)是计数器寄存器。它常用于循环计数和字符串操作,也可以用作通用寄存器。
EDX(Extended Data)是数据寄存器。它可以用于存储数据和执行I/O操作,也可以作为通用寄存器使用
大家只要看看就行,没必要现在就记住。
每个寄存器都能够存储一个32位的二进制数据(一个地址)。寄存器在计算机中被用来存储和处理指令和数据。也就是说32位下一个寄存器的大小就是4个字节。
由于栈顶是一个动态变化的,所以我们要知道栈顶指针会经常变动`
从标题可以看出我们要描述的是函数栈帧,那么函数栈帧到底是什么?
函数栈帧(Function stack frame)是指在程序执行过程中,每个函数被调用时所创建的一块内存区域,用于保存函数的局部变量、参数、返回地址等信息。这只是简单的描述,不敢太过于复杂。
大家只要记得是一块在栈区上所开辟的一块内存区域就行。
这里还有一个重要到底知识点,esp和ebp是用来维护函数栈帧的,也就是说,现在在调用哪个函数我esp,ebp就维护哪个函数的函数栈帧。
main函数居然被调用!🍊
我在Microsoft VC上展示的不是很全面:
这是调试过程中调用堆栈的例子:大家可能不是很理解我再换一张图:
这里就很明显的能看出特点,我们看到main函数是在Add函数的下面,也就意味着Add函数是后调用的,那么main函数下面还有一个函数,叫main
CRTStartup,说明这个函数其实是调用了main函数。但是不是这么简单
这里由于这个软件的原因,实际上是一个叫__tmainCRTStartup调用了main函数,__tmainCRTStartup是被mainCRTStartup调用的。
内存中的栈区🍊
这就是我们还未进入到main函数时栈区的情况,我们在开头介绍过栈顶其实是动态的,只要我们往栈区里面放东西,顶就会变高。
这里再补充说明一下我们所用的代码是这个:
push
现在我们调试起来转到反汇编,如果猜的没错你第一眼看到的就是push,
英文中的push是推动,压,强迫等意思,而在栈区这里的意思就是压栈,
压栈是指: 压栈就是把数据放如栈中,从栈顶放入。
既然说到压栈我们就说说出栈,出栈也是从栈顶取出。
而栈区具有有先进后出的特点!
就好像往盒子里放东西,先放入的东西后拿出来,后放入的东西先拿出来
我们来看看这个汇编代码
push ebp的含义是:
"push ebp"这一条汇编指令,用于将ebp寄存器的值压入栈中。 这个指令通常出现在函数的开头,用于保存上一个函数的栈帧指针。
我们知道ebp其实可以理解成指针变量,用来存放地址。
ebp指向的是栈底。
这里还有一个补充的知识:
我们知道内存里的变量能够取地址,但是寄存器是集成在CPU上的,不在内存当中。
具体的操作步骤如下:
- 将ebp寄存器的值压入栈中,此时ebp的值被保存在栈的顶部。
那为什么要这样做呢?
通过"push ebp"指令,可以将上一个函数的栈帧指针保存在栈中,以便在当前函数执行完毕后能够正确返回到上一个函数的位置。
大家只要记得还有一个返回的作用就行了。
在 这条汇编代码执行后,
我们一再强调esp是指向栈顶的所以,esp和栈顶都是一起在动态变化的,而这个ebp保存的就是_tmainCRTSartup的地址。
然后我们再关注下一条指令,
move🍊
在汇编语言中,move(或者是mov)是一条指令,用于将数据从一个位置复制到另一个位置。它的语法通常是:
move destination , source
其中,destination表示目标位置,source表示源位置。这条指令将源位置的数据复制到目标位置。
我们在一开始就已经介绍过,ebp和esp都是指针,如果说
int * p = &a ,那么p中存放的就是a的地址。这里也一样,把esp的地址赋给ebp,也就意味着,ebp指向的位置改变了,但我们之前说这个ebp不是栈底指针吗?怎么还可以移动?别急请听我慢慢道来!
我们再给出图像
sub 🍊
在汇编语言中,sub是一条指令,用于执行减法操作。它的语法通常是:
sub destination, source
以下是一些示例:
将寄存器eax中的值减去立即数5,并将结果存储在eax中:
sub eax, 5
将寄存器ebx中的值减去寄存器ecx中的值,并将结果存储在ebx中:
sub ebx, ecx
也就是说虽然是减去但不是只有减去,还要减去之后放在一个寄存器中
那么这行代码呢?
4Ch是什么呢?
HEX:hexadecimal,十六进制的;
打开程序员计算器就可以看到
也就是说4C就是个16进制数,转换为十进制就是
4 * 16 ^ 1+12*16 ^ 0= 76
也就是说这行代码的意思是:将esp减去76,然后再放到esp,
也就是说esp这个栈顶指针要移动了,为什么是减去呢?
我们知道在栈区是先使用高地址再使用低地址的。
那么为什么要移动这么大一块空间,实际上就是为main函数开辟的函数栈帧。
我们再次给出栈区变化图。
这个-76个地址画的可能不太准确但是为了方便截图就这样简单画一下。
栈区讲解🍊
由于最开始已经讲过Push这里我们直接给图
大家不要太在乎esi,edi有什么用,只要知道是寄存器就行。
通过上面的图,我们已经知道了,esp已经移动到了一个比较远的地方,那么我们的栈顶当然也随之增高了。
现在我们要压ebx,esi,edi
当然由于是一次压一个寄存器,所以,esp会移动三次,这里我们就直接画出最终的结果。
lea🍊
在汇编语言中,lea(Load Effective Address)是一条指令,用于将一个内存地址的有效地址(即计算出的地址)加载到一个寄存器中。它的语法通常是:
lea destination, source
其中,destination表示目标寄存器,source表示源操作数,可以是一个内存地址或者一个寄存器。
eg:
将数组arr的第3个元素的地址加载到寄存器ebx中:
lea ebx, [arr+2 * 4]
这里的arr+2 * 4表示数组arr的起始地址加上2个元素的偏移量,乘以每个元素的大小(4字节)。
那么也就是说将[ebp - 4Ch]这个地址加载到edi里面,
我们之前就算过4Ch就是76,ebp我们很早就压在栈区,
这当中肯定有一种设计不然不会这么巧妙~
我们给出图。
为什么为局部变量的值是随机值?🍊
来看这行代码,现在我们再来看我们的栈区
发现好像没有ecx和eax啊,因为我们并没有push(压栈)但这里其实并不需要把这两个压在栈里。
这两个move很好理解,就是把后面的十六进制放到这两个对应的寄存器中,13h=19(十进制) ,而这个0XCCCCCCCC是不是有点像我们没有初始化的时候数组里面放的随机值。
那到底是怎么放进去的呢?
我们看最后一个代码
我们来介绍一下这行代码
rep stos是一条汇编指令,用于重复执行字符串存储操作。它的语法通常是:
rep stos destination
其中,destination表示目标操作数,通常是一个内存地址。
dword ptr是一个操作数大小的修饰符,用于指定操作数的大小为双字(32位)。word是两个自己,double word就是4个字节,刚好我们的寄存器就是4个字节(32位)
[edi]是一个寻址方式,表示通过寄存器edi中的地址作为目标操作数。
因此,指令rep stos dword ptr [edi]的含义是重复执行将32位数据存储到地址edi指向的内存位置的操作。
具体操作的次数由寄存器ecx中的计数值决定。在执行rep stos之前,通常会将计数值存储到ecx寄存器中。
我们看看edi中的地址在哪里
也就是我们main函数开始的地方,然后从下初始化ecx次CCCCCCCC就行了,为什么ecx里是19(十进制)呢?因为19 *4 =76。
那么eax呢?不是要把0xCCCCCCCC放进去吗?但是这个地方我们不需要管这么多,只要知道这三行汇编语言是初始化一个函数栈帧就行了。
这就是为什么我们的局部变量不初始化就会变成0xCCCCCCCC的原因。
开始执行代码🍊
dword ptr是一个操作数大小的修饰符,用于指定操作数的大小为双字(32位)。word是两个自己,double word就是4个字节,刚好我们的寄存器就是4个字节(32位)
int a = 10 :
我们看到了mov,也就是说把0A这个16进制数放到ebp-8这个地址上,0A转换为十进制就是10,ebp-4是哪个地方的地址?
我们直接看图
而我们对比一下在VS 2022中的汇编代码
也就是说在VS2022要隔4个字节,但是过程基本是一致的。
然后我们给出我们现在栈区的图解
函数调用前的准备(就是传参)🍊
由于前面已经讲的很清楚了,这里我们就简单说一下,
把ebp-8地址的值引用然后,放到eax里,再push eax,
同理ecx也一样。
我们给出图解
这个过程其实就是相当于我们的函数传参。
函数调用🍊
call 即 调用
为什么我们要记住这个地址呢?
在汇编语言中,"call"是一个指令,用于调用(跳转到)一个子程序或函数。它的作用是将程序的控制权转移到指定的子程序或函数,并在子程序或函数执行完毕后返回到调用它的地方。
这里调用的地址是是0x00401095
但是call指令还有一个作用:"call"指令会将下一条指令的地址(也称为返回地址)压入栈中,然后将控制权转移到目标地址。
就是这个地址,我们在调用完成后回来这,当然不要忘记我们要压这个地址入栈,先不用讲add,因为我们还要进入函数。
我们先给出Add函数汇编代码,让大家有个概念。这次我们才真正来到Add函数。
大家有没有发现和我们前面所讲解的main函数完全一样!
这就是在为Add函数准备它的函数栈帧。
我们为了加深印象还是简单的讲解
这是ebp初始的位置,
这就是这两行的意思。
44h是68(十进制)。
为了方便对比我将ebx,esi,edi的颜色是一样的。这个过程中esp也会多次移动。
这张大图很容易对比
然后是这个。
先初始化
然后就是——直接看图吧!
这就完全能够说明函数在传参过程中,真的会临时创建变量来接收,而我们之前的ecx,和eax就是接收实参,而且是先接收左边的b,再接收a。 然后在Add函数需要的时候借用这两个寄存器传过去。
当然还没有结束还要return!
我们再来观察
我就不在图上改了,这里就是相当于把eax的值变成30,很明显因为我们出函数时要销毁局部变量所以只能通过寄存器传递值。
pop(出栈)
"pop"是一个汇编指令,用于从栈中弹出(取出)一个值,并将其存储到指定的目标操作数中。
通常情况下,"pop"指令用于从栈中弹出一个值并将其存储到寄存器中。例如,"pop eax"将从栈中弹出一个值,并将其存储到eax寄存器中。
弹弹弹~ edi esi ebx
把 ebp 赋给esp
当然我们的Add函数也就没必要存在了
最后是
弹出ebp再并将其存储到ebp寄存器中。
这两个ebp要注意,上一个ebp其实就是我们main函数的栈底。这个ebp弹出后除了栈顶指针要-4,我们的栈底指针ebp也会回到main函数的栈底
也就是说我们回到了main函数了。
通过使用"ret"指令,可以从栈中弹出之前存储的返回地址,将控制权返回给调用者。
"ret"指令用于从子程序或函数中返回到调用者,并将控制权返回到之前存储的返回地址。
回来了
然后就是这一条指令,我们之前故意留着。
为什么要esp要add一个8呢?
我们看
我们之前用ecx,eax来接收实参,现在我们还需要用吗?
当然不需要那么我们栈底还要它们干什么???
这里我们猜想,如果传三个值那么是不是要+12呢?当然我也不知道,我只是猜测而言,合理的猜测。
然后是这几条。
将eax赋给ebp-12。
这个eax和我们接收实参的eax是不同的,是我们专门用来存储返回值的eax
ebp-12就是我们的c
接下来看这几条
我们先看第一条,意思是讲c的值取出放到edx,这个寄存器在我们之前完全没有使用过。
然后还是压一个edx.
接下来,使用"push"指令将字符串常量的地址(offset string “%d\n”)推入栈中。这是为了准备将其作为参数传递给printf函数,这个字符串格式控制符将被用来打印先前提供的值
然后我们调用printf这个库函数。
在printf函数结束后,使用"add"指令将esp寄存器的值增加8个字节。这是为了清除栈上的参数,以便恢复栈的状态。
最后是xor
"xor"是一个汇编指令,用于执行两个操作数之间的异或运算。
"xor"指令可以用于执行寄存器之间的异或运算,也可以用于执行寄存器和内存之间的异或运算。
例如,"xor eax, ebx"将执行eax寄存器和ebx寄存器之间的异或运算,并将结果存储回eax寄存器中。
XOR操作在计算机科学和密码学中有多种应用。它可以用于数据加密、校验和计算、数据交换等场景,提供了一种简单而有效的数据处理和安全保护机制。可能是为了保护我们的最后的结果,我猜测
然后弹出edi,esi,ebx
最后只剩这么一点了。
cmp一下仅供了解:
仅供了解:
“cmp"指令的语法通常是"cmp operand1,
operand2”,其中operand1和operand2可以是寄存器、内存地址或立即数。当执行"cmp"指令时,计算机会将operand1的值与operand2的值进行比较,并根据比较结果设置标志位。具体来说,"cmp"指令执行以下操作:
将operand1的值减去operand2的值,并更新标志位。这个减法操作不会修改operand1和operand2的值,只是用于比较它们的大小。
根据减法结果设置标志位。比较的结果可以分为三种情况:
如果operand1等于operand2,则零标志位(ZF)被设置为1。
如果operand1小于operand2,则符号标志位(SF)被设置为1。
如果operand1大于operand2,则无符号溢出标志位(CF)和符号溢出标志位(OF)被设置为1。
“cmp"指令通常与条件跳转指令(如"je”、“jne”、"jg"等)一起使用,用于根据比较结果执行不同的操作。总之,"cmp"指令用于比较两个操作数的值,并根据比较结果设置标志位。这样可以在程序中根据比较结果来做出不同的决策,例如执行条件跳转或选择不同的执行路径。
"call __chkesp (00401160)"是一个汇编语言指令,它的作用是调用名为 “__chkesp” 的过程或函数。
在汇编语言中,“call” 指令用于调用一个过程或函数,它将指令流跳转到目标过程或函数的起始地址,并将当前指令的地址(即 call
指令的下一条指令地址)保存在堆栈上,以便在返回时回到正确的位置。“__chkesp” 是一个函数或过程的名称,后面的 “(00401160)”
是函数的入口地址。入口地址是函数在内存中的位置,它唯一地标识了函数的起始地址。注意,这个地址是一个虚拟地址,并以十六进制表示。因此,“call __chkesp (00401160)” 意味着执行一个函数或过程,该函数的名称为 “__chkesp”,其起始地址为
00401160。执行到这条指令时,程序流会跳转到函数的起始地址,并将当前指令的地址保存在堆栈上,以便在函数执行完毕后返回到正确的位置。
最后这三句我就不画了。后面的仅供了解了解,不必深究,我现在也没有这个能力。
总结🍊
***这篇博客我们系统的介绍了函数栈帧的创建和销毁。我们解决了这些问题。
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?
- 传参的顺序是怎么样的?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
这篇博客写下来感觉我的C语言功法又进一步。虽然有点累但是真的很有成就感
最后如果这篇博客有帮助到你,欢迎点赞关注加收藏
如果本文有任何错误或者有疑点欢迎在评论区评论