摘要:本文主要描述x86_64机器中C++代码在汇编中的具体代码。
关键字:cpp,IA32,asm
注意:本书假定你拥有基本的C++软件开发能力,能够理解基本的C++代码。并且熟悉汇编代码,了解基本的取址模式并且熟悉IA32指令集(文中会对IA32的部分指令集进行描述,但是不会过于详细的深入)。
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
无符号整数
无符号数都是直接存储在内存中,唯一需要注意的是不同机器的存储方式不同。
- 大端:高位存储高位(Windows,Linux);
- 小端:高位存储低位(OSX);
有符号整数
有符号数和无符号数的区别是,有符号数的最高位表示当前数正/负。无符号数和有符号数能够表示的数值范围大小相同,只是具体能够表示的范围不同。如果数值为正数,则代码中的数值和无符号数无区别;如果为负数,则代码中的数值存储是按照补码存储(起始正数也是补码,不过正数的补码是其自身,这样做是为了方便利用加法计算减法)。
补码:数的所有位取反+1。
单精度浮点类型和双精度浮点类型
浮点类型在内存中表示是按照IEEE 754标准存储的,float
和double
而这表示方式差不多,只是表示的范围有差异。因为按照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 +1127−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。可以看到实际存储的数值是有精度误差的,这也是为什么浮点不能==
。
另外能够注意到代码中用到的不是一般的通用寄存器而是mmx寄存器。这是因为MMX是在最初的浮点寄存器ST上扩展而来。
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)
指针
地址就是进程地址空间中的一个索引,而指针变量,就是存储一块地址内容的变量。所以其重点是其本身是一个变量,只不过存储的内容是一个地址索引而已。一个指针的大小根据系统位数不同而不同,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
可参考程序员自我修养阅读笔记——运行库
加法,减法和乘法,自增自减
加法,减法和乘法都有对应的计算指令,比如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
除法
除法也有对应的汇编指令div
和idiv
实现,但是除法相对于其他操作指令周期更长,效率更低,因此编译器会尽可能尝试用其他指令优化当前的除法操作。
C++中的整数除法的结果依然是整数,其结果是向0求整(比如3.5求整为3,-3.5求整为-3)。编译器会对不同的除数进行不同的优化:
n
是固定的(32或者64)因此可以在与编译期计算出来;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=c2n→c=M2n=0xaaaaaaab232=2.9999999996507540345598531381041≈3
有符号整数的触发的魔数刚好为无符号的一半。
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次方取余的方式有很多:
k
位的亦或运算计算;非2次幂,则 x % a = x − a b ∗ b x\% a = x - \frac{a}{b} * b x%a=x−ba∗b。
实验三
#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
C++中关系运算符是计算一个表达式的结果然后配合cmp
和test
检查表达式结果,最终利用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);
}
书上将条件表达式分为了四种情况,但是从下面的反汇编中能够看到只有两种情况:
下面的反汇编注释的比较清楚,唯一需要注意的是有变量情况下创建的临时变量。
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
时就会出现下面的情况。可以看到代码大大简化了:
jmp
指令而是尝试cmov指令优化;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)
代码优化:利用现有的资源对已有的程序进行实现上的改变以达到优化内存/执行速度等上的目的。代码优化前后的程序行为是等价的,即优化行为只影响执行速度等指标,不影响实际结果。
编译器将一个程序编译成二进制文件的过程是:预处理->词法分析->语法分析->语义分析->中间代码生成->目标代码生成(后端实现中省略了部分节点,比如指令重拍,寄存器选择等)。这里只会记录书中记录的部分执行速度优化的一部分内容(Intel处理器为参考)。
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()
;一条指令被CPU执行会经历:取指、译码、访存(访存分为读地址和取值两部分)、执行、写回(将计算的结果写回到对应地址或者寄存器)。其中取指、译码、执行是每个指令都需要的,比如nop指令。流水线就是将上述的多个步骤错开执行提升CPU的指令运行吞吐率。比如在执行第一条的译码工作时就可以尝试读取下一条指令准备译码。
指令流水线的实现有两种方式:
影响流水线并行度的几个因素:
数据局对也会影响性能,对齐后的数据能够一次定位到,而没有对齐的数据可能需要多次访问。
流程控制是程序的重要组成部分,而流程控制是通过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...else
和if...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
switch
语句是一种多分支结构,其结构相比于if
更加复杂。编译器针对不同类型的switch
语句进行针对性的优化:
switch
语句中的选项数量小于等于3时,会生成类似if...elseif
的代码;switch
语句中的选项数量大于3且选项之间有明显的的线性关系时,编译器会生成一个跳转表来表示switch
;switch
语句中的选项数量大于3小于256且无法构成明显的线性关系时,编译器会生成两个表格,第一个表格存储跳转表的索引,第二个表格存储跳转表。程序执行时先通过当前值索引第一个表格得到跳转表索引,再通过该索引找到跳转地址(实测发现clang
的优化策略和cl
优化策略不同);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;
}
我们一步一步分析下面的反汇编:
type
减去选项的最小值并将这个值存到栈上一个临时变量中,然后和选项最大值和最小值只差作差;default
的代码块,否则继续执行;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
指令被替换成了dec
和cmp
。0x0040115d
这条指令存储的不是代码块的跳转表,而是字符串的表格,因为我们这里的代码比较简单全是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
来做反汇编测试。
type - 3
,和之前的流程相同,之后将计算后的值和
57(0x39)比较,大于则跳转调用
default```的代码; 代码块我们就不看了,比较简单。我们直接看跳转表。跳转表索引中有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;
}
如下面的反汇编所示,编译器会根据当前已经有的值生成判定树,不断判断跳转,直至跳转到对应的代码块。下面是对应代码判定树的结构:
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,太麻烦了。
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
}
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
}
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
}
函数调用是通过栈实现的,每个函数都有自己的栈帧。栈帧中保存了当前函数调用过程中的函数参数,局部变量,函数的返回地址等内容。每一次函数被调用都会生成当前函数的栈帧,函数返回后会进行栈平衡,即返栈空间。因为栈空间是有限的的,所以我们无法无限递归调用某个函数这会导致爆栈。
调用函数时需要申请栈,函数调用结束时需要平衡栈。具体由哪一方负责做这个事情?根据不同的调用约定,函数入参的顺序和栈的维护方不同:
_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;
}
根据下面的反汇编这里描述下一个完整的函数调用过程:
push ebp
,当前ebp
的值为被调用方的栈底,用于平衡栈;esp
;ebp
;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
函数调用过程中参数传递、局部变量的创建都是通过栈或者寄存器传递。
局部变量都是栈上的一块内存,通常可以通过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
全局变量和静态变量
在了解全局变量和静态变量的存储方式之前,应该了解可执行文件的基本格式。无论是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
}
堆内存时通过new
或者malloc
申请的,区别是new
会构造对象,malloc
不会。销毁的调用free
和delete
同理。
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
数组就是一些列相同大小的元素的集合,通常通过首地址+偏移计算需要访问的具体元素。在反汇编中数组的访问就是通过地址偏移进行访问。当数组作为函数参数时,数组会退化为指针,因此传入的参数实际就是数组的首地址。另外需要注意的是反汇编中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
}
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
}
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
}
类对象的传参与返回相比于普通的变量传参要复杂一点,主要是类对象一般都比较大,很难用几个寄存器传值。
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
}
C++中的类的初始化是在构造函数中完成的,当类被实例化就会调用构造函数。而析构函数是对象被销毁时销毁类对象的。C++中调用构造函数和析构函数的时机有:
new
创建堆对象时就会调用构造函数,反之用delete
析构对象时就会调用析构函数;main
之前被初始化,退出之前被析构;另外需要注意几个点,一,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
}
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;
}
建议直接看C++对象模型更直观。
识别异常处理:
eax
设置为FuncInfo
数据的地址,然后跳往___CxxFrameHandler
。__CxxThrowException
函数完成,该函数使用了两个参数,一个是抛出异常的关键字throw
的参数指针,另一个是抛出信息类型的指针(ThrowInfo*
)。ThrowInfo
数据的地址以及FunInfo
表结构的地址。根据记录的异常类型,进行try
块的匹配工作。try
块,则析构异常对象,返回ExceptionContinueSearch
,继续下一个异常回调函数的处理。try
块时,通过TryBlockMapEntry
表结构中的pCatch
指向catch
信息表,用ThrowInfo
表结构中的异常类型遍历查找与之匹配的catch
块,比较关键字名称(如整型为.h
,单精度浮点为.m
),找到有效的catch
块。catch
块中使用的异常对象(有4种不同的产生方法)。catch
块,执行catch
块代码。_JumpToContinuation
函数,返回所有catch语句块的结束地址