在现代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来访问内存映射的HW寄存器。

然而,对于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来查询。对于特定原子变量的特定答案(这可能取决于对齐)可以通过检查CCD_ 13来获得,因为C++11。

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

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

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

这些函数都在等待一个标志,但它们的翻译方式不同:

#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

默认顺序一致访问的Atomic产生同步访问:

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:

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

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

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

    解决方案:volatile或编译器感知。

  2. 由于访问多条指令中的一个变量,并在访问过程中被使用同一变量的ISR中断而导致的重复性和竞争条件错误。

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

  3. 指令重新排序、多核执行、分支预测导致的并行性或预取缓存错误。

    解决方案:内存障碍或未缓存的内存区域中的分配/执行。CCD_ 18访问在一些系统上可以充当也可以不充当存储器屏障。

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

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

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

遗憾的是,大多数编译器只使用interruptetc关键字来生成正确的调用约定和返回指令。就在几周前,我最近在帮助SE网站上的某个人时遇到了丢失的volatile错误,当时他们正在使用现代ARM工具链。因此,我不相信编译器在2020年还能处理这个问题,除非他们明确记录下来。如果有疑问,请使用volatile

关于2)和可重入性,现代编译器现在确实支持_Atomic,这使得事情变得非常容易。如果它在编译器上可用且可靠,请使用它。否则,对于大多数裸金属系统,您可以利用中断是不可中断的这一事实,并使用普通bool作为"中断";mutex lite";(例如),只要没有指令重新排序(对于大多数MCU来说,这种情况不太可能)。

注意,2)是一个与volatile无关的单独问题。volatile不能解决线程安全访问问题。线程安全访问不会解决不正确的优化。所以,不要把这两个不相关的概念混在一起,就像在So.上经常看到的那样

在我测试过的不是基于gcc或clang的商业编译器中,所有这些编译器都会将通过volatile指针或左值进行的读取或写入视为能够访问任何其他对象,而不考虑指针或左值是否有可能命中有问题的对象。一些,如MSVC,正式记录了volatile写具有发布语义,volatile读具有获取语义的事实,而另一些则需要读/写对来实现获取语义。

这样的语义使得可以使用CCD_ 28对象来构建一个互斥体;普通的";具有强内存模型的系统(包括具有中断的单核系统)上的对象,或者在硬件内存排序级别而不仅仅是编译器排序级别应用获取/释放屏障的编译器上。

然而,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中可能会产生与存储在那里的值交互的副作用。

[编辑:进一步的实验表明,icc会对volatile对象的访问进行重新排序,但由于它会对它们进行重新排序,甚至相对于彼此,我不知道该怎么做,因为这似乎会被任何可以想象的标准解释所打破]。

简短回答:始终使用std::atomic<T>,其is_lock_free()返回true

推理:

  1. volatile可以在简单的架构(单核、无缓存、ARM/Cortex-M)上可靠地工作,如STM32F2或ATSAMG55,例如IAR编译器。但是
  2. 它可能无法在更复杂的体系结构(带缓存的多核)上按预期工作,并且当编译器试图进行某些优化时(其他答案中的许多示例不会重复)
  3. atomic_flagatomic_int(如果应该是is_lock_free())在任何地方使用都是安全的,因为它们的工作方式类似于volatile,在需要时添加了内存分隔符/同步(避免了前一点中的问题)
  4. 我特别指出的原因是,您必须只使用is_lock_free()true的那些,因为您不能停止IRQ,因为您可以停止线程。不,IRQ中断主循环并完成它的工作,它不能等待互斥锁的锁定,因为它正在阻塞它将要等待的主循环

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

最新更新