经典三问
Q: LED 模式是什么?
A: LED 模式是用状态和计时参数描述点闪、闪烁、流水或呼吸等输出行为的程序规则。
Q: 为什么要把 LED 做成模式?
A: 把显示行为从主循环里拆出来后,同一盏灯可以表达运行、等待、故障等不同状态而不互相覆盖。
Q: LED 模式常见使用场景?
A: 系统状态指示、通信活动提示、故障码闪烁和没有 OLED 时的最小人机反馈。
上一回让灯呼吸,是把亮度一档一档用 Delay 推出来的:推完一档睡一会儿,整个函数要 800ms 才返回。呼吸是有了,可那 800ms 里主循环被占死,按键按下去没人理,屏幕上的数字也停住。
灯还是那颗灯,这一回换一种写法:不让任何代码停下来等,而是让一个每 1ms 走一步的节拍不停往前走,灯自己看"走到哪了"决定这一刻该亮还是该灭。
非阻塞不是不等待,是把等待拆开
阻塞写法做"等 800ms 再关",是直接睡 800ms:睡着的时候 CPU 什么也不干,主循环里排队的其他事全被挡住。
非阻塞写法不睡。它把"等一段时间"改成四件事:
- 把起点记下来------开始的那一刻,存一个起始时间;
- 保持一个状态------现在进行到哪一步了,用变量记住;
- 让一个固定的短节拍反复来------每 1ms 检查一次;
- 每一拍都先问"到时间了没"------到了就动一小步,没到就立刻返回。
这套形状可以套到大多数周期动作上:灯要呼吸、轮子要每 20ms 算一次控制、屏幕要每 100ms 刷一次,都是同一个做法。下面用三颗灯把它走一遍。
接线和写引脚
三颗外接 LED 沿用前面的参考分配:PC13、PC14、PC15,长脚接 GPIO,短脚接 GND,高电平点亮;两端对调时只改最底层那个写引脚的函数。
(补一句我自己的经历:这三颗灯我直接接在 PC13~PC15 上做过,点亮都正常。后来翻数据手册第 20 页的注才知道:PC13、PC14、PC15 由电源开关供电,开关只能吸收有限的电流(3 mA),所以这三个脚作输出时有四条限制------同一时间只能有一个作输出 、输出时只能工作在 2 MHz 、最大驱动负载 30 pF、并且不能作为电流源(手册举的例子就是驱动 LED)。高电平点亮正好落在最后一条上,所以这种做法可能会导致一些问题:灯偏暗、几颗一起用时亮度不匀,换一块板子表现也可能不一样。这里我不改接线,实验和照片就是这么做的;真要严格守手册,就改成"LED 正极经限流电阻接 3.3 V、负极接 GPIO"(低电平点亮),或者把灯挪到普通 GPIO 上。)
写引脚的两个函数,把"第几颗"和"一起写"分开:
c
static const uint16_t led_pins[] =
{
GPIO_Pin_13, GPIO_Pin_14, GPIO_Pin_15
};
void LED_WriteOne(uint8_t index, uint8_t on)
{
if (on != 0U)
{
GPIO_SetBits(GPIOC, led_pins[index]);
}
else
{
GPIO_ResetBits(GPIOC, led_pins[index]);
}
}
void LED_WriteAll(uint8_t on)
{
uint8_t index;
for (index = 0; index < 3; index++)
{
LED_WriteOne(index, on);
}
}
上面的代码从此不再直接碰 GPIO,只调用这两个函数。实物排线反了,就调 led_pins[] 的顺序。
让一个计数一直往前涨
先给六种说法起好名字,后面就按这些名字切换:
c
typedef enum
{
LED_MODE_OFF = 0,
LED_MODE_ON,
LED_MODE_SLOW,
LED_MODE_FAST,
LED_MODE_DOT,
LED_MODE_BREATH,
LED_MODE_COUNT
} LED_Mode;
灯怎么闪,其实只取决于一件事:从这一轮开始到现在过了多久。
时间轴来自那个 1ms 定时中断,里面只做一件事------让一个计数往前涨一格,再叫一次 1ms 任务:
c
volatile uint32_t tick_ms = 0;
void TIM1_UP_IRQHandler(void)
{
if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
tick_ms++;
Mode_Tick();
}
}
tick_ms 是这条时间轴,Mode_Tick() 是每 1ms 被叫一次的任务------它做什么由当前模式决定,实现放在下面的框架一节。给它两个变量,它就知道该怎么点灯:
c
static uint32_t mode_start_ms = 0; /* 这一轮从哪一刻开始 */
mode_start_ms 在这一轮开始时记下;elapsed 不另存成全局,每个 Tick() 里都现算一次 tick_ms - mode_start_ms,得到的才是从起点走过的毫秒数。闪烁不过是拿 elapsed 对一个周期取余,看它落在周期的哪一段:
| 模式 | 这一毫秒该不该亮 | 说明 |
|---|---|---|
| 灭 | 一直不亮 | 三颗全灭 |
| 常亮 | 一直亮 | 三颗全亮 |
| 慢闪 | (elapsed % 1000U) < 500U |
亮 500ms、灭 500ms |
| 快闪 | (elapsed % 100U) < 50U |
亮 50ms、灭 50ms |
| 点闪 | (elapsed % 1000U) < 100U |
亮 100ms、灭 900ms |
| 呼吸 | breath_phase < breath_brightness |
见下 |
前五档都是同一句话:取余、比较、决定亮灭。想改节奏,只改那两个数;想加一档,就再加一行比较。灯没有"记得自己在闪",它每一毫秒都重新算一次自己现在该是什么样。
呼吸档多一层。一个亮度周期切成 20 个相位,elapsed % 20 决定这一刻处在哪个相位;另有一条慢得多的线,每 50ms 把亮度 breath_brightness 加一档、到顶再往回落。相位小于亮度的那些毫秒亮,其余灭,于是亮暗的比例慢慢变化:
c
breath_phase = (uint8_t)(elapsed % 20U);
LED_WriteAll((uint8_t)(breath_phase < breath_brightness));
亮度的推进也放在同一个 1ms 任务里,所以整个呼吸过程没有一处 Delay------每一毫秒只更新一次相位就返回。上一回那种"推一档睡一会儿"的写法,在这里连一次都不需要。
模式切换框架:每个模式四个函数
现在每种模式都有自己的起点、状态和输出。如果全写进一个巨大的 switch,加一档就要动一次老代码。把模式抽象成一组函数,是更省事的办法。
一个模式由四个函数描述:
Init():进入这个模式时执行一遍,做初始化;Loop():主循环里反复执行,做与时间无关的事;Exit():离开这个模式前执行一遍,做收尾;Tick():1ms 节拍里执行,做与时间有关的事。
用一张表把模式和函数绑起来:
c
typedef struct
{
void (*Init)(void);
void (*Loop)(void);
void (*Exit)(void);
void (*Tick)(void);
} Mode_TypeDef;
切换靠两个变量:curr_mode 是当前所在的模式,next_mode 是想要去的模式。谁想让程序换档,只需要改 next_mode:
c
static uint8_t curr_mode = LED_MODE_OFF;
static uint8_t next_mode = LED_MODE_OFF;
void Mode_Loop(void)
{
if (curr_mode != next_mode)
{
mode_table[curr_mode].Exit();
curr_mode = next_mode;
mode_table[curr_mode].Init();
}
mode_table[curr_mode].Loop();
}
void Mode_Tick(void)
{
mode_table[curr_mode].Tick();
}
Mode_Loop() 放在主循环里,Mode_Tick() 就是前面中断里那个每 1ms 被叫一次的任务。mode_table 是那张模式表,紧接着下面给出。两个变量相等时只是执行当前模式的 Loop();一旦 next_mode 变了,先让旧模式 Exit() 收尾,再让新模式 Init() 开场,然后才开始执行它。切档这件事本身就变成了"改一个变量的值"。
呼吸档要用到的几个状态量,和六档共用的那个 Init() 一起放在这里:
c
static uint32_t last_breath_ms = 0;
static uint8_t breath_phase = 0;
static uint8_t breath_brightness = 0;
static int8_t breath_direction = 1;
static void Mode_Common_Init(void)
{
mode_start_ms = tick_ms; /* 重新记下这一轮的起点 */
last_breath_ms = 0; /* 和 elapsed 同一个基准:进档那一刻是 0 */
breath_phase = 0U;
breath_brightness = 0U;
breath_direction = 1;
}
Init() 里记起点这件事很关键:不重记,新模式就会继承上一轮已经走了很久的 elapsed,一进档就直接跳到周期中段,看起来像"从半路开始闪"。
六档的 Tick() 就是前面那张表的直接翻译,每档一个短函数:
c
static void Mode_Off_Tick(void)
{
LED_WriteAll(0);
}
static void Mode_On_Tick(void)
{
LED_WriteAll(1);
}
static void Mode_Slow_Tick(void)
{
uint32_t elapsed = tick_ms - mode_start_ms;
LED_WriteAll((uint8_t)((elapsed % 1000U) < 500U));
}
static void Mode_Fast_Tick(void)
{
uint32_t elapsed = tick_ms - mode_start_ms;
LED_WriteAll((uint8_t)((elapsed % 100U) < 50U));
}
static void Mode_Dot_Tick(void)
{
uint32_t elapsed = tick_ms - mode_start_ms;
LED_WriteAll((uint8_t)((elapsed % 1000U) < 100U));
}
static void Mode_Breath_Tick(void)
{
uint32_t elapsed = tick_ms - mode_start_ms;
breath_phase = (uint8_t)(elapsed % 20U);
if ((uint32_t)(elapsed - last_breath_ms) >= 50U)
{
last_breath_ms = elapsed;
if (breath_direction > 0)
{
if (breath_brightness < 20U)
{
breath_brightness++;
}
else
{
breath_direction = -1;
}
}
else if (breath_brightness > 0U)
{
breath_brightness--;
}
else
{
breath_direction = 1;
}
}
LED_WriteAll((uint8_t)(breath_phase < breath_brightness));
}
Loop() 和 Exit() 这一篇用不到,给一个空实现顶上:
c
static void Mode_Empty(void)
{
}
模式函数都定好以后,表就是一句话的事:
c
static const Mode_TypeDef mode_table[] =
{
{ Mode_Common_Init, Mode_Empty, Mode_Empty, Mode_Off_Tick },
{ Mode_Common_Init, Mode_Empty, Mode_Empty, Mode_On_Tick },
{ Mode_Common_Init, Mode_Empty, Mode_Empty, Mode_Slow_Tick },
{ Mode_Common_Init, Mode_Empty, Mode_Empty, Mode_Fast_Tick },
{ Mode_Common_Init, Mode_Empty, Mode_Empty, Mode_Dot_Tick },
{ Mode_Common_Init, Mode_Empty, Mode_Empty, Mode_Breath_Tick }
};
加一档的流程也固定下来:写一个 Tick()、在表里加一行、把 LED_MODE_COUNT 加一,框架本身一行都不用改。Loop() 和 Exit() 现在是空的,等某个模式真的需要"进入时做点别的、离开时收个尾",它们自然会被填上内容。
主循环和按键切档
主循环现在很干净:
c
while (1)
{
if (Mode_NextPressed() != 0U)
{
next_mode = (uint8_t)((next_mode + 1U) % LED_MODE_COUNT);
}
Mode_Loop();
OLED_ShowMode(curr_mode);
}
按键这回还是老办法:按下,消抖确认,把 next_mode 换成下一档,再等松手。
c
static uint8_t Mode_NextPressed(void)
{
if (Key_IsDown(KEY_A) == 0U)
{
return 0U;
}
Delay_ms(20);
if (Key_IsDown(KEY_A) == 0U)
{
return 0U;
}
while (Key_IsDown(KEY_A) != 0U)
{
}
return 1U;
}
这里可以看清:灯已经不排在主循环里了 。按住 KEY_A 不放,主循环卡在 while 里,屏幕不再刷新,可 1ms 中断照常来,Mode_Tick() 照常推进------灯一秒都不会停。
Key_IsDown() 读的是按键稳定之后的电平,Delay_ms() 是毫秒级的阻塞延时,两个都是前面用过的接口。
屏幕上看到当前模式
OLED 那两行用一个函数刷出来:
c
static const char *mode_names[] =
{
"OFF", "ON", "SLOW", "FAST", "DOT", "BREATH"
};
void OLED_ShowMode(uint8_t mode)
{
OLED_ShowString(0, 0, (char *)mode_names[mode], OLED_6X8);
OLED_ShowNum(0, 8, tick_ms / 100U, 5, OLED_6X8);
OLED_Update();
}
第一行是当前模式名,第二行是那个一直在涨的时间计数除以 100。计数每 100ms 涨一格;一旦它停住,说明主循环被别的东西堵住了。
两个现场实验
按住按键,看灯还跑不跑
切到呼吸档,等灯开始一圈圈变亮变暗。按住 KEY_A 不松:屏幕上的计数停住,呼吸灯照常亮暗------灯已经不在主循环里了。松手,计数猛跳一截,模式换到下一档。
如果按住时灯也停了,先检查 Mode_Tick() 是不是真的在 1ms 中断里被调用,而不是被放在主循环的某个函数后面。
改一个数,看节奏变没变
把慢闪的 1000U 和 500U 改成 200U 和 100U,慢闪变成快闪;再单独把点闪的 100U 改成 300U,亮的比例变大。两次分开改,只动一个数。
这两个数一个管"一轮多长",一个管"一轮里亮多久"。改的时候会发现它们只管自己那一档:慢闪改完,快闪和点闪照旧,不用跟着动------每一档都各算各的。
最后橘猫说
三颗灯把非阻塞的四件事走了一遍:记起点、保持状态、1ms 节拍反复来、到点动一小步。再加上"每个模式一组函数、切换只改一个变量"的框架,同一盏灯就能说出六种话。
这套东西的价值不在灯上。轮子要每 20ms 算一次控制、屏幕要每 100ms 刷一次、按键要被持续扫描------它们需要的都是同一个形状:一个不睡的主循环,加一个按时来敲门的节拍。灯只是第一个把它演示清楚的对象。