从状态机到任务链:嵌入式时序控制的重构

在嵌入式系统中,周期性时序控制是最常见的需求之一。比如控制两个方向的信号灯,按照“绿灯→黄灯→红灯→绿灯”的周期循环,且两个方向的灯序互补。
初看需求简单,几段 if-else 就能跑通。但当你想加一个“绿灯最后 3 秒闪烁”,或想精确保证周期总时长时,原来的写法立刻捉襟见肘。
本文记录了一段“直觉式”代码的诞生与结构化重构——用自定义的轻量任务调度器,将时序逻辑从流程控制中彻底解放出来。


一、原始设计:把时间逻辑“揉”进中断状态机

1. 想法的起点

对于“四个阶段循环切换”这个需求,最直接的映射就是用一个 flag 变量标识当前阶段,再在每个阶段维护各自的倒计时变量:

  • flag = 0:方向一绿灯,方向二红灯;
  • flag = 1:方向一黄灯,方向二红灯;
  • flag = 2:方向一红灯,方向二绿灯;
  • flag = 3:方向一红灯,方向二黄灯。
    定时器每 1 ms 中断一次,在中断服务函数内累加一个计数器,每满 1000 次(1 秒)就递减当前阶段的剩余时间,余时归零则切换 flag 并重置下一阶段的时间值。

2. 核心实现片段

全局变量平铺直叙:

int green = 10, red = 13, yellow = 3;   // 方向一各灯时长
int Egreen = 10, Ered = 13, Eyellow = 3;// 方向二各灯时长
int flag = 0;          // 当前阶段标志
int time_1ms = 0;      // 1ms计数器

中断函数承载了全部时序逻辑,对 IO 的控制也通过直接操作引脚寄存器完成:

void tim4_IRQ(void)
{
    time_1ms++;
    if(flag == 0) {
        if(time_1ms > 999) {
            green--;
            Ered--;
            if(green < 1) {
                flag = 1;
                yellow = 3;
                green = 1;
            }
            time_1ms = 0;
        }
    }
    else if(flag == 1) {
        if(time_1ms > 999) {
            yellow--;
            Ered--;
            if(yellow < 1) {
                yellow = 1;
                flag = 2;
                Egreen = 10;
                red = 13;
            }
            time_1ms = 0;
        }
    }
    else if(flag == 2) {
        // 类似地递减 red 和 Egreen
    }
    else if(flag == 3) {
        // 类似地递减 Eyellow 和 red
    }
}

主循环则根据 flag 直接驱动 IO 并刷新数码管显示:

while(1) {
    if(flag == 0) {
        DIGIT_DisplayTime5(green, Ered);
        LED1R = 1; LED1Y = 0; LED1G = 0;  // 方向一红灯
        LED2R = 0; LED2Y = 0; LED2G = 1;  // 方向二绿灯
    } else if(flag == 1) {
        DIGIT_DisplayTime5(yellow, Ered);
        LED1R = 1; LED1Y = 0; LED1G = 0;
        LED2R = 0; LED2Y = 1; LED2G = 0;
    }
    // ... 其余状态
    delay_ms(100);
}

3. 这种写法的痛点

  • 时序逻辑高度重复
    每个状态分支都重复着“等待 1 秒 → 递减变量 → 判断临界 → 切换状态并重置变量”的模板,代码量随状态数线性膨胀。
  • 跨阶段的计时容易出错
    例如方向二的红灯时长本应为 13 秒,但在 flag=1 分支内 Ered 持续递减却从未重置,导致其红灯实际时长受前一阶段影响,难以保证精确。
  • IO 操作与业务逻辑强耦合
    控制灯的代码散落在主循环的各个 if 分支里,不仅可读性差,更换硬件引脚时必须逐一查找修改,极易遗漏。
  • 扩展困难
    若需求变为“绿灯最后 3 秒闪烁”,则必须在递减 green 的同时再做剩余时间判断,侵入式修改会进一步让逻辑纠缠。
  • 职责混乱
    时间推进、状态决策和输出控制全部堆在中断和主循环里,无法单独测试或复用。

二、优化设计:用任务链与 IO 抽象取代状态机

1. 思维转变的完整历程:从“状态切换”到“动作序列”的顿悟

当我们用状态机写完初始版本,看似满足了基础需求,但随着功能迭代,各种问题接踵而至——这背后,是我们对需求的抽象出现了偏差。这个思维转变的过程,并非一蹴而就,而是经历了“默认合理→困惑碰壁→顿悟重构”三个清晰的阶段。

(1)第一阶段:状态机思维的“天然合理性”

为什么我们第一反应都是状态机?核心是问题的表面特征与我们的思维习惯高度契合。

交通灯的直观表现就是“绿灯”“黄灯”“红灯”这几个离散状态,我们的眼睛看到的是“现在是绿灯”“现在变成黄灯了”,大脑自然会用“状态”这个概念来建模。再加上嵌入式入门教材常以交通灯作为状态机的经典案例,进一步固化了“这种问题就该用状态机解决”的思维定式。

在需求最简单的阶段,状态机确实表现出色:用flag标识状态,用if\-else分支匹配状态执行操作,代码量少、编写速度快,此时我们完全不觉得有什么问题,甚至会觉得“这问题没必要搞复杂”。这就是思维的第一个阶段——默认状态机写法合理,未发现潜在隐患。

(2)第二阶段:需求变化暴露的深层矛盾,陷入困惑

真正的转折点,来自看似微小的需求变更,而这些变更,恰恰戳中了状态机抽象的致命缺陷,让我们从“觉得没问题”陷入“为什么这么麻烦”的困惑。

第一个裂痕来自“绿灯最后3秒闪烁”的需求。原本干净的flag==0分支,不得不嵌入额外的闪烁判断逻辑——在递减绿灯时长的同时,还要判断剩余时间是否小于3秒,再通过定时器计数实现500ms间隔的闪烁。这时候我们会发现,同一个“绿灯状态”里,又出现了“常亮”和“闪烁”两个子状态,代码开始变得臃肿、混乱,侵入式修改让逻辑纠缠在一起。

第二个裂痕是时序准确性问题。调试时会发现,方向二的红灯时长总是不准——明明设置了13秒,实际却时长时短。排查后才发现,在flag==1(黄灯阶段)的分支里,方向二的红灯时长变量Ered还在持续递减,却从未被重置。这不是粗心,而是状态机的本质缺陷:它把完整的周期拆成了四个孤立的片段,每个片段都在修改全局变量,却没有任何机制保证这些修改协调一致,跨状态的变量操作很容易出现遗漏。

第三个裂痕是互补时序的复杂性。交通灯的核心需求是两个方向时序互补,但在状态机模型里,这种互补是通过硬编码实现的——每个flag值对应两个方向的灯态,修改一个方向的时序,必须同步修改所有相关的flag分支,稍有遗漏就会出现逻辑错误。如果需求扩展到三个方向的交通灯,整个状态机架构都要推倒重来。

此时我们会陷入困惑:明明是简单的时序控制,为什么代码会变得这么复杂?那些看似“显而易见”的优化思路,到底从哪里来?这就是思维的第二个阶段——发现状态机的弊端,却找不到破局的方向。

(3)第三阶段:顿悟时刻——重新抽象问题,找到本质

当被状态机的各种问题折磨得焦头烂额时,停下来反问自己一个核心问题:我们真正要控制的是什么?

答案很简单:不是flag变量的值,也不是状态之间的切换,而是“在什么时间,让哪个灯亮多久”。这个提问,让我们的注意力从“状态”转移到“动作”和“时间”上,这正是思维转变的关键。

我们把交通灯的完整周期拆解开来,忽略“状态”的概念,只关注具体动作:方向一的动作的是“绿灯亮10秒→黄灯亮3秒→红灯亮13秒→循环”,方向二的动作是“红灯亮13秒→绿灯亮10秒→黄灯亮3秒→循环”。此时会突然顿悟:根本没有什么“状态”,只有一个接一个的“动作”,每个动作都有明确的持续时间——所谓的“状态”,只是“某个动作正在执行中”的副产品。

我们之前一直在用“副产品”(状态)建模,而忽略了“本质”(动作序列)。这就是那个“显而易见”的结论的由来:它不是天生的真理,而是踩坑之后的反思、抽象之后的顿悟。当想通这一点,我们就进入了思维的第三个阶段——理解了动作序列的本质,明确了重构的方向。

2. 思维转变:从状态切换转向任务调度

重新审视需求,周期性时序本质上是一组带有固定持续时间的动作序列,而非几个需要来回切换的“状态”。
方向一的动作序列为:

绿灯亮 10 秒 → 黄灯亮 3 秒 → 红灯亮 13 秒 → 回到绿灯……

方向二的动作序列与之互补:

红灯亮 13 秒 → 绿灯亮 10 秒 → 黄灯亮 3 秒 → 回到红灯……

如果能有一个程序能够实现将这些动作序列直观的简单的表示出来就像下面的这段程序:

TimerTask_t TimerTask[3] = {
    {10000, LED_EW_G},   // 绿灯 10 秒
    { 3000, LED_EW_Y},   // 黄灯 3 秒
    {13000, LED_EW_R},   // 红灯 13 秒
};

利用结构体将时间与动作结合在一起,使用数组表示多个序列发生的动作,再设计一个能自动按照设想规则执行的调度器程序。

这样,每个方向都可以抽象成一个任务链,由同一个调度器按时间顺序驱动执行。

3. 实现一个轻量任务调度器

任务定义:包含持续时间和一个回调函数,回调会收到任务的剩余时间。

typedef struct {
    uint32_t time_ms;                 // 任务时长
    void (*callback)(uint32_t time_ms); // 回调,参数为剩余时间(ms)
} TimerTask_t;

调度器:负责累加时间、顺序查找当前应执行的任务,并调用其回调。

typedef struct {
    uint32_t time;          // 本周期已流逝时间
    TimerTask_t *timerTask;
    uint16_t timerTaskLen;
} TimerTaskRun_t;
void timerTaskRun(TimerTaskRun_t *run, uint16_t ms)
{
    if(!run || !run->timerTask || !run->timerTaskLen) return;
    uint32_t time = run->time += ms;
    uint16_t i;
    do {
        for(i = 0; i < run->timerTaskLen; ++i) {
            TimerTask_t *task = &run->timerTask[i];
            if(time <= task->time_ms) {
                // 当前任务未结束,调用回调,传剩余时间
                if(task->callback) task->callback(task->time_ms - time);
                break;
            } else {
                time -= task->time_ms; // 已完成,扣除时长继续
            }
        }
        if(i == run->timerTaskLen) {
            run->time = time;   // 完成一轮,保留余数,立即重新开始
        }
    } while(i == run->timerTaskLen);
}

4. 封装 IO 操作,剥离硬件依赖

原始代码中到处散落着 LED1R = 1; LED1Y = 0; LED1G = 0; 这样的直接寄存器操作,可读性差且难维护。我们将其抽象为两个独立的灯控函数,每个函数接收一个字符表示目标颜色:

void LED_EW(char c)
{
    switch(c){
        case 'r':case 'R': LED1R = 1; LED1Y = 0; LED1G = 0; break;
        case 'g':case 'G': LED1R = 0; LED1Y = 0; LED1G = 1; break;
        case 'y':case 'Y': LED1R = 0; LED1Y = 1; LED1G = 0; break;
        case 'c':case 'C': LED1R = 0; LED1Y = 0; LED1G = 0; break;
    }
}
void LED_SN(char c)
{
    switch(c){
        case 'r':case 'R': LED2R = 1; LED2Y = 0; LED2G = 0; break;
        case 'g':case 'G': LED2R = 0; LED2Y = 0; LED2G = 1; break;
        case 'y':case 'Y': LED2R = 0; LED2Y = 1; LED2G = 0; break;
        case 'c':case 'C': LED2R = 0; LED2Y = 0; LED2G = 0; break;
    }
}

有了这两层封装,所有灯控都变成了语义清晰的函数调用,例如 LED_EW('g') 表示开方向一绿灯;LED_SN('c') 表示关闭方向二所有灯。日后更换引脚,只需修改这两个函数,其余代码纹丝不动。

5. 用任务链描述灯序

方向一(EW)的任务链:

TimerTask_t TimerTask_EW[3] = {
    {10000, LED_EW_G},   // 绿灯 10 秒
    { 3000, LED_EW_Y},   // 黄灯 3 秒
    {13000, LED_EW_R},   // 红灯 13 秒
};
TimerTaskRun_t TimerTaskRun_EW = {0,TimerTask_EW,3};

方向二(SN)的任务链:

TimerTask_t TimerTask_SN[3] = {
    {13000, LED_SN_R},   // 红灯 13 秒
    {10000, LED_SN_G},   // 绿灯 10 秒
    { 3000, LED_SN_Y},   // 黄灯 3 秒
};
TimerTaskRun_t TimerTaskRun_SN = {0,TimerTask_SN,3};

每个回调函数直接完成两件事:控制灯的亮灭(调用封装好的 IO 函数),并更新显示用的倒计时秒数
例如绿灯回调,在其中轻松实现了“最后 3 秒闪烁”:

void LED_EW_G(uint32_t time_ms)
{
    // 剩余 >3000ms 时常亮,否则以 500ms 间隔闪烁
    LED_EW(time_ms > 3000 ? 'g' : (time_ms % 1000 < 500 ? 'g' : 'c'));
    timer_EW = (time_ms + 999) / 1000;  // 剩余秒数,向上取整显示
}

红灯和黄灯的回调则更简单:

void LED_EW_R(uint32_t time_ms) {LED_EW('r');timer_EW = (time_ms + 999) / 1000;}
void LED_EW_Y(uint32_t time_ms) {LED_EW('y');timer_EW = (time_ms + 999) / 1000;}

方向二的回调完全同理,仅方向不同。

6. 中断与主循环变得极简

定时器中断现在只需驱动两个调度器向前走:

void tim4_IRQ(void)
{
    timerTaskRun(&TimerTaskRun_EW, 1);
    timerTaskRun(&TimerTaskRun_SN, 1);
}

主循环不再需要判断 flag,只负责周期性刷新数码管(显示 timer_SNtimer_EW):

while(1) {
    DIGIT_DisplayTime5(timer_SN, timer_EW);
    delay_ms(100);
}

所有时序、切换、闪烁逻辑均已内聚在任务链和回调中。


三、优化了什么:一张对比表看清区别

维度 原始状态机 任务调度器
代码结构 状态分支嵌入中断,重复模板代码 任务链数组定义动作,中断仅驱动调度器
时序准确性 跨状态变量复位易遗漏,产生累积误差 调度器严格按任务时长循环,无误差
硬件控制 主循环中直接操作寄存器,散落各处 封装为 LED_EW / LED_SN 函数,一处修改全局生效
可读性 需追踪多个 if 分支和变量赋值才能理解完整周期 扫一眼任务数组即知每个方向的完整动作序列
扩展性 增加绿灯闪烁等需求必须修改多个状态分支 新功能封装在回调函数内,调度框架无需改动
复用性 时间逻辑与业务强耦合,新项目需重写 timerTaskRun 是通用组件,只需更换任务链

四、适用场景全覆盖

1.1 交通灯

// 东西向交通灯时序
TimerTask_t TimerTask_EW[] = {
    {10000, LED_EW_G},  // 绿灯10秒
    { 3000, LED_EW_Y},  // 黄灯3秒
    {13000, LED_EW_R},  // 红灯13秒
};

1.2 洗衣机

// 洗衣机完整洗涤时序
TimerTask_t washer_tasks[] = {
    { 5000, water_in},      // 进水5秒
    {30000, wash},          // 洗涤30秒
    {10000, drain},         // 排水10秒
    {15000, spin},          // 脱水15秒
    { 5000, water_in},      // 进水5秒
    {20000, rinse},         // 漂洗20秒
    {10000, drain},         // 排水10秒
    {20000, spin},          // 脱水20秒
    { 2000, beep_finish},   // 完成提示2秒
};

1.3 电饭煲

// 电饭煲煮饭时序
TimerTask_t rice_cooker_tasks[] = {
    {600000, heat},         // 加热10分钟
    {900000, boil},         // 沸腾15分钟
    {300000, simmer},       // 焖饭5分钟
    {  2000, beep_finish},  // 完成提示
    {3600000, keep_warm},   // 保温1小时
};

1.4 LED 流水灯 / 呼吸灯

// 4位LED流水灯时序
TimerTask_t led_flow_tasks[] = {
    {500, led1_on},
    {500, led2_on},
    {500, led3_on},
    {500, led4_on},
};

1.5 广告屏 / 数码管显示切换

// 广告屏轮播时序
TimerTask_t ad_display_tasks[] = {
    {3000, show_ad1},
    {3000, show_ad2},
    {3000, show_ad3},
    {5000, show_logo},
};

1.6 电机正反转控制

// 电机正反转循环时序
TimerTask_t motor_tasks[] = {
    {5000, motor_forward},   // 正转5秒
    {1000, motor_stop},      // 停止1秒
    {5000, motor_backward},  // 反转5秒
    {1000, motor_stop},      // 停止1秒
};

1.7 倒计时器 / 定时器

// 10分钟倒计时器
TimerTask_t countdown_tasks[] = {
    {600000, update_display},  // 更新显示
    {  2000, beep_finish},     // 时间到提示
};

五、更进一步的思考:调度器的可扩展性

上文的 TimerTaskRun_t 只保留了一个累积计时 time,以最精简的方式实现了循环任务链。在实际项目中,它还可以非常自然地扩展更多控制维度,例如:

  • 暂停/恢复:添加 uint8_t pause 标志,在 timerTaskRun 开头判断,暂停时直接返回,时间不再累加。
  • 停止/重启:增加 uint8_t enable 标志,停止时清空时间并调用一个“全灭”回调;重启时重新开始。
  • 跳转任务:在 TimerTaskRun_t 中增加 uint16_t targetIndex,主调函数可强制将调度器直接跳到指定任务执行。
  • 单次/循环模式:加入模式标志,一轮结束后自动停止而非循环,适合“执行一次”的场景。
    这些扩展只需在调度框架内部调整,完全不影响现有的任务链定义和各回调函数——这正是解耦带来的红利。

六、总结

面对“几个阶段来回切换”的需求,用 flag 和定时器写一套状态机是几乎本能的反应。但当任务的复杂度稍微增加——互补时序、闪烁效果、精确周期要求——这种直接映射就显现出它的脆弱。

优化的实质是做了一个关注点分离

  • 任务调度器只负责“时间的推移和任务的按序触发”;
  • 硬件抽象层LED_EW / LED_SN)负责屏蔽寄存器细节;
  • 任务回调只负责“在某个时间点执行什么动作”。

由此,“什么时候做什么”这一信息从过程式代码中抽离,变成了一段清晰的数据(任务链数组)。代码退居为框架,业务逻辑则提升为可配置的规则。

这种思路不仅适用于信号灯控制,在任何需要精确时序执行序列的嵌入式场景——例如顺序控制系统、传感器分时采样、LED动画驱动——都能带来同样的简洁与稳健。希望这次重构的历程,能给你的下一个项目一些启发。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐