公司动态
嵌入式状态机驱动开发实战:充电桩案例与四步工作流
上一篇聊了 RTOS 任务划分这篇回到更上游的地方拿到需求的那一刻。很多嵌入式工程师的开发流程是这样的看完需求打开编辑器开始写代码。写到一半发现逻辑不对改改完又发现漏了个分支再改。最后代码能跑了但自己都看不懂了。问题出在哪一个让我印象深刻的教训前两年我带一个新人做充电桩的控制板程序。需求不算复杂插枪检测、启动充电、充满停止、异常保护四五个流程串起来就行。他效率很高第二天就跟我说写得差不多了。我打开代码一看好家伙一个 while(1) 里面套了十几个 if-else七八个全局 flag 散落在各处。is_charging、gun_inserted、fault_flag、charge_done……变量名倒是能看懂但它们之间的配合关系只有上帝知道。我问他如果充电过程中用户拔枪同时检测到过温你这代码会怎么走他想了半分钟说我试试。结果一试程序卡死了。后来我花了一个下午帮他从头梳理了一遍。不是重写代码而是做了一件他跳过的事情先画状态图再写代码。画完图之后他自己就发现了原来代码里的三个逻辑漏洞。这件事让我意识到嵌入式开发中最被低估的能力不是写代码而是在写代码之前把系统的行为想清楚。而状态机就是帮你想清楚的最佳工具。你可能一直在跳步开发回想一下你平时拿到需求后是怎么做的大多数人的开发流程里缺了中间最关键的一步把需求翻译成系统行为模型。说人话就是你没有在动手之前把系统有几种工作状态、每种状态下干什么、什么条件会切换到另一种状态这件事搞清楚。结果就是代码写到哪算哪想到一个分支加一个 if发现一个异常补一个 flag。功能越加越多分支越嵌越深直到你自己都绕晕了。正确的打开方式是先想清楚再动手。下面我就用一个具体的例子手把手走一遍这个流程。用状态机工作流开发一个充电桩控制器需求很简单我精简成四句话检测到插枪后等待用户刷卡启动刷卡成功后开始充电实时监测电流电压充满或用户主动停止则结束充电充电过程中出现过温/过流等异常立即停止并报警Step 1从需求中提炼状态读一遍需求提炼系统可能处于的几种模式IDLE空闲等待插枪READY就绪已插枪等待刷卡CHARGING充电中正在充电DONE结束充电完成FAULT故障异常保护五个状态不多不少。这里有个诀窍。如果你发现某个状态描述的是一个瞬间动作比如发送启动指令那它多半不是状态而是一个转移动作。状态描述的是系统正在做什么或者系统在等什么。充电中是在做一件事持续输出电流、持续计量等待刷卡是在等一件事。这两个都是状态。而发启动指令是一瞬间就完成的它是从 READY 跳到 CHARGING 那条箭头上附带的动作不是状态本身。新手最容易在这里犯错把动作当状态结果状态机越画越乱每个动作一个圈最后画出来二三十个状态自己都理不清。Step 2画出状态转移图有了状态列表下一步就是找出从 A 状态到 B 状态需要什么条件触发。画完图有三个好处逻辑完备性一目了然每个状态有几条出路、几条入口看一眼就知道。异常路径不会被遗忘画图时自然会问自己这个状态下出异常怎么办。这张图本身就是最好的文档新人接手五分钟就能理解整个系统。充电桩的状态转移大概是这样IDLE 收到插枪信号跳 READYREADY 收到刷卡成功跳 CHARGINGCHARGING 收到充满或用户停止跳 DONECHARGING 收到过温/过流跳 FAULTDONE 和 FAULT 都能复位回 IDLE。几条线一画整个系统的行为就钉死了。Step 3定义事件和转移条件把每条箭头翻译成明确的事件和条件用表整理。比如CHARGING → DONE这条线触发事件是充满检测或刷卡停止转移条件是电流小于阈值持续 N 秒或读到停止卡。写完这张表代码几乎不用设计了照着表翻译就行。表上有几行代码里就有几个 TransitionTo表上每行的条件是什么代码里 if 就判断什么。这就是后面要说的图码一一对应。Step 4把状态图映射成代码/* 状态定义 */ typedef struct { const char *name; void (*enter)(void); /* 进入时执行 */ void (*run)(void); /* 运行中执行 */ void (*exit)(void); /* 离开时执行 */ } State_t; /* 充电中状态节选 */ static void Charging_Enter(void) { Relay_Close(); /* 闭合继电器 */ Meter_Start(); /* 启动计量 */ LED_Set(LED_BLUE); Display_Show(充电中...); } static void Charging_Run(void) { /* 充满判断 */ if (Meter_IsFull()) { FSM_TransitionTo(charger_fsm, state_done); return; } /* 用户主动停止 */ if (Card_SwipeDetected()) { FSM_TransitionTo(charger_fsm, state_done); return; } /* 异常检测 */ if (Sensor_OverTemp() || Sensor_OverCurrent()) { FSM_TransitionTo(charger_fsm, state_fault); return; } } static void Charging_Exit(void) { Relay_Open(); /* 无论跳到哪都要断开继电器 */ Meter_Stop(); }这套结构的核心是把每个状态拆成三个回调进入时干什么、运行中干什么、离开时干什么。enter 和 exit 各跑一次run 在停留期间被反复调用。重点看 Charging_Exit() 里的 Relay_Open()。不管是充满了、用户停了、还是出故障了只要离开充电中状态继电器一定会被断开。写一遍管三条路。这就是状态机相比散装 if-else 最值钱的地方收口。散装代码里断继电器这个动作得在每个跳转分支里各写一遍漏一个就是安全隐患。状态机里所有离开 CHARGING 的路都必经 Exit收口在一个地方想漏都难。几条踩坑之后总结的经验状态不要太多也不要太少。一个状态机 5 到 8 个状态比较舒服。超过 15 个就该考虑拆分或者上分层状态机HSM把同类的状态归到一个父状态下公共的 enter/exit 提到父层。先画图后写码改需求也先改图。需求变了先回到状态图上改确认改动合理之后再同步到代码。很多人改需求是直接冲进代码里加 if改完图和代码就对不上了下次再改就抓瞎。图是源头代码是图的投影改投影不改源头迟早错乱。异常处理在画图阶段就要考虑。画正常流程很简单但别忘了问自己每个状态下如果出了异常怎么办我习惯给每个状态都配一条任意异常 → FAULT的兜底箭头。画图时多花十分钟想异常比上线后半夜被叫起来查故障便宜得多。状态图要能反推出代码代码也要能反推出状态图。图上有几个圈代码就有几个状态图上有几条线代码就有几个 TransitionTo。做到一一对应。哪天你发现代码里有个 TransitionTo 在图上找不到对应的线那就是需求漂移了要么补图要么删代码。什么时候该上状态机状态机不是什么银弹它解决不了所有问题。但在嵌入式开发中绝大多数的控制逻辑都天然适合用状态机来建模。设备流程类比如充电桩、洗衣机、电梯、门禁本质上就是几个状态来回切换。通信协议类比如 Modbus、AT 指令解析、蓝牙配对协议规范本身就是用状态图描述的照着画就行。用户交互类比如菜单导航、配网流程、OTA 升级界面用户每一步操作对应一次状态转移。反过来纯数据流的东西比如一个滤波算法、一个 PID 环就不需要状态机一个函数搞定。状态机是用来管有明确阶段划分的行为的没有阶段划分的别硬套。写在最后回到开头那个充电桩项目。那个新人在我带他走了一遍状态机工作流之后花了半天重写了代码。新代码比原来少了三分之一的行数但逻辑覆盖率反而更高了。他说以前写代码是在解题写一步看一步。现在感觉是在施工先有图纸再动手心里踏实多了。这句话我一直记着。嵌入式开发里写代码从来不是最难的难的是动手之前把系统行为想清楚。状态机就是那张图纸画图的时间永远比写错代码再返工的时间短。养成先画图再写码的习惯比学会任何一个具体的编码技巧都重要。有用的话点个在看让更多拿到需求就开干的嵌入式工程师看到。标签嵌入式 状态机 FSM 充电桩 状态转移图