和学弟讨论volatile关键字时提到的一个有意思的案例,汇编视角分析编译优化的不同影响
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| void fun() { extern unsigned char _end; unsigned char *p = &_end; *p = 0x0; while(*p != 0xff); *p = 0x33; *p = 0x34; *p = 0x86; }
int main(void){ fun(); return 0; }
|
情况1:*p = 0x0,无volatile,-O2优化
我们使用-O2优化运行代码,不出所料程序卡死在fun()的死循环中。
笔者比较熟悉RISC-V汇编,将其交叉编译后进行反汇编。
1 2 3
| > riscv64-linux-gnu-gcc -O2 ./fun.c -o fun_rv > riscv64-linux-gnu-objdump -d ./fun_rv | code - Reading from stdin via: /tmp/code-stdin-syc
|
汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 00000000000005a0 <main>: 5a0: 00002797 auipc a5,0x2 5a4: a907b783 ld a5,-1392(a5) # 2030 <_GLOBAL_OFFSET_TABLE_+0x10> 5a8: 00078023 sb zero,0(a5) 5ac: a001 j 5ac <main+0xc>
0000000000000668 <fun>: # 取p的地址 668: 00002797 auipc a5,0x2 66c: 9c87b783 ld a5,-1592(a5) # 2030 <_GLOBAL_OFFSET_TABLE_+0x10> # 将0x0写入p对应的地址 670: 00078023 sb zero,0(a5) # 死循环 674: a001 j 674 <fun+0xc>
|
编译器认为 while(*p != 0xff); 的结果 可在编译期推导:*p 已经被写死为 0x0,所以循环条件永远为真。
可以看到原C代码中while(*p != 0xff)
,在汇编中并未读取有关p的值就进行循环,而且优化后的程序只对p对应地址写入了一次,优化掉了while后的三次写入操作。同时在main和fun中没有函数返回(ret)的操作。
这就是为什么不加 volatile,编译器会把内存访问假设为“寄存器中的确定值”,导致代码和预期完全不同。
情况2:*p = 0x0,有volatile,-O2优化
我们将unsigned char *p = &_end;
前加上volatile
,-02优化,重新编译运行。这一次程序也卡死在fun()的死循环中。
我们相同操作进行反汇编。
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
| 00000000000005a0 <main>: # 取p的地址 5a0: 00002717 auipc a4,0x2 5a4: a9073703 ld a4,-1392(a4) # 2030 <_GLOBAL_OFFSET_TABLE_+0x10> # 将0x0写入p对应地址 5a8: 00070023 sb zero,0(a4) # 将p地址处对应的值写入a4寄存器中,将立即数0xff写入a3寄存器中,并比较两者的值,若不相等即循环读p地址的值比较 # 就是这里的while(*p != 0xff); 5ac: 0ff00693 li a3,255 5b0: 00074783 lbu a5,0(a4) 5b4: fed79ee3 bne a5,a3,5b0 <main+0x10> # 将0x33写入p对应地址 5b8: 03300793 li a5,51 5bc: 00f70023 sb a5,0(a4) # 将0x34写入p对应地址 5c0: 03400793 li a5,52 5c4: 00f70023 sb a5,0(a4) # 将0x86写入p对应地址 5c8: f8600793 li a5,-122 5cc: 00f70023 sb a5,0(a4) # 函数返回值为0(RV32调用约定) 5d0: 4501 li a0,0 # 函数返回 5d2: 8082 ret
# 函数内联导致的重复 000000000000068c <fun>: 68c: 00002717 auipc a4,0x2 690: 9a473703 ld a4,-1628(a4) # 2030 <_GLOBAL_OFFSET_TABLE_+0x10> 694: 00070023 sb zero,0(a4) 698: 0ff00693 li a3,255 69c: 00074783 lbu a5,0(a4) 6a0: fed79ee3 bne a5,a3,69c <fun+0x10> 6a4: 03300793 li a5,51 6a8: 00f70023 sb a5,0(a4) 6ac: 03400793 li a5,52 6b0: 00f70023 sb a5,0(a4) 6b4: f8600793 li a5,-122 6b8: 00f70023 sb a5,0(a4) 6bc: 8082 ret
|
在这里值得注意的是,编译器将fun内联进了main里,做了函数内联优化,故在main里进行了fun的操作而不存在函数调用。而同时fun函数符号又存在于ELF文件当中,因此也存在于反汇编结果之中。
编译器可能会将非易失性变量的值存储在 CPU 寄存器中,以便更快地访问,避免重复从内存读取。对于volatile
变量,编译器每次访问时都会强制从内存读取该值。
我们可以看到,在C源代码中每一次对p的读取/写入操作都没有被编译器优化。
情况3:*p = 0xff,无volatile,-O2优化
我们将volatile unsigned char *p = &_end;
的volatile
删去,*p = 0x0;
改为*p = 0xff;
,-02优化,重新编译运行。程序现在可以正常退出。
我们进行反汇编查看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 00000000000005a0 <main>: # 取p的地址 5a0: 00002797 auipc a5,0x2 5a4: a907b783 ld a5,-1392(a5) # 2030 <_GLOBAL_OFFSET_TABLE_+0x10> # 将0x86写入p对应地址 5a8: f8600713 li a4,-122 5ac: 00e78023 sb a4,0(a5) # 函数返回值为0(RV32调用约定) 5b0: 4501 li a0,0 # 函数返回 5b2: 8082 ret
# 函数内联导致的重复 000000000000066c <fun>: 66c: 00002797 auipc a5,0x2 670: 9c47b783 ld a5,-1596(a5) # 2030 <_GLOBAL_OFFSET_TABLE_+0x10> 674: f8600713 li a4,-122 678: 00e78023 sb a4,0(a5) 67c: 8082 ret
|
在这里编译器将C源代码中从*p = 0x0;
到*p = 0x34;
这一段代码全部优化掉了,是因为在这里while循环和对p的多次写入无意义,编译器可以直接优化成最后的*p = 0x86;
。(死代码消除与值传播)
总结:编译优化
我们可以从程序行为的角度来理解编译优化: 如果两个程序在某种意义上”一致”, 就可以用”简单”的替代”复杂”的. 其中, 遵循C语言标准逐条语句执行的行为称为”严格执行”. 以”严格执行”为基准, C语言标准对上文的”一致”作了严谨的定义, 即优化后的程序应满足”程序可观测行为”(C99标准手册5.1.2.3节第6点)的一致性, 具体包括:
- 对volatile关键字修饰变量的访问需要严格执行
- 程序结束时, 写入文件的数据需要与严格执行时一致
- 交互式设备的输入输出(stdio.h)需要与严格执行时一致
“可观测行为”刻画的是从外部视角看C程序对外界的影响, 例如, 第2点要求那些没有实时性的外部操作在最后”看起来一致”, 第3点要求那些有实时性的外部操作在执行过程中”看起来一致”.
因此, 只要优化后仍然满足程序可观测行为的一致性, 这种优化都是”正确”的. 在这个条件下, 如果优化后的程序变量更少, 或者语句更少, 可以预期程序的性能表现就会更优.