做嵌入式开发做到第三四个项目的时候,很多人会跟我有同样的感受:裸机程序写到最后,主循环里flag多到自己都分不清,功能一多就靠delay硬撑,按键一长按,整个系统跟着卡一下。后来我重构手里的一个物联网网关项目,彻底换成了状态机加soft_timer这套嵌入式软件设计架构,主循环里只剩一个事件分发,代码肉眼可见地变得清爽。这篇文章就把这套架构里的两个核心模块拆开讲清楚:状态机运行器怎么设计,soft_timer定时器模块怎么落地,以及两者如何配合处理真实业务。正在被裸机逻辑折磨的朋友,尤其适合参考。
1. 裸机代码的失控现场与状态机架构的破局思路
1.1 标志位和delay堆出来的系统为什么不可维护
先还原一个我见过很多次的场景。一个典型的产品代码,主循环长这样:检查按键标志、处理显示刷新、查询传感器、处理串口收到的数据、根据某个mode变量决定行为。中间穿插着几个delay_ms(20)、delay_ms(500)。刚开始功能少,看起来逻辑清晰。等到需求叠加,按键要支持长短按,通信要支持超时重传,传感器要支持掉线检测,事情就开始失控。
失控的根本原因,是所有行为都靠“当前应该干什么”的隐式顺序来维系。按键按下时如果恰好在处理传感器,按键逻辑就被拖慢了;通信超时依赖一个累计变量,但这个变量在多个地方被修改;为了不让界面卡顿,有人会把delay改成非阻塞检查,结果每个模块都维护自己的计时变量,状态又多又乱。这套代码不是说不能跑,而是每次加需求都像在雷区里走路,改一个bug引出三个新bug。
我后来意识到,这种混乱的本质是逻辑顺序没有被显式建模。系统在每个时刻处于什么阶段、遇到什么事件该做什么、下一步该转移到哪里,全凭开发者的“记忆”在维持。状态机解决的就是这个问题。
1.2 状态机的核心价值:把系统行为放到明面上
状态机不是玄学,它做的最重要一件事,是把系统行为从“时序代码”转成“一张表”。每个时刻系统处于一个确定的状态,只有接收到特定事件才发生转移,转移时可以执行动作。这么一来,系统的所有可能性都是可枚举的,不会再出现“理论上不该发生”的路径。
举个例子。按键长短按检测,用传统写法可能需要三个标志位加两个计时变量,状态交错很难理清。用状态机写就非常规矩:空闲态、按下防抖态、长按生效态、释放等待态。每个事件从哪个状态到哪个状态,一清二楚。出了问题查表,而不是翻几百行代码找逻辑。
我用状态机还有个特别实际的收益:可测试性。因为状态转移是纯函数式的,给定当前状态和输入事件,结果是可以预见且可以断言的。这意味着很多逻辑不需要上板子就能在本地跑测试,编译一个小工具直接喂事件序列,看状态转移对不对。这对产品质量的提升是决定性的。
1.3 soft_timer模块在架构中的定位
状态机解决了“逻辑组织”的问题,但嵌入式系统还有一个绕不开的现实问题——时间。按键要防抖,通信要超时重传,指示灯要闪烁,传感器要周期性采集。这些需求都需要做延时或定时,但硬件定时器的数量永远是有限的。
soft_timer模块就是解决这个问题的。它在软件层面把时间片切分、管理多个定时任务,外部只需要注册一个回调,时间到了由模块统一触发。把它和状态机放一起,效果非常奇妙:状态机里那些用delay实现的等待,可以全部改造成“进入某个状态时启动一个定时器,收到超时事件后再转移”。延时不再是阻塞,系统在等待期间可以处理其他事件,CPU利用率大幅提升。
所以这套架构的核心思路就是两条线:状态机管逻辑流向,soft_timer管时间触发。两条线通过事件机制汇合,状态机下发“启动定时器”的指令,定时器到点后用事件通知状态机。下面从细节上拆解。
2. 状态机建模从定义到跑通:事件、状态表和运行器实现
2.1 事件、状态、转移三要素的数据结构怎么定
先用C语言把这套状态机的基础结构定义出来。这里我用的平铺状态机,也就是网上常说的FSM(有限状态机),事件和状态都用枚举表示。
typedef enum { EVENT_NONE = 0, EVENT_BTN_DOWN, EVENT_BTN_UP, EVENT_TIMEOUT, EVENT_UART_RX, EVENT_MAX } event_id_t; typedef struct { event_id_t id; uint32_t param; uint32_t timestamp; } event_t; typedef struct state_machine state_machine_t; typedef void (*state_action_t)(state_machine_t *sm, const event_t *ev); typedef struct { uint8_t current_state; event_id_t event; uint8_t next_state; state_action_t action; } state_transition_t; struct state_machine { uint8_t current_state; const state_transition_t *table; uint16_t table_size; void *user_data; };这三个结构就是状态机运行的全部支撑。event_t是状态机收到的输入,state_transition_t描述一条转移规则,state_machine_t保存当前状态和查表用的转移表。要注意的是action函数指针不是必须的,很多人做状态机只转状态不执行动作,但那样系统的响应就只能依靠进入某个状态后的轮询,反而不直观。我的习惯是把动作挂在转移上,转移发生即执行动作,逻辑最集中。
user_data这个字段非常重要,它可以让多个状态机实例共用一个转移表而不冲突。比如两路按键,状态一样,只是user_data不同,彻底避免复制两份几乎相同的代码。
2.2 表驱动状态机和switch-case的取舍
在决定状态机实现方案时,我先在表驱动和switch-case之间纠结了一阵。
switch-case的写法很直观,每个状态写一个case,内部再对事件做条件判断。状态少的时候看代码很舒服,但我后来遇到的十几个状态的项目里,switch-case会迅速膨胀成一个几百行的大函数,而且状态和事件的组合数一多,漏掉某条转移是家常便饭。
表驱动则不同,全部的转移规则被压缩进一张静态数组,哪两个状态之间可转移一目了然。看漏没看漏,数一下行数就行。代码里查表的那部分逻辑是通用的,新增一套状态机只需要定义一张新表,运行器代码完全复用。
static const state_transition_t s_btn_trans[] = { { BTN_IDLE, EVENT_BTN_DOWN, BTN_DEBOUNCE, act_btn_down }, { BTN_DEBOUNCE, EVENT_TIMEOUT, BTN_PRESSED, act_btn_debounce }, { BTN_DEBOUNCE, EVENT_BTN_UP, BTN_IDLE, act_btn_cancel }, { BTN_PRESSED, EVENT_TIMEOUT, BTN_LONG_WAIT, act_btn_long }, { BTN_PRESSED, EVENT_BTN_UP, BTN_IDLE, act_btn_short }, { BTN_LONG_WAIT, EVENT_BTN_UP, BTN_IDLE, act_btn_long_ok }, };但如果状态和事件的值不是从0开始的连续整数,表驱动查起来就要做线性查找,性能略受影响。我的判断标准是:状态数少于15个、事件数少于10个,线性查表的开销完全可以接受,毕竟一次查表就是几次整数比较。如果状态量大,那就退回去用switch-case或者引入哈希索引,老实说嵌入式常规逻辑很少会到那个复杂度。
2.3 事件队列与状态分发循环的实现要点
转移表只负责“规则”,运行器负责“驱动”。运行器要做的一件事就是接事件、查表、执行动作、更新状态。事件从哪来?中断服务函数、定时器回调、主循环读取的外设数据,都可能产生事件。所以必须有个事件队列做缓冲。
事件队列我用的是环形缓冲区,简单可靠,不需要动态内存。
#define EVENT_QUEUE_SIZE 16 static event_t s_queue[EVENT_QUEUE_SIZE]; static uint8_t s_head = 0; static uint8_t s_tail = 0; int event_post(const event_t *ev) { uint8_t next = (s_head + 1) % EVENT_QUEUE_SIZE; if (next == s_tail) { return -1; // queue full } s_queue[s_head] = *ev; s_head = next; return 0; } int event_get(event_t *out) { if (s_head == s_tail) { return -1; } *out = s_queue[s_tail]; s_tail = (s_tail + 1) % EVENT_QUEUE_SIZE; return 0; }主循环的逻辑就变得非常干净:
while (1) { event_t ev; soft_timer_poll(); if (event_get(&ev) == 0) { sm_dispatch(&button_sm, &ev); sm_dispatch(&comm_sm, &ev); } }注意这里soft_timer_poll()放在取事件之前,保证每个循环周期先处理到期的定时器,把超时事件放进队列,再统一分发。这个顺序很重要,如果反过来,定时器到期了事件却要晚一个循环才被处理,时间精度会打折。
sm_dispatch的实现不复杂:
void sm_dispatch(state_machine_t *sm, const event_t *ev) { for (uint16_t i = 0; i < sm->table_size; i++) { const state_transition_t *t = &sm->table[i]; if (t->current_state == sm->current_state && t->event == ev->id) { if (t->action) { t->action(sm, ev); } sm->current_state = t->next_state; break; } } }这个查表过程里有两个细节值得留意。第一,动作执行是发生在状态转移之前的,因为动作函数里可能需要访问旧状态做判断。第二,状态转移写在动作后面,动作里如果调用sm->current_state,读到的还是旧状态,这个符合预期;万一动作里意外再次dispatch导致状态变了,转移就会错乱,这个问题我后面会单独讲。
3. soft_timer模块的完整实现思路
3.1 节拍基准:如何挑选系统tick并绑定硬件定时器
soft_timer需要有一个时间基准,也就是芯片内部需要有一个周期性的硬件中断来推进系统时钟。最常见的方案是用SysTick定时器,在STM32这类Cortex-M内核芯片上,SysTick就是为操作系统节拍准备的,直接拿来用最省事。
节拍周期怎么定?这是第一个要做的选择题。我见过有人把tick设成1ms,有人设成10ms。我的建议是看系统最短的需要定时的时间粒度。按键防抖如果需要5ms级别的精度,那10ms的tick就不够;但tick太频繁会增加中断开销,系统一直在跑中断,干不了正事。
我自己一般选择1ms作为默认节拍,原因有两条。一是1ms是很多通信协议(比如串口超时、Modbus帧间隔)的常用时间粒度,二是1ms的tick对主流MCU来说负担很小,Cortex-M0跑到48MHz处理一个tick中断可能只要几微秒。如果系统的定时精度要求比较粗,比如只做秒级的轮询,也可以把tick放宽到10ms,省下的中断开销还是很可观的。
SysTick的配置很简单,初始化时按照内核时钟频率装载重装载值,启动中断即可:
void systick_init(uint32_t cpu_freq) { SysTick_Config(cpu_freq / 1000); // 1ms tick } volatile uint32_t g_tick; void SysTick_Handler(void) { g_tick++; }3.2 定时器存储结构:静态数组配合链表,还是最小堆
soft_timer的核心数据结构,决定了模块的性能和复杂度。我调研和实测下来,嵌入式场景里最流行的是两种方案:链表和最小堆。
链表的思路是从简单出发。每个定时器节点保存到期时间、周期、回调函数、激活标志,再用单向链表把所有节点串起来。tick中断或主循环里逐个检查到期时间,到期的执行回调。这个方案代码量最少,适合定时器数量在几个到十几个之间的场景,绝大多数单片机项目就在这个范围里。
最小堆的复杂度高一个档次,但解决了一个痛点:链表方案检查到期要遍历所有节点,定时器很多时浪费时间。最小堆把到期时间最近的节点放在堆顶,每次只需要检查堆顶,插入和删除的时间复杂度是O(logN)。问题是代码量上去了,还要小心边界条件。
我的建议是:先把链表方案写出来跑通全局,测CPU占用率,确认定时器数量确实成了瓶颈,再考虑上最小堆。绝大多数项目根本不会走到这一步。
具体实现上,我会用一个静态结构体数组来预分配定时器控制块,避免运行时动态申请内存带来的碎片问题:
#define SOFT_TIMER_MAX_NUM 8 typedef void (*soft_timer_cb_t)(void *arg); typedef struct soft_timer { uint32_t expire_count; uint32_t period; uint8_t active; soft_timer_cb_t cb; void *arg; struct soft_timer *next; } soft_timer_t; static soft_timer_t s_timer_pool[SOFT_TIMER_MAX_NUM]; static soft_timer_t *s_timer_head; static volatile uint32_t g_tick;启动一个定时器时,从空闲池里捞一个节点,初始化字段,再按到期时间递增插入链表。这个插入可以做成按到期顺序维护的链表,这样expire_count最小的节点永远在头部,虽然插入时有O(N)的比较,但到期检查只需要看头节点。
3.3 中断里促发、主循环回调:一个更安全的时间处理模型
这里有个很多新手没想清楚的坑:到期回调到底在哪里执行?在tick中断里直接调用回调函数,还是只在中断里标记,回到主循环再执行?
直接在中端里跑回调,代码写着简单,但后患无穷。回调里如果有一点点耗时操作,整个tick都被拉长,系统实时性崩塌;回调里如果再申请事件队列、操作链表,还可能和主循环发生数据竞争,需要给临界区加锁,复杂度立刻飙升。
我的处理方式是:tick中断只负责g_tick++,主循环里通过soft_timer_poll()统一检查链表并执行回调。这样所有定时器回调都运行在主循环上下文,天然避开了并发问题。
void soft_timer_poll(void) { soft_timer_t *t = s_timer_head; while (t) { soft_timer_t *next = t->next; if (t->active && (int32_t)(g_tick - t->expire_count) >= 0) { if (t->period == 0) { soft_timer_stop(t); } else { t->expire_count += t->period; } if (t->cb) { t->cb(t->arg); } } t = next; } }这段代码里有一个非常关键的细节:判断是否到期用了(int32_t)(g_tick - t->expire_count) >= 0。为什么不直接用g_tick >= t->expire_count?因为tick是32位计数,早晚会翻转回0。用有符号差值比较,把计数差控制在±2^31范围内,翻转问题就被巧妙吞掉了,这是嵌入式时间处理的标准姿势。
另外我在遍历前先取了next指针,在回调里即使用户停止或重启了当前定时器,链表遍历也不会乱掉。这个细节是踩过坑之后才补上的。
4. 状态机和soft_timer如何配合:超时事件与按键长短按实战
4.1 把超时当成普通事件注入状态机
状态机和soft_timer单独看都不复杂,组合起来才体现架构威力。核心思路一句话:定时器回调里不发业务动作,只发一个EVENT_TIMEOUT事件放回事件队列,状态机收到后再决定干什么。
比如一个通信状态机,发起请求后进入COMM_WAIT_ACK状态,同时启动一个500ms超时定时器。正常情况下收到ACK事件,状态机转入成功。如果500ms内没收到,定时器到期发EVENT_TIMEOUT,状态机转入重试或报错。整个过程,通信模块自己不用关心“怎么计时”,只关心“超时事件到了没有”。
把这个模式抽象出来,就是状态机里一个非常常见的元素——超时转移。几乎每个有交互协议的场景都会用到。有些架构会为状态机专门封装一个辅助函数,比如“进入某状态时自动启动默认超时”,目的就是减少重复代码,让状态表更聚焦。
4.2 一个带防抖、短按、长按的按键状态机
拿一个真实的按键需求来完整走一遍。需求列表是这样的:支持物理按键消抖,短按触发一次“短按事件”,长按超过800ms触发一次“长按事件”,长按释放后不能再触发短按。
先定义状态和相关事件。我把状态机分成四个态:空闲态、防抖态、已按下态、长按等待释放态。事件有三种:按键按下、按键释放、超时。
状态转移关系整理成表:
| 当前状态 | 事件 | 动作 | 下一状态 |
|---|---|---|---|
| BTN_IDLE | BTN_DOWN | 启动10ms防抖定时器 | BTN_DEBOUNCE |
| BTN_DEBOUNCE | TIMEOUT | 启动800ms长按定时器 | BTN_PRESSED |
| BTN_DEBOUNCE | BTN_UP | 取消防抖定时器 | BTN_IDLE |
| BTN_PRESSED | TIMEOUT | 上报长按开始 | BTN_LONG_WAIT |
| BTN_PRESSED | BTN_UP | 取消长按定时器,上报短按 | BTN_IDLE |
| BTN_LONG_WAIT | BTN_UP | 上报长按结束 | BTN_IDLE |
这套状态设计解决了传统按键代码最烦的一个问题:长按和短按的边界。传统逻辑很容易在长按超时后,释放时再误触一次短按。状态机里按下态释放走短按分支,长按超时后已经进入长按等待态,释放走另一个分支,两条路径互不干扰。
动作函数里怎么操作定时器?以进入防抖态为例:
static efp_soft_timer_t s_debounce_timer; static efp_soft_timer_t s_long_timer; void app_btn_enter_debounce(state_machine_t *sm, const event_t *ev) { soft_timer_start(&s_debounce_timer, 10, 0, btn_debounce_timeout, sm); } void app_btn_debounce_timeout(void *arg) { event_t ev; ev.id = EVENT_TIMEOUT; ev.param = BTN_TIMEOUT_DEBOUNCE; ev.timestamp = g_tick; event_post(&ev); }定时器回调里不直接改状态机,只是post一个事件。因为post事件是在主循环上下文里做的(我们的poll也在主循环),所以没有并发问题,很安全。
4.3 这套协同事处理异步等待的通用模式
按键只是一个例子,这套配合模式能覆盖的场景远不止按键。我实际项目里还用它处理过:LCD菜单的按键长按翻页、NB-IoT模块的等待注册超时、HTTP请求重试、温湿度传感器的周期采集。它们的共同架构都是“启动定时器、等待超时事件、按状态转移”。
把这个模式再抽象一层,就得到了嵌入式里特别重要的通用设计原则:异步化。一个操作如果暂时无法完成,不应该阻塞当前执行流,而是记录“我在等什么”,然后立刻返回。等条件满足了,用事件通知。这个思路和RTOS里的信号量、消息队列本质上是同一回事,只是用状态机加soft_timer实现时,占用资源更少,逻辑更可控。
异步化带来的直接好处是系统吞吐量的提升。以前一个模块在等串口回复时CPU在空转,现在同一段时间里,状态机可以去处理按键、刷新显示、轮询其他外设。对于没有RTOS的裸机环境,这套方法几乎是标准答案。
5. 落地过程中踩过的坑和我的优化建议
5.1 定时器回调里乱发事件造成的递归与重入
第一次把soft_timer和状态机整合进真实项目时,我遇到过一种诡异的死机现象:系统跑一段时间后,栈溢出复位。查了很久才定位到问题。
根源在动作函数里直接调用了事件分发。想象一下这个调用链:事件A触发状态转移,动作函数启动一个定时器,定时器的回调如果被我写成了直接调sm_dispatch而不是event_post,那这个dispatch在某个路径上又可能启动新的定时器并立即触发回调……一层套一层,栈就这么被吃光了。
这其实违反了“事件驱动”最基本的规则:状态转换只能由事件循环统一驱动,不允许在执行路径上递归驱动。我后来把回调改成只post事件,并明确要求所有动作函数里禁止调用sm_dispatch,这类问题就再没出现过。
另一个类似的坑是事件队列被写满。如果某个状态下定时器超时事件高频产生,而状态机没有及时消费,队列就会溢出。我在event_post里加了溢出计数,调试时一旦发现这个数在涨,就说明某个模块的逻辑有失控嫌疑,值得专门查。
5.2 状态机日志和复现技巧
状态机调试和普通逻辑调试很不一样,光看单点代码很难看出问题,必须看整条时间线。我强烈建议从一开始就给状态机加日志功能,最好在dispatch函数里统一加。
我的日志长这样:打印当前状态编号、事件编号、下一状态编号。格式就三行,但配合时间戳,就能完整还原系统的行为序列:
[1000] EVT_BTN_DOWN: BTN_IDLE -> BTN_DEBOUNCE [1010] EVT_TIMEOUT : BTN_DEBOUNCE -> BTN_PRESSED [1810] EVT_TIMEOUT : BTN_PRESSED -> BTN_LONG_WAIT实际排查时我最常用的是把这个日志输出到串口,板子上复现问题后拉日志,用脚本过滤出状态转移部分,一眼就能看到有没有“非法转移”。很多bug在这种时间线视角下是秒破的,比如两个事件顺序反了、某个状态漏了转移,日志里清清楚楚。
如果条件允许,可以把整条状态转移记录导出成文本,再用脚本生成可视化的状态图,对比设计文档找偏差。我在好几个疑难问题上都是靠这招缩小范围,比对着代码硬猜效率高得多。
5.3 定时器槽位耗尽和控制块泄漏的防护
soft_timer的静态数组池子用完以后会发生什么?soft_timer_start找不到空闲节点,返回错误。但如果调用方不检查返回值,这个定时器就等于没启动,状态机会一直等一个永远不来的超时事件,整个流程冻结。
我在实际项目中遇到过的最隐蔽版本,是周期定时器忘了停止,某个事件每来一次就重新start一个新节点,池子被占满后,其他模块的定时器全部启动失败。当时现象是通信偶发超时,特别难复现。
之后的防护措施有三条。第一,soft_timer_start必须返回错误码,调用方必须检查。第二,定义每个定时器节点的归属,一个模块一个节点,不能动态new。第三,定期把空闲槽位数打进日志,作为系统健康指标。加了这三条以后,这类问题几乎在研发阶段就会暴露,而不是等出厂后靠用户反馈。
5.4 tick溢出和时间漂移的处理办法
前面提到用(int32_t)(g_tick - expire_count) >= 0来吞掉tick翻转问题,这招在绝大概率下管用,但还有一个边界情况要提防:定时器的超时周期不能超过2^31个tick。如果用1ms tick,那就是最多约24.8天。超过这个周期会导致误判,因为差值又绕回来了。工程上所有定时器都是秒级周期,离这个上限差很远,所以这个限制基本不构成问题。
时间漂移则是另一个方向的问题。如果是周期定时器,很多人的第一版实现是这样做的:
t->expire_count = g_tick + t->period;启动时记当前时间加周期,每次重新用当前时间算,有两个问题:一是如果主循环繁忙,到期检查晚了一点,下一轮周期就被这个延迟累加,时间越走越慢;二是回调里如果执行时间不固定,抖动会叠加。
我对周期定时器的做法是让expire_count在每次回调后累加固定的period值,而不是重新取g_tick:
t->expire_count += t->period;这样周期是绝对稳定的,不受回调执行时间影响。代价是如果系统曾经长时间停止调度(比如进了低功耗,tick停走),累加式会让定时器在恢复后疯狂补偿。这种场景我在实际产品里遇得不多,但如果遇到,就需要在低功耗设计时统一处理tick的暂停,或者在恢复时做一次时间校准。
最后说一个心得体会:这套架构对项目最大的改变不是性能数字,而是改代码的心态。以前加一个新功能,生怕动到别的模块;现在状态表里多一行,定时器池子里多一个节点,行为边界清清楚楚,心里有底。如果你现在正被裸机业务逻辑缠得焦头烂额,我建议至少先把状态机运行器搭起来,哪怕不用soft_timer,先把逻辑理顺,后面的路都会好走很多。