在嵌入式 C 开发中使用 volatile

电器工程 微控制器 C 嵌入式
2022-01-03 03:49:17

我一直在阅读一些关于使用volatile关键字来防止编译器对可能以编译器无法确定的方式更改的对象应用任何优化的文章和 Stack Exchange 答案。

如果我正在从 ADC 读取数据(我们称之为变量adcValue),并且我将这个变量声明为全局变量,volatile在这种情况下我应该使用关键字吗?

  1. 不使用volatile关键字

    // Includes
    #include "adcDriver.h"
    
    // Global variables
    uint16_t adcValue;
    
    // Some code
    void readFromADC(void)
    {
       adcValue = readADC();
    }
    
  2. 使用volatile关键字

    // Includes
    #include "adcDriver.h"
    
    // Global variables
    volatile uint16_t adcValue;
    
    // Some code
    void readFromADC(void)
    {
       adcValue = readADC();
    }
    

我问这个问题是因为在调试时,我看不出两种方法之间没有区别,尽管最佳实践表明在我的情况下(直接从硬件更改的全局变量),那么使用volatile是强制性的。

4个回答

的定义volatile

volatile告诉编译器变量的值可能会在编译器不知道的情况下发生变化。因此编译器不能仅仅因为 C 程序似乎没有改变它就假设该值没有改变。

另一方面,这意味着在编译器不知道的其他地方可能需要(读取)变量的值,因此它必须确保对变量的每个赋值实际上都是作为写入操作执行的。

用例

volatile需要时

  • 将硬件寄存器(或内存映射 I/O)表示为变量 - 即使永远不会读取寄存器,编译器也不能只是跳过写操作认为“愚蠢的程序员。试图将值存储在他/她的变量中永远不会回读。如果我们省略写入,他/她甚至都不会注意到。相反,即使程序从未向变量写入值,其值仍可能被硬件更改。
  • 在执行上下文(例如 ISR/主程序)之间共享变量(参见kkramo 的回答

的影响volatile

声明变量时volatile,编译器必须确保程序代码中对它的每个分配都反映在实际的写操作中,并且程序代码中的每次读取都从(映射的)内存中读取值。

对于非易失性变量,编译器假定它知道变量的值是否/何时发生变化,并且可以以不同的方式优化代码。

一方面,编译器可以通过将值保存在 CPU 寄存器中来减少对内存的读/写次数。

例子:

void uint8_t compute(uint8_t input) {
  uint8_t result = input + 2;
  result = result * 2;
  if ( result > 100 ) {
    result -= 100;
  }
  return result;
}

在这里,编译器可能甚至不会为result变量分配 RAM,并且永远不会将中间值存储在 CPU 寄存器中的任何地方。

如果result是 volatile,C 代码中的每次出现result都需要编译器执行对 RAM(或 I/O 端口)的访问,从而导致性能降低。

其次,编译器可以为性能和/或代码大小对非易失性变量的操作重新排序。简单的例子:

int a = 99;
int b = 1;
int c = 99;

可以重新订购

int a = 99;
int c = 99;
int b = 1;

这可能会节省汇编指令,因为99不必加载该值两次。

如果a,bc是易失性的,编译器将不得不发出指令,这些指令按照程序中给出的确切顺序分配值。

另一个经典的例子是这样的:

volatile uint8_t signal;

void waitForSignal() {
  while ( signal == 0 ) {
    // Do nothing.
  }
}

如果在这种情况下signal不是volatile,编译器会“认为”这while( signal == 0 )可能是一个无限循环(因为循环内的signal代码永远不会改变)并且可能会生成相当于

void waitForSignal() {
  if ( signal != 0 ) {
    return; 
  } else {
    while(true) { // <-- Endless loop!
      // do nothing.
    }
  }
}

volatile价值观的周到处理

如上所述,当一个volatile变量的访问频率高于实际需要时,它可能会带来性能损失。为了缓解这个问题,您可以通过分配给非易失性变量来“非易失性”值,例如

volatile uint32_t sysTickCount;

void doSysTick() {
  uint32_t ticks = sysTickCount; // A single read access to sysTickCount

  ticks = ticks + 1; 

  setLEDState( ticks < 500000L );

  if ( ticks >= 1000000L ) {
    ticks = 0;
  }
  sysTickCount = ticks; // A single write access to volatile sysTickCount
}

这在 ISR 中可能特别有用,因为当您的 ISR 运行时,值不会改变,您希望尽可能快地不多次访问相同的硬件或内存,因为知道它是不需要的。当 ISR 是变量值的“生产者”时,这很常见,就像sysTickCount上面的例子一样。doSysTick()在 AVR 上,让函数访问内存中相同的四个字节(四个指令 = 每次访问 8 个 CPU 周期)五到六次而不是仅仅两次会特别痛苦sysTickCount,因为程序员确实知道该值不会在他/她doSysTick()运行时从其他代码更改。

使用此技巧,您基本上可以执行编译器对非易失性变量所做的完全相同的操作,即仅在必须时从内存中读取它们,将值保存在寄存器中一段时间​​,并仅在必须时才写回内存; 但是这一次,如果/何时必须发生读/写,比编译器更清楚,所以你把编译器从这个优化任务中解脱出来,自己做。

的限制volatile

非原子访问

volatile不提供多字变量的原子访问。对于这些情况,除了使用volatile. 在 AVR 上,您可以使用ATOMIC_BLOCKfrom<util/atomic.h>或 simplecli(); ... sei();调用。相应的宏也充当内存屏障,这在访问顺序方面很重要:

执行顺序

volatile仅对其他 volatile 变量施加严格的执行顺序。这意味着,例如

volatile int i;
volatile int j;
int a;

...

i = 1;
a = 99;
j = 2;

保证赋值 1i赋值2 j但是,不能保证a会在两者之间分配;编译器可以在代码片段之前或之后执行该分配,基本上在任何时间直到第一次(可见)读取a.

如果不是因为上述宏的内存屏障,编译器将被允许翻译

uint32_t x;

cli();
x = volatileVar;
sei();

x = volatileVar;
cli();
sei();

要么

cli();
sei();
x = volatileVar;

(为了完整起见,我必须说,内存屏障,就像 sei/cli 宏所暗示的那样,实际上可以避免使用volatile,如果所有访问都用这些屏障括起来。)

volatile 关键字告诉编译器对变量的访问具有可观察到的效果。这意味着每次您的源代码使用该变量时,编译器都必须创建对该变量的访问。无论是读取还是写入访问。

这样做的效果是,在正常代码流之外对变量的任何更改也将被代码观察到。例如,如果中断处理程序更改了值。或者,如果变量实际上是一些自行更改的硬件寄存器。

这个巨大的好处也是它的缺点。对变量的每一次访问都通过变量进行,并且该值永远不会保存在寄存器中,以便在任何时间内更快地访问。这意味着 volatile 变量会很慢。幅度较慢。所以只在实际需要的地方使用 volatile。

在您的情况下,就您显示的代码而言,全局变量仅在您自己更新时才会更改adcValue = readADC();编译器知道这种情况何时发生,并且永远不会将 adcValue 的值保存在可能调用该readFromADC()函数的东西的寄存器中。或者它不知道的任何功能。或者任何会操纵可能指向的指针的东西adcValue真的不需要 volatile,因为变量永远不会以不可预测的方式发生变化。

存在两种必须volatile在嵌入式系统中使用的情况。

  • 从硬件寄存器读取时。

    这意味着,内存映射寄存器本身是 MCU 内部硬件外围设备的一部分。它可能会有一些神秘的名字,比如“ADC0DR”。该寄存器必须在 C 代码中定义,或者通过工具供应商提供的一些寄存器映射,或者由您自己定义。要自己做,你会做(假设 16 位寄存器):

    #define ADC0DR (*(volatile uint16_t*)0x1234)
    

    其中 0x1234 是 MCU 映射寄存器的地址。由于volatile已经是上述宏的一部分,因此对它的任何访问都将是 volatile 限定的。所以这段代码很好:

    uint16_t adc_data;
    adc_data = ADC0DR;
    
  • 当使用 ISR 的结果在 ISR 和相关代码之间共享变量时。

    如果你有这样的事情:

    uint16_t adc_data = 0;
    
    void adc_stuff (void)
    {
      if(adc_data > 0)
      {
        do_stuff(adc_data);
      } 
    }
    
    interrupt void ADC0_interrupt (void)
    {
      adc_data = ADC0DR;
    }
    

    然后编译器可能会想:“adc_data 始终为 0,因为它没有在任何地方更新。而且 ADC0_interrupt() 函数永远不会被调用,因此变量不能被更改”。编译器通常没有意识到中断是由硬件调用的,而不是由软件调用的。所以编译器会去删除代码if(adc_data > 0){ do_stuff(adc_data); },因为它认为它永远不可能是真的,从而导致一个非常奇怪且难以调试的错误。

    通过声明adc_data volatile,不允许编译器做出任何此类假设,也不允许优化对变量的访问。


重要笔记:

  • ISR 应始终在硬件驱动程序中声明。在这种情况下,ADC ISR 应该在 ADC 驱动程序内部。除了驱动程序应该与 ISR 进行通信之外,别无其他 - 其他一切都是意大利面条式编程。

  • 在编写 C 语言时,必须保护ISR 和后台程序之间的所有通信免受竞争条件的影响。总是,每次,没有例外。MCU 数据总线的大小无关紧要,因为即使您在 C 中进行单个 8 位复制,该语言也无法保证操作的原子性。除非您使用 C11 功能,否则不会_Atomic如果此功能不可用,则必须使用某种信号量或在读取期间禁用中断等。内联汇编器是另一种选择。volatile不保证原子性。

    可能发生的情况是: -
    将堆栈中的值加载到寄存器
    中 - 发生中断 -
    使用寄存器中的值

    然后,“使用价值”部分本身是否是一条指令并不重要。可悲的是,所有嵌入式系统程序员中的很大一部分都没有注意到这一点,这可能使其成为有史以来最常见的嵌入式系统错误。总是断断续续,难招惹,难找。


正确编写的 ADC 驱动程序示例如下所示(假设 C11_Atomic不可用):

adc.h

// adc.h
#ifndef ADC_H
#define ADC_H

/* misc init routines here */

uint16_t adc_get_val (void);

#endif

ADC.c

// adc.c
#include "adc.h"

#define ADC0DR (*(volatile uint16_t*)0x1234)

static volatile bool semaphore = false;
static volatile uint16_t adc_val = 0;

uint16_t adc_get_val (void)
{
  uint16_t result;
  semaphore = true;
    result = adc_val;
  semaphore = false;
  return result;
}

interrupt void ADC0_interrupt (void)
{
  if(!semaphore)
  {
    adc_val = ADC0DR;
  }
}
  • 此代码假设中断本身不能被中断。在这样的系统上,一个简单的布尔值可以充当信号量,它不需要是原子的,因为如果在设置布尔值之前发生中断也没有什么坏处。上述简化方法的缺点是它会在竞争条件发生时丢弃 ADC 读取,而使用之前的值。这也可以避免,但是代码变得更加复杂。

  • 这里volatile可以防止优化错误。它与源自硬件寄存器的数据无关,只是数据与 ISR 共享。

  • static通过将变量设置为驱动程序的本地变量来防止意大利面条式编程和命名空间污染。(这在单核、单线程应用程序中很好,但在多线程应用程序中不行。)

嵌入式 C 应用程序中 volatile 关键字的主要用途是标记在中断处理程序中写入的全局变量。在这种情况下,它当然不是可选的。

没有它,编译器就无法证明初始化后是否曾经写入过该值,因为它无法证明曾经调用过中断处理程序。因此它认为它可以优化不存在的变量。