PAYATHON 2026

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:

  1. 屏障可能读取它,因此屏障前的赋值必须体现在内存中。
  2. 屏障可能修改它,因此屏障后的读取不能直接沿用屏障前已知的值。

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 时,应检查最终链接产物的反汇编。检查汇编能确认这次构建中的加载和存储,但不能替代线程程序所需的语言层面保证。

备注:内容仅供参考。