在编译优化角度分析volatile关键字-一个有趣的案例

和学弟讨论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点)的一致性, 具体包括:

  1. 对volatile关键字修饰变量的访问需要严格执行
  2. 程序结束时, 写入文件的数据需要与严格执行时一致
  3. 交互式设备的输入输出(stdio.h)需要与严格执行时一致
    “可观测行为”刻画的是从外部视角看C程序对外界的影响, 例如, 第2点要求那些没有实时性的外部操作在最后”看起来一致”, 第3点要求那些有实时性的外部操作在执行过程中”看起来一致”.
    因此, 只要优化后仍然满足程序可观测行为的一致性, 这种优化都是”正确”的. 在这个条件下, 如果优化后的程序变量更少, 或者语句更少, 可以预期程序的性能表现就会更优.

在编译优化角度分析volatile关键字-一个有趣的案例
http://example.com/2025/09/01/在编译优化角度分析volatile关键字-一个有趣的案例/
作者
lethe
发布于
2025年9月1日
许可协议