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

问题描述

我已经看到了这些问题的一些风格,并且看到了各种各样的答案,但仍不确定它们是否是最新的并且完全适用于我的用例,因此我将在这里提出。让我知道它是否重复!

鉴于我正在为使用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位访问不一定是无锁的,因此在不检查实现的情况下不应该依赖原子。根据实现方式的不同,如果锁定机制不重入并且在保持锁定的同时发生中断,则系统可能会死锁。从C ++ 17开始,可以通过检查atomic<T>::is_always_lock_free来查询。自C ++ 11起,可以通过检查flagA.is_lock_free()获得特定原子变量的特定答案(这可能取决于对齐方式)。

因此,必须通过一种单独的机制来保护更长的数据(例如,通过关闭访问周围的中断并使变量成为atomic或volatile)。

因此,正确的方法是使用std::atomic,只要访问是无锁的即可。如果您担心性能,可能会选择合适的内存顺序并坚持可以在一条指令中加载的值。

任何一个都不使用是错误的,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,#8]
        cmp     r3,#0
        bne     .L23
.L22:                        // infinite loop!
        b       .L22
.L23:
        bx      lr
.L24:
        .word   .LANCHOR0
flag:
flagA:
wrongFlag:
,

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

这种语义使得可以使用volatile对象来构建互斥体,该互斥体可以保护具有强大内存模型的系统(包括具有中断的单核系统)上的“普通”对象,或应用于应用/ release障碍在硬件内存订购级别上,而不仅仅是在编译器订购级别上。

clang或gcc都没有提供-O0之外的任何选项,它们会提供这种语义,因为它们会阻止“优化”,否则它们将能够转换执行看似冗余的加载和存储的代码[正确操作所需的代码]转换为“更有效”的代码[无效]。为了使自己的代码可以使用这些代码,我建议定义一个“内存破坏者”宏(对于clang或gcc,该宏为asm volatile ("" ::: "memory");),并在需要先执行易失性写操作的操作与写操作之间调用它,或在易失性读取和需要执行的第一个操作之间。如果这样做的话,只需将宏定义为空扩展,就可以使自己的代码易于适应既不支持也不要求这种障碍的实现。

请注意,尽管某些编译器将所有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中可能会产生副作用,该副作用可能会与存储在其中的值发生相互作用

[edit:进一步的实验表明icc将对volatile对象的访问进行重新排序,但是由于即使彼此相对也对它们进行重新排序,所以我不确定该如何处理,因为对标准的任何可想象的解释似乎都破坏了它。]

,

要了解该问题,您必须首先了解为什么首先需要volatile

这里有三个完全独立的问题

  1. 错误的优化,因为编译器没有意识到实际上调用了ISR之类的硬件回调。

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

  2. 由于在多个指令中访问变量,并且由于使用同一变量的ISR在中间中断而导致重新进入和竞争条件错误。

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

  3. 由指令重新排序,多核执行,分支预测引起的并行或预取缓存错误。

    解决方案:未缓存的内存区域中的内存障碍或分配/执行。 volatile访问在某些系统上可能会或可能不会成为内存屏障。

一旦有人提出这样的SO问题,您总是会得到很多PC程序员在不了解或不了解1的情况下不休地学习2和3。这是因为他们一生中从未编写过ISR和PC具有多线程的编译器通常都知道将执行线程回调,因此在PC程序中这通常不是问题。

您需要做的解决1)在您的情况下,是查看编译器是否实际生成了用于读取while (!flag)的代码,无论是否启用了优化。拆卸并检查。

理想情况下,编译器文档将告诉编译器了解某些特定于编译器的扩展的含义,例如非标准关键字interrupt,并在发现该扩展时不假设未调用该函数。

可惜的是,大多数编译器仅使用interrupt等关键字来生成正确的调用约定并返回指令。几周前,我在遇到SE网站上的某人并且正在使用现代ARM工具链时遇到了volatile缺失的错误。因此,我不相信编译器在2020年之前仍然能够处理此问题,除非他们明确记录了它。如有疑问,请使用volatile

关于2)和重新进入,当今的现代编译器确实支持_Atomic,这使事情变得非常容易。使用它是编译器上可用且可靠的。否则,对于大多数裸机系统,只要不对指令进行重新排序,就可以利用中断是不可中断的事实,并使用普通布尔变量作为“ mutex lite”(example)。对于大多数MCU)。

但是注意2)是与volatile无关的单独问题。 volatile不能解决线程安全的访问。线程安全访问不能解决错误的优化。因此,不要像在SO上经常看到的那样,将这两个不相关的概念混在一起。

,

简短的回答:始终使用std::atomic<T>返回is_lock_free()的{​​{1}}。

理由:

  1. true可以在诸如STM32F2或ATSAMG55之类的简单架构(单核,无缓存,ARM / Cortex-M)上可靠地工作。 IAR编译器。但是...
  2. 在更复杂的体系结构(带缓存的多核)上,以及在编译器尝试进行某些优化时(其他答案中的许多示例,将不再重复),它可能无法按预期工作。
  3. volatileatomic_flag(如果应该使用atomic_int(如果应该使用is_lock_free())是安全的,因为它们就像volatile 一样工作,并增加了内存屏障/同步功能需要(避免前面提到的问题)。
  4. 我专门说您只需要使用is_lock_free()true的那些原因是因为您无法停止IRQ,因为您可以停止线程。不,IRQ会中断主循环并执行其工作,它无法在互斥锁上等待锁定,因为它阻塞了正在等待的主循环。

实用说明:我个人使用atomic_flag(唯一可以保证工作的一种)来实现自旋锁,其中ISR在找到被锁定的锁时会禁用自身,而主循环始终会重新解锁后启用ISR。或者,我使用使用atomit_int的双计数器无锁队列(SPSC-单生产者,单消费者)。 (有一个读卡器计数器和一个写卡器计数器,相减即可找到真实的计数。适用于UART等。)