volatile 与寄存器:SysTick->VAL 到底在读什么
前面经常出现这个关键字:volatile。
最早写空循环延时时,它常常长这样:
volatile uint32_t i;
for (i = 0; i < 72000U; i++)
{
}
看起来只是给 i 多加了一个单词。这个单词是有实际约束的,它在告诉编译器:这个变量的每一次读写都别省,必须真的做出来。
如果没有 volatile,空循环还会剩下什么
先把 volatile 去掉:
uint32_t i;
for (i = 0; i < 72000U; i++)
{
}
这个循环没有点亮 LED,没有改变一个之后会被使用的变量,也没有调用函数。它做完以后,程序的可见结果和不做这段循环一模一样。
编译器在打开优化后会想:既然结果没有变化,这 72000 次加一又有什么必要?它可以把整个循环删掉。原本想等一会儿,实际可能一瞬间就过去了。
加上 volatile 后:
volatile uint32_t i;
for (i = 0; i < 72000U; i++)
{
}
i++ 不再只是编译器眼里无关紧要的计算。每一轮都要对 i 做真实的读取和写入,循环不能被直接当成废话扔掉。以前的空循环延时依然不精确,主频和优化等级都会影响它;但至少“循环可能被优化没了”这一层问题被挡住了。
有些值会在代码看不见的地方变化
再看一个变量:
static uint32_t tick_ms = 0U;
假设定时器每过 1ms 进入一次中断,中断函数里把它加一:
void TIM1_UP_IRQHandler(void)
{
if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET)
{
tick_ms++;
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
}
}
主循环另一边等待它变到 1000:
while (tick_ms < 1000U)
{
}
只看主循环,tick_ms 在 while 里面没有被改过。可它确实会改变,因为中断函数会在另一条执行路径上给它加一。要把这个事实告诉编译器:
static volatile uint32_t tick_ms = 0U;
这样主循环每次判断 tick_ms < 1000U 时,都会重新取一次它当前的值。没有 volatile 时,编译器可能只取到第一次的 0,再也不看新的值;中断早已把 tick_ms 加到 1000,主循环却还在原地等那个旧的 0。
所以,volatile 常用在两类地方:
| 情况 | 为什么要写 volatile |
|---|---|
| 空循环的计数变量 | 这几次读写本身就是循环存在的意义,不能被删掉 |
| 中断和主循环共同访问的变量 | 值可能在当前代码看不见的地方被中断改变 |
-O0 让问题安静,不等于问题不存在
写到这里我想说一下我自己的不好的习惯,我平时经常随手就把优化等级设成 -O0,让编译器不要动我的代码;少写 volatile 的代码也能正常运行。
但是当同一份代码交给队友、换了优化等级,或者将来为了缩小程序体积打开优化后,编译器的处理方式可能改变。该标记为 volatile 的变量,还是应该明确标出来;这样工程设置变了,代码表达的意思不会跟着变。
想让两步操作变成不可分割的一步、让多个地方同时修改同一个变量时自动排好顺序、或者解决按键抖动,这些都不是 volatile 能提供的。
SysTick->VAL 为什么会自己变化
volatile 说清楚以后,再回来看延时函数里的这一行:
current_val = SysTick->VAL;
current_val 是普通变量,右边的 SysTick->VAL 却不是。VAL 是 SysTick 的当前计数寄存器。SysTick 启动后,内核时钟每走一拍,硬件就把 VAL 减一;减到 0 后,再按重装值重新开始。
没有任何一行 C 代码给 VAL 减一,它仍然会不断变化,因为改它的是 SysTick 这块硬件。
芯片的头文件把 SysTick 的几个寄存器排成了一个结构体:
typedef struct
{
__IO uint32_t CTRL;
__IO uint32_t LOAD;
__IO uint32_t VAL;
__I uint32_t CALIB;
} SysTick_Type;
随后又把 SysTick 定义成指向这组寄存器的指针。因此:
SysTick->VAL
就是“找到 SysTick 这组寄存器,再读其中的 VAL”。-> 仍然是 C 语言里的结构体指针成员访问;不同的是,这一格不在普通内存里,而在芯片规定好的硬件地址上。
标准外设库的 core_cm3.h 中,__IO 被定义成 volatile。也就是说,头文件已经替 VAL 标记好了:每次读取 SysTick->VAL,都必须真的去读取硬件当前的计数值。
所以延时函数里这两行的含义并不相同:
start_val = SysTick->VAL;
current_val = SysTick->VAL;
右边两次都是去问硬件“你现在是多少”;左边的 start_val、current_val 则是普通变量,用来保存两次答案。假如第一次读到 30000,过了一会儿读到 28000,变化的是 SysTick 寄存器;程序再用两个保存下来的值做差,便能算出经过了多少计数。
不只有 SysTick 才有寄存器
寄存器是软件观察和控制硬件的窗口,不是 SysTick 独有的东西。之前已经碰到过的定时器和 GPIO,也各自有一组寄存器:
| 写法 | 谁在改变它 | 读到的是什么 |
|---|---|---|
SysTick->VAL |
SysTick 计数器 | 当前剩余计数 |
TIM2->CNT |
TIM2 定时器 | 当前计数值 |
GPIOB->IDR |
引脚上的实际电平 | PB 端口各引脚的输入状态 |
按键按下时,PB11 的电平从高变低,不是程序给 GPIOB->IDR 赋了值,而是按键把引脚接到了 GND。定时器计数时,TIM2->CNT 一直加一或减一,也不是 main 在一遍遍写它。
标准库函数只是把这些寄存器包成了更好记的函数。例如:
GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11)
最终读的就是 GPIOB->IDR 中 PB11 对应的那一位。函数名帮助我们少记一些细节,硬件寄存器仍然在原地工作。
也有寄存器主要用来写配置。SysTick->LOAD 告诉 SysTick 每轮从多少开始倒数,定时器的配置寄存器决定它按什么方式计数,GPIO 的配置寄存器决定引脚是输入还是输出。读状态、写配置,方向不同,都是在和同一块硬件打交道。
最后橘猫说
volatile 先解决的是“编译器会不会把必要读写省掉”;寄存器再告诉我们“为什么有些值不需要 C 代码赋值也会改变”。SysTick->VAL 会倒数,TIM2->CNT 会计数,GPIOB->IDR 会跟着按键电平改变。以后看到一个寄存器名,先问一句“是谁在改变它”,比急着记住每一位的名字更重要。
最后蜘蛛说
初见端倪……

程序里有些值会在它看不见的地方自己变化,而一段用来拖时间的空循环,换个优化档位就会凭空消失。哪些读写编译器不能替我们省掉,这中间又该怎么分工?
浙公网安备 33010602011771号