C++ 反汇编简要

  摘要:本文主要描述x86_64机器中C++代码在汇编中的具体代码。
  关键字:cpp,IA32,asm
  注意:本书假定你拥有基本的C++软件开发能力,能够理解基本的C++代码。并且熟悉汇编代码,了解基本的取址模式并且熟悉IA32指令集(文中会对IA32的部分指令集进行描述,但是不会过于详细的深入)。

1 前言

  C/C++都需要经过编译器变成对应的机器码,通常编译器对程序员是个黑盒子。有些时候我们可能会纠结编译器会不会进行RVO,EBO等优化,以及一些在我们看起来应该正常的代码因为一些UB的行为被C++编译器优化成了不可预期的代码。这时候如果我们了解具体代码是如何编译成对应的二进制机器码对我们查具体的问题非常有益。另一种场景,在开发软件时,线上环境能够复现的问题,我们本地可能是无法复现的。这就需要我们根据线上的堆栈分析具体的原因。而在一些场景下线上堆栈可能是被破坏了的,这就需要我们根据汇编判断线上的堆栈的正确性,从而帮助我们高效快速的分析问题。
  本文主要参考《C++反汇编逆向分析计数揭秘》这本书,在Linux环境下探索C++反汇编的代码,更加深入理解C++工程。

测试环境

Linux Ubuntu 5.15.0-69-generic #76~20.04.1-Ubuntu SMP Mon Mar 20 15:54:19 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
clang version: Ubuntu clang version 13.0.1-2ubuntu2~20.04

2 基本的数据类型

2.1 数值类型

无符号整数
  无符号数都是直接存储在内存中,唯一需要注意的是不同机器的存储方式不同。

  • 大端:高位存储高位(Windows,Linux);
  • 小端:高位存储低位(OSX);

有符号整数
  有符号数和无符号数的区别是,有符号数的最高位表示当前数正/负。无符号数和有符号数能够表示的数值范围大小相同,只是具体能够表示的范围不同。如果数值为正数,则代码中的数值和无符号数无区别;如果为负数,则代码中的数值存储是按照补码存储(起始正数也是补码,不过正数的补码是其自身,这样做是为了方便利用加法计算减法)。

  补码:数的所有位取反+1。

单精度浮点类型和双精度浮点类型
  浮点类型在内存中表示是按照IEEE 754标准存储的,floatdouble而这表示方式差不多,只是表示的范围有差异。因为按照IEEE 754存储浮点是由精度误差的,因此在比较浮点类型的大小是不能用==,而是val - val2 < elps

在这里插入图片描述

布尔类型
  在内存中就是0或者1。

实验
  参考的C++代码:

#include 

int main(int argc, char **argv){
    uint32_t a = 12345678;
    uint64_t a1 = 12345678;

    int32_t b = 12345678;
    int64_t b1 = 12345678;
    int64_t b2 = -1245678;
    uint64_t b3 = 12345678912345678;
    uint64_t b4 = 1;

    char c = 'a';
    short d = 12;
    float e = 1.678;
    double f = 1.678;
    bool g = 1;
}

  反汇编代码(代码中省略了不需要我们关注的内容):

  本文中的反汇编代码中也会省略很多我们不必要关注的内容,避免干扰我们分析问题。

0000000000401110 
: 401110: 55 push %rbp 401111: 48 89 e5 mov %rsp,%rbp 401114: 89 7d fc mov %edi,-0x4(%rbp) 401117: 48 89 75 f0 mov %rsi,-0x10(%rbp) 40111b: c7 45 ec 4e 61 bc 00 movl $0xbc614e,-0x14(%rbp) ;uint32_t a = 12345678; 401122: 48 c7 45 e0 4e 61 bc movq $0xbc614e,-0x20(%rbp) ;uint64_t a1 = 12345678; 401129: 00 40112a: c7 45 dc 4e 61 bc 00 movl $0xbc614e,-0x24(%rbp) ;int32_t b = 12345678; 401131: 48 c7 45 d0 4e 61 bc movq $0xbc614e,-0x30(%rbp) ;int64_t b1 = 12345678; 401138: 00 401139: 48 c7 45 c8 12 fe ec movq $0xffffffffffecfe12,-0x38(%rbp) ;int64_t b2 = -1245678; 401140: ff 401141: 48 b8 4e d6 14 5e 54 movabs $0x2bdc545e14d64e,%rax 401148: dc 2b 00 40114b: 48 89 45 c0 mov %rax,-0x40(%rbp) ;uint64_t b3 = 12345678912345678; 40114f: 48 c7 45 b8 01 00 00 movq $0x1,-0x48(%rbp) ;uint64_t b4 = 1; 401156: 00 401157: c6 45 b7 61 movb $0x61,-0x49(%rbp) ;char c = 'a'; 40115b: 66 c7 45 b4 0c 00 movw $0xc,-0x4c(%rbp) ;short d = 12; 401161: f3 0f 10 05 a7 0e 00 movss 0xea7(%rip),%xmm0 # 402010 <_IO_stdin_used+0x10> 401168: 00 401169: f3 0f 11 45 b0 movss %xmm0,-0x50(%rbp) ; 40116e: f2 0f 10 05 92 0e 00 movsd 0xe92(%rip),%xmm0 # 402008 <_IO_stdin_used+0x8> 401175: 00 401176: f2 0f 11 45 a8 movsd %xmm0,-0x58(%rbp) 40117b: c6 45 a7 01 movb $0x1,-0x59(%rbp) 40117f: 31 c0 xor %eax,%eax 401181: 5d pop %rbp 401182: c3 retq 401183: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1) 40118a: 00 00 00 40118d: 0f 1f 00 nopl (%rax) 0000000000402000 <_IO_stdin_used>: 402000: 01 00 add %eax,(%rax) 402002: 02 00 add (%rax),%al 402004: 00 00 add %al,(%rax) 402006: 00 00 add %al,(%rax) 402008: 0c 02 or $0x2,%al 40200a: 2b 87 16 d9 fa 3f sub 0x3ffad916(%rdi),%eax 402010: b4 c8 mov $0xc8,%ah 402012: d6 (bad) 402013: 3f (bad)

  从上面的反汇编中我们能够看出,int32_t的有符号和无符号在内存中存储方式相同都是0xbc614e(并且从内存存储方式能够看出第大端存储),而负数是通过补码的方式存储的即0xFFFF FFFF FFFF FFFF FF43 9EB2
  浮点数1.68对应的float为0x3FD6C8B4存储在rodata段中的0x402010,double为0x3FFAD916872B020C存储在rodata段中的0x402008(直接看内存)。我们可以尝试根据0x3FD6C8B4反推一下具体在内存中的浮点数的值。0x3FD6C8B4->0 01111111 10101101100100010110100->0 127 10101101100100010110100 + 1 127 − 127 + 2 − 1 + 2 − 3 + 2 − 5 + 2 − 6 + 2 − 8 + 2 − 9 + 2 − 12 + 2 − 16 + 2 − 18 + 2 − 19 + 2 − 21 = + 1.6779999732971191 +1^{127-127} + 2^{-1} + 2^{-3}+2^{-5}+2^{-6}+2^{-8}+2^{-9}+2^{-12}+2^{-16}+2^{-18}+2^{-19}+2^{-21}=+1.6779999732971191 +1127127+21+23+25+26+28+29+212+216+218+219+221=+1.6779999732971191。可以看到实际存储的数值是有精度误差的,这也是为什么浮点不能==

  另外能够注意到代码中用到的不是一般的通用寄存器而是mmx寄存器。这是因为MMX是在最初的浮点寄存器ST上扩展而来。

2.2 字符串

  C++中的字符串是以\0为结束符的一段内存。唯一需要注意的是不同编码的字符串每个字符占用的字节数不同,ascii每个字符占1个字节,unicode每个占2个字节等等。
实验

int main(){
    const char *ptr = "hello";
    const wchar_t *p2 = L"hello";
}

  C++中字符串是常量因此存储在rodata中,反汇编中68 65 6c 6c 6f就是hello,而p2中每个字符占2个字节。

0000000000401110 
: ;... 401111: 48 89 e5 mov %rsp,%rbp 401114: 48 b8 04 20 40 00 00 movabs $0x402004,%rax 40111b: 00 00 00 40111e: 48 89 45 f8 mov %rax,-0x8(%rbp) 401122: 48 b8 0c 20 40 00 00 movabs $0x40200c,%rax ;... 0000000000402000 <_IO_stdin_used>: 402004: 68 65 6c 6c 6f pushq $0x6f6c6c65 402009: 00 00 add %al,(%rax) 40200b: 00 68 00 add %ch,0x0(%rax) 40200e: 00 00 add %al,(%rax) 402010: 65 00 00 add %al,%gs:(%rax) 402013: 00 6c 00 00 add %ch,0x0(%rax,%rax,1) 402017: 00 6c 00 00 add %ch,0x0(%rax,%rax,1) 40201b: 00 6f 00 add %ch,0x0(%rdi) 40201e: 00 00 add %al,(%rax) 402020: 00 00 add %al,(%rax)

2.3 地址,指针,引用和常量

指针
  地址就是进程地址空间中的一个索引,而指针变量,就是存储一块地址内容的变量。所以其重点是其本身是一个变量,只不过存储的内容是一个地址索引而已。一个指针的大小根据系统位数不同而不同,32bit机器指针大小为4字节,64bit机器指针大小为8字节。而解释这块地址的内容的方式是根据其类型而来的,因此在对指针变量进行++等操作时,其结果是根据具体的类型而来的:

ptr + n = 当前ptr地址+sizeof(Type) * n

引用
  引用是变量的别名,其实现和指针基本相同。可以认为,引用和指针是相同的东西(实际使用中也差不多如此),只是编译器实现是隐藏了很多东西。比如引用计算++是对原数据的计算,而不是向指针那行对指针变量的操作。

常量
  需要注意的是C++中的常量只是语法上的常量,并不是编译期常量也不是运行期常量。编译期常量应该使用constexpr,运行期常量在不使用const_cast进行强制转换时此语义是可以保证的。

实验

int main(int argc, char **argv){
    int a = 1;
    int *p = &a;
    p+=1;
    char *pc = (char*)p;
    pc+=1;

    int &b = a;
    b = 2;

    const int c = 33;
    b = c;
    b = *p + c;
    return 0;
}

  从下面的汇编代码能够看出来,指针和引用的区别就是指针在进行操作时需要使用*解引用才能操作对应内存中的值,而引用直接能操作内存中的值。另外const值的立即数在预处理时就被替换了,对于一些复杂的场景,const在汇编中和普通变量没有区别,不可变是由编译器语法限制的。

0000000000401110 
: 401110: 55 push %rbp 401111: 48 89 e5 mov %rsp,%rbp 401114: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 40111b: 89 7d f8 mov %edi,-0x8(%rbp) 40111e: 48 89 75 f0 mov %rsi,-0x10(%rbp) 401122: c7 45 ec 01 00 00 00 movl $0x1,-0x14(%rbp) ;a = 1 401129: 48 8d 45 ec lea -0x14(%rbp),%rax 40112d: 48 89 45 e0 mov %rax,-0x20(%rbp) ;int *p = &a; 401131: 48 8b 45 e0 mov -0x20(%rbp),%rax 401135: 48 83 c0 04 add $0x4,%rax 401139: 48 89 45 e0 mov %rax,-0x20(%rbp) ;p=+1 40113d: 48 8b 45 e0 mov -0x20(%rbp),%rax 401141: 48 89 45 d8 mov %rax,-0x28(%rbp) ;char *pc = (char*)p; 401145: 48 8b 45 d8 mov -0x28(%rbp),%rax 401149: 48 83 c0 01 add $0x1,%rax 40114d: 48 89 45 d8 mov %rax,-0x28(%rbp) ;pc+=1 401151: 48 8d 45 ec lea -0x14(%rbp),%rax 401155: 48 89 45 d0 mov %rax,-0x30(%rbp) ;int &b = a; 401159: 48 8b 45 d0 mov -0x30(%rbp),%rax 40115d: c7 00 02 00 00 00 movl $0x2,(%rax) ;b = 2; 401163: c7 45 cc 21 00 00 00 movl $0x21,-0x34(%rbp) ;const int c = 33; 40116a: 48 8b 45 d0 mov -0x30(%rbp),%rax 40116e: c7 00 21 00 00 00 movl $0x21,(%rax) ;b = c = 33; 401174: 48 8b 45 e0 mov -0x20(%rbp),%rax 401178: 8b 08 mov (%rax),%ecx ;eax = *p; 40117a: 83 c1 21 add $0x21,%ecx 40117d: 48 8b 45 d0 mov -0x30(%rbp),%rax 401181: 89 08 mov %ecx,(%rax) ;b = *p + c 401183: 31 c0 xor %eax,%eax 401185: 5d pop %rbp

3 MSVC和gcc的入口函数

  可参考程序员自我修养阅读笔记——运行库

4 表达式求值

4.1 算法表达式

加法,减法和乘法,自增自减
  加法,减法和乘法都有对应的计算指令,比如add,sub,mul,imul实现,比较简单。需要注意的是乘法计算中如果乘以的是2的幂数,则编译器会将对应的计算优化为位移运算。自增和自减也比较简单,主要关注后置自增和自减,实际上实现是把表达式拆分了。

实验一

#include 
int main(){
    int a = 4096;
    int b = 3;
    int c = 1;
    c = a + b + (1 + 2);
    a = a - ++b;
    b = a * b++ * 4;
    return 0;
}

  下面的反汇编比较简单,能够看到都是使用对应的指令实现的。乘法中对于2幂次数利用左移进行了优化。

0000000000401110 
: 401110: 55 push %rbp 401111: 48 89 e5 mov %rsp,%rbp 401114: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 40111b: c7 45 f8 00 10 00 00 movl $0x1000,-0x8(%rbp) ;a = 4096 401122: c7 45 f4 03 00 00 00 movl $0x3,-0xc(%rbp) ;b = 3 401129: c7 45 f0 01 00 00 00 movl $0x1,-0x10(%rbp) ;c = 1 401130: 8b 45 f8 mov -0x8(%rbp),%eax 401133: 03 45 f4 add -0xc(%rbp),%eax ;a + b 401136: 83 c0 03 add $0x3,%eax ;a + b + 3 401139: 89 45 f0 mov %eax,-0x10(%rbp) 40113c: 8b 45 f8 mov -0x8(%rbp),%eax 40113f: 8b 4d f4 mov -0xc(%rbp),%ecx 401142: 83 c1 01 add $0x1,%ecx ;++b 401145: 89 4d f4 mov %ecx,-0xc(%rbp) 401148: 29 c8 sub %ecx,%eax ;a - ++b 40114a: 89 45 f8 mov %eax,-0x8(%rbp) 40114d: 8b 45 f8 mov -0x8(%rbp),%eax 401150: 8b 4d f4 mov -0xc(%rbp),%ecx 401153: 89 ca mov %ecx,%edx 401155: 83 c2 01 add $0x1,%edx ;b++ 401158: 89 55 f4 mov %edx,-0xc(%rbp) 40115b: 0f af c1 imul %ecx,%eax ;a * b 40115e: c1 e0 02 shl $0x2,%eax ;a * b * 4 401161: 89 45 f4 mov %eax,-0xc(%rbp) 401164: 31 c0 xor %eax,%eax 401166: 5d pop %rbp 401167: c3 retq

除法
  除法也有对应的汇编指令dividiv实现,但是除法相对于其他操作指令周期更长,效率更低,因此编译器会尽可能尝试用其他指令优化当前的除法操作。
  C++中的整数除法的结果依然是整数,其结果是向0求整(比如3.5求整为3,-3.5求整为-3)。编译器会对不同的除数进行不同的优化:

  1. 除数为2的幂次。编译器会对除数是2的幂次的数利用右移指令优化;
  2. 除数为无符号非2次幂。编译器会通过下面的公式将除法转换为乘法,因为n是固定的(32或者64)因此可以在与编译期计算出来;
    x c = x 2 n c 1 2 n \frac{x}{c}=x\frac{2^n}{c}\frac{1}{2^n} cx=xc2n2n1
  3. 有符号整数相比无符号多了一个位表示富奥因此,其n的取值为n-1

实验二

#include 

int main(){
    int a = 4096;
    scanf("%d", &a);
    int b = (unsigned int)a / (unsigned int)3;
    int c = 1;
    printf("%d %d", b, c);
    b = (int)a / (int)3;
    c = 2;
    printf("%d %d", b, c);
    return 0;
}

  上面的scanf语句是为了避免编译器过度优化。
  无符号除法,我们可以从上面的公式中推导出具体的值, M = 2 n c → c = 2 n M = 2 3 2 0 x a a a a a a a b = 2.9999999996507540345598531381041 ≈ 3 M=\frac{2^n}{c}\rightarrow c=\frac{2^n}{M}=\frac{2^32}{0xaaaaaaab}=2.9999999996507540345598531381041\approx 3 M=c2nc=M2n=0xaaaaaaab232=2.99999999965075403455985313810413
  有符号整数的触发的魔数刚好为无符号的一半。

0000000000401138 
: 401138: 53 push %rbx 401139: 48 83 ec 10 sub $0x10,%rsp 40113d: 48 8d 5c 24 0c lea 0xc(%rsp),%rbx 401142: c7 03 00 10 00 00 movl $0x1000,(%rbx) 401148: bf 07 20 40 00 mov $0x402007,%edi 40114d: 48 89 de mov %rbx,%rsi 401150: 31 c0 xor %eax,%eax 401152: e8 e9 fe ff ff callq 401040 <__isoc99_scanf@plt> 401157: 8b 03 mov (%rbx),%eax 401159: be ab aa aa aa mov $0xaaaaaaab,%esi 40115e: 48 0f af f0 imul %rax,%rsi 401162: 48 c1 ee 21 shr $0x21,%rsi ;(unsigned)a / (unsigned)3; 401166: bf 04 20 40 00 mov $0x402004,%edi 40116b: ba 01 00 00 00 mov $0x1,%edx 401170: 31 c0 xor %eax,%eax 401172: e8 b9 fe ff ff callq 401030 401177: 48 63 03 movslq (%rbx),%rax 40117a: 48 69 f0 56 55 55 55 imul $0x55555556,%rax,%rsi 401181: 48 89 f0 mov %rsi,%rax 401184: 48 c1 e8 3f shr $0x3f,%rax 401188: 48 c1 ee 20 shr $0x20,%rsi ;(int)a / (int)3 40118c: 01 c6 add %eax,%esi 40118e: bf 04 20 40 00 mov $0x402004,%edi 401193: ba 02 00 00 00 mov $0x2,%edx 401198: 31 c0 xor %eax,%eax 40119a: e8 91 fe ff ff callq 401030 40119f: 31 c0 xor %eax,%eax 4011a1: 48 83 c4 10 add $0x10,%rsp 4011a5: 5b pop %rbx 4011a6: c3 retq

取余
  对2的k次方取余的方式有很多:

  1. 直接通过k位的亦或运算计算;
  2. 通过公式 x % 2 k = ( x + ( 2 k − 1 ) & ( 2 k − 1 ) ) − ( 2 k − 1 ) x\% 2^k=(x+(2^k - 1) \& (2^k - 1)) - (2^k - 1) x%2k=(x+(2k1)&(2k1))(2k1)
  3. 方案三:
    1. 正数: x % 2 k = x − ( x & ! ( 2 k − 1 ) ) x\% 2^k = x - (x\&!(2^k-1)) x%2k=x(x&!(2k1))
    2. 负数: x % 2 k = x − ( x + ( 2 k − 1 ) & ! ( 2 k − 1 ) ) x\% 2^k = x - (x + (2^k - 1) \& !(2^k - 1)) x%2k=x(x+(2k1)&!(2k1))

  非2次幂,则 x % a = x − a b ∗ b x\% a = x - \frac{a}{b} * b x%a=xbab

实验三

#include 
int main(){
    int a = 3;
    scanf("%d", &a);
    int b = a % 4;
    scanf("%d", &a);
    int d = a % 3;
    printf("%d %d %d %d", a, b, c, d);
}
0000000000401138 
: 401138: 53 push %rbx 401139: 48 83 ec 10 sub $0x10,%rsp 40113d: 48 8d 5c 24 0c lea 0xc(%rsp),%rbx 401142: c7 03 03 00 00 00 movl $0x3,(%rbx) 401148: bf 0d 20 40 00 mov $0x40200d,%edi 40114d: 48 89 de mov %rbx,%rsi 401150: 31 c0 xor %eax,%eax 401152: e8 e9 fe ff ff callq 401040 <__isoc99_scanf@plt> 401157: 4c 63 03 movslq (%rbx),%r8 40115a: 41 8d 40 03 lea 0x3(%r8),%eax 40115e: 45 85 c0 test %r8d,%r8d 401161: 41 0f 49 c0 cmovns %r8d,%eax 401165: 83 e0 fc and $0xfffffffc,%eax 401168: 44 89 c2 mov %r8d,%edx 40116b: 29 c2 sub %eax,%edx 40116d: 49 69 c0 56 55 55 55 imul $0x55555556,%r8,%rax 401174: 48 89 c1 mov %rax,%rcx 401177: 48 c1 e9 3f shr $0x3f,%rcx 40117b: 48 c1 e8 20 shr $0x20,%rax ;这里明显为一个除法 a / 3 40117f: 01 c8 add %ecx,%eax 401181: 8d 04 40 lea (%rax,%rax,2),%eax 401184: 41 29 c0 sub %eax,%r8d 401187: c7 03 01 00 00 00 movl $0x1,(%rbx) 40118d: bf 04 20 40 00 mov $0x402004,%edi 401192: be 01 00 00 00 mov $0x1,%esi 401197: b9 02 00 00 00 mov $0x2,%ecx 40119c: 31 c0 xor %eax,%eax 40119e: e8 8d fe ff ff callq 401030 4011a3: 31 c0 xor %eax,%eax 4011a5: 48 83 c4 10 add $0x10,%rsp 4011a9: 5b pop %rbx 4011aa: c3 retq

4.2 关系运算、位运算和逻辑运算

  C++中关系运算符是计算一个表达式的结果然后配合cmptest检查表达式结果,最终利用jmp相关指令实现。关系运算中的表达式短路也是类似的逻辑,如果前半部分的表达式满足就不会再计算后半部分,即便后半部分会引发严重问题因为不运行所以没有任何问题。
  条件表达式的实现和if语句类似都是通过跳转实现,下面是一个简单的例子。位运算比较简单,每个位运算都有对应的汇编指令,具体不详细说了。

实验
  

int func(int c){
    c && (c += func(c - 1));
    return c;
}

int func2(int c){
    return c && func2(c - 1);
}

  上面短路表达式和逻辑表达式都是通过je等跳转指令实现的,都是先计算前半部分的值然后判断,其代码等价于:

  //num && func(num - 1)
  if(num){ func(num - 1); }
0000000000000000 <_Z4funci>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 10             sub    $0x10,%rsp
   8:   89 7d fc                mov    %edi,-0x4(%rbp)      ;c
   b:   83 7d fc 00             cmpl   $0x0,-0x4(%rbp)      ;if(c == 0)
   f:   0f 84 17 00 00 00       je     2c <_Z4funci+0x2c>   ;jump to func + 2c
  15:   8b 7d fc                mov    -0x4(%rbp),%edi      ;
  18:   83 ef 01                sub    $0x1,%edi            ;c - 1
  1b:   e8 00 00 00 00          callq  20 <_Z4funci+0x20>   ;func(c - 1)
  20:   03 45 fc                add    -0x4(%rbp),%eax      ;c + 返回值
  23:   89 45 fc                mov    %eax,-0x4(%rbp)      ;c += 返回值
  26:   83 f8 00                cmp    $0x0,%eax
  29:   0f 95 c0                setne  %al
  2c:   8b 45 fc                mov    -0x4(%rbp),%eax
  2f:   48 83 c4 10             add    $0x10,%rsp
  33:   5d                      pop    %rbp
  34:   c3                      retq
  35:   66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  3c:   00 00 00
  3f:   90                      nop

0000000000000040 <_Z5func2i>:
  40:   55                      push   %rbp
  41:   48 89 e5                mov    %rsp,%rbp
  44:   48 83 ec 10             sub    $0x10,%rsp
  48:   89 7d fc                mov    %edi,-0x4(%rbp)      ;c
  4b:   31 c0                   xor    %eax,%eax
  4d:   83 7d fc 00             cmpl   $0x0,-0x4(%rbp)      ;if(c == 0)
  51:   88 45 fb                mov    %al,-0x5(%rbp)
  54:   0f 84 14 00 00 00       je     6e <_Z5func2i+0x2e>  ; jump to func2 + 2e
  5a:   8b 7d fc                mov    -0x4(%rbp),%edi
  5d:   83 ef 01                sub    $0x1,%edi            ;c - 1
  60:   e8 00 00 00 00          callq  65 <_Z5func2i+0x25>  ;func2(c - 1)
  65:   83 f8 00                cmp    $0x0,%eax
  68:   0f 95 c0                setne  %al
  6b:   88 45 fb                mov    %al,-0x5(%rbp)
  6e:   8a 45 fb                mov    -0x5(%rbp),%al
  71:   24 01                   and    $0x1,%al            ; c && func2(c - 1) 
  73:   0f b6 c0                movzbl %al,%eax
  76:   48 83 c4 10             add    $0x10,%rsp
  7a:   5d                      pop    %rbp
  7b:   c3                      retq

  
实验二

int func1(int c){
    return c == 3 ? 2 : 1;
}

int func2(int c){
    return c == 3 ? 2 : 5;
}

int func3(int c){
    return c == 3 ? 2 : c + 1;
}

int func4(int c){
    return c / 2 == 1 ? func4(c * 2): func4(c - 1);
}

  书上将条件表达式分为了四种情况,但是从下面的反汇编中能够看到只有两种情况:

  1. 如果条件表达式的两边都是常数,则将对应的两边的常数计算出来后放入寄存器,然后根据比较的变量的值判断是否需要将返回值的寄存器的值覆盖。这样的反汇编代码无分支的;
  2. 如果两边的表达式中有一个是有变量的表达式,则是利用jmp指令来实现。

  下面的反汇编注释的比较清楚,唯一需要注意的是有变量情况下创建的临时变量。

0000000000000000 <_Z5func1i>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d fc                mov    %edi,-0x4(%rbp)
   7:   8b 55 fc                mov    -0x4(%rbp),%edx  ;edx = c
   a:   b8 01 00 00 00          mov    $0x1,%eax        ;eax = 1
   f:   b9 02 00 00 00          mov    $0x2,%ecx        ;ecx = 2
  14:   83 fa 03                cmp    $0x3,%edx        ;c =? 3 
  17:   0f 44 c1                cmove  %ecx,%eax        ;eax = ecx
  1a:   5d                      pop    %rbp
  1b:   c3                      retq
  1c:   0f 1f 40 00             nopl   0x0(%rax)

0000000000000020 <_Z5func2i>:
  20:   55                      push   %rbp
  21:   48 89 e5                mov    %rsp,%rbp
  24:   89 7d fc                mov    %edi,-0x4(%rbp)
  27:   8b 55 fc                mov    -0x4(%rbp),%edx  ;edx = c
  2a:   b8 05 00 00 00          mov    $0x5,%eax        ;eax = 5
  2f:   b9 02 00 00 00          mov    $0x2,%ecx        ;ecx = 2
  34:   83 fa 03                cmp    $0x3,%edx        ;c =? 3
  37:   0f 44 c1                cmove  %ecx,%eax        ;eax = ecx
  3a:   5d                      pop    %rbp
  3b:   c3                      retq
  3c:   0f 1f 40 00             nopl   0x0(%rax)

0000000000000040 <_Z5func3i>:
  40:   55                      push   %rbp
  41:   48 89 e5                mov    %rsp,%rbp
  44:   89 7d fc                mov    %edi,-0x4(%rbp)
  47:   83 7d fc 03             cmpl   $0x3,-0x4(%rbp)        ;c =? 3
  4b:   0f 85 0d 00 00 00       jne    5e <_Z5func3i+0x1e>    ;jump to 5e
  51:   b8 02 00 00 00          mov    $0x2,%eax              ;eax = 2
  56:   89 45 f8                mov    %eax,-0x8(%rbp)        ;tmp = eax
  59:   e9 09 00 00 00          jmpq   67 <_Z5func3i+0x27>
  5e:   8b 45 fc                mov    -0x4(%rbp),%eax        ;eax = c
  61:   83 c0 01                add    $0x1,%eax
  64:   89 45 f8                mov    %eax,-0x8(%rbp)        ;tmp = c + 1
  67:   8b 45 f8                mov    -0x8(%rbp),%eax        ;eax = tmp
  6a:   5d                      pop    %rbp
  6b:   c3                      retq
  6c:   0f 1f 40 00             nopl   0x0(%rax)

0000000000000070 <_Z5func4i>:
  70:   55                      push   %rbp
  71:   48 89 e5                mov    %rsp,%rbp
  74:   89 7d fc                mov    %edi,-0x4(%rbp)
  77:   83 7d fc 03             cmpl   $0x3,-0x4(%rbp)      ;c =? 3
  7b:   0f 85 0e 00 00 00       jne    8f <_Z5func4i+0x1f>  ;jump to 8f
  81:   8b 45 fc                mov    -0x4(%rbp),%eax      ;eax = c
  84:   83 c0 02                add    $0x2,%eax  
  87:   89 45 f8                mov    %eax,-0x8(%rbp)      ;tmp = c + 2
  8a:   e9 09 00 00 00          jmpq   98 <_Z5func4i+0x28>
  8f:   8b 45 fc                mov    -0x4(%rbp),%eax      ;eax = c
  92:   c1 e0 01                shl    $0x1,%eax            ;c * 2
  95:   89 45 f8                mov    %eax,-0x8(%rbp)      ;tmp = c * 2
  98:   8b 45 f8                mov    -0x8(%rbp),%eax      ;eax = tmp
  9b:   5d                      pop    %rbp
  9c:   c3                      retq

  另外上面是无优化的代码,当我们开启优化选项为-Os时就会出现下面的情况。可以看到代码大大简化了:

  1. 当两边都是常量且差为1时,编译期会利用++指令来优化代码;
  2. 当两边差大于1时,会利用add指令来替代cmov指令;
  3. 当两边有变量且表达式简单时,编译器不使用jmp指令而是尝试cmov指令优化;
  4. 但是当两边的表达式比较复杂时,编译器就会使用jmp指令实现。
0000000000000000 <_Z5func1i>:
   0:   31 c0                   xor    %eax,%eax
   2:   83 ff 03                cmp    $0x3,%edi
   5:   0f 94 c0                sete   %al
   8:   ff c0                   inc    %eax
   a:   c3                      retq

000000000000000b <_Z5func2i>:
   b:   31 c0                   xor    %eax,%eax
   d:   83 ff 03                cmp    $0x3,%edi
  10:   0f 95 c0                setne  %al
  13:   8d 04 40                lea    (%rax,%rax,2),%eax
  16:   83 c0 02                add    $0x2,%eax
  19:   c3                      retq

000000000000001a <_Z5func3i>:
  1a:   8d 4f 01                lea    0x1(%rdi),%ecx
  1d:   83 ff 03                cmp    $0x3,%edi
  20:   b8 02 00 00 00          mov    $0x2,%eax
  25:   0f 45 c1                cmovne %ecx,%eax
  28:   c3                      retq

Disassembly of section .comment:

0000000000000000 <.comment>:
   0:   00 55 62                add    %dl,0x62(%rbp)
   3:   75 6e                   jne    73 <_Z5func4i+0x4a>
   5:   74 75                   je     7c <_Z5func4i+0x53>
   7:   20 63 6c                and    %ah,0x6c(%rbx)
   a:   61                      (bad)
   b:   6e                      outsb  %ds:(%rsi),(%dx)
   c:   67 20 76 65             and    %dh,0x65(%esi)
  10:   72 73                   jb     85 <_Z5func4i+0x5c>
  12:   69 6f 6e 20 31 33 2e    imul   $0x2e333120,0x6e(%rdi),%ebp
  19:   30 2e                   xor    %ch,(%rsi)
  1b:   31 2d 32 75 62 75       xor    %ebp,0x75627532(%rip)        # 75627553 <_Z5func4i+0x7562752a>
  21:   6e                      outsb  %ds:(%rsi),(%dx)
  22:   74 75                   je     99 <_Z5func4i+0x70>
  24:   32 7e 32                xor    0x32(%rsi),%bh
  27:   30 2e                   xor    %ch,(%rsi)
  29:   30 34 2e                xor    %dh,(%rsi,%rbp,1)
  2c:   31 00                   xor    %eax,(%rax)

4.3 编译器优化

  代码优化:利用现有的资源对已有的程序进行实现上的改变以达到优化内存/执行速度等上的目的。代码优化前后的程序行为是等价的,即优化行为只影响执行速度等指标,不影响实际结果。

  编译器将一个程序编译成二进制文件的过程是:预处理->词法分析->语法分析->语义分析->中间代码生成->目标代码生成(后端实现中省略了部分节点,比如指令重拍,寄存器选择等)。这里只会记录书中记录的部分执行速度优化的一部分内容(Intel处理器为参考)。

4.3.1 常见的优化

  • 常量折叠:比如x = 2 * 3;这种代码,编译器可以在编译期完成计算直接生成x = 6相关代码;
  • 常量传播:依赖其他变量可以在编译期完成计算的代码都会生成直接的代码,比如y=x + 3;会生成y = 9;
  • 减少变量:对于一些临时变量有没有并不影响程序的执行,比如x=2*i,y=2*j;if(x > y){}if(i>j)等价;
  • 公共表达式:多个变量依赖相同的表达式,比如x=2 * i, y= 2 * i;等价于x=2*i,y=x
  • 复写传播:类似常量传播,比如x=a,y=x+c等价于y=a+c
  • 剪枝:比如if(1 < 2)这种永远不会执行的代码会被删除;
  • 顺序语句替代分支:比如上面的三元表达式编译器会尽量使用cmov来优化;
  • 强度削弱:用加法或移位替代乘法,用乘法替代除法;
  • 数学变换:将一些简单的数学表达式替换为对应相同结果的表达式,比如x=a * y + b * y等价于x=(x + b) * y
  • 代码外提:比如while(func())如果func()的结果是固定值,编译器就会尝试t=func();while(t),而不用每次循环都执行func()

4.3.2 流水线优化

  一条指令被CPU执行会经历:取指、译码、访存(访存分为读地址和取值两部分)、执行、写回(将计算的结果写回到对应地址或者寄存器)。其中取指、译码、执行是每个指令都需要的,比如nop指令。流水线就是将上述的多个步骤错开执行提升CPU的指令运行吞吐率。比如在执行第一条的译码工作时就可以尝试读取下一条指令准备译码。

C++ 反汇编简要_第1张图片

  指令流水线的实现有两种方式:

  1. 长指令:比如Intel的CSIC架构,每个指令划分更多的阶段单个指令更长,每个步骤的工作简单更容易设计,但是在出现跳转指令且分支预测失败是失败的成本也高;
  2. 精简指令:比如Arm的RSIC架构,每个指令更短,流水线数量更多,分支预测带来的错误成本更低,但是电路更复杂,流水线的管理成本更高。

  影响流水线并行度的几个因素:

  1. 指令相关。即数据依赖,后一条指令的运行依赖前一条指令的结果,就会形成指令依赖,影响并行效率(现如今编译器都会进行指令重排来提升并行度);
  2. 地址相关。前一条指令需要读取一块儿内存而后一条指令需要写入这块儿内存,就会形成地址依赖,影响并行度;
  3. 分支预测。CPU中会有分支预测器对分支指令进行预测,预测下一步将会执行哪个指令以提高指令并行度。因此写代码时尽量避免分支代码;
  4. 高速缓存优化。现代CPU都有多级高速缓存(L1,L2,L3)将需要处理的数据集中存放可以有效利用CPU的缓存策略提升性能。

数据局对也会影响性能,对齐后的数据能够一次定位到,而没有对齐的数据可能需要多次访问。

5 流程控制

5.1 if语句

  流程控制是程序的重要组成部分,而流程控制是通过if语句实现的。汇编中是通过jmp``来实现if语句的。但是针对不同的控制语句编译器会采用不同的优化策略来加速程序的执行。下面就从简到难逐步看不同控制结构的实际实现。 **if**   if是使用jmp实现,那为了符合if的语义,应该是满足条件则继续执行不满足则跳转。所以从汇编中看到的是和if相反的判断。对于一个if语句中一个表达式expr是否为真,实际反汇编中应该是判断!expr```满足则跳转。

实验一

int func(int a){
    if(a == 3){
        printf("the value is 3");   //printf是为了防止代码被优化,如果是无用代码会被编译器删除
    }

    return 0;
}
0000000000000000 <_Z4funci>:
   0:   83 ff 03                cmp    $0x3,%edi
   3:   75 11                   jne    16 <_Z4funci+0x16>
   5:   50                      push   %rax
   6:   bf 00 00 00 00          mov    $0x0,%edi
   b:   31 c0                   xor    %eax,%eax
   d:   e8 00 00 00 00          callq  12 <_Z4funci+0x12>
  12:   48 83 c4 08             add    $0x8,%rsp
  16:   31 c0                   xor    %eax,%eax
  18:   c3                      retq

if…else
  if...else相比于if多了要执行的场景。因为代码是顺序的,为了实现else``在原来if的基础上需要两条jmp指令。从下面的实验中能够看到if…else…是通过两条jmp指令实现:当输入符合条件时顺序执行对应的代码块,之后调用jmp到结尾;当输入不符合条件时直接跳转到else```对应的代码块执行。当开始代码优化时,编译器会对条件语句进行优化,这里能够看到优化的结果和条件跳转类似。

实验二

int ifelse(int a){
    if(a == 3){
        printf("the value is 3");
    }else{
        printf("the value is not 3");
    }
    return 0;
}
;;clang -O0
0000000000000030 <_Z6ifelsei>:
  30:   55                      push   %rbp
  31:   48 89 e5                mov    %rsp,%rbp
  34:   48 83 ec 10             sub    $0x10,%rsp
  38:   89 7d fc                mov    %edi,-0x4(%rbp)
  3b:   83 7d fc 03             cmpl   $0x3,-0x4(%rbp)
  3f:   0f 85 16 00 00 00       jne    5b <_Z6ifelsei+0x2b>   ;;if(argc != 3) goto else
  45:   48 bf 00 00 00 00 00    movabs $0x0,%rdi
  4c:   00 00 00
  4f:   b0 00                   mov    $0x0,%al
  51:   e8 00 00 00 00          callq  56 <_Z6ifelsei+0x26>   ;; call printf
  56:   e9 11 00 00 00          jmpq   6c <_Z6ifelsei+0x3c>   ;; goto end
  5b:   48 bf 00 00 00 00 00    movabs $0x0,%rdi              ;;else
  62:   00 00 00
  65:   b0 00                   mov    $0x0,%al
  67:   e8 00 00 00 00          callq  6c <_Z6ifelsei+0x3c>   ;; call printf
  6c:   31 c0                   xor    %eax,%eax
  6e:   48 83 c4 10             add    $0x10,%rsp
  72:   5d                      pop    %rbp
  73:   c3                      retq

;;clang -Os
0000000000000019 <_Z6ifelsei>:
  19:   50                      push   %rax
  1a:   83 ff 03                cmp    $0x3,%edi
  1d:   b8 00 00 00 00          mov    $0x0,%eax
  22:   bf 00 00 00 00          mov    $0x0,%edi
  27:   48 0f 44 f8             cmove  %rax,%rdi          ;条件移动
  2b:   31 c0                   xor    %eax,%eax
  2d:   e8 00 00 00 00          callq  32 <_Z6ifelsei+0x19>
  32:   31 c0                   xor    %eax,%eax
  34:   59                      pop    %rcx
  35:   c3                      retq

if…else if…else

  if...else if...elseif...else的情况类似,只不过编译器会针对不同的情况优化。比如下面的代码jmp只是用来设置printf的输入参数,而不是有多个printf的call指令。

int ifelseif(int a){
    if(a == 3){
        printf("the value is 3");
    }
    else if(a == 10){
        printf("the value is 10");
    }
    else if(a == 100){
        printf("the value is 100");
    }else{
        printf("the value is not a number");
    }

    return 0;
}
0000000000000000 <_Z8ifelseifi>:
   0:   83 ff 03                cmp    $0x3,%edi
   3:   74 11                   je     16 <_Z8ifelseifi+0x16>         ;a == 3 ? goto 16
   5:   83 ff 64                cmp    $0x64,%edi
   8:   74 13                   je     1d <_Z8ifelseifi+0x1d>         ;a == 100? goto 1d
   a:   83 ff 0a                cmp    $0xa,%edi
   d:   75 15                   jne    24 <_Z8ifelseifi+0x24>         ;a != 10 ? goto 24
   f:   bf 00 00 00 00          mov    $0x0,%edi
  14:   eb 13                   jmp    29 <_Z8ifelseifi+0x29>         ;goto printf
  16:   bf 00 00 00 00          mov    $0x0,%edi
  1b:   eb 0c                   jmp    29 <_Z8ifelseifi+0x29>         ;goto printf
  1d:   bf 00 00 00 00          mov    $0x0,%edi
  22:   eb 05                   jmp    29 <_Z8ifelseifi+0x29>         ;goto printf
  24:   bf 00 00 00 00          mov    $0x0,%edi
  29:   50                      push   %rax
  2a:   31 c0                   xor    %eax,%eax
  2c:   e8 00 00 00 00          callq  31 <_Z8ifelseifi+0x31>         ;call printf
  31:   31 c0                   xor    %eax,%eax
  33:   59                      pop    %rcx
  34:   c3                      retq

5.2 switch语句

  switch语句是一种多分支结构,其结构相比于if更加复杂。编译器针对不同类型的switch语句进行针对性的优化:

  1. switch语句中的选项数量小于等于3时,会生成类似if...elseif的代码;
  2. switch语句中的选项数量大于3且选项之间有明显的的线性关系时,编译器会生成一个跳转表来表示switch
  3. switch语句中的选项数量大于3小于256且无法构成明显的线性关系时,编译器会生成两个表格,第一个表格存储跳转表的索引,第二个表格存储跳转表。程序执行时先通过当前值索引第一个表格得到跳转表索引,再通过该索引找到跳转地址(实测发现clang的优化策略和cl优化策略不同);
  4. switch语句中的选项数量大于255时,编译器会使用判定树来优化。即以每个选项的值作为树的节点来生成判定分支代码。

实验一:switch语句跳转少于等于3

int switch1(int type){
    switch(type){
    case 1:
        printf("the value is 1");break;
    case 2:
        printf("the value is 2");break;
    case 3:
        printf("the value is 3");break;
    default:
        printf("unknown value");
    }
    return 0;
}

  从下面的反汇编能够简单的看出,当条件小于3时生成的汇编的代码和if...else if有些类似(只是类似,还是有区别的)。上半部分,即4011b3以上的部分汇编是计算当前的值和switch...case的选项的比较,然后跳转到对应的代码块,下半部分就是具体的代码块儿。

//-O0
0000000000401130 <_Z7switch1i>:
  401130:       55                      push   %rbp
  401131:       48 89 e5                mov    %rsp,%rbp
  401134:       48 83 ec 10             sub    $0x10,%rsp
  401138:       89 7d fc                mov    %edi,-0x4(%rbp)
  40113b:       8b 45 fc                mov    -0x4(%rbp),%eax
  40113e:       89 45 f8                mov    %eax,-0x8(%rbp)
  401141:       83 e8 01                sub    $0x1,%eax                  
  401144:       0f 84 27 00 00 00       je     401171 <_Z7switch1i+0x41>    ;eax ?= 1 jump
  40114a:       e9 00 00 00 00          jmpq   40114f <_Z7switch1i+0x1f>
  40114f:       8b 45 f8                mov    -0x8(%rbp),%eax
  401152:       83 e8 02                sub    $0x2,%eax
  401155:       0f 84 2c 00 00 00       je     401187 <_Z7switch1i+0x57>    ;eax ?= 2 jump
  40115b:       e9 00 00 00 00          jmpq   401160 <_Z7switch1i+0x30>
  401160:       8b 45 f8                mov    -0x8(%rbp),%eax
  401163:       83 e8 03                sub    $0x3,%eax
  401166:       0f 84 31 00 00 00       je     40119d <_Z7switch1i+0x6d>    ;eax ?= 3 jump
  40116c:       e9 42 00 00 00          jmpq   4011b3 <_Z7switch1i+0x83>
  401171:       48 bf 04 20 40 00 00    movabs $0x402004,%rdi
  401178:       00 00 00
  40117b:       b0 00                   mov    $0x0,%al
  40117d:       e8 ae fe ff ff          callq  401030           ;printf the value is 1
  401182:       e9 3d 00 00 00          jmpq   4011c4 <_Z7switch1i+0x94>
  401187:       48 bf 13 20 40 00 00    movabs $0x402013,%rdi
  40118e:       00 00 00
  401191:       b0 00                   mov    $0x0,%al
  401193:       e8 98 fe ff ff          callq  401030           ;printf the value is 2
  401198:       e9 27 00 00 00          jmpq   4011c4 <_Z7switch1i+0x94>
  40119d:       48 bf 22 20 40 00 00    movabs $0x402022,%rdi
  4011a4:       00 00 00
  4011a7:       b0 00                   mov    $0x0,%al
  4011a9:       e8 82 fe ff ff          callq  401030           ;printf the value is 3
  4011ae:       e9 11 00 00 00          jmpq   4011c4 <_Z7switch1i+0x94>
  4011b3:       48 bf 31 20 40 00 00    movabs $0x402031,%rdi
  4011ba:       00 00 00
  4011bd:       b0 00                   mov    $0x0,%al 
  4011bf:       e8 6c fe ff ff          callq  401030           ;printf the value is unknwon
  4011c4:       31 c0                   xor    %eax,%eax                    ;函数准备返回
  4011c6:       48 83 c4 10             add    $0x10,%rsp
  4011ca:       5d                      pop    %rbp
  4011cb:       c3                      retq
  4011cc:       0f 1f 40 00             nopl   0x0(%rax)

实验二:switch语句大于3且选项有限性关系

int switch2(int type){
    switch(type){
    case 1:
        printf("the value is 1");break;
    case 2:
        printf("the value is 2");break;
    case 3:
        printf("the value is 3");break;
    case 4:
        printf("the value is 3");break;
    default:
        printf("unknown value");
    }

    return 0;
}

  我们一步一步分析下面的反汇编:

  1. 首先type减去选项的最小值并将这个值存到栈上一个临时变量中,然后和选项最大值和最小值只差作差;
  2. 当差值大于0时则跳转到default的代码块,否则继续执行;
  3. 之后利用第1步存储的临时变量作为索引在跳转表格中找打实际的跳转地址,直接跳转到对应地址执行;
  4. 跳转到对应的地址执行完对应的代码之后,跳转到switch的结尾处。

  跳转表存储在_IO_stdin_used地址,从对应的地址中我们能够看到0x004011fe,0x00401214,0x0040122a,0x00401240四个地址,刚好对应每个选项的代码块的起始地址。

;-O0
00000000004011d0 <_Z7switch2i>:
  4011d0:       55                      push   %rbp
  4011d1:       48 89 e5                mov    %rsp,%rbp
  4011d4:       48 83 ec 10             sub    $0x10,%rsp
  4011d8:       89 7d fc                mov    %edi,-0x4(%rbp)
  4011db:       8b 45 fc                mov    -0x4(%rbp),%eax
  4011de:       83 c0 ff                add    $0xffffffff,%eax           ;type - 1
  4011e1:       89 c1                   mov    %eax,%ecx                
  4011e3:       48 89 4d f0             mov    %rcx,-0x10(%rbp)           ;tmp = type - 1
  4011e7:       83 e8 03                sub    $0x3,%eax                  
  4011ea:       0f 87 66 00 00 00       ja     401256 <_Z7switch2i+0x86>  ;if type > 4 then jump
  4011f0:       48 8b 45 f0             mov    -0x10(%rbp),%rax
  4011f4:       48 8b 04 c5 08 20 40    mov    0x402008(,%rax,8),%rax     ;从表格找中根据索引获取需要跳转的地址
  4011fb:       00
  4011fc:       ff e0                   jmpq   *%rax
  4011fe:       48 bf 28 20 40 00 00    movabs $0x402028,%rdi             ;当前代码块首地址
  401205:       00 00 00
  401208:       b0 00                   mov    $0x0,%al
  40120a:       e8 21 fe ff ff          callq  401030           ;printf
  40120f:       e9 53 00 00 00          jmpq   401267 <_Z7switch2i+0x97>    ;break
  401214:       48 bf 37 20 40 00 00    movabs $0x402037,%rdi             ;当前代码块首地址
  40121b:       00 00 00
  40121e:       b0 00                   mov    $0x0,%al
  401220:       e8 0b fe ff ff          callq  401030           ;printf
  401225:       e9 3d 00 00 00          jmpq   401267 <_Z7switch2i+0x97>    ;break
  40122a:       48 bf 46 20 40 00 00    movabs $0x402046,%rdi             ;当前代码块首地址
  401231:       00 00 00
  401234:       b0 00                   mov    $0x0,%al
  401236:       e8 f5 fd ff ff          callq  401030 
  40123b:       e9 27 00 00 00          jmpq   401267 <_Z7switch2i+0x97>    ;break
  401240:       48 bf 46 20 40 00 00    movabs $0x402046,%rdi             ;当前代码块首地址
  401247:       00 00 00
  40124a:       b0 00                   mov    $0x0,%al
  40124c:       e8 df fd ff ff          callq  401030           ;call printf
  401251:       e9 11 00 00 00          jmpq   401267 <_Z7switch2i+0x97>    ;break;
  401256:       48 bf 55 20 40 00 00    movabs $0x402055,%rdi
  40125d:       00 00 00
  401260:       b0 00                   mov    $0x0,%al
  401262:       e8 c9 fd ff ff          callq  401030           ;call printf("unknown value")
  401267:       31 c0                   xor    %eax,%eax                    ;switch处理完的部分代码
  401269:       48 83 c4 10             add    $0x10,%rsp
  40126d:       5d                      pop    %rbp
  40126e:       c3                      retq
  40126f:       90                      nop

0000000000402000 <_IO_stdin_used>:
  402000:       01 00                   add    %eax,(%rax)
  402002:       02 00                   add    (%rax),%al
  402004:       00 00                   add    %al,(%rax)
  402006:       00 00                   add    %al,(%rax)
  402008:       fe                      (bad)
  402009:       11 40 00                adc    %eax,0x0(%rax)
  40200c:       00 00                   add    %al,(%rax)
  40200e:       00 00                   add    %al,(%rax)
  402010:       14 12                   adc    $0x12,%al
  402012:       40 00 00                add    %al,(%rax)
  402015:       00 00                   add    %al,(%rax)
  402017:       00 2a                   add    %ch,(%rdx)
  402019:       12 40 00                adc    0x0(%rax),%al
  40201c:       00 00                   add    %al,(%rax)
  40201e:       00 00                   add    %al,(%rax)
  402020:       40 12 40 00             adc    0x0(%rax),%al
  402024:       00 00                   add    %al,(%rax)
  402026:       00 00                   add    %al,(%rax)

  -Os的代码相比于-O0更简单,相比而言sub指令被替换成了deccmp0x0040115d这条指令存储的不是代码块的跳转表,而是字符串的表格,因为我们这里的代码比较简单全是printf函数唯一不同的地方是参数。因此编译器对这种场景优化,只存储字符串的跳转表。

;-Os
000000000040114c <_Z7switch2i>:
  40114c:       50                      push   %rax
  40114d:       89 f8                   mov    %edi,%eax
  40114f:       ff c8                   dec    %eax
  401151:       bf 31 20 40 00          mov    $0x402031,%edi
  401156:       83 f8 03                cmp    $0x3,%eax
  401159:       77 0a                   ja     401165 <_Z7switch2i+0x19>
  40115b:       48 98                   cltq
  40115d:       48 8b 3c c5 58 20 40    mov    0x402058(,%rax,8),%rdi
  401164:       00
  401165:       31 c0                   xor    %eax,%eax
  401167:       e8 c4 fe ff ff          callq  401030 
  40116c:       31 c0                   xor    %eax,%eax
  40116e:       59                      pop    %rcx
  40116f:       c3                      retq

实验三:选项数大于3小于256且不具备任何线性关系

int switch3(int type, int a) {
	switch (type) {
	case 3:
		type = sqrt(a); break;
	case 6:
		type = pow(a, 10); break;
	case 34:
		type = log(a); break;
	case 60:
		type = abs(a); break;
	default:
		printf("unknown value");
	}

  printf("the value is %d", type);
	return type;
}

  测试发现clang一直是通过jmp来实现,连跳转表都没有生成。这里用visual studio来做反汇编测试。

  1. 第一步是type - 3,和之前的流程相同,之后将计算后的值和57(0x39)比较,大于则跳转调用default```的代码;
  2. 如果不跳转则先从索引表中获取跳转表的索引,然后根据索引找到需要跳转的地址,跳转到对应地址执行。

  代码块我们就不看了,比较简单。我们直接看跳转表。跳转表索引中有58个选项,刚好对应3-60。而跳转表里面存储了5个地址(0x00a21079, 0x00a2108c, 0x00a210a7, 0x00a210ba, 0x00a210c5)分别对应具体的执行代码块。

;;跳转表索引
0x00A210FC  00 04 04 01 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 02 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04  
0x00A2112D  04 04 04 04 04 04 04 04 03 3b 0d 04 30 a2 00 f2 75 02 f2 c3 f2 e9 79 02 00 00 56 6a 01 e8 44 0b 00 00 e8 81 06 00 00 50 e8 6f 0b 00 00 e8 9a 0b 00

;;跳转表
0x00A210E8  79 10 a2 00 8c 10 a2 00 a7 10 a2 00 ba 10 a2 00 c5 10 a2 00

;;实际测试发现clang好像不会生成跳转表,下面的反汇编来自于visual studio cl.exe
int main(int argc, char **argv) {       //这里switch直接被内联了
00A21050  push        ebp  
00A21051  mov         ebp,esp  
00A21053  and         esp,0FFFFFFF8h  
00A21056  push        ecx  
	switch3(argc, argv[0][0]);
00A21057  mov         eax,dword ptr [argv]  
00A2105A  push        esi  
00A2105B  mov         esi,dword ptr [argc]  
00A2105E  mov         eax,dword ptr [eax]  
00A21060  movsx       ecx,byte ptr [eax]  
00A21063  lea         eax,[esi-3]  
00A21066  cmp         eax,39h                       ;type >? 60
00A21069  ja          main+75h (0A210C5h)  
00A2106B  movzx       eax,byte ptr [eax+0A210FCh]   ;跳转表索引
00A21072  jmp         dword ptr [eax*4+0A210E8h]    ;跳转表
00A21079  movd        xmm0,ecx  
00A2107D  cvtdq2pd    xmm0,xmm0  
	switch3(argc, argv[0][0]);
00A21081  call        __libm_sse2_sqrt_precise (0A21D2Fh)  
00A21086  cvttsd2si   esi,xmm0  
00A2108A  jmp         main+82h (0A210D2h)         ;break
00A2108C  movsd       xmm1,mmword ptr [__real@4024000000000000 (0A22128h)]  
00A21094  movd        xmm0,ecx  
00A21098  cvtdq2pd    xmm0,xmm0  
00A2109C  call        __libm_sse2_pow_precise (0A21D29h)  
00A210A1  cvttsd2si   esi,xmm0  
00A210A5  jmp         main+82h (0A210D2h)         ;break  
00A210A7  movd        xmm0,ecx  
00A210AB  cvtdq2pd    xmm0,xmm0  
00A210AF  call        __libm_sse2_log_precise (0A21D23h)  
00A210B4  cvttsd2si   esi,xmm0  
00A210B8  jmp         main+82h (0A210D2h)         ;break  
00A210BA  mov         eax,ecx  
00A210BC  cdq  
00A210BD  mov         esi,eax  
00A210BF  xor         esi,edx  
00A210C1  sub         esi,edx  
00A210C3  jmp         main+82h (0A210D2h)         ;break  
00A210C5  push        offset string "unknown value" (0A22108h)        ;default 代码块
00A210CA  call        printf (0A21010h)  
00A210CF  add         esp,4  
00A210D2  push        esi                 
00A210D3  push        offset string "the vaue is %d" (0A22118h)  
00A210D8  call        printf (0A21010h)  
00A210DD  add         esp,8  
	return 0;
00A210E0  xor         eax,eax  
}

实验四:选项数量大于3且数值大于255

int switch3(int type, int a) {
	switch (type) {
	case 3:
		type = sqrt(a); break;
	case 6:
		type = pow(a, 10); break;
	case 34:
		type = log(a); break;
	case 606:
		type = abs(a); break;
	default:
		printf("unknown value");
	}

	printf("the vaue is %d", type);
	return type;
}

  如下面的反汇编所示,编译器会根据当前已经有的值生成判定树,不断判断跳转,直至跳转到对应的代码块。下面是对应代码判定树的结构:
C++ 反汇编简要_第2张图片

int main(int argc, char **argv) {
00961050  push        ebp  
00961051  mov         ebp,esp  
00961053  and         esp,0FFFFFFF8h               
00961056  push        ecx  
	switch3(argc, argv[0][0]);
00961057  mov         eax,dword ptr [argv]  
0096105A  push        esi  
0096105B  mov         esi,dword ptr [argc]  
0096105E  mov         eax,dword ptr [eax]  
00961060  movsx       eax,byte ptr [eax]  
00961063  cmp         esi,22h                       
00961066  jg          main+65h (09610B5h)           ;;if type > 34
00961068  je          main+52h (09610A2h)           ;;if type == 34
0096106A  cmp         esi,3  
0096106D  je          main+3Fh (096108Fh)           ;;if type == 3
0096106F  cmp         esi,6  
00961072  jne         main+6Dh (09610BDh)           ;;if type != 6
	switch3(argc, argv[0][0]);                        ;case 6 代码块
00961074  movsd       xmm1,mmword ptr [__real@4024000000000000 (0962128h)]  
0096107C  movd        xmm0,eax  
00961080  cvtdq2pd    xmm0,xmm0  
00961084  call        __libm_sse2_pow_precise (0961CD9h)  
00961089  cvttsd2si   esi,xmm0  
0096108D  jmp         main+83h (09610D3h)  
0096108F  movd        xmm0,eax                      ;;case 3 代码块
00961093  cvtdq2pd    xmm0,xmm0  
00961097  call        __libm_sse2_sqrt_precise (0961CDFh)  
0096109C  cvttsd2si   esi,xmm0  
009610A0  jmp         main+83h (09610D3h)  
009610A2  movd        xmm0,eax                      ;;case 34 代码块
009610A6  cvtdq2pd    xmm0,xmm0  
009610AA  call        __libm_sse2_log_precise (0961CD3h)  
009610AF  cvttsd2si   esi,xmm0  
009610B3  jmp         main+83h (09610D3h)  
009610B5  cmp         esi,25Eh  
009610BB  je          main+7Ch (09610CCh)           ;if type == 606
009610BD  push        offset string "unknown value" (0962108h)  
009610C2  call        printf (0961010h)  
009610C7  add         esp,4  
009610CA  jmp         main+83h (09610D3h)  
009610CC  cdq                                      ;;case 606 代码块
009610CD  mov         esi,eax  
009610CF  xor         esi,edx  
009610D1  sub         esi,edx  
009610D3  push        esi                           ;switch结束块
009610D4  push        offset string "the vaue is %d" (0962118h)  
009610D9  call        printf (0961010h)  
009610DE  add         esp,8  
	return 0;
009610E1  xor         eax,eax  
}

  下面的代都会用vs去分析,不再使用clang-linux,太麻烦了。

5.3 循环语句

5.3.1 do...while

  do...while循环的代码比较简单就是,循环的判断在代码块的下面。

实验

int dowhile(int a) {
	do {
		a = a + 30;
	} while (a < 60);
	return a;
}

int main(int argc, char **argv) {
	int a = dowhile(argc);
	printf("%d", a);
	return 0;
}
int main(int argc, char **argv) {
00B81040  push        ebp  
00B81041  mov         ebp,esp  
	int a = dowhile(argc);
00B81043  mov         eax,dword ptr [argc]  
00B81046  add         eax,1Eh           ;循环开头  a = a + 30
00B81049  cmp         eax,3Ch           
00B8104C  jl          main+6h (0B81046h); if a < 60 then jmp  
	printf("%d", a);
00B8104E  push        eax  
00B8104F  push        offset string "%d" (0B820F8h)  
00B81054  call        printf (0B81010h)  
00B81059  add         esp,8  
	return 0;
00B8105C  xor         eax,eax  
}

5.3.2 while

  while相比于do...while是先判断再循环。

实验

int dowhile(int a) {
	while(a<60) {
		a = a + 2;
	} ;
	return a;
}

int main(int argc, char **argv) {
	int a = dowhile(argc);
	printf("%d", a);
	return 0;
}
int main(int argc, char **argv) {
00A81040  push        ebp  
00A81041  mov         ebp,esp  
	int a = dowhile(argc);
00A81043  mov         ecx,dword ptr [argc]  
00A81046  cmp         ecx,3Ch  
00A81049  jge         main+1Ah (0A8105Ah)       ;;循环条件判断
00A8104B  mov         eax,3Bh  
00A81050  sub         eax,ecx  
00A81052  shr         eax,1  
00A81054  lea         ecx,[ecx+eax*2]  
00A81057  add         ecx,2  
	printf("%d", a);
00A8105A  push        ecx               ;;循环结束
00A8105B  push        offset string "%d" (0A820F8h)  
00A81060  call        printf (0A81010h)  
00A81065  add         esp,8  
	return 0;
00A81068  xor         eax,eax  
}

5.3.3 for

  for相比于while循环在结尾处多了条件的处理。

实验

int dowhile(int a) {
	for(int i = 0;i < a;i *= 37){
		printf("the value is %d", a);
	} 

	return a;
}

int main(int argc, char **argv) {
	int a = dowhile(argc);
	printf("%d", a);
	return 0;
}
int main(int argc, char **argv) {
00581040  push        ebp  
00581041  mov         ebp,esp  
00581043  push        esi  
00581044  push        edi  
	int a = dowhile(argc);
00581045  mov         edi,dword ptr [argc]  
00581048  xor         esi,esi  
0058104A  test        edi,edi  
0058104C  jle         main+25h (0581065h)  
0058104E  xchg        ax,ax  
00581050  push        edi                       ;循环体开头
00581051  push        offset string "the value is %d" (05820F8h)  
00581056  call        printf (0581010h)  
0058105B  imul        esi,esi,25h               ;a *= 37
0058105E  add         esp,8                 
00581061  cmp         esi,edi  
00581063  jl          main+10h (0581050h)       ;i < a jump
	printf("%d", a);
00581065  push        edi  
	printf("%d", a);
00581066  push        offset string "%d" (0582108h)  
0058106B  call        printf (0581010h)  
00581070  add         esp,8  
	return 0;
00581073  xor         eax,eax  
00581075  pop         edi  
00581076  pop         esi  
}

6 函数

6.1 函数调用

  函数调用是通过栈实现的,每个函数都有自己的栈帧。栈帧中保存了当前函数调用过程中的函数参数,局部变量,函数的返回地址等内容。每一次函数被调用都会生成当前函数的栈帧,函数返回后会进行栈平衡,即返栈空间。因为栈空间是有限的的,所以我们无法无限递归调用某个函数这会导致爆栈。
  调用函数时需要申请栈,函数调用结束时需要平衡栈。具体由哪一方负责做这个事情?根据不同的调用约定,函数入参的顺序和栈的维护方不同:

  • _cdecl:C\C++默认的调用方式,调用方平衡栈,不定参数的函数可以使用这种方式;
  • _stdcall:被调方平衡栈,不定参数的函数无法使用这种方式;
  • _fastcall:寄存器方式传参,被调方平衡栈,不定参数的函数无法使用这种方式。

详情可见函数的调用约定
需要注意x64和x86的调用约定不同,x64只有一种调用约定。函数调用的前4个参数用寄存器传参即rcx,rdx,r8,r9,由右向左传参,任何大于8字节或者不是1字节,2字节,4字节,8字节的参数使用引用传参。浮点都是通过xmm寄存器传参(如果同时有浮点和整数则按原来的顺序传参,比如参数为float,int,float,int则使用的寄存器分别为xmm0,rdx,xmm2,r9)。虽然前四个参数用寄存器传参但是栈底也有对应的预留空间避免寄存器不够用。

实验一

void _stdcall stdcall(int v) {
	printf("stdcall %d", v);
}

void _cdecl cdecalll(int v) {
	printf("cdcel %d", v);
	printf("cdcel %d", v);
	printf("cdcel %d", v);
	printf("cdcel %d", v);
}

int main(int argc, char **argv) {
	stdcall(argc);
	cdecalll(argc);
	return 0;
}

  根据下面的反汇编这里描述下一个完整的函数调用过程:

  1. 首先push ebp,当前ebp的值为被调用方的栈底,用于平衡栈;
  2. 然后提升栈底为esp
  3. 调整栈顶指针,开辟栈帧(下面的反汇编因为代码中没有用到局部变量因此没有开辟额外的占空间);
  4. 执行函数相关代码;
  5. 执行完成后,跳帧栈底指针,弹出ebp
  6. 栈平衡

  cdcel复写传播优化,当调用多个相同函数时并不会每次都在函数结尾平衡栈,而是统一平衡栈。
C++ 反汇编简要_第3张图片

void _stdcall stdcall(int v) {
007E1080  push        ebp  
007E1081  mov         ebp,esp  
	printf("stdcall %d", v);
007E1083  mov         eax,dword ptr [v]  
007E1086  push        eax  
007E1087  push        7E21B8h  
007E108C  call        printf (07E1040h)  
007E1091  add         esp,8  
}
007E1094  pop         ebp  
007E1095  ret         4  


void _cdecl cdecalll(int v) {
00E31050  push        esi  
00E31051  push        edi  
00E31052  mov         esi,ecx  
	printf("cdcel %d", v);
00E31054  mov         edi,offset string "cdcel %d" (0E32104h)  
00E31059  push        esi  
00E3105A  push        edi  
00E3105B  call        printf (0E31025h)  
	printf("cdcel %d", v);
00E31060  push        esi  
00E31061  push        edi  
00E31062  call        printf (0E31025h)  
	printf("cdcel %d", v);
00E31067  push        esi  
00E31068  push        edi  
00E31069  call        printf (0E31025h)  
	printf("cdcel %d", v);
00E3106E  push        esi  
00E3106F  push        edi  
00E31070  call        printf (0E31025h)  
00E31075  add         esp,20h           ;;复写传播优化
00E31078  pop         edi  
00E31079  pop         esi  
}
00E3107A  ret

6.2 函数寻址

  函数调用过程中参数传递、局部变量的创建都是通过栈或者寄存器传递。
  局部变量都是栈上的一块内存,通常可以通过ebp - n的方式访问,因为对于一个栈帧,ebp是固定的,只需要根据ebp的偏移访问即可。对于某些栈况可以通过esp访问避免ebp的操作节省指令。
  函数中的参数传递是通过push执行将对应的参数值复制到当前栈顶,因此可以通过ebp + n的方式访问参数,对于某些优化的情况下可以直接利用寄存器访问函数参数。
  函数调用是使用call指令,该指令会将当前指令地址的下一个地址压入栈中,等函数返回就可以找到返回的地址,ret时就是利用该地址返回的。而函数返回时通常会使用eax传递返回值,但是当返回值大于机器地址长度个字节时会使用其他寄存器,如果太大就会通过基址寻址拷贝。

a getV() {
	return a{};
}

int main(int argc, char **argv) {
	a a1 = getV();
	return 0;
}
a getV() {
00511010  push        ebp  
00511011  mov         ebp,esp  
00511013  sub         esp,20h  
00511016  push        esi  
00511017  push        edi  
	return a{};
00511018  xor         eax,eax  
0051101A  mov         dword ptr [ebp-20h],eax  
0051101D  mov         dword ptr [ebp-1Ch],eax  
00511020  mov         dword ptr [ebp-18h],eax  
00511023  mov         dword ptr [ebp-14h],eax  
00511026  mov         dword ptr [ebp-10h],eax  
00511029  mov         dword ptr [ebp-0Ch],eax  
0051102C  mov         dword ptr [ebp-8],eax  
0051102F  mov         dword ptr [ebp-4],eax  
00511032  mov         ecx,8  
00511037  lea         esi,[ebp-20h]  
0051103A  mov         edi,dword ptr [ebp+8]  
0051103D  rep movs    dword ptr es:[edi],dword ptr [esi]  
005

7 变量寻址

7.1 全局变量和静态变量

全局变量和静态变量
  在了解全局变量和静态变量的存储方式之前,应该了解可执行文件的基本格式。无论是Windows上还是Linux上的可执行性文件都是以类COFF格式存储,Linux上是ELF文件,Windows上是PE文件。可执行文件中根据不同数据的类型将代码和数据以段的方式分开存储,比如代码存储在代码段,数据存储在数据段,数据段又分只读数据段。
  而全局变量和静态变量就存储在数据段上,根据是否初始化又分为bss和data段。因此在实际程序运行时可以从装载到对应内存的地址访问到全局变量和静态变量的数据。在程序中使用静态变量和全局变量的方式是相同的,区别是编译器从语义上对不同符号进行了不用的签名,符号表上可见性也不同来保证语法正确。

实验一

int a = 1;
static int b = 1;
int main(int argc, char **argv) {
	printf("%d %d", a, b);
	return 0;
}

  从下面的反汇编中可以看出无论是静态变量还是全局变量都是通过绝对地址访问,其数据存储于数据区。

int a = 1;
static int b = 1;
int main(int argc, char **argv) {
00341080  push        ebp  
00341081  mov         ebp,esp  
	printf("%d %d", a, b);
00341083  mov         eax,dword ptr [b (034301Ch)]  
00341088  push        eax  
00341089  mov         ecx,dword ptr [a (0343018h)]  
0034108F  push        ecx  
00341090  push        3421F4h  
00341095  call        printf (0341040h)  
0034109A  add         esp,0Ch  
	return 0;
0034109D  xor         eax,eax  
}

局部静态变量
  局部静态变量只能被初始化一次。编译器通过一个标志位来判断静态变量是否被初始化过,如果已经初始化就不再初始化。

实验二

void func() {
	static int i = atoi("1");
	if (i != 1) {
		scanf("%d", i);
	}

	printf("%d", i);
}

  从下面的反汇编可以看到初始化静态变量前会检查0x8a43b0h这个地址,初始化完后就会改写该标志位。

void func() {
008A1100  push        ebp  
008A1101  mov         ebp,esp  
	static int i = atoi("1");
008A1103  mov         eax,dword ptr fs:[0000002Ch]  
008A1109  mov         ecx,dword ptr [eax]  
008A110B  mov         edx,dword ptr ds:[8A43B0h]  
008A1111  cmp         edx,dword ptr [ecx+4]  
008A1117  jle         func+4Fh (08A114Fh)  
008A1119  push        8A43B0h  
008A111E  call        _Init_thread_header (08A1346h)      ;创建标志位
008A1123  add         esp,4  
008A1126  cmp         dword ptr ds:[8A43B0h],0FFFFFFFFh  ;检查静态变量是否被初始化
008A112D  jne         func+4Fh (08A114Fh)  
	static int i = atoi("1");
008A112F  push        8A32E8h  
008A1134  call        dword ptr [__imp__atoi (08A306Ch)]  
008A113A  add         esp,4  
008A113D  mov         dword ptr [i (08A4028h)],eax  
008A1142  push        8A43B0h  
008A1147  call        _Init_thread_footer (08A12FCh)      ;写标志位
008A114C  add         esp,4  
	if (i != 1) {
008A114F  cmp         dword ptr [i (08A4028h)],1  
008A1156  je          func+6Bh (08A116Bh)  
		scanf("%d", i);
008A1158  mov         eax,dword ptr [i (08A4028h)]  
008A115D  push        eax  
008A115E  push        8A32ECh  
008A1163  call        scanf (08A10C0h)  
008A1168  add         esp,8  
	}

	printf("%d", i);
008A116B  mov         ecx,dword ptr [i (08A4028h)]  
008A1171  push        ecx  
008A1172  push        8A32F0h  
008A1177  call        printf (08A1050h)  
008A117C  add         esp,8  
}

7.2 堆内存

  堆内存时通过new或者malloc申请的,区别是new会构造对象,malloc不会。销毁的调用freedelete同理。
  CRT的堆内存管理是通过下面的双向链表管理,每个节点存储了当前内存的大小和一些其他信息。

struct _CrtMemBlockHeader{
    _CrtMemBlockHeader* _block_header_next;
    _CrtMemBlockHeader* _block_header_prev;
    char const*         _file_name;
    int                 _line_number;

    int                 _block_use;
    size_t              _data_size;

    long                _request_number;
    unsigned char       _gap[no_mans_land_size];

    // Followed by:
    // unsigned char    _data[_data_size];
    // unsigned char    _another_gap[no_mans_land_size];
};

  下面是一段new int的内存,该内存的值为2。0x12a7628是具体的内存地址,地址前后的fd是越界检查。

0x012A75F7  00 00 00 00 00 00 00 00 00 30 15 43 7f 00 13 00 88 58 74 2a 01 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 04 00 00 00 4f 00 00 00 fd fd fd fd
0x012A7628  02 00 00 00 fd fd fd fd

7.3 数组和指针寻址

  数组就是一些列相同大小的元素的集合,通常通过首地址+偏移计算需要访问的具体元素。在反汇编中数组的访问就是通过地址偏移进行访问。当数组作为函数参数时,数组会退化为指针,因此传入的参数实际就是数组的首地址。另外需要注意的是反汇编中type arr[]={}type *arr={}的区别,前者会将初始化列表中的值拷贝给对应的局部变量地址,而后置数据存储在data中,这里只是通过指针访问而已。其他类型的指针访问,比如函数指针,返回值为指针等都和普通寻址方式相同,只是具体对象不同而已。

实验

void func() {
	static int i = atoi("1");
	if (i != 1) {
		scanf("%d", i);
	}

	printf("%d", i);
}

int main(int argc, char **argv) {
	const char *a = "12345";
	char b[] = "12345";
	auto t = func;
	printf("%s %s", a, b);
	t();
	return 0;
}
int main(int argc, char **argv) {
00571190  push        ebp  
00571191  mov         ebp,esp  
00571193  sub         esp,14h  
00571196  mov         eax,dword ptr [__security_cookie (0574008h)]  
0057119B  xor         eax,ebp  
0057119D  mov         dword ptr [ebp-4],eax  
	const char *a = "12345";
005711A0  mov         dword ptr [a],5732F4h           ;这里只是将局部变量给a
	char b[] = "12345";
005711A7  mov         eax,dword ptr ds:[005732FCh]  
005711AC  mov         dword ptr [b],eax               ;这里对整块内存进行了拷贝  
005711AF  mov         cx,word ptr ds:[573300h]  
005711B6  mov         word ptr [ebp-8],cx  
	auto t = func;
005711BA  mov         dword ptr [t],offset func (0571100h)  
	printf("%s %s", a, b);
005711C1  lea         edx,[b]  
005711C4  push        edx  
005711C5  mov         eax,dword ptr [a]  
005711C8  push        eax  
005711C9  push        573304h  
005711CE  call        printf (0571050h)  
005711D3  add         esp,0Ch  
	t();
005711D6  call        dword ptr [t]  
	return 0;
005711D9  xor         eax,eax  
}

9 结构体和类

9.1 类的内存布局

  C++中类和结构体除了访问控制上的区别没有任何区别。一个类或者结构体就是一些列对象的集合,所以类中的成员布局是按照实际的类定义的顺序布局的。类的首地址一般和类的第一个成员的地址相同(有虚函数的类例外)。一般类的大小大于等于所有成员大小之和,一方面是因为虚函数表的存在导致有虚函数的类多一个虚函数表指针,另一方面编译器一般会做内存对齐,所以实际的大小根据编译器的不同而带下不同。下面的类大小计算公式中,成员不包含静态成员,另外空类的大小为1,1个字节大小是一个占位符,来表示一个类。

类的静态成员本身不属于某个类实例,而是属于类。因此其和普通静态变量一样存储在静态区。

s i z o e f ( 类对象 ) = s i z o e f ( 虚函数表指针 ) (此项对于有虚函数的类生效) + s i z e o f ( 成员 1 ) + s i z e o f ( 成员 2 ) + . . . + s i z e o f ( 成员 n ) + 对齐的内存大小 sizoef(类对象)=sizoef(虚函数表指针)(此项对于有虚函数的类生效)+sizeof(成员1)+sizeof(成员2)+...+sizeof(成员n)+对齐的内存大小 sizoef(类对象)=sizoef(虚函数表指针)(此项对于有虚函数的类生效)+sizeof(成员1)+sizeof(成员2)+...+sizeof(成员n)+对齐的内存大小

  其中比较复杂的就是内存对齐,一般对齐的大小以期望对齐的大小M和成员的大小中的最小值作为对齐宽度。

class myclass {
public:
	int a;
	char b;
	static int c;
};

int myclass::c = 0;

int main(int argc, char **argv) {
	myclass cls = {};
	printf("%d %d", cls.a, cls.b);
	return 0;
}

  从下面的反汇编中可以看到myclass:c并不占用类空间,myclass也是4字节对齐的。

int main(int argc, char **argv) {
003E1080  push        ebp  
003E1081  mov         ebp,esp  
003E1083  sub         esp,0Ch  
003E1086  mov         eax,dword ptr ds:[003E3004h]  
003E108B  xor         eax,ebp  
003E108D  mov         dword ptr [ebp-4],eax  
	myclass cls = {};
003E1090  xor         eax,eax  
003E1092  mov         dword ptr [ebp-0Ch],eax       ;cls.a = 0; 
003E1095  mov         dword ptr [ebp-8],eax         ;cls.b = 0;
	printf("%d %d", cls.a, cls.b);
003E1098  movsx       ecx,byte ptr [ebp-8]  
003E109C  push        ecx  
003E109D  mov         edx,dword ptr [ebp-0Ch]  
003E10A0  push        edx  
003E10A1  push        3E21F4h  
003E10A6  call        003E1040  
003E10AB  add         esp,0Ch  
	return 0;
003E10AE  xor         eax,eax  
}

9.2 this指针

  C++的类中,类成员变量时存储在对应的地址上,而类成员函数是存储在只读代码段的。当调用一个成员函数时,起始其会额外传入一个参数this,该函数内所有针对成员变量的访问都是通过该this指针访问的。对于一个成员函数cls.func()其调用等价于func(&cls)
  另外,类的成员函数的默认调用约定为__thiscall。函数的第一个参数this指针都会用ecx传递(而对于x64程序第一个参数本身就是ecx,所有需要判断是否符合this指针的条件否则不一定是this指针)。

实验

class myclass {
public:
	int a;
	int func() {
		return a;
	};
};

int main(int argc, char **argv) {
	myclass cls = {};
	printf("%d %d", cls.func());
	return 0;
}

  上面声明的函数没有参数但是下面函数调用时却给函数传递了个参数ecx

int main(int argc, char **argv) {
002C1090  push        ebp  
002C1091  mov         ebp,esp  
002C1093  sub         esp,8  
002C1096  mov         eax,dword ptr ds:[002C3004h]  
002C109B  xor         eax,ebp  
002C109D  mov         dword ptr [ebp-4],eax 
	myclass cls = {};
002C10A0  xor         eax,eax  
	myclass cls = {};
002C10A2  mov         dword ptr [ebp-8],eax  ;cls.a = 0
	printf("%d %d", cls.func());
002C10A5  lea         ecx,[ebp-8]  ;this -> ecx
002C10A8  call        002C1080  
002C10AD  push        eax  
002C10AE  push        2C21F4h  
002C10B3  call        002C1040  
002C10B8  add         esp,8  
	return 0;
002C10BB  xor         eax,eax  
}

9.3 类对象的传参与返回

  类对象的传参与返回相比于普通的变量传参要复杂一点,主要是类对象一般都比较大,很难用几个寄存器传值。

class myclass {
public:
	int64_t a;
	int64_t b;
};

myclass func(myclass cls) {
	printf("%lld", cls.a);
	return myclass{1, 2};
}

int main(int argc, char **argv) {
	myclass cls = {};
	cls = func(cls);
	return 0;
}

  下面的反汇编中发生了很多次的内存对象拷贝,但是实际代码中不会这样,编译器会优化避免多次拷贝。

myclass func(myclass cls) {
00971080  push        ebp  
00971081  mov         ebp,esp  
00971083  sub         esp,10h  
	printf("%lld", cls.a);
00971086  mov         eax,dword ptr [ebp+10h]  ;取出a和b的值访问
00971089  push        eax  
0097108A  mov         ecx,dword ptr [ebp+0Ch]  
0097108D  push        ecx  
0097108E  push        9721F4h  
00971093  call        00971040  
00971098  add         esp,0Ch  
	return myclass{1, 2};
0097109B  mov         dword ptr [ebp-10h],1       ;初始化一个myclass临时对象
009710A2  mov         dword ptr [ebp-0Ch],0  
009710A9  mov         dword ptr [ebp-8],2  
009710B0  mov         dword ptr [ebp-4],0  
009710B7  mov         edx,dword ptr [ebp+8]  
009710BA  mov         eax,dword ptr [ebp-10h]     ;将该临时对象的内存拷贝给返回的预留栈上
009710BD  mov         dword ptr [edx],eax  
009710BF  mov         ecx,dword ptr [ebp-0Ch]  
009710C2  mov         dword ptr [edx+4],ecx  
009710C5  mov         eax,dword ptr [ebp-8]  
009710C8  mov         dword ptr [edx+8],eax  
009710CB  mov         ecx,dword ptr [ebp-4]  
009710CE  mov         dword ptr [edx+0Ch],ecx  
009710D1  mov         eax,dword ptr [ebp+8]  
}
009710D4  mov         esp,ebp  
009710D6  pop         ebp  
009710D7  ret  


int main(int argc, char **argv) {
009710E0  push        ebp  
009710E1  mov         ebp,esp  
009710E3  sub         esp,34h  
009710E6  mov         eax,dword ptr ds:[00973004h]  
009710EB  xor         eax,ebp  
009710ED  mov         dword ptr [ebp-4],eax  
	myclass cls = {};
009710F0  xor         eax,eax  
009710F2  mov         dword ptr [ebp-14h],eax  ;初始化cls
009710F5  mov         dword ptr [ebp-10h],eax  
009710F8  mov         dword ptr [ebp-0Ch],eax  
009710FB  mov         dword ptr [ebp-8],eax  
	cls = func(cls);
009710FE  sub         esp,10h                  ;栈上预留10个字节的空间
00971101  mov         ecx,esp  
00971103  mov         edx,dword ptr [ebp-14h]  ;下面是将cls的内存拷贝到预留栈空间上
00971106  mov         dword ptr [ecx],edx  
00971108  mov         eax,dword ptr [ebp-10h]  
0097110B  mov         dword ptr [ecx+4],eax  
0097110E  mov         edx,dword ptr [ebp-0Ch]  
00971111  mov         dword ptr [ecx+8],edx  
00971114  mov         eax,dword ptr [ebp-8]  
00971117  mov         dword ptr [ecx+0Ch],eax  
0097111A  lea         ecx,[ebp-34h]  
0097111D  push        ecx                       ;传入类的首地址
	cls = func(cls);
0097111E  call        00971080  
00971123  add         esp,14h  
00971126  mov         edx,dword ptr [eax]       ;将返回值取出给临时变量
00971128  mov         dword ptr [ebp-24h],edx  
0097112B  mov         ecx,dword ptr [eax+4]  
0097112E  mov         dword ptr [ebp-20h],ecx  
00971131  mov         edx,dword ptr [eax+8]  
00971134  mov         dword ptr [ebp-1Ch],edx  
00971137  mov         eax,dword ptr [eax+0Ch]  
0097113A  mov         dword ptr [ebp-18h],eax  
0097113D  mov         ecx,dword ptr [ebp-24h]   ;将临时变量的内存拷贝给cls  
00971140  mov         dword ptr [ebp-14h],ecx  
00971143  mov         edx,dword ptr [ebp-20h]  
00971146  mov         dword ptr [ebp-10h],edx  
00971149  mov         eax,dword ptr [ebp-1Ch]  
0097114C  mov         dword ptr [ebp-0Ch],eax  
0097114F  mov         ecx,dword ptr [ebp-18h]  
00971152  mov         dword ptr [ebp-8],ecx  
	return 0;
00971155  xor         eax,eax  
}

9.4 构造函数和析构函数

  C++中的类的初始化是在构造函数中完成的,当类被实例化就会调用构造函数。而析构函数是对象被销毁时销毁类对象的。C++中调用构造函数和析构函数的时机有:

  1. 局部对象。通常局部变量的创建就会调用构造函数;
  2. 堆对象。通过new创建堆对象时就会调用构造函数,反之用delete析构对象时就会调用析构函数;
  3. 参数对象。当将一个对象传递给函数,而该函数的类又是值传递时就会触发拷贝构造函数,此对象出作用域被销毁时就会调用析构函数;
  4. 全局对象。全局对象在进入main之前被初始化,退出之前被析构;
  5. 静态对象。静态对象的生命周期和全局对象类似,唯一不同的是局部静态对象的初始化时lazy的。

  另外需要注意几个点,一,C++中存在很多优化,比如RVO优化,就不会创建临时对象产生对象的拷贝等工作;二,并不是所有的类都会生成默认构造函数,必须是非trival的类才行,非trival的类并不会生成任何构造函数,也就不存在构造和析构。如果是trival的类自己显式声明了构造函数或者析构函数也会调用。

实验

class myclass {
public:
	myclass() {}
	myclass(const myclass &cls) {}
	int a;
};

myclass func(myclass cls) {
	printf("%lld", cls.a);
	return myclass{};
}

int main(int argc, char **argv) {
	myclass *cls = new myclass;
	myclass ret = func(*cls);
	delete cls;
	return 0;
}

  下面的调用关系比较清晰,不详细描述了。

myclass func(myclass cls) {
007310A0  push        ebp  
007310A1  mov         ebp,esp  
	printf("%lld", cls.a);
007310A3  mov         eax,dword ptr [cls]  
007310A6  push        eax  
	printf("%lld", cls.a);
007310A7  push        73227Ch  
007310AC  call        printf (0731040h)  
007310B1  add         esp,8  
	return myclass{};
007310B4  mov         ecx,dword ptr [ebp+8]  
007310B7  call        myclass::myclass (0731080h)  ;返回时调用构造函数构造一个类
007310BC  mov         eax,dword ptr [ebp+8]  
}
007310BF  pop         ebp  
007310C0  ret 


int main(int argc, char **argv) {
007310D0  push        ebp  
007310D1  mov         ebp,esp  
007310D3  push        0FFFFFFFFh  
007310D5  push        731F3Fh  
007310DA  mov         eax,dword ptr fs:[00000000h]  
007310E0  push        eax  
007310E1  sub         esp,24h  
007310E4  mov         eax,dword ptr [__security_cookie (0733004h)]  
007310E9  xor         eax,ebp  
007310EB  mov         dword ptr [ebp-10h],eax  
007310EE  push        eax  
007310EF  lea         eax,[ebp-0Ch]  
007310F2  mov         dword ptr fs:[00000000h],eax  
	myclass *cls = new myclass;
007310F8  push        4  
007310FA  call        operator new (07311B0h)         ;new申请内存
007310FF  add         esp,4  
00731102  mov         dword ptr [ebp-1Ch],eax  
00731105  mov         dword ptr [ebp-4],0  
0073110C  cmp         dword ptr [ebp-1Ch],0  
00731110  je          main+4Fh (073111Fh)               ;检查内存是否申请成功
00731112  mov         ecx,dword ptr [ebp-1Ch]  
00731115  call        myclass::myclass (0731080h)       ;调用构造函数  
0073111A  mov         dword ptr [ebp-20h],eax  
0073111D  jmp         main+56h (0731126h)  
0073111F  mov         dword ptr [ebp-20h],0  
00731126  mov         eax,dword ptr [ebp-20h]  
00731129  mov         dword ptr [ebp-28h],eax  
0073112C  mov         dword ptr [ebp-4],0FFFFFFFFh  
00731133  mov         ecx,dword ptr [ebp-28h]  
00731136  mov         dword ptr [cls],ecx  
	myclass ret = func(*cls);
00731139  push        ecx  
0073113A  mov         ecx,esp  
0073113C  mov         dword ptr [ebp-30h],esp  
0073113F  mov         edx,dword ptr [cls]  
00731142  push        edx  
00731143  call        myclass::myclass (0731090h)  ;调用拷贝构造函数拷贝对象
00731148  lea         eax,[ret]  
0073114B  push        eax  
0073114C  call        func (07310A0h)  
00731151  add         esp,8  
	delete cls;
00731154  mov         ecx,dword ptr [cls]  
00731157  mov         dword ptr [ebp-24h],ecx  
0073115A  push        4  
0073115C  mov         edx,dword ptr [ebp-24h]  
0073115F  push        edx  
00731160  call        operator delete (07311E0h)        ;因为类是一个trival的类,因此只调用了delete没有掉调用析构函数
00731165  add         esp,8  
00731168  cmp         dword ptr [ebp-24h],0  
0073116C  jne         main+0A7h (0731177h)  
0073116E  mov         dword ptr [ebp-2Ch],0  
00731175  jmp         main+0B4h (0731184h)  
00731177  mov         dword ptr [cls],8123h  
0073117E  mov         eax,dword ptr [cls]  
00731181  mov         dword ptr [ebp-2Ch],eax  
	return 0;
00731184  xor         eax,eax  
}

9.5 虚函数

  C++中为了实现多态,每个虚类中都一个默认的成员,虚函数表指针。该指针位于类的首地址,表中存储了当前类对应的虚函数,因此调用虚函数时需要先寻址到虚函数表,再索引到具体第几个虚函数指针。另外,虚函数表的-1位置存储的是typeinfo的信息,可以通过该信息类识别类的RTTI信息。
  C++中虚类的继承关系比较复杂,这里不会深究,建议深入了解下类的对象模型,了解下类对象是如何布局的。另外,需要注意的是clang和msvc的虚类布局不同。这里只简单看下普通虚类的结构。

实验

int main(int argc, char **argv) {
00F71040  push        ebp  
00F71041  mov         ebp,esp  
00F71043  sub         esp,0Ch  
	myclass *cls = new myclass;
00F71046  push        8  
00F71048  call        operator new (0F71096h)  
00F7104D  add         esp,4  
00F71050  mov         dword ptr [ebp-4],eax  
00F71053  cmp         dword ptr [ebp-4],0  
00F71057  je          main+26h (0F71066h)  
00F71059  mov         ecx,dword ptr [ebp-4]  
00F7105C  call        myclass::myclass (0F71020h)  
00F71061  mov         dword ptr [ebp-8],eax  
00F71064  jmp         main+2Dh (0F7106Dh)  
00F71066  mov         dword ptr [ebp-8],0  
00F7106D  mov         eax,dword ptr [ebp-8]  
00F71070  mov         dword ptr [cls],eax  
	cls->func();
00F71073  mov         ecx,dword ptr [cls]   ;类首地址,即虚函数表的地址
00F71076  mov         edx,dword ptr [ecx]   ;通过虚函数表地址找到虚函数表指针
00F71078  mov         ecx,dword ptr [cls]   ;this入参
00F7107B  mov         eax,dword ptr [edx]   ;找打虚函数表的第一项,如果有两项,第二项就是[edx + 4]
00F7107D  call        eax  
	return 0;
00F7107F  xor         eax,eax  
}
00F71081  mov         esp,ebp  
00F71083  pop         ebp  
00F71084  ret 
class myclass {
public:
	virtual void func() {}
	int a;
};

int main(int argc, char **argv) {
	myclass *cls = new myclass;
	cls->func();
	return 0;
}

9.6 多继承和多重继承

  建议直接看C++对象模型更直观。

10 异常处理

  识别异常处理:

  1. 在函数入口处设置异常回调函数,回调函数先将eax设置为FuncInfo数据的地址,然后跳往___CxxFrameHandler
  2. 异常的抛出由__CxxThrowException函数完成,该函数使用了两个参数,一个是抛出异常的关键字throw的参数指针,另一个是抛出信息类型的指针(ThrowInfo*)。
  3. 在异常回调函数中 ,可以得到异常对象的地址和对应ThrowInfo数据的地址以及FunInfo表结构的地址。根据记录的异常类型,进行try块的匹配工作。
  4. 如果没有找到try块,则析构异常对象,返回ExceptionContinueSearch,继续下一个异常回调函数的处理。
  5. 当找到对应的try块时,通过TryBlockMapEntry表结构中的pCatch指向catch信息表,用ThrowInfo表结构中的异常类型遍历查找与之匹配的catch块,比较关键字名称(如整型为.h,单精度浮点为.m),找到有效的catch块。
  6. 执行栈展开操作,产生catch块中使用的异常对象(有4种不同的产生方法)。
  7. 正确析构所有生命周期已结束的对象。
  8. 跳转到catch块,执行catch块代码。
  9. 调用_JumpToContinuation函数,返回所有catch语句块的结束地址

参考文献

  • IEEE 754
  • x86-instructions
  • Fraction Converter
  • MMX
  • 精简指令集
  • 分支预测

你可能感兴趣的:(c++,基础知识,c++,开发语言)