一盏 LED 不够用了:让同一盏灯说出不同的话

经典三问

Q: LED 模式是什么?

A: LED 模式是用状态和计时参数描述点闪、闪烁、流水或呼吸等输出行为的程序规则。

Q: 为什么要把 LED 做成模式?

A: 把显示行为从主循环里拆出来后,同一盏灯可以表达运行、等待、故障等不同状态而不互相覆盖。

Q: LED 模式常见使用场景?

A: 系统状态指示、通信活动提示、故障码闪烁和没有 OLED 时的最小人机反馈。


上一回让灯呼吸,是把亮度一档一档用 Delay 推出来的:推完一档睡一会儿,整个函数要 800ms 才返回。呼吸是有了,可那 800ms 里主循环被占死,按键按下去没人理,屏幕上的数字也停住。

灯还是那颗灯,这一回换一种写法:不让任何代码停下来等,而是让一个每 1ms 走一步的节拍不停往前走,灯自己看"走到哪了"决定这一刻该亮还是该灭。

非阻塞不是不等待,是把等待拆开

阻塞写法做"等 800ms 再关",是直接睡 800ms:睡着的时候 CPU 什么也不干,主循环里排队的其他事全被挡住。

非阻塞写法不睡。它把"等一段时间"改成四件事:

  1. 把起点记下来------开始的那一刻,存一个起始时间;
  2. 保持一个状态------现在进行到哪一步了,用变量记住;
  3. 让一个固定的短节拍反复来------每 1ms 检查一次;
  4. 每一拍都先问"到时间了没"------到了就动一小步,没到就立刻返回。

这套形状可以套到大多数周期动作上:灯要呼吸、轮子要每 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 刷一次、按键要被持续扫描------它们需要的都是同一个形状:一个不睡的主循环,加一个按时来敲门的节拍。灯只是第一个把它演示清楚的对象。

相关推荐
坐看风云变幻3 小时前
《嵌入式工程师的软硬件修仙之路》第 5 期|STM32/GPIO 寄存器操作:从地址映射到点亮一盏灯的全过程
stm32·单片机·嵌入式硬件
dqsh064 小时前
STM32开发用调试器ST-Link V2.1的制作 保姆级教程
stm32·单片机·嵌入式硬件·调试工具·st-link
zjxtxdy6 小时前
STM32HAL库开发-ADC
stm32·单片机·嵌入式硬件
笨笨饿7 小时前
# 59_CSDN博客自动写流程文章
linux·jvm·stm32·单片机·嵌入式硬件·mcu·51单片机
wuyk5557 小时前
模块解耦实战:用 C 语言实现面向对象编程的三个层次
stm32·单片机·嵌入式硬件·性能优化·架构
深圳老胡8 小时前
STM32 工程在 VSCode 中用 AI 搭建软件骨架架构:从零到可运行的分层实践
笔记·stm32·单片机·代码规范
深圳老胡9 小时前
STM32 工程在 VS Code 中的编译与调试:AI 辅助工具实战指南
笔记·stm32·单片机·代码规范
zhangzhangkeji10 小时前
stm32, HAL 库,定时器,三:通道 1 的输入配置举例,通道输入时的 CCR 值更新原理,输入通道的脉宽捕获测量原理,对通道输入脉冲的计数,
stm32
送外卖的工程师10 小时前
STM32空气质量检测系统(机智云)
stm32·单片机·嵌入式硬件