volatile 全局变量与 asm volatile memory clobber 有何区别?
结论
对于题中具有外部链接的全局变量 g_flag,你对 GCC 编译器行为的判断基本正确:
- 写入后的
asm volatile("" ::: "memory")要求编译器在屏障前完成必要的内存存储,不能只把true留在寄存器里。 - 循环中的
"memory"clobber 会使屏障前读取的g_flag值失效,下一次条件检查需要重新读取。前提是每条返回条件检查的路径都经过屏障,而且循环体内没有其他能确定g_flag值的操作。
因此,“必须把这个全局变量声明为 volatile,compiler memory clobber 无法达到这种效果”的说法不成立。
不过,不能把这种效果理解为“所有寄存器中的变量都会写回,之后全部重新加载”。这些编译器约束也不能让普通变量直接用于合法的 C 线程同步。
"memory" clobber 约束了什么
对于这条语句:
__asm__ __volatile__("" ::: "memory");
GCC 必须假定这段汇编可能读取或修改它能够访问的内存。空字符串不会生成机器指令,但声明的内存副作用仍然参与优化分析。
具体到题中的 g_flag:
- 屏障可能读取它,因此屏障前的赋值必须体现在内存中。
- 屏障可能修改它,因此屏障后的读取不能直接沿用屏障前已知的值。
GCC 文档的表述是“可能需要写回特定值”和“按需重新加载”,没有要求保存所有寄存器,也没有要求在屏障后立即加载所有变量。GCC Extended Asm 文档
例如,地址从未逃逸的局部变量通常不受影响:
int example(void)
{
int x = 42;
__asm__ __volatile__("" ::: "memory");
return x;
}
编译器知道这段汇编无法访问 x,所以 GCC 可以直接返回常量 42,无需为它分配栈空间,也无需先存储再加载。
这里的“写回”指生成必要的内存存储指令,不涉及 CPU cache 清理。
题中的代码会生成什么样的汇编
去掉原代码中省略的循环体内容后,示例如下:
#include <stdbool.h>
bool g_flag = false;
void publish(void)
{
g_flag = true;
__asm__ __volatile__("" ::: "memory");
}
void wait_flag(void)
{
__asm__ __volatile__("" ::: "memory");
while (!g_flag) {
__asm__ __volatile__("" ::: "memory");
}
}
下面的 Thumb 汇编仅作示意,按一字节的 bool 展示,并非某个 GCC 版本的实测输出。实际的寄存器分配、地址装载和分支布局都可能不同。
写入端必须有实际存储
publish:
ldr r0, =g_flag
movs r1, #1
strb r1, [r0]
@ compiler memory barrier,不生成指令
bx lr
movs r1, #1 只在寄存器里准备了数值,尚未更新 g_flag。后面的 asm 声明了可能读取内存,因此编译器必须在它之前完成必要的 strb。
存储可以更早发生,不必紧贴屏障。屏障约束的是优化后的内存访问关系,并不规定固定的指令模板。
读取端的循环需要重新加载
wait_flag:
ldr r0, =g_flag
@ 循环前的 compiler memory barrier
.Lcheck:
ldrb r1, [r0]
cmp r1, #0
bne .Ldone
@ 循环体内的 compiler memory barrier
b .Lcheck
.Ldone:
bx lr
循环体内的屏障让编译器必须考虑 g_flag 可能已被修改,因此下一次检查不能沿用上一次的 r1,需要重新取得屏障之后的值。
r0 可以一直保存 g_flag 的地址,因为地址不变并不代表它指向的内容不变。
如果只在循环前放屏障:
__asm__ __volatile__("" ::: "memory");
while (!g_flag) {
}
这次屏障只能影响第一次检查。循环内部没有使已知值失效的操作,GCC 仍可能把读取提到循环外,生成“加载一次,若为零就一直空转”的结构。循环前的一次屏障不会让后续所有访问自动获得 volatile 语义。
它与 volatile 全局变量有什么区别
volatile 约束对对象的访问,"memory" clobber 则在指定的程序位置施加约束。
例如:
volatile bool g_flag = false;
void wait_flag(void)
{
while (!g_flag) {
}
}
GCC 处理这个标量访问时,每次条件求值都会读取 g_flag,无需在循环体内另放 compiler barrier。
volatile 不会让周围普通对象的读写受到同样的约束。GCC 明确说明,不能用一个 volatile 对象为其他非 volatile 内存访问建立屏障。GCC Volatiles 文档
两个 compiler barrier 之间的普通访问仍然可以合并:
int a = g_flag;
int b = g_flag;
如果编译器看不到两次读取之间有修改,就可以只加载一次,用这个值分别初始化 a 和 b。因此,屏障虽然能满足题中循环的加载需求,但它与 volatile 的语义并不完全相同。
asm volatile 中的 volatile 也需要单独理解:它约束的是 asm 语句自身的优化处理。这里对普通内存的约束来自 "memory" clobber,仅写 asm volatile("") 没有同样的效果。
用于线程通信时,应使用原子变量
即使排除了硬件屏障问题,仍需遵守 C 语言的数据竞争规则:两个线程在没有同步的情况下读写同一个普通对象,只要至少一方写入,就可能构成数据竞争并导致未定义行为。将对象改成 volatile 也不能消除这个问题。C11 委员会草案 N1570,§5.1.2.4
如果标志只表示自身状态,不负责发布其他数据,可以使用 relaxed 原子操作:
#include <stdbool.h>
#include <stdatomic.h>
static atomic_bool g_flag = ATOMIC_VAR_INIT(false);
void publish(void)
{
atomic_store_explicit(&g_flag, true, memory_order_relaxed);
}
void wait_flag(void)
{
while (!atomic_load_explicit(&g_flag, memory_order_relaxed)) {
}
}
这里无需额外添加 compiler memory clobber。memory_order_relaxed 提供对标志自身的原子访问,不为其他数据建立 acquire/release 顺序。C11 委员会草案 N1570,§7.17.3
如果还要求实现不使用内部锁,就需要针对工具链和目标确认原子操作是否 lock-free,可以用 atomic_is_lock_free(&g_flag) 查询。中断处理程序与主程序之间的通信,还应遵循具体工具链和运行环境的约定。
如何检查自己的构建结果
将最小示例保存为 flag.c,使用实际项目中的 GCC 和优化选项生成汇编,例如:
arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -std=gnu11 -S flag.c -o flag.s
检查以下几点:
- 写入端是否保留了对
g_flag的存储。 - 读取端的循环回边是否返回到加载指令之前。
- 原代码中的
continue等路径是否绕过了循环内的屏障。
启用 LTO 时,应检查最终链接产物的反汇编。检查汇编能确认这次构建中的加载和存储,但不能替代线程程序所需的语言层面保证。
备注:内容仅供参考。
