在刚开始工作时,我不知道你们是否有疑惑过引用和指针到底有什么本质区别,是否纠结过是使用if else 还是 switch case,抑或纠结于使用i++还是++i。上述这些问题,哪怕我们对c++/c特性再明白,对《c++primer》看的再多,也无法解决。当然我们也可以到网上搜博客,但说实话,我看了那么多博客也没有找到太多满意的答案,而且零零散散的每次查阅,都要花费大量时间,所以在这自己做一次总结。
本文会使用大量的例子来展现c++和x86-64汇编代码之间的联系,同时也会穿插一些汇编指令的讲解。
下面的代码都基于intel x86-64 , gcc 12.1 编译,,编译选项为O0。下面move指令顺序为mov des,src。
每个函数都拥有一个栈空间(存储在内存上),栈扩展方向为从高地址向地址值扩展。
现在大多pc机都是小段字节序,意味着当寄存器从某个地址Addr读取n个字节,所读取的字节范围为[Addr,Addr+n]。
x86-64指令集,只提供了从内存到寄存器以及寄存器到内存的操作,不存在内存到内存的操作。
WORD占两个字节,DWORD为4个字节, BYTE为一个字节
常用寄存器大小:rip 64byte rbp 64byte rsp 64byte
rbp 为栈底寄存器,记录当前栈的栈底地址
rsp 为栈顶寄存器,记录当前栈的栈顶地址
push push rbp,将rbp寄存器中的值存储到栈中
立即数 立即数存放在指令中
首先我们看下数据类型。
void func(){
int a = 0;
char b = '0';
}
func():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0
mov BYTE PTR [rbp-5], 48
nop
pop rbp
ret
代码解读
- push rbp : 将rbp寄存器中的值(栈底寄存器旧值)压入栈中,修改rsp寄存器的值为原rsp值 - rbp字节数
- mov rbp, rsp: rbp寄存器读取rsp寄存器中的值,注意此时rsp为旧调用栈的栈顶。
- mov DWORD PTR [rbp-4], 0 : 从立即数0中读取4个字节到 rbp-4的地址上,这里对应c代码int a = 0
- mov BYTE PTR [rbp-5], 48 : 同上,对应char b = ‘0’;
- nop :空指令
- pop rbp :rbp寄存器读取保存在栈空间的rbp寄存器的旧值,同时修改rsp寄存器的值为当前rsp值 - rbp字
- ret : 函数返回,ret细节下文介绍。
由上可知,对于汇编代码而言,没有类型的概念。c/c++类型本质为固定长度的字节数。
void func(int a, int b, int c, int d, int e, int f){
}
void func(int a, int b, int c, int d, int e, int f, int g){
int g1 = g;
}
x86-64架构提供了6个寄存器用于函数传值: edi,esi,edx,ecx,r8d,r9d分别对应函数的形参。当形参数量多余6个时,多余的就会保存在上一个函数栈中。如图方框部分,可见形参g被保存再来rbp+16的内存上。[rbp,rbp+24]的这段内存保存了rip和rbp的旧值。
void func(){
{
int a = 1;
}
int a = 2;
}
void func1(){
func();
int a = 0;
}
call指令实际上分为两个步骤:1、将rip寄存器中的值压入栈中 ,那么问题来了rip在x86-64架构下为8byte,为什么rsp用16个字节存储呢?原因是在函数调用栈转移时,规定寄存器rsp扩展要满足16字节对齐 2、修改rip寄存器的值为目标地址(下一条需要运行的汇编指令地址)
栈的对齐
rip寄存器:存放下一条运行的指令地址
pop指令相当: mov %rbp,%rsp , add %rsp,8。从rsp读取8byte字节(这8个字节为旧的rbp的值)到rbp寄存器中,然后rsp收缩8个字节长度。这意味这调用pop时需要将rsp寄存器置于栈底。
ret相当于: mov %rip,%rsp, add rsp,16。则为从栈中弹出rip的旧值,将旧值重新付给rip寄存器,这样下一条指令就能从调用该函数处的下一条指令开始运行。
该指令是pop的封装,相当于 (1) move %rsp,%rbp (2)pop %rbp 。即先将rsp置于和rbp水平,然后从当前栈中弹出上一个栈的栈底地址给到rbp寄存器。
c/c++的数组没有边界检查,所以在学习时我们一定看到过如下字眼:数组越界可能会产生未定义的错误。未定义错误:最好的结果就是段错误,至少可以帮助我们排查问题;但有时不会出现段错误,这时候,问题排查就很困难了。
#include
#include
void func(){
long a[2];
a[0] = 1;
a[1] = 2;
a[3] = 0x401155;//func2的内存地址
}
void func2(){
// std::cout << "func2" << std::endl;
printf("ssssss");
exit(4);
}
int main(){
// char a[3] ={'a','a', 'a', '\x7d', '\x11', '\x40'};
// void (*p)() = (void (*)())(0x40117d);
// p();
// std::cout << (void*)func2 <
func();
return 0;
}
结果分析
上面代码模拟了栈溢出攻击。我们目前知道,main函数调用func函数前,将rip旧值(40117b)压入main的栈空间中,然后跳转到func函数执行,func函数定义了一个长度为2的long int数组(每个元素8个字节),我们知道在在栈底之上还push了rbp,a[2]对应位置即压入rbp数据的内存地址,a[3]对应之前保存的rip地址。这里我们修改rip的旧值为func2的地址,这样一来,func函数返回时就会执行func2的代码。
函数地址的本质就是该函数代码块的第一个汇编指令的地址。
#include
#include
void func2(){
// std::cout << "func2" << std::endl;
printf("ssssss");
exit(4);
}
int main(){
void (*p)() = (void (*)())(0x401136);
p();
return 0;
如果我们知道func2函数在内存的地址,我们可以直接转换为指针调用。
曾有一段时间,我一直纠结是使用switch case 还是 if else,后来查阅csapp,书上是说swicth使用跳表优化掉了比对逻辑。如下面代码如果使用if else,进入每个分支前我们都需要使用cmp进行对比,但使用调表,直接通过运算就可找到即将运行的代码块。
但是,最新的gcc12.1编译出的代码和csapp中上述例子却不一样
void SwitchTest(){
int a = 1;
switch (a){
case 1:
a = 2;
break;
case 2:
a = 3;
break;
}
}
void IfTest(){
int a = 1;
if (a == 1){
a =2;
}else if(a == 2){
a = 3;
}else{
}
}
我们发现最新gcc12.1,似乎以及取消了switch 调表的优化,和if esle没区别。所以工程中使用,在使用if esle和switch case时,我们无需考虑性能问题。
c/c++中的goto关键字,可以直接跳转到一个代码块(label处)。goto关键字在我看来,它其实更像一个汇编指令(jmp),也就是说使用goto实现的代码更接近于底层汇编,日常代码开发中,使用goto会影响代码的可读性。但日常学习中,goto可以帮助我们更好理解循环语句。
int loop1(){
int a = 0;
for(int i = 0; i < 1; i++){
++a;
};
return a;
}
使用goto改写
int loop2(){
int a = 0;
int i = 0;
goto loop;
l1:
++a;
i++;
loop:
if(i < 1){
goto l1;
goto loop;
}
return a;
}
根据汇编代码我们可以确定goto版本是正确的。根据goto实现版本,我们可以得出结论,循环语句本质上就是 判断(cmp)+跳转(jmp)。
当你搜索++i和i++有什么性能上的差别时,大多数博客都会说++i更好,性能更高。但其实不是这样的,这也需要分场合讨论
int loop1(int i){
++i;
}
int loop2(int i){
i++;
}
void loop3(){
int i = 0;
int a = ++i;
}
void loop4(){
int i = 0;
int a = i++;
}
首先我们简单比对上述两个代码,这时我们发现i++版本比++i版本多一条汇编指令,所以从运行来说++i版本运行更快(这里mov eax, DWORD PTR [rbp-4] ,即将i的值先存储到一个临时变量中。因为临时变量的产生,如果变量i不是内置类型,对象的拷贝上的性能损耗会更加明显)。好接下来我们分别看下loop3和loop4方框中的代码:
loop3
- DWORD PTR [rbp-4], 1 : 将rbp-4地址的值 + 1,并保存会内存rbp-4地址中。
- eax, DWORD PTR [rbp-4] : 将rbp-4地址的值(变量i的值)读取到eax寄存器中
- DWORD PTR [rbp-8], eax : 将eax寄存器的值存储到rbp-8地址的内存上(a变量)
loop4
- eax, DWORD PTR [rbp-4] : 将rbp-4地址的值存储到eax寄存器中(临时变量中)
- lea edx, [rax+1] : 将rax(rax和eax是一个寄存器)中的值+1,赋值给edx寄存器
- DWORD PTR [rbp-4], edx : 将edx的rbp值存储到rbp-4内存处,至此才完成了++i
- DWORD PTR [rbp-8], eax : 这里将eax(i自增前的值)赋给局部变量a
rax和eax区别
常用寄存器