利用汇编挖掘编程语言的本质

简介: 虽然现在基本不会直接使用汇编来开发程序,但是汇编仍然有学习研究的价值,可以利用汇编语来挖掘编程语言的本质。

@TOC

学习视频: 你了解每一行代码的本质么?利用汇编挖掘本质

Visual Studio 查看反汇编

在要执行的程序中打个断点(F9),点击开始调试(F5),右键 > 转到反汇编(CTRL + K, G)

程序的本质

通常,CPU 会先将内存中的数据存储到寄存器中,然后再对寄存器中的数据进行运算

假设内存中有块红色内存空间的值是 3,现在想把它的值加 1,并将结果存储到蓝色内存空间

  • CPU 首先会将红色内存空间的值放到 EAX 寄存器中:mov eax, 红色内存空间
  • 然后让 EAX 寄存器与 1 相加:add eax, 1
  • 最后将值赋值给内存空间:mov 蓝色内存空间, eax

总结:程序中任何操作都是在 CPU 中进行的,哪怕内存中的两个变量进行运算,也会先拿到 CPU 中进行计算,然后再放回内存空间。

编程语言的发展

编程语言的发展史:

  • 机器语言:由 0 和 1 组成
  • 汇编语言:用符号代替了 0 和 1,比机器语言便于阅读和记忆
  • 高级语言: C \ C++ \ Java \ JavaScript \ Python 等,更接近人类自然语言

例如对于这个操作:将寄存器 BX 的内容送入寄存器 AX

  • 机器语言:1000100111011000
  • 汇编语言:MOV AX, BX
  • 高级语言:AX = BX;
高级语言不允许直接操作寄存器,以上仅仅是举个例子

高级语言的运行步骤:

  • 汇编语言机器语言一一对应,每一条机器指令都有与之对应的汇编指令
  • 汇编语言可以通过编译得到机器语言机器语言可以通过反汇编得到汇编语言
  • 高级语言可以通过编译得到汇编语言 / 机器语言,但汇编语言 / 机器语言几乎不可能还原成高级语言

验证汇编语言 / 机器语言不能还原成高级语言

对于以下两段 C++ 代码,分别查看它们生成的汇编

struct Date {
int year;
int month;
int day;
};
Date d = { 1, 2, 3 };

int array[] = { 1, 2, 3 };

可以发现,两段不同的 C++ 代码,生成的汇编一模一样,因此汇编语言几乎不可能准确的还原成高级语言。

有一些软件可以实现汇编转高级语言,只能展现一些基本的逻辑代码。

CPU 架构不同,生成的汇编也是不同的(之前有说过,程序其实都是通过 CPU 执行的)

一些编程语言的本质区别

C++:可以轻易的反汇编

JavaScript:脚本语言,由浏览器进行解析

PHP:脚本语言,由 Zend Engine (ZE) 进行解析

Java:由 JVM 进行装载字节码

以上介绍可以将编程语言总结为三类:

  • 编译型的语言(不依赖虚拟机):C \ C++ \ OC \ Swift
  • 编译型的语言(依赖于虚拟机):Java \ Ruby
  • 脚本语言:Python \ JavaScript

代码的执行效率

if-elseswitch,谁的效率高?

int no = 4;

if (no == 1) {
    printf("no is 2");
} else if (no == 2) {
    printf("no is 2");
} else if (no == 3) {
    printf("no is 3");
} else if (no == 4) {
    printf("no is 4");
} else if (no == 5) {
    printf("no is 5");
} else {
    printf("other no");
}
int no = 4;

switch (no) {
    case 1:
        printf("no is 1");
        break;
    case 2:
        printf("no is 2");
        break;
    case 3:
        printf("no is 3");
        break;
    case 4:
        printf("no is 4");
        break;
    case 5:
        printf("no is 5");
        break;
    default:
        printf("other no");
        break;
}

定义一个变量,来计算代码的运行时间,这种方法是 “事后统计法”,它的缺点很显著:

  1. 严重依赖机器的性能
  2. 需要编写额外的测试代码

汇编代码简单知识:

  • cmp:compare,比较
  • jne:jump not equal,不相等才跳转
  • jmp:jump,无条件跳转

if-else 反汇编

if-else 反汇编的情况是固定的,不会随着条件多少变化:

  • 每个 if 语句都会经历 cmp 比较操作,然后进行 jne,不相等才跳转
  • 可见越后面满足条件的 if 语句,代码执行效率越低

    将可能性大的 if 条件提前,可以优化代码效率
int no = 4;
00007FF793D95FAB  mov         dword ptr [no],4  

    if (no == 1) {
00007FF793D95FB2  cmp         dword ptr [no],1  
    // jne 后面的地址,代表跳转到下个执行的地方,即 else if (no == 2) 处地址
00007FF793D95FB6  jne         test0+36h (`07FF793D95FC6h`)  
        printf("no is 2");
00007FF793D95FB8  lea         rcx,[string "no is 2" (07FF793D9AC28h)]  
00007FF793D95FBF  call        printf (07FF793D91190h)  
    // 07FF793D96022 这个地址已经跳出 if-else 语句
00007FF793D95FC4  jmp         test0+92h (07FF793D96022h)  

    } else if (no == 2) {
`00007FF793D95FC6`  cmp         dword ptr [no],2  
00007FF793D95FCA  jne         test0+4Ah (07FF793D95FDAh)  
        printf("no is 2");
00007FF793D95FCC  lea         rcx,[string "no is 2" (07FF793D9AC28h)]  
00007FF793D95FD3  call        printf (07FF793D91190h)  
00007FF793D95FD8  jmp         test0+92h (07FF793D96022h)  

    } else if (no == 3) {
00007FF793D95FDA  cmp         dword ptr [no],3  
00007FF793D95FDE  jne         test0+5Eh (07FF793D95FEEh)  
        printf("no is 3");
00007FF793D95FE0  lea         rcx,[string "no is 3" (07FF793D9AC38h)]  
00007FF793D95FE7  call        printf (07FF793D91190h)  
00007FF793D95FEC  jmp         test0+92h (07FF793D96022h)  

    } else if (no == 4) {
00007FF793D95FEE  cmp         dword ptr [no],4  
00007FF793D95FF2  jne         test0+72h (07FF793D96002h)  
        printf("no is 4");
00007FF793D95FF4  lea         rcx,[string "no is 4" (07FF793D9AC48h)]  
00007FF793D95FFB  call        printf (07FF793D91190h)  
00007FF793D96000  jmp         test0+92h (07FF793D96022h)  

    } else if (no == 5) {
00007FF793D96002  cmp         dword ptr [no],5  
00007FF793D96006  jne         test0+86h (07FF793D96016h)  
        printf("no is 5");
00007FF793D96008  lea         rcx,[string "no is 5" (07FF793D9AC58h)]  
00007FF793D9600F  call        printf (07FF793D91190h)  

    } else {
00007FF793D96014  jmp         test0+92h (07FF793D96022h)  
        printf("other no");
00007FF793D96016  lea         rcx,[string "other no" (07FF793D9AC68h)]  
00007FF793D9601D  call        printf (07FF793D91190h)  
    }

switch 反汇编

条件比较少的情况

int no = 4;

switch (no) {
    case 1:
        printf("no is 1");
        break;
    case 2:
        printf("no is 2");
        break;
    default:
        printf("other no");
        break;
}

int age = 4;

反汇编分析:

  • 条件比较少的情况,编译器不会生成优化代码,代码和 if-else 一样,与每个 case 的值进行比较
  • 此时 switch 和 if-else 效率差不多
    switch (no) {
00BB1A3C  mov         eax,dword ptr [no]  
00BB1A3F  mov         dword ptr [ebp-0DCh],eax  
00BB1A45  cmp         dword ptr [ebp-0DCh],1  
00BB1A4C  je          _$EncStackInitStart+3Dh (0BB1A59h)  
00BB1A4E  cmp         dword ptr [ebp-0DCh],2  
00BB1A55  je          _$EncStackInitStart+4Ch (0BB1A68h)  
00BB1A57  jmp         _$EncStackInitStart+5Bh (0BB1A77h)

    case 1:
        printf("no is 1");
00BB1A59  push        offset string "no is 1" (0BB7B6Ch)  
00BB1A5E  call        _printf (0BB10CDh)  
00BB1A63  add         esp,4  
        break;
00BB1A66  jmp         _$EncStackInitStart+68h (0BB1A84h)  

    case 2:
        printf("no is 2");
00BB1A68  push        offset string "no is 2" (0BB7B30h)  
00BB1A6D  call        _printf (0BB10CDh)  
00BB1A72  add         esp,4  
        break;
00BB1A75  jmp         _$EncStackInitStart+68h (0BB1A84h)  
    
    default:
        printf("other no");
00BB1A77  push        offset string "other no" (0BB7B60h)  
00BB1A7C  call        _printf (0BB10CDh)  
00BB1A81  add         esp,4  
        break;
    }

条件比较多的情况

case 值连续

反汇编分析:

  • switch 在一开始执行了多句汇编,用于计算 jmp 的地址
  • 不存在越后面满足条件的 case 语句效率越低的情况
    int no = 5;
00451AF5  mov         dword ptr [no],5  

    switch (no) {
00451AFC  mov         eax,dword ptr [no]  
00451AFF  mov         dword ptr [ebp-0DCh],eax  
00451B05  mov         ecx,dword ptr [ebp-0DCh]  
00451B0B  sub         ecx,1  
00451B0E  mov         dword ptr [ebp-0DCh],ecx  
00451B14  cmp         dword ptr [ebp-0DCh],4  
00451B1B  ja          $LN8+0Fh (0451B75h)  
00451B1D  mov         edx,dword ptr [ebp-0DCh]  
00451B23  jmp         dword ptr [edx*4+451BA0h]  

    case 1:
        printf("no is 1");
00451B2A  push        offset string "no is 1" (0457B6Ch)  
00451B2F  call        _printf (04510CDh)  
00451B34  add         esp,4  
        break;
00451B37  jmp         $LN8+1Ch (0451B82h)  

    case 2:
        printf("no is 2");
00451B39  push        offset string "no is 2" (0457B30h)  
00451B3E  call        _printf (04510CDh)  
00451B43  add         esp,4  
        break;
00451B46  jmp         $LN8+1Ch (0451B82h)  

    case 3:
        printf("no is 3");
00451B48  push        offset string "no is 3" (0457B3Ch)  
00451B4D  call        _printf (04510CDh)  
00451B52  add         esp,4  
        break;
00451B55  jmp         $LN8+1Ch (0451B82h)  

    case 4:
        printf("no is 4");
00451B57  push        offset string "no is 4" (0457B48h)  
00451B5C  call        _printf (04510CDh)  
00451B61  add         esp,4  
        break;
00451B64  jmp         $LN8+1Ch (0451B82h)  

    case 5:
        printf("no is 5");
00451B66  push        offset string "no is 5" (0457B54h)  
00451B6B  call        _printf (04510CDh)  
00451B70  add         esp,4  
        break;
00451B73  jmp         $LN8+1Ch (0451B82h)  

    default:
        printf("other no");
00451B75  push        offset string "other no" (0457B60h)  
00451B7A  call        _printf (04510CDh)  
00451B7F  add         esp,4  
        break;
    }

细节研究: 这种情况下是如何计算并跳转的

  • jmp 451BB0h:直接跳转到 451BB0h 这个地址所对应的代码
  • jmp [451BB0h]:去 451BB0h 这个地址的内存空间取出一个地址值,再跳转到取出的地址对应的代码
    int no = 5;
00451AF5  mov         dword ptr [no],5  

    switch (no) {
00451AFC  mov         eax,dword ptr [no]  
00451AFF  mov         dword ptr [ebp-0DCh],eax  
00451B05  mov         ecx,dword ptr [ebp-0DCh]  
    // ecx == no == 5
    // ecx = ecx - 1
00451B0B  sub         ecx,1
00451B0E  mov         dword ptr [ebp-0DCh],ecx  
    // no不在case的范围中的情况
00451B14  cmp         dword ptr [ebp-0DCh],4  
00451B1B  ja          $LN8+0Fh (0451B75h)  
    // edx == ecx == 4
00451B1D  mov         edx,dword ptr [ebp-0DCh] 
    // edx * 4 + 451BA0h == 4 * 4 + 451BA0h == 451BB0h
    // jmp  [451BB0h] 
    // jmp 451BB0h 这个地址存储的地址值
00451B23  jmp         dword ptr [edx*4+451BA0h]  
  • switch 跳转前其实已经将每个 case 代码的首地址算好并存储,并且其之间相差 4 字节
  • no 是否在 case 范围的计算:比较 no - minmax - minminmax 是 case 的最小最大值
  • 以上汇编使用的公式:jmp [(no-x) * 4 + 某个地址]x 是 switch 中最小的 case 的值

    所以 switch 中 case 乱序对效率没有影响!这点和 if-else 不同

总结:case 值连续的情况下,每个 case 代码的地址值已经在内存中提前存好,利用公式 (no-x) * 4 + 某地址 直接跳转。哪怕有 100 个 case,只要连续,也是以上流程。相比 if-else 最坏可能要算 100 次,switch 效率更高。

case 值不连续

case 值不连续的情况,其实算法和连续是一样的。

区别是:会把最小的 case 到最大的 case 跳转的地址先算出来(4 个字节)。

例如给的是 case 1、3、5、6,其实提前算好值是 case 1、2、3、4、5、6,其中 2、4 对应跳转到 default。

case 值跨度很大

提前将 case1、case2 ... case12 的要加的值存储在内存中(用 1 个字节存储)

no 是否在 case 范围的计算, 与之前一样:比较 no - minmax - minminmax 是 case 的最小最大值。因此如果输入 100,经过判断不在 case 范围内,直接跳转 default

特殊情况:跨度极其大,case 1,2,3,4,10000 这种,那么 switch 就无法做优化,其底层和 if-else 是一样的,每个条件进行比较再判断是否跳转。

switch 底层本质上是空间换时间的优化,跨度极其大情况下的空间换时间是很亏的事情

总结

对比 if-else,编译器会对 switch 做一定的优化,提高执行效率。

a++ 和 ++a

++a 反汇编

int a = 5;
int b = ++a + 2;

反汇编分析:

    int a = 5;
00291F55  mov         dword ptr [a],5  

    int b = ++a + 2;
    // eax = a, eax == 5
00291F5C  mov         eax,dword ptr [a]
    // eax = eax + 1, eax == 6
00291F5F  add         eax,1
    // a = eax, a == 6
00291F62  mov         dword ptr [a],eax  
    // ecx = a, ecx == 6
00291F65  mov         ecx,dword ptr [a]
    // ecx = ecx + 2, ecx == 8
00291F68  add         ecx,2   
    // b = ecx, b == 8
00291F6B  mov         dword ptr [b],ecx  

a++ 反汇编

int a = 5;
int b = a++ + 2;

反汇编分析:

    int a = 5;
00241F55  mov         dword ptr [a],5  

    int b = a++ + 2;
    // eax = a, eax == 5
00241F5C  mov         eax,dword ptr [a]
    // eax = eax + 2, eax == 7
00241F5F  add         eax,2  
    // b = eax, b == 7
00241F62  mov         dword ptr [b],eax
    // ecx = a, ecx == 5
00241F65  mov         ecx,dword ptr [a]
    // ecx = ecx + 1, exc == 6
00241F68  add         ecx,1  
    // a = ecx, a == 6
00241F6B  mov         dword ptr [a],ecx  

构造函数

构造函数(也叫构造器),在对象创建的时候自动调用,一般用于完成对象的初始化工作

简单使用:

class Person {
public:
    int m_age;
    void run() {
        cout << "age is " << m_age << ", run()------" << endl;
    }

    // 构造函数
    Person() {
        m_age = 0;
        cout << "Person()------" << endl;
    }

    Person(int age) {
        m_age = age;

        cout << "Person(int age)------" << endl;
    }
};

Person *p = new Person();
p->run();

Person *p1 = new Person();
p1->run();

Person *p2 = new Person();
p2->run();

构造函数反汇编

这种情况下,我们手动书写了 Car() 的无参构造函数。

class Car {
public:
    int m_price;
    Car() {
        cout << "Car()" << endl;
    }
};

int main() {
    Car *car = new Car();
    cout << car->m_price << endl;
    return 0;
}

反汇编:可以发现代码中有 call Car::Car (07FF7E5D0123Fh) ,确实调用了汇编

    Car *car = new Car();
00007FF7E5D027FB  mov         ecx,4  
00007FF7E5D02800  call        operator new (07FF7E5D0104Bh)  
00007FF7E5D02805  mov         qword ptr [rbp+108h],rax  
00007FF7E5D0280C  cmp         qword ptr [rbp+108h],0  
00007FF7E5D02814  je          main+4Bh (07FF7E5D0282Bh)  
00007FF7E5D02816  mov         rcx,qword ptr [rbp+108h]  
    // 此处调用了构造函数
00007FF7E5D0281D  call        Car::Car (07FF7E5D0123Fh)  
00007FF7E5D02822  mov         qword ptr [rbp+118h],rax  
00007FF7E5D02829  jmp         main+56h (07FF7E5D02836h)  
00007FF7E5D0282B  mov         qword ptr [rbp+118h],0  
00007FF7E5D02836  mov         rax,qword ptr [rbp+118h]  
00007FF7E5D0283D  mov         qword ptr [rbp+0E8h],rax  
00007FF7E5D02844  mov         rax,qword ptr [rbp+0E8h]  
00007FF7E5D0284B  mov         qword ptr [car],rax  

下面这种情况,我们没有手动写构造函数:

class Car {
public:
    int m_price;
};

int main() {
    Car *car = new Car();
    return 0;
}

反汇编:可以发现没有调用call Car::Car ,显然没有生成无参构造函数

    Car *car = new Car();
00512495  push        4  
00512497  call        operator new (0511140h)  
0051249C  add         esp,4  
0051249F  mov         dword ptr [ebp-0D4h],eax  
005124A5  cmp         dword ptr [ebp-0D4h],0  
005124AC  je          __$EncStackInitStart+4Ah (05124C6h)  
005124AE  xor         eax,eax  
005124B0  mov         ecx,dword ptr [ebp-0D4h]  
005124B6  mov         dword ptr [ecx],eax  
005124B8  mov         edx,dword ptr [ebp-0D4h]  
005124BE  mov         dword ptr [ebp-0DCh],edx  
005124C4  jmp         __$EncStackInitStart+54h (05124D0h)  
005124C6  mov         dword ptr [ebp-0DCh],0  
005124D0  mov         eax,dword ptr [ebp-0DCh]  
005124D6  mov         dword ptr [car],eax  

结论:

可以理解为,要做一些初始化操作的时候才会生成构造函数,没有任何操作就无需生成

在 Java 中,默认会给成员变量赋初值,默认应该是会生成构造函数的

以下情况下会生成构造函数,可以反汇编验证(这里不放汇编代码了):

  • 成员变量在声明的同时进行了初始化,会生成构造函数
class Car {
public:
    int m_price = 5;
};

int main() {
    Car* c = new Car();
    return 0;
}
  • 包含了对象类型的成员,且这个成员有构造函数,会生成构造函数
class Car {
public:
    int m_price = 5;
    Car() {
        cout << "Car()---" << endl;
    }
};

class Person {
    Car car;
};

int main() {
    Person* p = new Person();
    return 0;
}
  • 父类有构造函数,子类没有构造函数,子类会生成构造函数,在里面调用父类的构造函数
父子都有构造函数时,创建子类对象 会先调用父类的构造函数,再调用自己的构造函数
class Person {
public:
    Person() {}
};

class Student : public Person {
public:
};

int main() {
    Student* stu = new Student();
}

函数和方法

很多开发者都会这样去定义

  1. 方法是定义在类里面的
  2. 函数是定义在类外面的

在汇编层面看来,函数和方法没有任何区别,都存储在代码区,都是 call 一个内存地址

函数的内存布局

调用一个函数,会开辟一段栈空间给函数

寄存器

  • esp:永远指向栈顶,push 和 pop 操作会自动控制其指向

    push 操作会往栈顶新增数据,同时 esp 指向其内存地址

    pop 会弹出栈顶数据,同时 esp 指针指向上一个地址,被弹出数据的地址中数据仍在,相当于垃圾数据,等待以后 push 的数据将其覆盖。

初始
--------------------
esp ->    0x2009        |
--------------------

执行 push 4
--------------------
esp    ->    0x2005    4    |
        0x2009        |
--------------------
        
再执行 push 5
--------------------
esp    ->    0x2001    5    |
        0x2005    4    |
        0x2009        |
--------------------

执行 pop eax, 此时相当于 eax = 5
栈顶的内存空间不再被指向,相当于垃圾数据,等待被覆盖
--------------------
        0x2001    5(此时这段内存相当于垃圾数据)
esp    ->  0x2005    4    |
        0x2009        |
--------------------

执行 pop ebx, 此时相当于 ebx = 4
--------------------
        0x2001    5(此时这段内存相当于垃圾数据)
        0x2005    4(此时这段内存相当于垃圾数据)
esp    ->    0x2009        |
--------------------

执行 push 3, 把之前等待被覆盖的垃圾数据覆盖了
--------------------
        0x2001    5(此时这段内存相当于垃圾数据)
esp    ->    0x2005    3    |
        0x2009        |
--------------------
  • ebp:永远指向栈底
  • 栈指针寄存器

栈平衡:函数调用前后其栈顶指针指向是相同的,即调用完后栈 esp 指针会回到原来位置

栈空间是系统不断覆盖读写的,不存在释放操作
void test1(int v1, int v2) {
}

递归函数

递归的层次太深会导致栈空间不够用,栈空间溢出

相关文章
|
6月前
|
算法 程序员 编译器
C++与C语言的差异:编程语言之间的奥秘探索
C++与C语言的差异:编程语言之间的奥秘探索
96 0
|
3月前
|
Python
掌握Python算术与反算术精髓,解锁编程新境界,轻松驾驭数值计算,让每一行代码都精准无误!
【8月更文挑战第22天】Python中的算术运算符如加(+)、减(-)、乘(*)、除(/)、整除(//)、取模(%)及幂运算(**)是数值计算的基础,简化了编程过程并使代码更直观。例如,可以轻松计算矩形的面积与周长。而所谓的“反算术”操作,如取反(使用负号-)和求绝对值,则能进一步处理数值结果。这些运算符是编程中不可或缺的工具,帮助我们高效且清晰地解决问题。
31 0
|
5月前
|
Python 存储 数据处理
【Python数据类型的奥秘】:构建程序基石,驾驭信息之海
【Python数据类型的奥秘】:构建程序基石,驾驭信息之海
|
6月前
|
机器学习/深度学习 自然语言处理 算法
编译器:原理与技术的奥秘
编译器:原理与技术的奥秘
|
存储 编译器 程序员
程序环境和预处理 - 带你了解底层的的编译原理
程序环境和预处理 - 带你了解底层的的编译原理
102 1
|
6月前
|
存储 编译器 C++
嵌入式中C++ 编程习惯与编程要点分析
嵌入式中C++ 编程习惯与编程要点分析
53 1
|
6月前
|
存储 算法 程序员
从1024开始,我们漫谈编程的本质
从1024开始,我们漫谈编程的本质
56 0
|
存储 编译器 程序员
抽丝剥茧C语言(高阶)程序环境和预处理
抽丝剥茧C语言(高阶)程序环境和预处理
|
Serverless C语言
(用0基础的思维让你理解函数)初识C语言(0基础开始)三
(用0基础的思维让你理解函数)初识C语言(0基础开始)三
65 0
|
存储 编译器 C语言
【C】程序的编译/链接/预处理详解(C语言内功,精炼,基础)
【C】程序的编译/链接/预处理详解(C语言内功,精炼,基础)