状态机与状态图落地:Java、Verilog三段式、PLC与CANopen
1. 从一次设备卡死的排查说起状态机到底在解决什么问题前几年接手过一台包装设备的控制程序故障现象很怪设备运行到待料阶段后偶尔会自己跳回启动中然后卡在那儿不动复位也不管用。翻代码翻了两个小时最后发现问题出在一堆布尔标志位上——isRunning、hasMaterial、isPaused、isFault四个变量各自被三四个地方改某条分支里只改了两个状态就进了逻辑上根本不存在的组合。这种问题不是靠加日志能解决的因为程序里压根没有一个明确的当前处于什么状态的概念。这就是状态机和状态图存在的意义。状态机的核心思路很朴素把一个系统的行为描述成有限个状态 状态之间的迁移条件 迁移时执行的动作任何时刻系统只处于其中一个状态所有输入只能通过明确规定的迁移路径改变状态。状态图则是这套逻辑的可视化表达用圆角矩形表示状态、箭头表示迁移、箭头上的文字表示触发事件和动作。它解决的是同一类问题当一个系统有明显的过程性、阶段性、时序性时用散落的标志位去描述它必然随着分支增长而失控。状态机会逼着你先把有哪些状态什么条件能跳跳的时候干什么三件事想清楚再写代码。我个人的判断标准很简单如果你发现自己在维护三个以上互相关联的布尔变量来描述流程那就该上状态机了。适合看这篇内容的人我大致分成三类写业务后端的订单、审批、支付这类流程、写嵌入式或工控的设备时序、通信协议、以及做硬件数字逻辑的时序电路。三类人用的语言和工具完全不同但底层那套状态—事件—迁移的思维是一模一样的这也是为什么热词里会同时出现 Java 状态机、Verilog 三段式、PLC 写法、OMAC 程序、CANopen 状态图这些看起来八竿子打不着的词——它们其实是同一件事在不同战场上的变体。2. 状态图怎么画才不白画从草图到可执行模型的距离2.1 状态、事件、迁移、动作这四件套的画法约定画状态图最容易犯的错是把动作当成状态。比如有人会画出发送请求这个状态可它根本不是状态它是一次迁移上挂的动作。区分办法很实用状态是停留动作是发生。系统会在一个状态里待一段时间哪怕只有几微秒但动作是瞬间完成的。一张能落地到代码的状态图至少要标清楚四样东西元素图形表示代码里的对应物常见漏标状态圆角矩形枚举值 / 状态类漏了初始态和终态事件迁移箭头上的触发条件方法入参 / 信号漏了超时、异常事件迁移带箭头的连线状态转移表的一行漏了自迁移动作箭头上的/动作迁移方法体内的逻辑漏了进入/退出动作这里要特别强调进入动作entry和退出动作exit这是新手最容易漏、后期最痛的地方。比如一个加热状态进入时开加热器、退出时关加热器如果你把关加热器写在别的地方一旦新增一条从加热跳出去的迁移就忘了关设备就烧了。把资源申请和释放绑定在状态的进出上是状态图能长期维护的关键。我们说状态机让代码更安全一大半功劳在这儿。2.2 PowerDesigner 与 Archify 两类工具各自的适用姿势热词里出现了 PowerDesigner 和 Archify这俩定位差别很大选错了会很难受。PowerDesigner属于老牌建模工具它画的状态图是嵌在整套模型体系里的好处是能和类图、时序图、数据库模型产生关联特别适合那种需求文档要评审、模型要归档、后续还要出报告的正式项目。它的代价是重——你得先建模型工程、定义包、搞清各种引用关系改一个状态名可能要动好几处。我在做需要交付 UML 文档的项目时会用它纯粹为了画个流程草图时绝不用。Archify这类偏轻量的作图工具优势是打开就能画、拖拽顺手、支持导出成矢量图或图片塞进文档。它适合快速沟通和方案讨论阶段——会议上大家对着图吵吵完图一改就完事。缺点是图是死的跟代码没有联动改完图还得手动改代码两边容易不一致。我的实际做法是分阶段用不同工具方案讨论期用轻量工具快速迭代定稿后用建模工具或直接在代码里用文本化描述比如状态转移表固化下来。不要指望一张图从讨论一直用到上线图的价值在于对齐认知不在于永久存档。2.3 状态图画完之后要先做的三件自检图不是画完就完事的我每次定稿前会跑三个自检几乎每次都能揪出问题。第一可达性检查。从初始状态出发能不能走到每一个状态我见过不止一次图里画了个已取消状态但没有任何一条迁移指向它等于这个状态永远不会被进入代码里写了也是死代码反而误导后人。第二死锁检查。有没有某个状态进去之后没有任何出边除了刻意设计的终态其他状态都必须有出口。设备卡死类故障十有八九就是某个异常分支把系统推进了一个没有出口的状态。第三迁移完备性检查。对于同一个状态、同一类事件是否存在两条互相冲突的迁移比如已支付状态下收到取消事件一条迁移写着跳到已退款另一条写着跳回已下单这就是冲突。这种事在多人协作画图时极其常见因为两个人各画了半张图。提示把状态转移整理成一张二维表行是状态、列是事件、格子里填目标状态比看图更容易发现遗漏和冲突。表里出现空格的地方就是你还没想清楚这种情况该怎么办的地方。3. Java 侧的状态机落地手写、状态表还是引入框架3.1 枚举加 switch 的写法边界在哪里Java 里最省事的写法就是枚举加 switch。我贴一段简化过的例子这段代码我用了很多次够应付大部分中小型流程public enum OrderState { CREATED, PAID, SHIPPED, COMPLETED, CANCELED } public OrderState next(OrderState cur, String event) { switch (cur) { case CREATED: if (pay.equals(event)) return OrderState.PAID; if (cancel.equals(event)) return OrderState.CANCELED; break; case PAID: if (ship.equals(event)) return OrderState.SHIPPED; if (refund.equals(event)) return OrderState.CANCELED; break; case SHIPPED: if (confirm.equals(event)) return OrderState.COMPLETED; break; default: break; } throw new IllegalStateException(非法迁移: cur event); }这段代码的好处是零依赖、一眼看懂、调试友好。它的边界也很清楚当状态数超过 10 个、或者迁移逻辑里开始出现业务计算时就该换写法了。因为 switch 会膨胀成几百行每个分支里混着校验、计算、持久化改一处要通读全篇。我个人的红线是单个next方法超过 80 行或者 switch 嵌套超过两层就重构。另外注意最后那个throw。非法迁移必须显式抛异常不能悄悄返回原状态。我见过有实现把未知事件处理成什么都不做结果上游调错了事件名系统静默不动排查了半天以为是并发问题。让错误尽早、尽可能响亮地暴露出来是状态机实现里最重要的一条纪律。3.2 状态转移表驱动把逻辑变成数据当迁移规则多到 switch 扛不住时自然的选择是把迁移关系抽成数据。核心就是一张Map当前状态事件, 目标状态private static final MapString, OrderState TRANSITIONS new HashMap(); static { TRANSITIONS.put(key(OrderState.CREATED, pay), OrderState.PAID); TRANSITIONS.put(key(OrderState.CREATED, cancel), OrderState.CANCELED); TRANSITIONS.put(key(OrderState.PAID, ship), OrderState.SHIPPED); // ... } public OrderState next(OrderState cur, String event) { OrderState target TRANSITIONS.get(key(cur, event)); if (target null) { throw new IllegalStateException(非法迁移: cur event); } return target; }这么一改好处是迁移规则集中在一处、可枚举、可测试、可导出成文档。它最大的价值是可测试性——你可以写一个循环把表里每一行都跑一遍断言覆盖率轻松上去。而这种测试在 switch 版本里基本写不动。代价是迁移时要做的事情校验、副作用、发消息得另外挂。常见做法是给每条迁移配一个处理器public interface TransitionAction { void execute(OrderContext ctx); }然后在表里把目标状态和动作一起存。到这一步你其实已经手搓了一个小型状态机框架了。3.3 用户可自定义状态这个需求该怎么接热词里有一条Java 状态机能否由用户灵活定义这个问题很典型很多做工作流、审批流、低代码平台的人都会撞上。答案是可以但要把状态定义和状态行为分开。具体讲用户能在界面上自定义的应该是状态名称、状态列表、允许的迁移路径、迁移条件这些结构信息而每个迁移上要执行的业务代码比如调支付接口、写流水用户是定义不了的得由开发者提前注册好一组可复用的动作让用户在配置迁移时挑选。落地时通常这么做状态和迁移存数据库表比如wf_state和wf_transition两张表字段包括状态码、事件码、源状态、目标状态、条件表达式、绑定的动作标识启动或配置变更时把表加载进内存构建成上面那张TRANSITIONS映射条件表达式如果是简单判断可以用脚本引擎或规则表达式解析复杂的就走插件机制按动作标识去查开发者写的实现类。这个方案我做过两版最大的坑不在状态机本身而在版本兼容用户改了流程定义之后已经在途的实例怎么办是按老版本走完还是强行迁到新版本这个必须提前想清楚通常做法是给流程定义加版本号实例绑死创建时的版本。这一点后面第 5 节还会展开。4. 嵌入式与工控里的状态机Verilog 三段式与 PLC 写法4.1 Verilog 三段式状态机为什么被反复推荐数字电路里的状态机讲究更多因为硬件是并行执行的写错一个赋值顺序可能综合出的电路完全不是你想的那样。热词里verilog 三段式状态机被反复提是因为它几乎是行业公认的稳妥写法。三段分别指// 第一段状态寄存器只做时序赋值 always (posedge clk or negedge rst_n) begin if (!rst_n) cur_state IDLE; else cur_state next_state; end // 第二段次态组合逻辑只判断迁移条件 always (*) begin next_state cur_state; // 默认保持避免锁存器 case (cur_state) IDLE: if (start) next_state WORK; WORK: if (done) next_state DONE; DONE: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin busy 1b0; end else begin busy (next_state WORK); end end为什么非要拆成三段核心原因是把时序逻辑和组合逻辑彻底分开。第一段和第三段是时序的综合成触发器第二段是纯组合的综合成查找表。分清楚之后时序分析简单、不容易产生毛刺、也更容易加流水线。新手常犯的错是把输出直接写在cur_state的组合逻辑里结果输出带上毛刺下游电路就被误触发。第二段里那句next_state cur_state;的默认赋值也非常关键。没有这句默认赋值case 里没覆盖到的分支会综合出锁存器latch这是硬件设计里的大忌。这个坑我最早学的时候踩过综合工具报了 warning 没在意上板之后行为诡异查了很久才反应过来。4.2 PLC 编程里的状态机写法与步进顺序控制工控侧的思路和硬件很像但载体是梯形图或结构化文本ST。PLC 里做状态机主流有两种一种是步进顺序控制用步标志位推进另一种是用 ST 语言直接写CASE语句其实就是软件状态机的思路。步进控制的核心是一个步号寄存器比如step从 0 到 N每个扫描周期只执行当前步的逻辑CASE step OF 0: IF start_btn THEN step : 10; END_IF; 10: 加热 : TRUE; IF temp 80.0 THEN 加热 : FALSE; step : 20; END_IF; 20: 保压 : TRUE; IF timer.Q THEN 保压 : FALSE; step : 30; END_IF; 30: step : 0; END_CASE这种写法的好处是互锁天然成立因为一次只执行一步不存在两个动作互相打架的情况。相比之下如果用一堆IF并行判断就很容易出现加热和冷却同时开这种事。我见过的工控事故里很大一部分就是并行逻辑没做互锁。用CASE时有个细节要注意每个扫描周期只能让step前进一步。如果在同一次扫描里连续跳了两步那些本该被执行的中间动作就被跳过了比如加热还没到温就进了下一步。要避免这种情况把推进条件写成条件满足才赋值而不是写成一串顺序赋值。4.3 OMAC 状态机与 CANopen 状态图给我们的启发热词里出现了 OMAC 状态机程序和 CANopen 状态图这两个都是行业标准里已经定死的状态模型理解它们对设计自己的状态机非常有帮助。OMAC现在一般叫 PackML给包装设备定义了一套标准状态共十几个包括 Stopped、Starting、Execute、Holding、Held、Suspending、Suspended、Aborting、Aborted、Clearing、Stopping、Resetting、Completing、Complete 等。它最值得学的地方是把暂停和保持分成了两条独立的路径Holding/Held 表示因为异常需要原地保持Suspending/Suspended 表示计划内的暂停两条路径最终都要回到 Execute。这种两条路进来、一条路回去的设计避免了用同一套状态处理两种语义不同的暂停逻辑清爽很多。CANopen 的节点状态图也很有代表性它的状态少得多大致是 Initialization、Pre-operational、Operational、Stopped 四个迁移由 NMT 报文驱动。它的启发在于状态机的边界被定义得极其清晰每个状态里哪些通信可用、哪些不可用都写死在规范里。你去看国产伺服或 PLC 厂商比如步科的手册里面的 CANopen 通信章节基本都会附这张状态图因为工程师必须知道上电后节点停在 Pre-operational要发了启动报文才进 Operational不然通信死活不通。提示如果你的系统要对接外部设备或遵循某个行业标准先去翻标准里的状态图直接照抄它的状态划分。自己另起一套再去做映射转换纯属自找麻烦。5. 状态机上线之后测试、埋点与版本演进5.1 把状态迁移做成可穷举的用例矩阵状态机有个天大的好处它是有限且可枚举的。状态 N 个、事件 M 种组合一共 N×M 种穷举一遍不算多。所以我在状态机相关的模块上几乎不写那种点几下看看的测试而是直接写覆盖矩阵Test public void testAllTransitions() { for (OrderState cur : OrderState.values()) { for (String event : ALL_EVENTS) { OrderState target TRANSITIONS.get(key(cur, event)); if (target null) { assertThrows(IllegalStateException.class, () - service.next(cur, event)); } else { assertEquals(target, service.next(cur, event)); } } } }这一段测试就能把状态合法性全覆盖。更狠一点的做法是把合法迁移表单独抽成一个测试资源文件测试代码按表断言。这样任何人改了迁移规则只要忘了同步改表测试立刻红比 Code Review 可靠得多。除了迁移合法性还有一类测试不能漏同一事件重复投递。真实系统里消息重发太常见了你要明确已支付状态下再收到一次支付事件是报错、还是幂等忽略。这个语义必须在测试里钉死否则线上就会出现重复扣款之类的问题。5.2 日志和埋点该记哪几个字段状态机出问题的时候日志质量决定排查速度。我固定会记四个字段实例 ID哪个对象在动源状态 → 目标状态发生了什么迁移触发事件谁推动的耗时在上一个状态待了多久其中在上一个状态待了多久这个字段是最容易被忽略、但价值最高的。很多问题表现为某个流程整体变慢其实就是卡在某个中间状态上你有了停留时长一眼就能定位是哪一段。我在做审批流的时候加了这个耗时打点一次线上反馈审批太慢直接查到是待人工审核状态平均停留了 26 小时比任何猜测都准。日志的粒度也要控制。不要在状态机内部每一个动作里都打日志那样 I/O 会成为瓶颈尤其是高频触发的嵌入式场景。把日志集中在迁移这个点上已经能覆盖八成排查需求。5.3 状态机演化时的向后兼容处理状态机不是定完就不动的业务一变就得加状态、改迁移。这时候最麻烦的是存量数据或存量实例。我踩过最疼的一次给订单加了一个部分退款状态结果历史订单里那些已经已退款的记录在新代码下反序列化时找不到对应枚举值直接抛异常整个查询接口挂了。后来养成的习惯有三条。第一枚举里保留一个兜底的 UNKNOWN 值反序列化时把不认识的值映射过去先保证不崩再通过告警去排查。第二流程定义带版本号。前面讲自定义流程时提过实例创建时就绑定当时的流程版本走老版本定义走到结束新实例才用新定义。这样新旧逻辑彻底隔离不用写一堆兼容分支。第三迁移的删除要极其谨慎只加不删。哪怕某个迁移业务上已经不用了也先标记为废弃、保留代码路径一段时间等确认没有任何存量实例需要它了再删。删一条迁移看起来干净但它可能是某个在途实例唯一的出口——删了实例就成孤儿了既走不下去也结束不了。我个人在实际操作中的体会是状态机这套东西真正的成本从来不在写第一版而在后面每一次改动。第一版画图拍脑袋半小时搞定改的时候能不能不牵连一片完全取决于你当初有没有把状态进出动作、版本号、兜底值这几样东西留好。所以现在我看别人设计状态机不太关心他画得漂不漂亮更关心他有没有想过半年后要加一个状态这堆代码会怎么样。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →