在现代 C++ 中,是否仍应使用 volatile 与 ISR 共享数据?

Should volatile still be used for sharing data with ISRs in modern C++?

我看到了这些问题的一些变体,我看到了混合的答案,仍然不确定它们是否是最新的并且是否完全适用于我的用例,所以我会在这里问。如果它是重复的,请告诉我!

考虑到我正在使用 C++17 和 gcc-arm-none-eabi-9 工具链为 STM32 微控制器(裸机)开发:

我还需要使用 volatile 在 ISR 和 main() 之间共享数据吗?

volatile std::int32_t flag = 0;

extern "C" void ISR()
{
    flag = 1;
}

int main()
{
    while (!flag) { ... }
}

我很清楚,我应该始终使用 volatile 来访问内存映射硬件寄存器。

但是对于 ISR 用例,我不知道它是否可以被视为“多线程”的情况。在这种情况下,人们建议使用 C++11 的新线程功能(例如 std::atomic)。我知道 volatile(不优化)和 atomic(安全访问)之间的区别,所以建议 std::atomic 的答案在这里让我感到困惑。

对于 x86 系统上“真正的”多线程的情况,我还没有看到需要使用 volatile

换句话说:编译器可以知道flag可以在ISR内部改变吗?如果不是,它如何在常规多线程应用程序中知道它?

谢谢!

我认为在这种情况下,volatile 和 atomic 很可能在 32 位 ARM 上实际工作。至少在旧版本的 STM32 工具中,我看到实际上 C 原子是使用 volatile 实现的。

Volatile 可以工作,因为编译器可能不会优化掉对代码中出现的变量的任何访问。

但是,对于无法在单个指令中加载的类型,生成的代码必须不同。如果你使用 volatile int64_t,编译器会愉快地在两个单独的指令中加载它。如果 ISR 在加载变量的两半之间运行,您将加载旧值的一半和新值的一半。

不幸的是,如果实现不是无锁的,使用 atomic<int64_t> 也可能会因中断服务例程而失败。对于Cortex-M,64位访问不一定是lockfree的,所以不检查实现就不要依赖atomic。根据实现的不同,如果锁定机制不可重入并且在持有锁时发生中断,系统可能会死锁。从 C++17 开始,这可以通过检查 atomic<T>::is_always_lock_free. A specific answer for a specific atomic variable (this may depend on alignment) may be obtained by checking flagA.is_lock_free() since C++11.

来查询

因此必须通过单独的机制保护更长的数据(例如通过关闭访问周围的中断并使变量成为原子或易失性变量。

所以正确的方法是使用std::atomic,只要访问是无锁的。如果您关心性能,select 适当的内存顺序可能会有所回报,并坚持可以在单个指令中加载的值。

不使用任何一个都是错误的,compiler 只会检查标志一次。

这些函数都等待标志,但它们的翻译不同:

#include <atomic>
#include <cstdint>

using FlagT = std::int32_t;

volatile FlagT flag = 0;
void waitV()
{
    while (!flag) {}
}

std::atomic<FlagT> flagA;
void waitA()
{
    while(!flagA) {}    
}

void waitRelaxed()
{
    while(!flagA.load(std::memory_order_relaxed)) {}    
}

FlagT wrongFlag;
void waitWrong()
{
    while(!wrongFlag) {}
}

使用 volatile 你会得到一个循环,可以根据需要重新检查标志:

waitV():
        ldr     r2, .L5
.L2:
        ldr     r3, [r2]
        cmp     r3, #0
        beq     .L2
        bx      lr
.L5:
        .word   .LANCHOR0

具有默认顺序一致访问的原子会产生同步访问:

waitA():
        push    {r4, lr}
.L8:
        bl      __sync_synchronize
        ldr     r3, .L11
        ldr     r4, [r3, #4]
        bl      __sync_synchronize
        cmp     r4, #0
        beq     .L8
        pop     {r4}
        pop     {r0}
        bx      r0
.L11:
        .word   .LANCHOR0

如果您不关心内存顺序,您会得到一个与 volatile 一样的工作循环:

waitRelaxed():
        ldr     r2, .L17
.L14:
        ldr     r3, [r2, #4]
        cmp     r3, #0
        beq     .L14
        bx      lr
.L17:
        .word   .LANCHOR0

在启用优化的情况下,既不使用 volatile 也不使用 atomic 会让你感到厌烦,因为标志只检查一次:

waitWrong():
        ldr     r3, .L24
        ldr     r3, [r3, #8]
        cmp     r3, #0
        bne     .L23
.L22:                        // infinite loop!
        b       .L22
.L23:
        bx      lr
.L24:
        .word   .LANCHOR0
flag:
flagA:
wrongFlag:

在我测试过的不基于 gcc 或 clang 的商业编译器中,它们都将通过 volatile 指针或左值读取或写入视为能够访问任何其他对象,不考虑指针或左值是否有可能击中相关对象。一些,例如 MSVC,正式记录了易失性写入具有释放语义和易失性读取具有获取语义的事实,而其他人则需要 read/write 对来实现获取语义。

这样的语义使得使用 volatile 对象构建互斥体成为可能,该互斥体可以在具有强内存模型(包括 single-core 具有中断的系统)的系统上保护“普通”对象,或者在在硬件内存排序级别应用 acquire/release 障碍而不仅仅是编译器排序级别的编译器。

然而,除了 -O0 之外,clang 或 gcc 都没有提供任何可以提供这种语义的选项,因为它们会阻碍“优化”,否则可以转换执行 seemingly-redundant 的代码将 [正确操作实际需要的] 加载和存储到“更有效”的代码 [不起作用]。为了使一个人的代码可以与这些一起使用,我建议定义一个 'memory clobber' 宏(对于 clang 或 gcc 将是 asm volatile ("" ::: "memory");)并在需要先于 volatile 写入的操作和写入本身之间调用它,或者在 volatile 读取和需要跟随它的第一个操作之间。如果这样做,只需将宏定义为空扩展,就可以使代码轻松适应既不支持也不需要此类障碍的实现。

请注意,虽然一些编译器将所有 asm 指令解释为内存破坏,并且空的 asm 指令没有任何其他目的,但 gcc 只是忽略空的 asm 指令,而不是将它们解释为这样时尚。

gcc 的优化被证明有问题的情况示例(clang 似乎正确处理了这种特殊情况,但其他一些情况仍然存在问题):

short buffer[10];
volatile short volatile *tx_ptr;
volatile int tx_count;
void test(void)
{
    buffer[0] = 1;
    tx_ptr = buffer;
    tx_count = 1;
    while(tx_count)
        ;
    buffer[0] = 2;
    tx_ptr = buffer;
    tx_count = 1;
    while(tx_count)
        ;
}

GCC 将决定优化分配 buffer[0]=1; 因为标准不要求它认识到将缓冲区的地址存储到 volatile 可能会产生与值交互的副作用存储在那里。

[编辑:进一步的实验表明 icc 将重新排序对 volatile 对象的访问,但由于它重新排序它们 即使相对于彼此 ,我不是确定该怎么做,因为对标准的任何可以想象的解释似乎都会破坏它。

要理解这个问题,您必须首先理解为什么首先需要 volatile

这里有三个完全不同的问题:

  1. 不正确的优化,因为编译器没有意识到实际上调用了 ISR 等硬件回调。

    解决方案:volatile或编译器意识。

  2. Re-entrancy 和竞争条件错误,这是由于在多个指令中访问一个变量并在其中被使用相同变量的 ISR 中断而导致的。

    解决方案:使用互斥锁进行保护或原子访问,_Atomic,禁用中断等

  3. 指令 re-ordering、multi-core 执行、分支预测导致的并行性或 pre-fetch 缓存错误。

    解决方案:内存屏障或 allocation/execution 未缓存的内存区域。 volatile 访问在某些系统上可能会或可能不会充当内存屏障。

只要有人提出这种 SO 问题,你总会看到很多 PC 程序员喋喋不休地谈论 2 和 3,而对 1 却一无所知。这是因为他们一生中从未编写过 ISR multi-threading 的 PC 编译器通常知道线程回调将被执行,因此这在 PC 程序中通常不是问题。

你需要做什么来解决 1) 在你的情况下,是查看编译器是否实际生成用于读取 while (!flag) 的代码,启用或不启用优化。拆机检查

理想情况下,编译器文档会告诉编译器理解某些 compiler-specific 扩展的含义,例如 non-standard 关键字 interrupt 并且在发现它时不对该函数做出任何假设接到电话。

遗憾的是,大多数编译器仅使用 interrupt 等关键字来生成正确的调用约定和 return 指令。就在几周前,我最近在帮助 SE 站点上的某人时遇到了 volatile 错误,他们正在使用现代 ARM 工具链。所以我不相信编译器在 2020 年仍然可以处理这个问题,除非他们明确记录它。如有疑问,请使用 volatile.

关于2)和re-entrancy,现在现代编译器确实支持_Atomic,这让事情变得非常简单。如果它在您的编译器上可用且可靠,请使用它。否则,对于大多数裸机系统,只要没有指令 re-ordering,您就可以利用中断是 non-interruptable 的事实并使用普通 bool 作为“mutex lite”(example) ](对于大多数 MCU 来说不太可能)。

但是注意 2) 是一个独立的问题,与 volatile 无关。 volatile 不解决 thread-safe 访问。 Thread-safe 访问不解决不正确的优化。所以不要把这两个不相关的概念混为一谈,就像在 SO 上经常看到的那样。

简答:始终使用 std::atomic<T>is_lock_free() returns true.

推理:

  1. volatile 可以在 STM32F2 或 ATSAMG55 等简单架构(single-core,无缓存,ARM/Cortex-M)上可靠地工作,例如IAR 编译器。但是...
  2. 它可能无法在更复杂的架构(multi-core 带缓存)和编译器尝试进行某些优化时按预期工作(其他答案中的许多示例,不再重复)。
  3. atomic_flagatomic_int(如果 is_lock_free() 他们应该)在任何地方都可以安全使用,因为它们 像 volatile 一样工作内存障碍/需要时同步(避免前一点的问题)。
  4. 我特别说你必须只使用那些 is_lock_free()true 的原因是你不能像停止线程那样停止 IRQ。不,IRQ 会中断主循环并完成它的工作,它不能 wait-lock 在互斥锁上,因为它阻塞了它正在等待的主循环。

实用说明:我个人要么使用 atomic_flag(唯一保证有效的方法)来实现某种 spin-lock,其中 ISR 将在发现锁被锁定时自行禁用,而主循环解锁后总是 re-enable ISR。或者我使用 double-counter lock-free 队列(SPSC - 单一生产者,单一消费者)使用 atomit_int。 (有一个 reader-counter 和一个 writer-counter,减去以找到实际计数。适用于 UART 等。)