9、状态机与定时器:超时事件处理、定时器状态机、看门狗状态机

嵌入式系统里,时间是个绕不开的话题。我做了这么多年嵌入式开发,发现很多bug其实不是逻辑错了,而是时间没处理好。比如一个串口接收,你等数据等多久?等不到怎么办?设备死机了怎么自动恢复?这些问题,说白了都得靠定时器配合状态机来解决。

这一章,我们就来聊聊状态机里怎么玩转时间。我会从最基础的超时事件处理讲起,然后带你看看定时器状态机和看门狗状态机的实战写法。嗯,都是我在项目里踩过坑之后总结出来的经验。

9.1 超时事件处理:别让状态机死等

先问个问题:你的状态机在某个状态里,等一个外部事件。如果这个事件一直不来呢?

我见过不少新手写的代码,状态机进入等待状态后,就在那里死循环轮询。CPU跑满,功耗爆炸,关键是万一硬件出问题,整个系统就卡死了。这显然不行。

正确的做法是:给每个等待状态加一个超时机制。超时到了,就触发一个超时事件,状态机据此做出反应——重试、报错、或者回退到安全状态。

来看一个简单的超时状态机框架:

typedef enum {
    STATE_IDLE,
    STATE_WAITING,
    STATE_TIMEOUT,
    STATE_DONE
} State_t;

typedef struct {
    State_t state;
    uint32_t timeout_ms;      // 超时阈值
    uint32_t elapsed_ms;      // 已等待时间
    void (*on_timeout)(void); // 超时回调
} TimeoutSM_t;

void TimeoutSM_Tick(TimeoutSM_t *sm, uint32_t tick_ms) {
    switch (sm->state) {
        case STATE_IDLE:
            // 啥也不干
            break;
        case STATE_WAITING:
            sm->elapsed_ms += tick_ms;
            if (sm->elapsed_ms >= sm->timeout_ms) {
                sm->state = STATE_TIMEOUT;
                if (sm->on_timeout) sm->on_timeout();
            }
            break;
        case STATE_TIMEOUT:
            // 超时处理,可以重试或报错
            break;
        case STATE_DONE:
            // 正常完成
            break;
    }
}

这个模式我用了很多年。核心思想就一句话:状态机里不要有阻塞等待,用计数器代替延时。每次系统滴答(比如1ms中断)调用一次Tick函数,状态机自己累加时间,到了就触发超时。

重要原则:状态机里的所有等待,都必须有超时保护。没有超时的等待,就是一颗定时炸弹。

9.2 定时器状态机:把定时器本身当成一个状态机

你想想看,一个定时器从启动到超时,其实就是一个简单的两状态状态机:运行态和停止态。但实际项目中,定时器的行为往往更复杂——比如单次定时、周期定时、甚至带暂停和恢复功能。

我个人习惯把定时器也封装成状态机。这样上层逻辑清晰,而且容易扩展。来看一个支持单次和周期模式的定时器状态机:

typedef enum {
    TIMER_STOPPED,
    TIMER_RUNNING,
    TIMER_EXPIRED,
    TIMER_PAUSED
} TimerState_t;

typedef enum {
    TIMER_MODE_ONESHOT,
    TIMER_MODE_PERIODIC
} TimerMode_t;

typedef struct {
    TimerState_t state;
    TimerMode_t  mode;
    uint32_t     period_ms;
    uint32_t     counter_ms;
    void (*callback)(void);
} TimerSM_t;

void TimerSM_Start(TimerSM_t *tmr, uint32_t period_ms, TimerMode_t mode) {
    tmr->period_ms  = period_ms;
    tmr->counter_ms = 0;
    tmr->mode       = mode;
    tmr->state      = TIMER_RUNNING;
}

void TimerSM_Tick(TimerSM_t *tmr, uint32_t tick_ms) {
    switch (tmr->state) {
        case TIMER_RUNNING:
            tmr->counter_ms += tick_ms;
            if (tmr->counter_ms >= tmr->period_ms) {
                tmr->state = TIMER_EXPIRED;
                if (tmr->callback) tmr->callback();
                if (tmr->mode == TIMER_MODE_PERIODIC) {
                    tmr->counter_ms = 0;
                    tmr->state = TIMER_RUNNING;  // 自动重载
                }
            }
            break;
        case TIMER_EXPIRED:
            // 单次模式下,这里可以自动回到STOPPED
            if (tmr->mode == TIMER_MODE_ONESHOT) {
                tmr->state = TIMER_STOPPED;
            }
            break;
        case TIMER_PAUSED:
            // 暂停时不计数
            break;
        case TIMER_STOPPED:
        default:
            break;
    }
}

我在项目中遇到过一个问题:多个定时器同时运行时,回调函数里如果修改了定时器状态,很容易造成竞态。后来我定了个规矩——定时器回调里只发事件,不做状态变更。状态变更统一放在主循环里处理。这个习惯帮我避免了很多难以复现的bug。

小技巧:如果你需要很多定时器,可以用一个定时器链表。每次Tick遍历链表,更新所有定时器。这样只需要一个硬件定时器,就能管理N个软件定时器。

9.3 看门狗状态机:系统的最后一道防线

看门狗(Watchdog)这东西,说白了就是一条「保命绳」。系统正常运行时,定期喂狗;如果系统卡死或者跑飞了,看门狗超时就会复位系统。

但看门狗怎么喂?很多人的做法是在主循环里随便找个地方喂一下。这其实很危险——如果某个分支卡死了,但主循环还在跑,看门狗照样被喂,系统就不会复位。你想想看,这等于看门狗白装了。

正确的做法是:用状态机来管理看门狗。把喂狗操作放在状态机的特定状态里,只有所有关键路径都正常通过了,才允许喂狗。

来看一个我常用的看门狗状态机模型:

typedef enum {
    WDG_IDLE,
    WDG_ARMED,
    WDG_FEEDING,
    WDG_EXPIRED
} WdgState_t;

typedef struct {
    WdgState_t state;
    uint32_t   feed_interval_ms;
    uint32_t   last_feed_ms;
    uint32_t   max_feed_interval_ms;
    uint8_t    feed_count;       // 喂狗次数统计
    uint8_t    feed_threshold;   // 必须达到的喂狗次数
} WdgSM_t;

void WdgSM_Feed(WdgSM_t *wdg) {
    // 只有处于ARMED状态才能喂狗
    if (wdg->state == WDG_ARMED) {
        wdg->feed_count++;
        wdg->last_feed_ms = 0;
        if (wdg->feed_count >= wdg->feed_threshold) {
            wdg->state = WDG_FEEDING;
            // 这里执行真正的硬件喂狗操作
            HAL_Watchdog_Refresh();
            wdg->feed_count = 0;
            wdg->state = WDG_ARMED;
        }
    }
}

void WdgSM_Tick(WdgSM_t *wdg, uint32_t tick_ms) {
    switch (wdg->state) {
        case WDG_ARMED:
            wdg->last_feed_ms += tick_ms;
            if (wdg->last_feed_ms > wdg->max_feed_interval_ms) {
                wdg->state = WDG_EXPIRED;
                // 超时未喂狗,触发系统复位
                NVIC_SystemReset();
            }
            break;
        case WDG_FEEDING:
            // 喂狗完成后自动回到ARMED
            wdg->state = WDG_ARMED;
            break;
        case WDG_EXPIRED:
            // 已经复位了,这里不会执行到
            break;
        default:
            break;
    }
}

这个设计的关键点在于:喂狗不是随便调的,必须满足条件。比如你可以要求:只有任务A、任务B、任务C都正常跑完一轮,并且通信模块确认无异常,才允许喂狗。任何一个环节卡住,喂狗次数就达不到阈值,看门狗就会超时复位。

警告:千万不要在中断服务函数里喂狗!我曾经犯过这个错——中断频繁触发,看门狗一直被喂,主循环卡死了都不知道。后来产品在现场出了问题,排查了三天才找到原因。从那以后,我规定喂狗只能在主循环的特定位置进行。

9.4 三种定时器状态机的对比与选择

好了,三种定时器相关的状态机都讲完了。我整理了一个表格,方便你对比:

类型 核心用途 状态数 典型场景 注意事项
超时事件处理 给等待状态加超时保护 3-4个 串口接收超时、按键长按检测 每个等待状态都要有超时
定时器状态机 管理定时器的启停和模式 4-5个 周期任务调度、延时触发 回调里不要改状态
看门狗状态机 安全喂狗,防止误喂 3-4个 系统异常检测、自动复位 不要在中断里喂狗

实际项目中,这三种状态机经常组合使用。比如一个通信协议栈:用超时状态机处理报文等待,用定时器状态机管理重发间隔,用看门狗状态机确保整个协议栈正常运行。三者配合,系统的健壮性会提升一个档次。

9.5 核心知识体系

为了让你更直观地理解这一章的内容,我画了一张图,把三种定时器状态机的关系和核心逻辑展示出来:

状态机与定时器核心知识体系 超时事件处理 状态:IDLE 状态:WAITING 状态:TIMEOUT 状态:DONE 核心:计数器累加 触发超时事件 场景:串口超时 按键长按检测 定时器状态机 状态:STOPPED 状态:RUNNING 状态:EXPIRED 状态:PAUSED 模式:单次/周期 回调只发事件 场景:周期调度 延时触发 看门狗状态机 状态:IDLE 状态:ARMED 状态:FEEDING 状态:EXPIRED 喂狗需满足条件 超时自动复位 场景:异常检测 系统保护 三者组合使用,构建健壮的嵌入式系统

从这张图可以看得很清楚:三种状态机各有侧重,但核心思想是一致的——用状态机管理时间,而不是用延时函数阻塞CPU。这是嵌入式实时系统的基本功,也是区分新手和老手的分水岭。

好了,这一章的内容就到这里。定时器和状态机的组合,说白了就是给系统装上「时间感知」能力。你可以在自己的项目里试试看,从最简单的超时保护开始,逐步加入定时器状态机和看门狗状态机。相信我,系统的稳定性会有质的提升。


公众号:蓝海资料掘金营,微信deep3321