前言
这阵子在看面经,里面有一道题,C中的i和i哪个效率更高。说实话,看到这道题当场就蒙了,平时用C#写项目都是怎么高兴怎么来,到C这里还有这一说了? 不懂就看答案,答案给出的是++i性能更高,理由是i++会有一次临时变量的分配消耗,存储初始i值用来返回,而++i则直接返回i+1后的值。
嗯,看上去很有道理,但是咱也不知道到底实现是不是这样的啊,看汇编去。
C++汇编分析
环境
- C++环境:MinGW64 w64 3.4
- CMake:Bundled 3.15.3
- Debugger:MinGW-w64 GDB 7.8.1
- IDE:CLion 2019.3.3
- 汇编语法:标准的GAS AT&T语法
- 优化等级 O0(无优化)
前提分析
C只在早期的时候借助C编译器把自己翻译成汇编语言,很久之前就有了自己的编译器,所以直接反编译得到的就是C的汇编代码。
测试用例
源代码
1 2 3 4 5 6 7
| int main() { int i = 0; i++; ++i; return 0; }
|
汇编代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| Dump of assembler code for function main(): 0x0000000000401530 <+0>: push %rbp//入栈,堆栈基指针 0x0000000000401531 <+1>: mov %rsp,%rbp//建立被调用者函数的对栈框架 0x0000000000401534 <+4>: sub $0x30,%rsp//栈顶指针减去48,也就是向下扩充 0x0000000000401538 <+8>: callq 0x402100 <__main> 0x000000000040153d <+13>: movl $0x0,-0x4(%rbp)//设置栈底指针往下4位的寄存器值为0(i)
0x0000000000401544 <+20>: addl $0x1,-0x4(%rbp)//i++ => 0x0000000000401548 <+24>: addl $0x1,-0x4(%rbp)//++i
0x000000000040154c <+28>: mov $0x0,%eax//归零通用寄存器 0x0000000000401551 <+33>: add $0x30,%rsp//重置栈顶指针 0x0000000000401555 <+37>: pop %rbp//出栈 0x0000000000401556 <+38>: retq //栈顶的返回地址弹出到IP,然后按照IP此时指示的指令地址继续执行程序 End of assembler dump.
|
我们可以看到,只是两种自增写法都只是单纯的+1操作,没有任何区别。 难道是因为没有赋值对象? 我又添加了赋值对象,源代码变成这样:
1 2 3 4 5 6 7
| int main() { int i = 0, j = 0; j = i++; j = ++i; return 0; }
|
增加赋值对象后的汇编代码是这样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| Dump of assembler code for function main(): 0x0000000000401530 <+0>: push %rbp//入栈,堆栈基指针 0x0000000000401531 <+1>: mov %rsp,%rbp//建立被调用者函数的对栈框架 0x0000000000401534 <+4>: sub $0x30,%rsp//栈顶指针减去48,也就是向下扩充 0x0000000000401538 <+8>: callq 0x402110 <__main>
0x000000000040153d <+13>: movl $0x0,-0x4(%rbp)//设置栈底指针往下4位的寄存器值为0(i) 0x0000000000401544 <+20>: movl $0x0,-0x8(%rbp)//设置栈底指针往下8位的寄存器值为0(j)
//下面的ax后缀都是使用的同一个寄存器,只是高位和低位的区别 0x000000000040154b <+27>: mov -0x4(%rbp),%eax//i++:寄存器eax低32位存储当前i值 0x000000000040154e <+30>: lea 0x1(%rax),%edx//i++:寄存器rax高64位+1(也就是i+1),并把值地址赋给edx寄存器 0x0000000000401551 <+33>: mov %edx,-0x4(%rbp)//i++:把i+1赋值给i 0x0000000000401554 <+36>: mov %eax,-0x8(%rbp)//j=i++:把eax的值(原始i值)赋值给j
=> 0x0000000000401557 <+39>: addl $0x1,-0x4(%rbp)//i=i+1 0x000000000040155b <+43>: mov -0x4(%rbp),%eax//把i的值赋值给通用寄存器 0x000000000040155e <+46>: mov %eax,-0x8(%rbp)//把通用寄存器的值赋值给j
0x0000000000401561 <+49>: mov $0x0,%eax//归零通用寄存器 0x0000000000401566 <+54>: add $0x30,%rsp//重置栈顶指针 0x000000000040156a <+58>: pop %rbp//出栈 0x000000000040156b <+59>: retq //栈顶的返回地址弹出到IP,然后按照IP此时指示的指令地址继续执行程序 End of assembler dump.
|
对比可以发现,j=i++;与j=++i;差别就是,前者需要一次数据传送,一次地址赋值才能完成相对的一个ADD操作,但是后者只做了一次算术运算——add就达成了。所以硬要说差距的话,就是对比一次数据传送+地址赋值的效率是否高于一次加法运算。 但是我们也看到,第一版的代码里都是使用了add运算,所以是不是可以推断在需要使用i++/++i值的情况下确实++i的性能比i++要强
呢?
C#汇编分析
环境
- .Net Framework 4.7.2
- IDE:Rider 2019.3.3
- 汇编语法:IL
前提分析
C#源代码会被编译器编译成IL代码,运行时会被CLR(公共语言运行时)翻译成汇编语言运行。其实IL在语言的位置和汇编也差不多了,但是要比汇编高级一些,IL对汇编语言做了一些封装,就是这些封装让IL代码可读性高了很多。我们这里直接看IL代码就可以了。 也就是说,在jit编译IL到汇编的时候,是有可能再次优化的! 但是我一时间找不到看C#汇编的方法,所以我们这里直接看IL代码就可以了。(说到底还是条懒狗) 要查看一个编译好的IL代码,一般需要ILDasm.exe来对PE文件进行反编译了。 !{ILDasm界面}(https://myfirstblog.oss-cn-hangzhou.aliyuncs.com/2020/02/QQ截图20200203210854.png!webp) 但是Rider中内置了一个IL Viewer工具,可以实时查看IL代码,所以就不需要那么麻烦了(起飞)。
测试用例
一样的,先看看不赋值版本的 源代码
1 2 3 4 5 6
| static void Main(string[] args) { int i = 0; i++; ++i; }
|
IL代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| .class private auto ansi beforefieldinit Program extends [mscorlib]System.Object {
.method private hidebysig static void Main( string[] args ) cil managed { .entrypoint .maxstack 2 .locals init ( [0] int32 i//初始化内存快 )
IL_0000: nop
IL_0001: ldc.i4.0 IL_0002: stloc.0
IL_0003: ldloc.0 IL_0004: ldc.i4.1 IL_0005: add IL_0006: stloc.0
IL_0007: ldloc.0 IL_0008: ldc.i4.1 IL_0009: add IL_000a: stloc.0
IL_000b: ret
}
.method public hidebysig specialname rtspecialname instance void .ctor() cil managed { .maxstack 8
IL_0000: ldarg.0 IL_0001: call instance void [mscorlib]System.Object::.ctor() IL_0006: nop IL_0007: ret
} }
|
可以看到对于i++/++i生成的IL代码连顺序都没有变。。。 再看看有赋值类型的 C#源代码
1 2 3 4 5 6
| static void Main(string[] args) { int i = 0, j = 0; j = i++; j = ++i; }
|
IL代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| .class private auto ansi beforefieldinit Program extends [mscorlib]System.Object {
.method private hidebysig static void Main( string[] args ) cil managed { .entrypoint .maxstack 3 .locals init (//初始化内存快 [0] int32 i, [1] int32 j ) // // [8 5 - 8 6] IL_0000: nop // // [9 9 - 9 18] IL_0001: ldc.i4.0 IL_0002: stloc.0 // i // // [9 20 - 9 25] IL_0003: ldc.i4.0 IL_0004: stloc.1 // j // // [10 9 - 10 17] IL_0005: ldloc.0 // i IL_0006: dup//复制计算堆栈上当前最顶端的值,然后将副本推送到计算堆栈上 IL_0007: ldc.i4.1 IL_0008: add IL_0009: stloc.0 // i IL_000a: stloc.1 // j // // [11 9 - 11 17] IL_000b: ldloc.0 // i IL_000c: ldc.i4.1 IL_000d: add IL_000e: dup IL_000f: stloc.0 // i IL_0010: stloc.1 // j // // [12 5 - 12 6] IL_0011: ret // } // end of method Program::Main // .method public hidebysig specialname rtspecialname instance void .ctor() cil managed { .maxstack 8
IL_0000: ldarg.0 // this IL_0001: call instance void [mscorlib]System.Object::.ctor() IL_0006: nop IL_0007: ret
} // end of method Program::.ctor } // end of class Program
|
可以看到IL层的C#对于i++/i这种自增操作一视同仁,基本就可以得出C#i与++i性能没有差异。