跳转至

volatile 修饰

volatile(易变的)是 C/C++ 在嵌入式开发中常用的关键字,它的作用是告诉编译器,这个变量可能会在编译器不知道的情况下被改变,因此编译器不要对这个变量进行优化,而是完全遵循代码(按原样)进行读取、写入。

在嵌入式开发中,DMA 涉及的内容、中断回调中涉及的变量、读写寄存器等往往需要 volatile 修饰,因为它们的值可能会在正常的 C/C++ 代码流程之外被改变。

用法

中断变量

在中断回调函数中,如果要使用全局变量,那么这个全局变量往往需要被 volatile 修饰,否则编译器可能会对这个变量进行常量折叠、寄存器缓存等错误优化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
volatile uint32_t flag = 0;
void callback()
{
    flag = 1;
}
void run()
{
    flag = 0;
    start_it();
    while (flag == 0) {
        // do something
    }
    // flag = 1,执行完成
}

如果不使用 volatile 修饰 flag

  • run() 函数中,编译器按照正常流程分析,无法探查到 callback 被调用(中断是一种打破正常流程方式的强制“跳转”)
  • 编译器认为 flag 的值一直为 0。
  • 根据编译参数和编译器版本的不同,可能会把 while 循环条件优化成 0 == 0,导致 while 循环永远无法退出。

volatile 修饰后,编译器按照代码原样忠实地进行每一次内存读写,因此 while 循环可以正常退出。

DMA

DMA 也是一个典型的应当使用 volatile 的场景,因为 DMA 是硬件直接操作内存,因此编译器在进行流程分析时,无法探测到 DMA 的操作,导致在优化时对相关变量做出错误推断,因此需要使用 volatile 修饰 DMA 相关的变量。

注意:关于 Cache Line

对于带有 Cache Line 的 MCU,不仅需要使用 volatile 修饰变量,还可能需要手动刷新内核的 L1/L2 Cache。

寄存器

对于映射到内存的寄存器(Memory-mapped registers,如各种外设的寄存器,不包括内核内部的 R0 R1 等寄存器),也应当使用 volatile 修饰。这有多种原因:

  • 寄存器的值可能会在正常的代码流程之外被改变,如被相关外设直接改变。
  • 寄存器的值必须被直接传递给外设,而不是存在于编译器为优化而创建的某种中间变量上,否则外设无法正常工作。
  • 对于寄存器的读写,常常具有额外的副作用。

Example 1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void main()
{
    volatile uint32_t *reg_tx = (volatile uint32_t *)0x1;
    volatile uint32_t *reg_flag = (volatile uint32_t *)0x2;

    *reg_flag = 0; // 清零标志位
    *reg_tx = 0xFE; // 发送数据,发送完成后外设会置位标志位
    while (*reg_flag == 0) {
    }
    // 此时,标志位非零
}

在正常 C/C++ 代码流程下 *reg_tx = 0xFE 不会导致 reg_flag 改变,如果不使用 volatile 修饰 reg_flag,那么编译器可能优化为 while (0 == 0),因此 while 循环永远无法退出。

volatile 修饰后,编译器按照代码原样忠实地进行每一次内存读写,因此 while 循环可以正常退出。

Example 2

1
2
3
4
5
6
void main()
{
    volatile uint32_t *reg = (volatile uint32_t *)0x1;
    *reg = 0x1;
    *reg = 0x2;
}

如果不使用 volatile 修饰 reg,那么编译器可能优化成:

1
2
3
4
5
void main()
{
    volatile uint32_t *reg = (volatile uint32_t *)0x1;
    *reg = 0x2;
}

这对于普通内存来说,其效果是一样的。但是对于寄存器来说,往往还具有额外的副作用。假如这是某个通信相关的寄存器,且被设计为每次写入都会触发一次通信,那么第一段代码相当于发送了 0x10x2 数据,而第二段代码只发送了 0x2 数据,这显然是不一样的。

Example 3

1
2
3
4
5
void main()
{
    volatile uint32_t *reg = (volatile uint32_t *)0x1;
    *reg;
}

我们读取了寄存器的值,但并没有使用这个值。对于普通内存而言,这是一个无用的操作,编译器可能会优化掉,而使用 volatile 修饰可以保证这一操作得到保留。

对于寄存器来说,如果读取寄存器的值,可能会触发一些副作用。如状态寄存器 SR 常常被设计为读取后自动清零。如果编译器优化掉了这个读取操作,那么状态寄存器的值就无法被清零,这可能会导致一些问题。

作者:ArcticLampyrid