经典三问
Q: 阻塞式程序是什么?
A: 它在一个操作完成前一直停在原地,期间主循环无法及时处理其他任务。
Q: 为什么小车要减少阻塞?
A: 电机更新、按键扫描、显示刷新都有自己的时限,某个延时过长会让其他任务失去响应。
Q: 阻塞问题常见场景?
A: 延时消抖、动画播放、等待松手,以及任何把长操作直接写进 while(1) 的代码。
桌面上有一颗 LED 正在慢慢变亮、再慢慢变暗,OLED 上的时间数字每 100ms 涨一格。这时按下按键,想让蜂鸣器响 800ms 就停------手指按着不松,LED 停在半亮,时间数字不走了,蜂鸣器一声没出。松手以后,时间数字猛地跳出一大截。
这不是芯片罢工。是主循环走进了一扇没有出口的门。
呼吸灯是拿 Delay 一段段推出来的
要让 LED 从暗到亮、再回到暗,可以让"亮一会儿、灭一会儿"的占空比慢慢变:暗的时候大部分时间灭,亮的时候大部分时间亮。逐档推过去,包成一个函数:
c
static void LED_BreathOnce(void)
{
uint8_t brightness;
uint8_t phase;
for (brightness = 0; brightness <= 20; brightness++)
{
for (phase = 0; phase < 20; phase++)
{
if (phase < brightness)
{
LED_On(); /* 这一相位亮 */
}
else
{
LED_Off(); /* 这一相位灭 */
}
Delay_ms(1);
}
}
for (brightness = 20; brightness > 0; brightness--)
{
for (phase = 0; phase < 20; phase++)
{
if (phase < brightness)
{
LED_On();
}
else
{
LED_Off();
}
Delay_ms(1);
}
}
}
代码不长,效果也正常:LED 渐渐亮起来,再渐渐暗下去。可这个函数有个没人注意的代价------它不把控制权交出去。一个相位 Delay_ms(1),一档 20 个相位,从暗到亮 21 档、从亮到暗 20 档,整个函数跑完要 800 多毫秒。这 800ms 里主循环停在原地,呼吸灯后面排着的任务一个都轮不到。
"等它松手"是第二扇门
按键按下要消抖,最直觉的写法是这样:
c
static uint8_t Wait_KeyRelease(void)
{
if (Key_IsDown(KEY_A) == 0U)
{
return 0U; /* 没按下,直接返回 */
}
Delay_ms(20); /* 躲过按下瞬间的抖动 */
if (Key_IsDown(KEY_A) != 0U) /* 20ms 后仍按着,确认是真人 */
{
while (Key_IsDown(KEY_A) != 0U)
{
Key_Task1ms(); /* 等待松手:扫描不能停,判据才有人更新 */
}
return 1U; /* 等到了松手 */
}
return 0U; /* 20ms 后已经松了,不算等到了 */
}
Delay_ms(20) 已经让所有任务停了 20ms。真正麻烦的是下面那个 while:只要手指还压着,程序就永远回不到 while(1) 的其他位置。按住两秒,呼吸灯、OLED、蜂鸣器就一起冻两秒;按住十秒,就冻十秒。while 里那行 Key_Task1ms() 不能省------按键的稳定状态只由它更新,少了它,Key_IsDown(KEY_A) 读到的就一直是按下那一刻的值,松手也退不出来。
按键的稳定状态由谁维护?就是这样一个每 1ms 跑一遍的扫描函数------原始电平要连续保持 20 拍,才写进稳定状态。Key_IsDown() 读的是后者,不是引脚:
c
#define KEY_A 0U
#define KEY_B 1U
#define KEY_COUNT 2U
#define KEY_DEBOUNCE 20U /* 连续稳定 20 个 1ms 才算数 */
static uint8_t key_raw[KEY_COUNT] = {0U, 0U}; /* 上一拍的原始电平 */
static uint8_t key_stable[KEY_COUNT] = {0U, 0U}; /* 消抖后的稳定状态 */
static uint8_t key_cnt[KEY_COUNT] = {0U, 0U}; /* 已经连续一致了多少拍 */
static uint8_t Key_ReadRaw(uint8_t key)
{
if (key == KEY_A)
{
return (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == Bit_RESET) ? 1U : 0U;
}
return (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == Bit_RESET) ? 1U : 0U;
}
void Key_Task1ms(void)
{
uint8_t i;
uint8_t now;
for (i = 0U; i < KEY_COUNT; i++)
{
now = Key_ReadRaw(i);
if (now != key_raw[i])
{
key_raw[i] = now;
key_cnt[i] = 0U;
}
else if (key_cnt[i] < KEY_DEBOUNCE)
{
key_cnt[i]++;
if (key_cnt[i] == KEY_DEBOUNCE)
{
key_stable[i] = now;
}
}
}
}
uint8_t Key_IsDown(uint8_t key)
{
return key_stable[key];
}
低电平有效:按键 A 接 PB11、B 接 PB1,两脚都配成上拉输入,按下把引脚拉到地。
呼吸灯的代码没有错,OLED 的 I2C 也没有错,蜂鸣器更没有错。它们只是排在按键等待后面,轮不到执行。
蜂鸣器的 800ms 是一整条路障
想让蜂鸣器"响 800ms 就停",同样可以写成一个函数:
c
static uint8_t buzzer_active = 0U; /* 蜂鸣器现在是不是在响,给 OLED 用 */
static void Buzzer_BeepOnce(void)
{
buzzer_active = 1U;
Buzzer_Set(1);
Delay_ms(800);
Buzzer_Set(0);
buzzer_active = 0U;
}
几行在纸上很清楚:开、等、关。放进主循环却等于宣布:接下来 800ms,其他任务全部暂停。蜂鸣器响的那段时间,LED 停在半亮,OLED 的时间数字也不走。
三个任务在一条单行道上排队
把前面这三个函数按顺序排进同一个主循环:
c
while (1)
{
LED_BreathOnce(); /* 软件 PWM 呼吸,约 800ms 后才返回 */
if (Wait_KeyRelease() != 0U) /* 按键死等松手 */
{
Buzzer_BeepOnce(); /* 松手才响一次,Delay 800ms */
}
OLED_Refresh(); /* 显示刷新,排在最后 */
}
每个函数单独拿出来都正常:呼吸灯会亮暗变化,按键按下会等到松手,蜂鸣器会响够时长。串在一起,却没有任何两个任务在"同时"工作------呼吸灯不返回,按键连一次采样都拿不到;按键不返回,蜂鸣器和 OLED 就一直等;蜂鸣器占住 800ms,下一轮呼吸灯又要晚 800ms 开场。
它们争的不是某个 GPIO,而是主循环返回的机会。
OLED 上的时间数字是台体温计
程序里有一个由 1ms 定时中断维护的毫秒计数,每 1ms 走一步。OLED 每隔 100ms 把这个数除以 100 显示出来,所以正常时屏幕上的时间数字每 100ms 涨一格。主循环里排着的第四个函数就是它:
c
static void OLED_Refresh(void)
{
static uint32_t last_ms = 0U;
uint32_t now = Timer_Millis();
if ((uint32_t)(now - last_ms) < 100U)
{
return; /* 还没到 100ms,立刻返回 */
}
last_ms = now;
OLED_ShowNum(0, 0, buzzer_active, 1, OLED_6X8); /* 第 1 行:蜂鸣器状态 */
OLED_ShowNum(0, 8, Key_IsDown(KEY_A), 1, OLED_6X8); /* 第 2 行:按键状态 */
OLED_ShowNum(0, 16, now / 100U, 5, OLED_6X8); /* 第 3 行:时间数字 */
OLED_Update();
}
buzzer_active 是蜂鸣器的开关状态,Key_IsDown(KEY_A) 是按键的稳定状态,第三行那个 now / 100U 就是显示在屏幕上的时间数字。
OLED 的刷新本身也排在主循环里。主循环被哪段 Delay 占住,OLED 就轮不到刷新,屏幕上的数字停在最后一格。等主循环终于转回来,OLED 刷出的是此刻的真实时间------于是数字猛跳。跳了多少格,就是刚才被冻住的时长。
按住 KEY_A 五秒再松手,数字大约跳 50 格。那不是显示坏了,是把欠了五秒的时间一次补上了。
桌面实验:把三件事同时摆上桌
这里的演示不接电机,是一块桌面小系统:按键 A 接 PB11,蜂鸣器接 PB12(低电平触发,Buzzer_Set(1) 让它响),呼吸灯用板载 LED PC13,OLED 按之前的软件 I2C 接线。PB12 在整车中属于左电机方向脚,这里只做独立桌面演示,不要把蜂鸣器并到已经接电机的车上。
烧录后先看正常画面:LED 呼吸、OLED 时间数字每 100ms 涨一格、蜂鸣器安静。然后按住 KEY_A 不松,依次看三处:
- LED 停在某一个亮度,呼吸中断;
- OLED 的时间数字停住;
- 蜂鸣器没有响------按键等待还没结束,蜂鸣器根本没轮到。
松手后,时间数字猛跳一格大的,呼吸从半亮继续。把手指按久一点,数字跳得更多。
亲手量一量:阻塞把时间冻了多久
按键按多久,时间就冻多久
按住 KEY_A 分别撑 1 秒、3 秒、5 秒,每次松手后记下时间数字跳了几格。跳的格数约等于按住时长除以 100ms。按键等待把主循环按停多久,时间数字就停多久------它们本来就是同一件事。
蜂鸣器响两秒,程序就等两秒
把蜂鸣器那段 Delay_ms(800) 改成 Delay_ms(2000),重新下载。单击 KEY_A 再立刻松手:蜂鸣器开始响,响满 2 秒才停。这 2 秒里 LED 呼吸停、时间数字停。改回 800ms,时间数字停的时长也跟着缩短。
呼吸灯慢十倍,按键也跟着慢
把呼吸灯相位里的 Delay_ms(1) 改成 Delay_ms(10),一轮呼吸从约 800ms 变成 8 秒。运行后立刻按 KEY_A:呼吸灯正在跑,按键任务排在它后面,只能等这一轮呼吸结束才轮到。呼吸被调慢,按键的响应时间也被拖慢------阻塞会沿着主循环的顺序,传染给后面每一个任务。恢复 1ms。
三处 Delay 各调过一次以后,规律已经很清楚:主循环里任何一段不返回的等待,都会让排在后面的任务一起陪冻。呼吸灯、按键、蜂鸣器谁都没坏,它们只是没有机会运行。
最后橘猫说
阻塞不是原罪。上电后等待硬件复位、初始化里的微秒级时序,通常可以接受------那段时间系统还没有进入正常任务调度,也没有别的任务在等。
问题出在进入 while(1) 以后。按键、显示、电机、编码器都在抢主循环返回的机会,这时一段 Delay 或一个"等松手"的 while,就把一条本来应该反复检查的路,变成了只允许一辆车通过的窄巷。桌面演示里按住一个按键,呼吸灯和蜂鸣器就全停;把这些写法搬进小车,按住按键的瞬间,轮子、传感器和屏幕会一起停下来等。
阻塞最危险的地方在于它不会报错。程序不崩溃、不重启,看起来只是"反应慢了一点"------慢到某个任务永远轮不到,才以死机的样子出现。