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 会跟着按键电平改变。以后看到一个寄存器名,先问一句“是谁在改变它”,比急着记住每一位的名字更重要。

最后蜘蛛说

初见端倪……

posted @ 2026-10-01 08:49  Zw-awa  阅读(19)  评论(0)    收藏  举报