首先给出下面一段代码,本篇我将通过这部分代码的汇编指令来说明一段代码时怎样运行的。
#include<iostream>
using namespace std;
int sum(int a, int b)
{
int temp = 0;
temp = a + b;
return temp;
}
int main()
{
int a = 10;
int b = 20;
int ret = sum(a, b);
cout << "ret:" << ret << endl;
return 0;
}
上面这段代码在运行时,首先需要将其转换为汇编代码,汇编代码再转换为机器语言,通过机器语言操作硬件,才可以顺利执行出结果,并在显示器上显示,同样,我们先给出一些简单的概念了解。
一、小知识扫盲
1.机器语言
机器语言是硬件设施直接使用的语言,不需要经过翻译就可以直接操控硬件,对于硬件来说,它们作为一种电路元件在某部分电路上只能接受0和1两种指令,即断电和通电,或者也可以理解成执行或不执行,而机器语言就是直接对硬件执行操作的语言。其指令就是一段二进制字符串。是最底层的语言。
2.汇编语言
由于机器语言阅读起来十分的麻烦,所以人们又发明了汇编语言来代替机器语言,这样更容易操作,但其实也不能称为代替,因为只是交互的方式改变,汇编语言最终还是被翻译成机器语言来执行的,但相对于直接输入二进制指令,汇编还是要好的太多了,而任何高级语言的执行都是要经过“高级语言->汇编语言->机器语言”的过程才可以执行。
3.函数栈帧
在上一篇博客我小提了一下函数栈帧,对于每个函数,编译器都会在4G的虚拟地址空间中的stack段开辟一个栈帧,而栈帧的栈顶和栈底分别由指针寄存器esp和ebp来标识,由于stack段的特性我们知道,esp存放低地址,ebp存放高地址。
二、函数的执行过程
任何一个程序的执行都是从主函数开始的,对于开头给的那部分代码,下面按语句进行分析:
1.进入主函数
先申请栈帧,获取了esp和ebp,然后才开始执行剩下的语句。对此我们可以在VS2017编译器中打一个断点,开始调试再转到反汇编,看下它如何进入主函数的执行过程:
int main()
{
000C2670 push ebp
000C2671 mov ebp,esp
000C2673 sub esp,0E4h
000C2679 push ebx
000C267A push esi
000C267B push edi
000C267C lea edi,[ebp-0E4h]
000C2682 mov ecx,39h
000C2687 mov eax,0CCCCCCCCh
000C268C rep stos dword ptr es:[edi]
000C268E mov ecx,offset _D46D14F6_源@cpp (0CF027h)
000C2693 call @__CheckForDebuggerJustMyCode@4 (0C1280h)
可以看到对栈底指针存入数据等一系列操作。
2.执行语句
(1)由上一个博客可以得知主函数内定义的两个变量均生成指令而不是数据,而变量a、b生成的指令为:
int a = 10;
000C2698 mov dword ptr [a],0Ah
int b = 20;
000C269F mov dword ptr [b],14h
对变量a进行存储,在此VS编译器是做了简化的,事实上“mov dword ptr [a],0Ah”中的[a]应为[ebp-4],代表a变量的存储地址对于ebp的偏移量为4,方便对其访问,而对b的操作也是一样的,只不过偏移量为[ebp-8]。
(2)函数调用
在此语句中开始函数调用,函数调用之初是要对函数进行参数压栈(从右向左),他生成的汇编代码如下:
int ret = sum(a, b);
000C26A6 mov eax,dword ptr [b]
000C26A9 push eax
000C26AA mov ecx,dword ptr [a]
000C26AD push ecx
000C26AE call sum (0C109Bh)
000C26B3 add esp,8
000C26B6 mov dword ptr [ret],eax
从汇编指令中可以看到先从b的内存中取值,将其存在eax中,然后再将eax中的值压栈(push指令),然后对a执行相同的操作,eax和ecx是一组寄存器。
“call sum(0C109Bh)”则执行了函数调用,并将下一条汇编指令的地址进行入栈操作,以便于记忆当函数调用结束后从何处开始继续执行。call指令后边的指令在函数调用结束后再分析。

3.函数调用
当执行完地址入栈后则进入到sum函数,,sum函数生成的指令为:
int sum(int a, int b)
{
000C25F0 push ebp
000C25F1 mov ebp,esp
000C25F3 sub esp,0CCh
000C25F9 push ebx
000C25FA push esi
000C25FB push edi
000C25FC lea edi,[ebp-0CCh]
000C2602 mov ecx,33h
000C2607 mov eax,0CCCCCCCCh
000C260C rep stos dword ptr es:[edi]
000C260E mov ecx,offset _D46D14F6_源@cpp (0CF027h)
000C2613 call @__CheckForDebuggerJustMyCode@4 (0C1280h)
int temp = 0;
000C2618 mov dword ptr [temp],0
temp = a + b;
000C261F mov eax,dword ptr [a]
000C2622 add eax,dword ptr [b]
000C2625 mov dword ptr [temp],eax
return temp;
000C2628 mov eax,dword ptr [temp]
}
进入到sum函数后开始申请栈帧:先存储主函数的ebp(push ebp)然后再把现在栈帧的栈顶指针赋给栈底指针(mov ebp,esp),然后对栈顶指针进行减操作(sub esp,0CCh)。到此sum函数的栈帧开辟完毕。
rep stos指令是将函数栈帧内的值全部初始化一遍(gcc和g++不执行此语句)。剩下的语句则是对变量的申请内存以及赋值操作,和主函数中是一样的。注意此处的a和b是刚重新压栈的形参变量,而不是主函数中的a和b。最后将所得值存入到eax中准备带回。
4.sum函数的栈帧回退
sum函数调用结束,temp的值也存入到eax中准备带回了,此时对于sum函数的“}”也还是有指令的:
000C263B mov esp,ebp
000C263D pop ebp
000C263E ret
将ebp的值给esp;继续出栈(栈的特性使其先入后出,所以此时出栈的值应为先前存的地址)并将栈中的值传给ebp;而对于ret指令,则是把出栈的内容放到CPU的PC寄存器中(此次出栈的数据则是第二部分中call存的地址)。栈帧回退完毕,回到主函数中。
5.剩余工作
在第二部分中call的后续指令为:
000C26B3 add esp,8
000C26B6 mov dword ptr [ret],eax
此时add操作即将栈帧的地址加上8,就是把函数的形参变量的空间返还给系统了,这是esp的指向的是主函数的栈顶;再将eax中放到ret中。再输出即可。
三、总结
函数的栈帧开辟,栈帧回退,数据出栈,数据入栈,数据返回,地址存储,栈顶栈底移动等都比较重要,了解一下这些汇编指令还是可以更好的去理解代码是如何编写的,还有VS查看反汇编等都是比较实用的小方法,另外:
(1)在函数回退栈帧时,我们可以看到系统并没有对数据进行清空,而计算机存储数据也是相同的,只是将数据的划分界限移动了一下,并非真的抹掉所有数据。
(2)在函数C语言中,当一个变量的值<=4字节时是通过eax寄存器带回的,而>4字节且<=8字节的值则通过eax和edx传回。大于8个字节的值则通过产生临时量带回了。
感觉汇编语言还是看得人比较头疼,毕竟这个知识体系很庞大,光能看懂几个指令还是不行的,后续会自己再摸索摸索熟悉熟悉汇编指令。
黑暗过后就是黎明。