从x86-64汇编角度看c++代码

前言

在刚开始工作时,我不知道你们是否有疑惑过引用和指针到底有什么本质区别,是否纠结过是使用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汇编角度看c++代码_第1张图片

结论

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;
}

假设实际代码地址运行为行数。
从x86-64汇编角度看c++代码_第2张图片

call

call指令实际上分为两个步骤:1、将rip寄存器中的值压入栈中 ,那么问题来了rip在x86-64架构下为8byte,为什么rsp用16个字节存储呢?原因是在函数调用栈转移时,规定寄存器rsp扩展要满足16字节对齐 2、修改rip寄存器的值为目标地址(下一条需要运行的汇编指令地址)
栈的对齐
rip寄存器:存放下一条运行的指令地址

pop

pop指令相当: mov %rbp,%rsp , add %rsp,8。从rsp读取8byte字节(这8个字节为旧的rbp的值)到rbp寄存器中,然后rsp收缩8个字节长度。这意味这调用pop时需要将rsp寄存器置于栈底。

ret

ret相当于: mov %rip,%rsp, add rsp,16。则为从栈中弹出rip的旧值,将旧值重新付给rip寄存器,这样下一条指令就能从调用该函数处的下一条指令开始运行。

leave

该指令是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;
}

func对应编译后的汇编
从x86-64汇编角度看c++代码_第3张图片

func2对应的汇编
从x86-64汇编角度看c++代码_第4张图片
main函数

从x86-64汇编角度看c++代码_第5张图片

运行结果
从x86-64汇编角度看c++代码_第6张图片

结果分析

上面代码模拟了栈溢出攻击。我们目前知道,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 && if else

曾有一段时间,我一直纠结是使用switch case 还是 if else,后来查阅csapp,书上是说swicth使用跳表优化掉了比对逻辑。如下面代码如果使用if else,进入每个分支前我们都需要使用cmp进行对比,但使用调表,直接通过运算就可找到即将运行的代码块。
从x86-64汇编角度看c++代码_第7张图片
但是,最新的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{

    }
}

从x86-64汇编角度看c++代码_第8张图片

从x86-64汇编角度看c++代码_第9张图片
我们发现最新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;
}

从x86-64汇编角度看c++代码_第10张图片
根据汇编代码我们可以确定goto版本是正确的。根据goto实现版本,我们可以得出结论,循环语句本质上就是 判断(cmp)+跳转(jmp)。

++i和i++

当你搜索++i和i++有什么性能上的差别时,大多数博客都会说++i更好,性能更高。但其实不是这样的,这也需要分场合讨论

情景一

int loop1(int i){
    ++i;
}

int loop2(int i){
    i++;
}

从x86-64汇编角度看c++代码_第11张图片
我们发现两者并无区别.两者没有任何性能上的差距。

情景二

void loop3(){
    int i = 0;
    int a = ++i;
}

void loop4(){
    int i = 0;
    int a = i++;
}

从x86-64汇编角度看c++代码_第12张图片
结果分析

首先我们简单比对上述两个代码,这时我们发现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区别
常用寄存器

你可能感兴趣的:(汇编,c++,开发语言)