C语言嵌入式开发:什么情况下需要使用volatile修饰变量?

volatile 关键字是什么?

用C语言开发嵌入式程序,volatile是很重要的关键字,它的英文愿意是 “易变的”。它的主要作用是告知编译器,被其修饰的变量的值可能会在程序运行过程中,以一种编译器无法预测的方式发生改变,所以编译器在对代码进行优化时,不能对该变量进行常规的优化操作。每次访问该变量时都要直接从内存中读取其真实值,而不是使用寄存器中的缓存值,以确保程序对变量的访问是实时的。​
假设有一个变量flag,在正常情况下,编译器可能会认为在一段代码中,如果没有对flag进行显式的修改操作,那么它的值就不会发生变化,于是在后续的代码中,编译器可能会直接使用之前缓存到寄存器中的flag的值,而不再去内存中读取。但如果flag是一个会被硬件中断或者其他线程修改的变量,这种优化就会导致程序读取到的flag值不是最新的,从而引发错误。当使用volatile关键字修饰flag后,编译器就会知道这个变量的值随时可能改变,每次使用flag时都会从内存中读取它的最新值 ,避免了因优化导致的错误。

使用场景一:防止编译器优化​


编译器为了提高程序的执行效率,会对代码进行各种各样的优化。其中对变量的访问优化是很常见的一种。编译器在优化过程中,会假设变量的值在没有被显式修改的情况下是不会发生变化的。基于这个假设,编译器会将一些频繁访问的变量缓存到寄存器中 ,这样在后续访问该变量时,就可以直接从寄存器中读取,而不需要再去内存中读取,因为寄存器的访问速度比内存快得多,这样可以提高程序的执行效率。此外,编译器还会对代码中的一些 “冗余” 访问进行优化,例如若在一段代码中多次读取同一个变量的值,且在这期间该变量没有被修改,那么编译器可能会只读取一次该变量的值,然后在后续使用该变量的地方直接使用之前读取的值,而不再重复读取。​
虽然这些优化在大多数情况下能够提升程序性能,但在嵌入式开发等环境中,可能会带来问题。因为在嵌入式系统中,变量的值很可能会因为硬件设备的状态变化、中断服务程序的执行或者多线程(支持多线程的嵌入式系统)的并发操作等原因,在编译器意想不到的情况下被修改。如果编译器仍然按照常规的优化方式对这些变量进行处理,就会导致程序读取到的变量值不是最新的,从而引发逻辑错误。​
假设有一个简单的嵌入式系统,其中有一个硬件设备的状态寄存器,我们通过一个变量来读取这个状态寄存器的值,并且在程序中需要等待这个硬件设备的状态变为某个特定值。下面是一段简化的代码示例:​

#include <stdio.h>​
​
// 模拟硬件设备的状态寄存器,这里用一个普通变量代替​
int hardware_status = 0;​
​
// 等待硬件设备状态变为目标值​
void wait_for_device_ready() {​
while (hardware_status != 1) {​
// 循环等待,什么也不做​
}​
printf("Device is ready.\n");​
}​
​
int main() {​
// 假设在某个时刻,硬件设备的状态被改变为1​
// 这里模拟硬件设备状态的改变​
hardware_status = 1;​
​
wait_for_device_ready();​
​
return 0;​
}​
​

在这段代码中,hardware_status变量代表硬件设备的状态寄存器。wait_for_device_ready函数的作用是等待硬件设备的状态变为 1。在main函数中,我们模拟硬件设备状态被改变为 1 ,然后调用wait_for_device_ready函数。​
如果hardware_status没有使用volatile修饰,编译器可能会对wait_for_device_ready函数进行优化。编译器可能会认为在while循环中,hardware_status的值没有被显式修改,所以它的值不会发生变化,于是编译器可能会将hardware_status的值缓存到寄存器中,并且在循环中只从寄存器中读取hardware_status的值,而不再去内存中读取。这样一来,即使硬件设备的状态已经被改变为 1,由于编译器使用的是寄存器中的缓存值,while循环也永远不会结束,程序就会陷入死循环。​
接下来看看使用volatile修饰hardware_status变量后的代码:​

#include <stdio.h>​
​
// 使用volatile修饰,模拟硬件设备的状态寄存器​
volatile int hardware_status = 0;​
​
// 等待硬件设备状态变为目标值​
void wait_for_device_ready() {​
while (hardware_status != 1) {​
// 循环等待,什么也不做​
}​
printf("Device is ready.\n");​
}​
​
int main() {​
// 假设在某个时刻,硬件设备的状态被改变为1​
// 这里模拟硬件设备状态的改变​
hardware_status = 1;​
​
wait_for_device_ready();​
​
return 0;​
}​


当使用volatile修饰hardware_status变量后,编译器就会知道这个变量的值可能会在程序运行过程中以一种不可预测的方式发生改变,所以编译器在每次访问hardware_status变量时,都会直接从内存中读取其最新值,而不会使用寄存器中的缓存值。这样,当硬件设备的状态被改变为 1 时,while循环能够及时检测到这个变化,从而正常结束循环,程序也能按照预期继续执行。

使用场景二:硬件寄存器访问

在嵌入式系统中,硬件设备与处理器之间的通信是通过硬件寄存器来实现的。为了方便处理器对这些硬件寄存器进行访问,通常会将硬件寄存器映射到内存空间中的固定地址,这种方式被称为内存映射寄存器。通过内存映射,处理器可以像访问普通内存单元一样访问硬件寄存器,从而实现对硬件设备的控制和状态读取。​
例如,在一个简单的嵌入式系统中,可能有一个串口设备,串口设备的发送寄存器和接收寄存器被映射到内存地址 0x4000C000 和 0x4000C004。当处理器需要向串口发送数据时,只需要将数据写入到地址 0x4000C000 ,串口设备就会自动读取该地址中的数据并发送出去;当处理器需要读取串口接收到的数据时,只需要从地址 0x4000C004 读取数据即可。​
这些硬件寄存器的值是由外部硬件设备实时改变的,而不是由程序中的普通赋值操作改变的。例如,当有新的数据到达串口时,串口硬件会自动将数据存入接收寄存器中,这个过程是不受程序直接控制的。因此,编译器无法预测这些寄存器的值何时会发生变化,如果不使用volatile修饰这些寄存器对应的变量,编译器可能会对这些变量的访问进行优化,从而导致程序无法正确读取硬件寄存器的最新值,或者无法将数据正确写入硬件寄存器。​
下面以对串口发送寄存器进行操作为例,给出使用volatile修饰和不使用volatile修饰的代码示例。假设串口发送寄存器的地址为 0x4000C000 ,我们通过一个指针来访问这个寄存器。​
下面是不使用volatile修饰的代码:​

#include <stdio.h>​
​
// 串口发送寄存器的地址​
#define UART_TX_REGISTER 0x4000C000​
​
// 定义一个指向串口发送寄存器的指针​
unsigned int *uart_tx = (unsigned int *)UART_TX_REGISTER;​
​
// 向串口发送数据的函数​
void send_data_to_uart(int data) {​
*uart_tx = data;​
// 这里编译器可能会优化,认为不需要再次读取发送寄存器​
while (*uart_tx != 0); // 等待数据发送完成,假设发送完成后寄存器值为0​
}​
​
int main() {​
int data_to_send = 0x55;​
send_data_to_uart(data_to_send);​
return 0;​
}​


在这段代码中,uart_tx指针指向串口发送寄存器,但由于没有使用volatile修饰,编译器可能会对while (*uart_tx != 0)这一行进行优化。编译器可能会认为在*uart_tx = data之后,*uart_tx的值不会再发生变化(因为在这段代码中没有其他显式修改*uart_tx的操作),于是编译器可能会将*uart_tx的值缓存到寄存器中,并且在while循环中只从寄存器中读取*uart_tx的值,而不再去内存中读取真实的串口发送寄存器的值。这样一来,如果串口硬件在*uart_tx = data之后将数据发送出去并将寄存器值置为 0 ,由于编译器使用的是寄存器中的缓存值,while循环永远不会结束,程序就会陷入死循环。​
接下来看看使用volatile修饰的代码:​

#include <stdio.h>​
​
// 假设这是串口发送寄存器的地址​
#define UART_TX_REGISTER 0x4000C000​
​
// 使用volatile修饰指向串口发送寄存器的指针​
volatile unsigned int *uart_tx = (volatile unsigned int *)UART_TX_REGISTER;​
​
// 向串口发送数据的函数​
void send_data_to_uart(int data) {​
*uart_tx = data;​
while (*uart_tx != 0); // 等待数据发送完成,假设发送完成后寄存器值为0​
}​
​
int main() {​
int data_to_send = 0x55;​
send_data_to_uart(data_to_send);​
return 0;​
}​


当使用volatile修饰uart_tx指针后,编译器就会知道这个指针所指向的内存地址(即串口发送寄存器)的值可能随时会发生变化,而且这种变化是编译器无法预测的。所以在每次访问*uart_tx时,编译器都会从内存中读取真实的串口发送寄存器的值,而不会使用寄存器中的缓存值。这样,当串口硬件将数据发送出去并将寄存器值置为 0 时,while循环能够及时检测到这个变化,从而正常结束循环,程序也能按照预期继续执行。​

使用场景三:中断服务程序(ISR)中的共享变量

在主循环与中断交互的过程中,经常会出现主程序和中断服务程序共享同一个变量的情况。例如,在一个基于定时器中断的嵌入式系统中,主程序可能需要根据定时器中断的发生次数来执行某些操作,而定时器中断服务程序会在每次中断发生时修改一个表示中断次数的变量。由于中断的发生是异步的,它可以在主程序执行的任何时刻发生,这就导致了共享变量的值可能会在主程序意想不到的情况下被改变。如果主程序在访问这个共享变量时,编译器对其进行了优化,例如将变量值缓存到寄存器中,而在中断服务程序修改了变量的值后,主程序仍然使用寄存器中的旧值,就会导致程序逻辑错误。因此,为了确保主程序能够实时获取到共享变量的最新值,需要使用volatile关键字来修饰这个共享变量,这样编译器就不会对其进行优化,每次访问时都会从内存中读取最新值。​
假设有一个简单的嵌入式系统,它使用一个按键来触发外部中断,当按键按下时,中断服务程序会设置一个标志位,主程序会根据这个标志位来执行相应的操作,比如点亮一个 LED 灯。下面是实现这个功能的代码示例:​

#include <reg51.h> // 假设使用51单片机​
​
// 定义LED连接的端口​
sbit LED = P1^0; ​
​
// 定义中断标志位,注意这里使用volatile修饰​
volatile bit flag = 0; ​
​
// 外部中断0服务程序​
void External0_ISR(void) interrupt 0 {​
flag = 1; // 按键按下触发中断,设置标志位​
}​
​
void main() {​
// 配置外部中断0为下降沿触发​
IT0 = 1; ​
// 使能外部中断0​
EX0 = 1; ​
// 使能总中断​
EA = 1; ​
​
while (1) {​
if (flag) {​
LED = 0; // 点亮LED​
flag = 0; // 清除标志位,准备下次检测​
}​
}​
}​


在这段代码中,如果flag变量没有使用volatile修饰,编译器可能会对while循环中的if (flag)条件判断进行优化。编译器可能会认为在while循环中,flag的值没有被显式修改,所以它的值不会发生变化,于是编译器可能会将flag的值缓存到寄存器中,并且在循环中只从寄存器中读取flag的值,而不再去内存中读取。这样一来,即使按键按下触发中断,中断服务程序将flag设置为 1,但由于主程序使用的是寄存器中的缓存值,if (flag)条件判断永远不会成立,LED 灯也就永远不会被点亮。​
当使用volatile修饰flag变量后,编译器就会知道这个变量的值可能会在程序运行过程中以一种不可预测的方式发生改变(因为它会被中断服务程序修改),所以编译器在每次访问flag变量时,都会直接从内存中读取其最新值。这样,当按键按下触发中断,中断服务程序将flag设置为 1 后,主程序能够及时检测到flag的变化,从而执行点亮 LED 灯的操作 。