发布时间:2022-08-18 18:16
在各种高级语言发展成熟的今天,我们几乎不会再去用汇编进行编程了。但是在实际编程过程中,确经常会碰到一些莫名奇怪地问题,此时如果能从汇编的角度对代码进行理解的话也许就可以发现其中的问题所在,同时也便于更深次理解高级语言的运行原理。因此本篇文章旨在让大家能更好地理解C++,即使没有汇编基础的人也能看懂。
要说到它俩的关系,首先得提一下机器语言。机器语言是机器指令的集合,计算机只能执行机器指令,汇编语言代码和任何高级语言的代码,如果不经过编译器编译成机器指令都没法被计算机执行的。而机器指令都是数字,不便于人观看,因此就有了汇编语言。汇编指令和机器指令是一一对应的,通过汇编编译程序可以将汇编源代码编译成机器指令文件。C++是一种高级语言,它的源代码最终变成可执行程序(存放机器指令)需要经过编译、汇编、链接等阶段,编译阶段将C++翻译成汇编代码,汇编阶段将汇编代码变成目标文件,目标文件包含了程序自身的机器指令以及引入的其它库的一些重定位信息,链接阶段则将目标文件和其它库文件链接在一起成为最终的可执行文件。因此C++代码最终还是要变成汇编代码的。Visual Studio将这三步一气呵成地完成了,只要我们点击生成解决方案就行。中间也产生了汇编代码和目标文件,只不过VS没有保存下来,通过下图的设置我们可以将中间的汇编文件保存下来。这便于我们后面的分析。
在汇编语言层面上,是通过寄存器和存储器来进行操作。数据放在存储器中,将要运算的数据从存储器中取出来放到寄存器中,CPU对寄存器中的数据进行各种运算,运算完再放回存储器中。汇编中常用的寄存器有8个通用寄存器EAX、EBX、ECX、EDX、ESP、EBP、ESI、EDI,每个寄存器都是32位的,可以用来存放数据,ESP和EBP一般用在涉及堆栈的操作中比较多。其中EAX、EBX、ECX和EDX还可以专门访问它们的低位,如AX代表EAX的低2字节,AL代表EAX的最低字节,AH代表EAX第二低字节。还有1个程序指针寄存器EIP。程序在执行汇编代码的时候是一条一条指令往下执行的,EIP指向即将要执行的那条指令的位置,执行完之后其值增加,指向下一条指令。每条指令的长度不一样,因此EIP的值增加的也不一样。有很多跳转执行可以使得程序条到别的地方执行,这时EIP就会指向跳转到的地方。
堆栈是用于存放数据的存储区,通常用于存放临时变量和函数调用之间的参数传递。栈是向下增长的,ESP寄存器通常指向栈顶,即栈顶元素的偏移值。PUSH指令可以将操作数压入栈中,这里的操作数即可以来自寄存器也可以来自存储器,比如PUSH EAX将会把EAX的值压入栈中,这是ESP的值就会减4指向新的栈顶。POP指令从堆栈中弹出数据放到存储单元中,存储单元即可以是寄存器也可以是存储器。
当要访问存储器时是通过其偏移地址来确定访问单元的。比如要把地址为10H的内存单元的值给AL寄存器,那么就使用MOV AL, [10H],这叫直接寻址,如果不带中括号就是把10H给AL了。地址也可以放到寄存器中,假如BL寄存器里面放着10H,那么MOV AL, [EBX]就是把BL寄存器里面存的偏移地址指向的内存传给AL了,这叫寄存器间接寻址。上面两种方法还可以混着用,如MOV AL, [EBX+10H]表示把BL+10H的值作为偏移地址来定位内存并传值给AL寄存器,这叫寄存器相对寻址。10H除了可以放在中括号里面外,还可以放在中括号外面,如MOV AL, 10H[EBX],这给地址的表示方式带来了很大的灵活性,下面就会用到这种格式。
指明了操作数地址后,具体取多少字节的数据给寄存器视使用的寄存器而定。比如MOV AL,[10H]就是取一个字节给AL寄存器,MOV EAX,[10H]就是取4个字节给EAX寄存器。也可以显示指定取多少字节,如MOV EAX, dword ptr [10H]。dword是双字类型,即四个字节。
MOV DST, SRC ; 把数据从源操作数传送到目的操作数
如MOV EAX, 2将2放到EAX寄存器中,MOV EAX, EBX将EBX的值放到EAX中,MOV byte ptr [EBX], 将3放到EBX指向的一字节内存单元中。
ADD OPRD1, OPRD2 ; 将操作数OPRD1和操作数OPRD2相加,结果放到操作数OPRD1中
如ADD AL, 5将AL的值和5相加再放到AL中去,ADD [BX+15H], 3将BX+15H指向的内存单元加3。
SUB OPRD1, OPRD2 ; 将操作数OPRD1和操作数OPRD2相减,结果放到操作数OPRD1中
CMP OPRD1, OPRD2 ; 将操作数OPRD1和操作数OPRD2相减,但结果不放到操作数OPRD1中,它会影响标志寄存器的值,通常和跳转指令相结合,待会看具体的例子。
JMP DST; 程序跳转到DST指向的位置继续执行,这通常用于if、for、while等语句中
CALL DST; 程序跳转到DST指向的位置继续指向,通常用于函数调用中。它在跳转前,EIP会先指向接下来的下一条指令,然后将EIP的值PUSH到堆栈中。在子程序结束之后会调用RET指令返回来,RET指令会从堆栈中POP出EIP的值赋给EIP,这样就可以从调用出接着执行了。
LEA REG, OPRD; 把操作数OPRD的偏移地址传送到寄存器REG
如LEA EDX, [EBX+3]将EBX+3指向的内存单元的偏移地址传给EDX。
有了上面这些准备知识就可以看懂一些C++的汇编代码了。
int a = 1;
int b = 2;
int c = a + b;
printf("c = %d\n", c);
上面几行的语句很简单,我把产生的汇编文件中主要的汇编语句摘过来,因为里面会有一些用于错误检查的语句,所以实际的代码比这多,分配的空间也更大。
CONST SEGMENT ; 程序通常分为几个段,如代码段、数据段、常量数据段等等,这里就是在定义常量代码段,里面放着我们的“c = %d\n”字符串
FAPMFBFP DB 'c = %d', 0aH, 00H ;的定义,oaH表示换行,00H是字符串结尾,DB表示定义一系列字节类型的数据,后面的字符串中每一个字符
CONST ENDS ; 都是一个字节
_TEXT SEGMENT ; 这是在定义代码段
_c$ = -12; ; 这是在定义常量,即变量c的相对偏移,这是伪指令,不是指令,这条伪指令定义的常数最后会被汇编编译器替换成具体的数
_b$ = -8;
_a$ = -4;
_main PROC ; 主函数的定义
push ebp ; 将ebp的值存到堆栈中,因为下面会修改它的值,函数执行完之后需要恢复所有寄存器它们原来的值
mov ebp, esp ; 将esp的传给ebp,这么做是因为我们要给变量a,b,c分配空间了,局部变量的空间都分配在堆栈上,所有esp原来的值需要保存
sub esp, 12 ; 三个变量长12字节,全部放在栈上,将栈指针减12栈上便多出来了12字节
mov DWORD PTR _a$[ebp], 1; 将堆栈中指定放变量a的位置赋值1
mov DWORD PTR _b$[ebp], 2;
mov eax, DWORD PTR _a$[ebp] ; 将存放变量a的内存的值传给eax
add eax, DWORD PTR _b$[ebp] ; 相加结果放在eax
mov DWORD PTR _c$[ebp], eax ; 相加结果放到变量c的内存中
mov eax, DWORD PTR _c$[ebp] ; 变量c的值放到eax中,由于eax已经是放的变量c的值,这么做其实多余了,所以编译器产生的代码并不是最优的
push eax ; 将eax的值入栈,下面要调用printf函数,函数参数是通过堆栈来传递
push OFFSET FAPMFBFP ; 将字符串的偏移压栈,OFFSET用来获得变量的偏移
call DWORD PTR __imp__printf ; 调用printf函数
add esp, 8 ; 函数调用结束,堆栈中压入的参数可以弹出了,弹出两次的结果就是将esp加8,所以可以直接将esp加8,效果一样
xor eax, eax ; 对eax进行异或操作清0
add esp, 12 ; 从堆栈上分配的a,b,c变量的内存可以收回来了,直接调整esp
mov esp, ebp ; ebp的值是给变量分配前的堆栈栈顶指针,这里的赋值是堆栈恢复原状,其实上一步之后已经恢复原状了,这里保险起见
pop ebp ; 恢复ebp的值
_main ENDP
_TEXT ENDS
上面汇编的结果解释得和详细了,通常C++里面说的指针就是指的汇编中的变量地址偏移,相对于其段所在开始位置处的偏移,具体值可以不用关心。在上面我们定义了在给堆栈分配了a,b,c变量的内存之后堆栈的情况如下图所示。EBP指向的堆栈内存放的是EBP被改变之前的值,图中的框都表示四个字节,所以这里的指向指的是指向其中第一个字节的地址,ESP指向栈顶即变量c的内存。_a$[ebp]即[ebp-4],得到的刚好存放变量a的内存的值。
int Add(int oper1, int oper2)
{
int result = oper1 + oper2;
return result;
}
void main()
{
int a = 1;
int b = 2;
int c;
c = Add(a, b);
return ;
}
上面的语句也很简单,main函数的汇编语句如下
_TEXT SEGMENT
_c$ = -12
_b$ = -8
_a$ = -4
_main PROC
push ebp
mov ebp, esp
sub esp, 12
mov DWORD PTR _a$[ebp], 1
mov DWORD PTR _b$[ebp], 2
mov eax, DWORD PTR _b$[ebp]
push eax
mov ecx, DWORD PTR _a$[abp]
push eax ; 从这里可以看出,先将变量b入栈,再将变量a入栈,和参数传递顺序相反
call ?Add@@YAHHH@Z ; 调用ADD函数,函数名这么奇怪是因为被C++编译器进行了重整
add esp, 8 ; 恢复堆栈
mov DWORD PTR _c$[ebp], eax ; 函数的结果是放在eax中的,将其值放到变量c的内存中
xor eax, eax
add esp, 12
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
下面再看看函数Add的汇编代码
_TEXT SEGMENT
_result$ = -4
_oper1$ = 8
_oper2$ = 12
?Add@@YAHHH@Z PROC
push ebp
mov ebp, esp
sub esp, 4 ; 给变量_result分配空间
mov eax, DWORD PTR _oper1$[ebp]
add eax, DWORD PTR _oper2$[ebp]
mov DWORD PTR _result$[ebp], eax
mov eax, DWORD PTR _result$[ebp] ; 结果放到eax寄存器中
mov esp, ebp ; 这里没有用esp+4来恢复堆栈,直接用保存在ebp中的esp值来恢复
pop ebp
ret 0
?Add@@YAHHH@Z ENDP
_TEXT ENDS
在给result分配好空间之后,堆栈的状态如下图所示。前面讲过,CALL指令调用函数时会将EIP的值压入栈中便于函数返回,因此EBP之前有一个EIP,变量a的空间即oper1的空间,所以oper1的位置是EBP+8,oper2的位置是EBP+12
关于函数调用有一个调用约定的问题,上面我们调用函数传参数时将参数入栈,调用结束之后再恢复堆栈。这个恢复的过程也可以由函数内部来完成,对于Add函数来说,如果最后不用ret 0而用ret 4,含义是返回之后将ESP的值加4,这样调用函数的地方就不用负责恢复ESP了。由调用者恢复也就是_cdecl调用约定,由函数自己恢复也就是_stdcall调用约定,VS的默认调用约定是_cdecl。我们在编程时有时碰到ESP值异常,堆栈被破坏的异常,这往往就是因为调用约定不一致。
struct Struct
{
public:
int a;
void Add(int num)
{
a += num;
}
};
class Class
{
public:
int a;
virtual void Add(int num)
{
a += num;
}
};
void main()
{
Struct s;
Class c;
s.Add(1);
c.Add(2);
return ;
}
上述C++代码分别测试了结构体和类,结构体和类在汇编之后没有本质区别,下面先看main函数的汇编代码
_TEXT SEGMENT
_c$ = -12
_s$ = -4
_main PROC
push ebp
mov ebp, esp
sub esp, 12
lea ecx, DWORD PTR _c$[ebp] ; 获取对象c的地址,这就是this指针,this指针通过ecx寄存器传递到成员函数中
call ??0Class@@QAE@XZ ; 这是类的构造函数
push 1
lea ecx, DWORD PTR _s$[ebp] ; 要调用Add函数,需要传递this指针
call ?Add@Struct@@QAEXH@Z
push 2
lea ecx, DWORD PTR _c$[ebp]
call ?Add@Class@@UAEXH@Z
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
上面的代码中,对于对象c调用了构造函数,但是我们并没有写构造函数,而对于结构体变量s就没有调用构造函数,造成区别对待的原因是类中有一个虚函数。对于有虚函数的类来说,内部通常会有一个虚函数表,虚函数表是在构造函数中初始化的,因此如果有虚函数的话,构造函数是少不了,于是编译器就为我们生成了默认的构造函数,如果类中没有虚函数的话编译器也不会为它生成构造函数。下面是构造函数的汇编代码
_TEXT SEGMENT
_this$ = -4
??0Class@@QAE@XZ PROC
push ebp
mov ebp, esp
sub esp, 4 ; 给this指针分配的空间
mov DWORD PTR _this$[ebp], ecx ; this指针放在ecx中,将其存到堆栈内存中
mov eax, DWORD PTR _this$[ebp] ; this指针传给eax
mov DWORD PTR [eax], OFFSET ??_7Class@@6B@ ;将虚函数表放到this指向内存的第一个双字空间内
mov esp, ebp
pop ebp
ret 0
??0Class@@QAE@XZ ENDP
_TEXT ENDS
构造函数中只做了一件事,那就是将??_7Class@@6B@的偏移放到this指向内存的第一个双字,??_7Class@@6B@这玩意儿又是什么呢?我们在汇编代码中找到这东西的定义,如下图所示,它是位于常量段的虚函数表的定义,这段里面定义了两个双字数据,第二个就是成员函数Add了,第一个是根RTTI运行时信息等相关的一些东西。
再来看看Add函数
_TEXT SEGMENT
_this$ = -4
_num$ = 8
?Add@Class@@UAEXH@Z PROC
push ebp
mov ebp, esp
mov DWORD PTR _this$[ebp], ecx
mov eax, DWORD PTR _this$[ebp]
mov ecx, DWORD PTR [eax+4] ; this指针指向内存的第二个双字就是成员a了
add ecx, DWORD PTR _num$[ebp]
mov edx, DWORD PTR _this$[ebp]
mov DWORD PTR [edx+4], ecx
mov esp, ebp
pop ebp
ret 4
?Add@Class@@UAEXH@Z ENDP
_TEXT ENDS
从上面的代码可以看到,Add函数在返回的时候调用ret 4,将堆栈中的参数清除了,这是_stdcall约定,跟全局函数的调用约定不一样。对类成员函数对成员变量的操作是通过this指针传递来实现的。
从上面的例子我们可以看到:
1. 函数的参数是通过堆栈来传递的,并且从右往左入栈,但是this指针确是通过ECX寄存器来传递的;
2. 全局函数默认是使用_cdecl调用约定,但是类成员函数使用的是_stdcall调用约定;
3. 类如果有虚函数,并且没有显示提供构造函数,那么编译器就会自动生成一个构造函数用于对虚函数表进行初始化,每个对象除了包含成员变量之外,还包含指向虚函数表的指针,该指针放在对象内存区域的最前面。
上面只例句了一些简单语句的汇编代码,各位读者如果有兴趣的话也可以研究下其它语句的汇编代码,这样就可以对C++的机制了解得更加透彻了。