在中断服务程序和多线程程序中使用'C volatile'关键字?

10

我了解使用C语言中的volatile关键字在内存映射硬件寄存器、ISR和多线程程序中的用法。

1)寄存器

uint8_t volatile * pReg;
while (*pReg == 0) { // do sth } // pReg point to status register 

2) ISR

int volatile flag = 0;
int main() 
{
    while(!flag) { // do sth }
}
interrupt void rx_isr(void)
{
    //change flag
}

3) 多线程

int volatile var = 0;
int task1()
{
    while (var == 0) { // do sth }
}
int task2()
{
    var++;
}

我可以理解为什么在情况1下,如果没有volatile,编译器可能会错误地优化while,因为变量的更改来自硬件,编译器可能无法看到从代码中进行的任何变量更改。
但是对于情况2和3,为什么需要使用volatile呢?在这两种情况下,变量被声明为全局,编译器可以看到它在多个位置中被使用。所以,如果变量不是volatile,为什么编译器会优化while循环呢?
是因为编译器设计上不知道“异步调用”(在ISR的情况下)还是多线程吗?但这不可能,对吧?
此外,情况3看起来像是一个常见的多线程程序,没有volatile关键字。假设我给全局变量添加了一些锁定(没有volatile关键字):
int var = 0;
int task1()
{
    lock();   // some mutex
    while (var == 0) { do sth }
    release()
}
int task2()
{
    lock();
    var++;
    release();
}

在我看来,它看起来很正常。那么在多线程编程中,我真的需要使用volatile吗?为什么我以前从未见过在多线程程序中添加volatile限定符以避免优化的变量呢?


1
请注意,最后一个 task1 存在未定义的行为。 - Kerrek SB
请阅读对https://dev59.com/8XVD5IYBdhLWcg3wJIAK的各种回答。 - D.Shawley
嗨Shawley,实际上我看到了那篇帖子。我提出的问题是关于话题中一个我不太理解的小而具体的部分。我确定我需要在这个主题上多读一些资料。 - user1559625
5个回答

10
使用volatile关键字的主要目的是防止编译器生成使用CPU寄存器作为表示变量的更快方法的代码。这强制编译后的代码在每次访问变量时访问RAM中的确切内存位置以获取其最新值,该值可能已被其他实体更改。通过添加volatile,我们确保我们的代码意识到任何其他人(如硬件或ISR)对变量所做的更改,并且不会发生一致性问题。
在没有volatile关键字的情况下,编译器尝试通过将变量的内容从RAM中读取到CPU寄存器中一次并在循环或函数中使用缓存值来生成更快的代码。访问RAM可能比访问CPU寄存器慢十倍以上。
我经历过第1和第2项,但我认为您不需要在多线程环境中将变量定义为volatile。添加锁定/解锁机制是解决同步问题的必要措施,与volatile无关。

嗨,Kamyar,我认为你在写的前两段中也有同样的想法,但是对于第二项,我不明白为什么编译器不能看到变量在ISR和main()中都被更改。编译器只基于作用域和调用堆栈进行优化吗?所以它无法看到ISR中的更改? - user1559625
1
如果isr handler被用作函数指针在某个向量表中,编译器很可能无法理解其如何被调用。 - Alexandre Vinçon
4
此外,“volatile”关键字可以防止编译器对变量的连续读写操作进行优化。例如:flag = 1; flag = 2; 如果没有“volatile”关键字,编译器将通过忽略第一个赋值来优化代码。但是,当您写入硬件寄存器时,多次向同一寄存器写入值是有意义的,不能跳过任何步骤。反之,在从某些硬件缓冲区(例如:串行端口)读取时,连续多次读取“flag”可能是有意义的。同样,“volatile”关键字可以防止优化。 - Alexandre Vinçon
Alex是正确的。编译器可能能够在代码中检测到ISR,但是将使用/不使用volatile选项留给程序员。 - Kamyar Souri
2
这里关于多线程的评论是错误的。在多线程环境中,通常确实需要锁定,但您还需要声明变量为volatile。锁定是为了确保您不会看到一个多访问字宽变量“部分更改”,而volatile是为了确保内存值实际上被更改。 - Chris Stratton

3
是因为编译器本身对于“异步调用”(在ISR情况下)或多线程没有概念吗?但这不可能,对吧?

是的,确实如此。

在C语言中,编译器没有并发的概念,因此它允许重新排序和缓存内存访问,只要从单个线程的视角看不出区别。

这就是为什么你需要使用volatile(阻止变量进行此类优化),内存屏障(在程序的单个点上为所有变量阻止它)或其他形式的同步,例如锁定(通常作为内存屏障)。


2
编译器确实可以让除了某些特定条件下的易变量以外的其他内容都不能改变它们。其中之一是volatile访问;另外还有一些编译器屏障。
在你脑海中可能存在的编写多线程代码的幼稚方式确实容易出错,并且会被视为未定义行为。如果你有正确的多线程代码,那么要么优化仍然合法(就像在最终的task1中一样,循环仍然是未定义行为并可能被删除),要么同步原语将包含必要的屏障(通常在某些原子变量的内部)。
为了总结,这里是一个修正后的多线程示例:
 for (;;)
 {
     lock();
     if (var != 0) { unlock(); break; }
     unlock();
 }
unlock()函数的实现引入了编译器屏障,确保循环不能被优化消除。

抱歉如果我的问题不够清晰,我已经进行了一些修改并在while循环中添加了“do sth”,之前我不知道是否会引起误解。你能看一下这个问题吗?而且我仍然不明白为什么像你所说的那样,最终的task1具有未定义的行为,对此我感到抱歉。 - user1559625
@user1559625:看一下1.10/24。基本上,除非循环执行易失访问、原子同步或库I/O调用,否则不能有无限循环。 - Kerrek SB

0

在多线程软件中,您可以通过使用屏障来自由避免易失性变量。您可以在Linux内核源代码中找到许多示例。而且,使用屏障而不是易失性变量可以让编译器生成更高效的代码。


volatile 是标准的 C 关键字,在 C11 之前的版本中,屏障不是标准的一部分。但你说得对,仔细使用屏障可能会让熟练的程序员节省几条指令,但代价可能是可读性和可移植性的降低。 - vgru

0

关于第二种情况,

我已经多次编写了与您问题中第二种情况相同的代码,并且没有遇到任何问题。我认为这是因为现代编译器可以处理这种情况。也就是说,编译器可以“看到”我在“rx_isr”内部更改了“flag”,并且不会添加任何优化。然而,由于以下原因,这是不安全的:

1)您的编译器的优化级别可能会影响以下原因3)

2)您的isr的调用方法,可能函数指针超出了编译器的视野

3)编译器实现,不同的编译器可能对“在isr中看到标志已更改”的定义有所不同

...

因此,为了达到最大的安全性和可移植性,只需添加“volatile”。

“我认为这是因为现代编译器可以处理这种情况。” - 启用优化后,大多数现代编译器将使用无限循环替换第2种情况(https://godbolt.org/z/qRRri9)。其他函数中的代码不相关,在这种情况下省略`volatile`将是程序员的错误。 - vgru

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接