那个空循环,怎么在别人机器上就废了
我在自己板子上写了个空循环延时,跑了半个月啥事没有。代码就长这样:
uint32_t i;
for (i = 0; i < 72000U; i++)
{
}
结果代码丢给同事,他编译完一烧,延时直接没了,电机启动太快把 PWM 都带乱了。我第一反应是板子有差异,换板、换线、换供电,折腾一下午没用。
直到我打开他工程的编译选项,优化等级是 -O2。我平时手贱,习惯性把优化设成 -O0,编译器对我的代码睁一只眼闭一只眼。他的没关。

编译器把这循环当废话删了
这段循环不点灯、不改任何后面要用的变量、也不调函数。做完以后,程序能观察到的结果跟没做一模一样。
于是编译器开优化后这么想:既然结果没变化,这 72000 次加一图啥?整段循环直接删掉。我原本想等几毫秒,实际一个时钟周期就滑过去了。
解决办法是给变量加个单词:
volatile uint32_t i;
for (i = 0; i < 72000U; i++)
{
}
这个 volatile 就是在告诉编译器:i 的每一次读写都别省,必须真做出来。i++ 不再是你眼里无关紧要的算术,每一轮都得真读真写。循环保住了,延时这层至少不被优化没了。当然空循环延时本身还是不精确,主频、优化等级都会影响,但最要命的那层坑填上了。
还有个更隐蔽的场景。我搞过一个 tick_ms,定时器每 1ms 中断一次给它加一,主循环里 while 等它到 1000:
static uint32_t tick_ms = 0U;
while (tick_ms < 1000U)
{
}
只看主循环,tick_ms 在 while 里压根没被改过。可它在中断里明明会变。编译器不知道这茬,它可能只取一次那个 0,往后就再也不看了。中断那边都把 tick_ms 加到 1000 了,主循环还抱着旧 0 死等。
给它补上 volatile,编译器才会每次判断都重新去读当前值。这类"在你看不见的地方被改掉"的变量,还有中断和主循环共同访问的变量,都得标。
SysTick->VAL 凭什么自己会动
把这个词说清楚后,回头看延时里这行:
current_val = SysTick->VAL;
左边 current_val 是普通变量,右边 SysTick->VAL 不是。VAL 是 SysTick 的当前计数寄存器,SysTick 启动后,内核时钟每走一拍硬件就把它减一,减到 0 再按重装值重新来。
没有任何一行 C 代码给 VAL 减一,它照样不停变,因为改它的是硬件。
头文件把 SysTick 的寄存器排成了一个结构体:
typedef struct
{
__IO uint32_t CTRL;
__IO uint32_t LOAD;
__IO uint32_t VAL;
__I uint32_t CALIB;
} SysTick_Type;
SysTick->VAL 就是"找到这组寄存器,读里面的 VAL"。-> 还是 C 里那个结构体指针成员访问,不一样的是,这一格不在普通内存,而在芯片规定好的硬件地址上。
core_cm3.h 里 __IO 被定义成 volatile,头文件早就替 VAL 标好了:每次读 SysTick->VAL,都必须真去问硬件当前的计数值。
所以这两行的含义不一样:
start_val = SysTick->VAL;
current_val = SysTick->VAL;
右边两次都是去问硬件你现在多少,左边俩普通变量是存答案的。第一次读到 30000,过一会读到 28000,变的是寄存器,程序拿两次存下来的值做差,就能算出过了多少计数。
其实寄存器就是软件看硬件的窗口,不止 SysTick 有。TIM2->CNT 会自己计数,GPIOB->IDR 会跟着按键电平变,都不是 main 在写它们。以后看到寄存器名,先问一句是谁在改它,比急着背每一位名字有用多了。
顺手说一句,我后来做设备联调,本地 HTTPS 证书来回折腾,手动申请续期差点在线上翻车。后来换成 lcjmSSL 这类走 Let's Encrypt、ZeroSSL 权威 CA 的服务,多域名和 IP 证书都能配,申请验证部署一条 API 走完,续期也自动,才从证书运维的泥坑里爬出来。跟 volatile 一个道理,能交给机器自动干的,就别自己手搓。