状态模式与动作类解耦:嵌入式状态机设计进阶实践

张开发
2026/5/14 23:07:57 15 分钟阅读

分享文章

状态模式与动作类解耦:嵌入式状态机设计进阶实践
1. 从“硬编码”到“解耦”状态模式中动作分离的必要性在嵌入式或者任何需要处理复杂流程的软件设计中状态机Finite State Machine, FSM是一个无比强大的工具。它能把一堆令人头疼的“如果...那么...”逻辑梳理成清晰的状态和事件响应。很多朋友在初学状态机时常常会写出类似这样的代码在某个状态的事件处理函数里直接调用具体的硬件操作或者业务逻辑。就像原文中提到的闸机Turnstile例子在LOCKED状态下刷卡事件处理函数locked_card不仅负责切换状态到UNLOCKED还直接执行了一个printf(unlock\n)动作。这种写法在项目初期跑通Demo时会给人一种“简单直接”的错觉。我早期做车载控制器开发时也这么干过状态机里混杂着CAN信号发送、IO口控制、日志打印代码写得飞快。但问题很快就会暴露当你需要修改一个动作的实现时比如把打印日志改为写入Flash或者控制不同的外设你不得不去修改状态机的核心逻辑文件。更糟糕的是如果多个状态都调用了同一个动作或者这个动作的实现细节变得复杂需要初始化、需要上下文数据这种“硬编码”的方式会让代码迅速变得僵化且难以维护。这相当于把房子的电路布线直接浇灌在混凝土承重墙里以后想换个插座位置就得砸墙。原文提到的“动作类”Action Class概念正是为了解决这个耦合问题。其核心思想是将“状态转移的逻辑”和“状态转移后要执行的具体动作”分离开。状态机只负责根据当前状态和发生的事件决定下一个状态是什么以及“需要执行哪个动作”。至于这个动作具体是如何实现的状态机不关心它只调用一个定义好的接口。这就是设计模式中常说的“依赖接口而非实现”。2. 动作类的设计演进从函数指针到策略对象原文的示例给出了一个非常经典的起点将动作抽象为独立的函数。我们深入拆解一下这个过程并探讨其在实际项目中如何演进。2.1 基础解耦动作函数库如程序清单 4.23和4.24所示首先将四个动作lock, unlock, alarm, thankyou封装成独立的函数。这带来了最直接的好处修改隔离动作的实现比如从printf改为控制某个GPIO引脚只需要修改对应的.c文件状态机的核心代码无需任何变动。复用性这些动作函数可以被系统中任何其他模块调用而不仅仅是状态机。例如系统自检时可能需要直接调用turnstile_action_alarm()鸣响警报。此时状态机的事件处理函数进化成这样void locked_card(turnstile_t *p_turnstile) { turnstile_state_set(p_turnstile, unlocked_state); // 1. 状态转移 turnstile_action_unlock(); // 2. 执行解耦后的动作 }代码变得清晰多了第一行管状态第二行管动作。但这仅仅是第一步。这个模式假设所有动作都是无状态的、不需要任何上下文数据。在简单的闸机模型里这没问题。但现实项目往往更复杂。2.2 引入上下文带参数的动作接口假设我们的闸机升级了unlock动作需要知道是哪个具体的闸机门有多个闸机并且需要记录本次解锁的操作员ID。无参数的函数就无法满足需求了。这时我们就需要为动作引入上下文。一种常见的做法是修改动作函数的签名传入状态机实例或特定的上下文结构体。// 动作函数声明新 void turnstile_action_unlock(turnstile_context_t *ctx); // 在状态机中调用 void locked_card(turnstile_t *p_turnstile) { turnstile_state_set(p_turnstile, unlocked_state); turnstile_action_unlock(p_turnstile-ctx); // 传入上下文 }其中turnstile_context_t可能包含typedef struct { uint8_t gate_id; // 闸机编号 uint32_t operator_id; // 操作员ID void* hardware_port; // 指向具体硬件控制寄存器的指针 } turnstile_context_t;这一步的关键在于动作函数能获取到执行所需的所有环境信息而不仅仅是写死的常量。这大大增强了灵活性。例如同一个unlock函数通过不同的gate_id可以控制不同的电磁锁。2.3 面向对象封装真正的“动作类”当动作变得足够复杂它可能不仅需要数据还需要有自己的初始化、反初始化、甚至内部状态管理。这时将其封装成一个真正的“类”在C语言中即结构体关联函数就更合适了。这也是原文末尾提到的方向。我们可以定义一个动作基类接口和具体的实现类// 动作接口抽象基类 typedef struct turnstile_action_interface { void (*do_action)(struct turnstile_action_interface *self, turnstile_context_t *ctx); // 可以添加其他公共方法如 init, deinit } turnstile_action_interface_t; // 具体的“解锁动作”实现 typedef struct { turnstile_action_interface_t interface; // 继承接口 uint32_t unlock_duration_ms; // 解锁保持时间私有配置 } unlock_action_t; void unlock_action_do(unlock_action_t *self, turnstile_context_t *ctx) { printf(“Unlocking gate %d by operator %lu for %lu ms.\n”, ctx-gate_id, ctx-operator_id, self-unlock_duration_ms); // 实际硬件操作... // hardware_drive(ctx-hardware_port, UNLOCK); // delay(self-unlock_duration_ms); // hardware_drive(ctx-hardware_port, LOCK); } // 初始化具体动作对象 unlock_action_t g_unlock_action { .interface.do_action (void(*)(turnstile_action_interface_t*, turnstile_context_t*))unlock_action_do, .unlock_duration_ms 2000, // 默认解锁2秒 };在状态机中调用方式变为void locked_card(turnstile_t *p_turnstile) { turnstile_state_set(p_turnstile, unlocked_state); // 通过接口调用完全不知道背后是哪个具体实现 p_turnstile-current_action-interface.do_action((turnstile_action_interface_t*)p_turnstile-current_action, p_turnstile-ctx); }这种方式的威力在于它支持“策略模式”Strategy Pattern。我可以轻易地替换动作的实现。比如针对调试环境我实现一个debug_unlock_action它只打印日志针对生产环境则使用real_hardware_unlock_action。状态机的代码一行都不用改只需要在初始化时注入不同的动作对象即可。这是应对“变化”的终极武器之一。3. 状态模式完整实现与动作类的协同前面我们深入剖析了动作类的演变现在让我们把镜头拉远看看它如何融入状态模式State Pattern的完整实现中。状态模式的核心是将每个状态抽象成一个独立的类或结构体每个状态类负责定义在该状态下所有可能事件的行为。这与动作类的思想一脉相承都是通过多态来消除条件判断提升扩展性。3.1 状态接口与具体状态定义首先我们定义状态接口和具体的状态结构。每个状态都是一个包含事件处理函数指针集合的对象。// 状态接口每个状态都必须实现这些事件处理函数 typedef struct turnstile_state_interface { void (*on_card_event)(struct turnstile_state_interface *state, turnstile_t *fsm); void (*on_pass_event)(struct turnstile_state_interface *state, turnstile_t *fsm); void (*on_coint_event)(struct turnstile_state_interface *state, turnstile_t *fsm); } turnstile_state_interface_t; // 具体状态锁定状态 typedef struct { turnstile_state_interface_t interface; } locked_state_t; // 具体状态解锁状态 typedef struct { turnstile_state_interface_t interface; } unlocked_state_t; // 全局状态实例单例模式因为状态通常无实例数据 locked_state_t g_locked_state { .interface.on_card_event locked_card, .interface.on_pass_event locked_pass, .interface.on_coin_event locked_coin }; unlocked_state_t g_unlocked_state { .interface.on_card_event unlocked_card, .interface.on_pass_event unlocked_pass, .interface.on_coin_event unlocked_coin };3.2 状态机主体与动作的注入状态机主体结构需要持有当前状态以及可能需要的动作对象。// 状态机主体结构 typedef struct turnstile { const turnstile_state_interface_t *current_state; // 当前状态指针 turnstile_context_t ctx; // 上下文数据 turnstile_action_interface_t *action_unlock; // 注入的动作对象 turnstile_action_interface_t *action_lock; turnstile_action_interface_t *action_alarm; turnstile_action_interface_t *action_thankyou; } turnstile_t; // 状态转移函数 void turnstile_state_set(turnstile_t *fsm, const turnstile_state_interface_t *new_state) { if (fsm new_state) { fsm-current_state new_state; } } // 事件分发函数供外部调用 void turnstile_on_card(turnstile_t *fsm) { if (fsm fsm-current_state fsm-current_state-on_card_event) { fsm-current_state-on_card_event(fsm-current_state, fsm); } }3.3 具体状态事件处理的实现现在我们可以实现具体状态的事件处理了。这里以locked_card为例展示其与动作类的完美协作。void locked_card(turnstile_state_interface_t *state, turnstile_t *fsm) { // 1. 执行状态转移逻辑 turnstile_state_set(fsm, g_unlocked_state.interface); // 2. 通过注入的动作对象执行具体操作而非硬编码 if (fsm-action_unlock) { fsm-action_unlock-do_action(fsm-action_unlock, fsm-ctx); } // 3. 可选执行其他与状态转移相关的逻辑如更新显示、发送通知等 // update_display(“UNLOCKED”); } void locked_pass(turnstile_state_interface_t *state, turnstile_t *fsm) { // 非法通行触发警报 if (fsm-action_alarm) { fsm-action_alarm-do_action(fsm-action_alarm, fsm-ctx); } // 状态保持在LOCKED }请注意这里的精妙之处locked_card函数完全不知道unlock动作是如何完成的。它只是调用了fsm-action_unlock这个接口。今天这个动作是控制一个电磁锁明天可以换成控制一个伺服电机或者同时点亮一个LED灯locked_card函数都无需任何修改。这就是“开闭原则”对扩展开放对修改关闭的生动体现。3.4 初始化与配置组装你的状态机最后我们需要在系统初始化时组装好这个状态机。turnstile_t g_turnstile; void turnstile_init(void) { // 1. 初始化上下文 g_turnstile.ctx.gate_id 1; g_turnstile.ctx.operator_id 0; // 0表示系统 g_turnstile.ctx.hardware_port (void*)0x40000000; // 假设的硬件地址 // 2. 注入具体的动作策略 #ifdef USE_REAL_HARDWARE g_turnstile.action_unlock (turnstile_action_interface_t*)g_real_unlock_action; g_turnstile.action_lock (turnstile_action_interface_t*)g_real_lock_action; g_turnstile.action_alarm (turnstile_action_interface_t*)g_real_alarm_action; g_turnstile.action_thankyou (turnstile_action_interface_t*)g_real_thankyou_action; #else // 使用调试/模拟动作 g_turnstile.action_unlock (turnstile_action_interface_t*)g_debug_unlock_action; // ... 其他动作 #endif // 3. 设置初始状态 turnstile_state_set(g_turnstile, g_locked_state.interface); printf(“Turnstile FSM initialized.\n”); }这个初始化过程就像在组装一台机器装上“锁定状态”模块、“解锁状态”模块再配上“真实硬件解锁器”或“模拟调试解锁器”组件。整个架构清晰耦合度低替换任何部件都非常方便。4. 实战经验与避坑指南理论看起来很美但在实际嵌入式项目中应用状态模式和动作类时会碰到一些教科书上不会写的细节问题。这里分享我踩过的一些坑和总结的经验。4.1 内存与性能考量在资源紧张的MCU如STM32F103只有几十KB RAM上为每个状态和动作都创建对象实例可能会消耗过多内存。此时可以采用“单例状态”模式。经验如果状态对象自身没有独有的数据只有函数指针那么就像上面的例子一样使用全局单例g_locked_state。所有状态机实例共享同一个状态对象因为它们的函数指针是相同的。这能节省大量内存。只有当状态对象需要保存私有数据例如状态进入的次数、超时时间等时才需要为每个状态机实例分配独立的状态对象。避坑动作对象也类似。如果动作是无状态的例如简单的GPIO操作使用单例。如果动作需要保存配置如上面unlock_action_t里的unlock_duration_ms并且不同闸机可能需要不同配置那么就需要为每个闸机实例化一个动作对象。4.2 动作执行与状态转移的时序这是一个极易出错的地方。动作执行应该在状态转移之前还是之后或者过程中基本原则先执行旧状态下的“退出动作”再进行状态转移最后执行新状态的“进入动作”。但我们的简单模型里没有区分“进入/退出动作”。实战场景假设unlock动作是让电机转动90度。这个动作需要一定时间比如500ms。你是在状态切换到UNLOCKED后启动电机并立即返回还是等待电机转动完成才切换状态异步处理通常在嵌入式系统中耗时动作应异步执行。locked_card函数里只发送“开始解锁”指令然后立即切换到UNLOCKING一个中间状态。在UNLOCKING状态下等待电机到位信号一个事件再切换到UNLOCKED状态。千万不要在事件处理函数里使用delay(500)来等待动作完成这会阻塞整个状态机无法响应其他事件。代码示意void locked_card(turnstile_state_interface_t *state, turnstile_t *fsm) { // 发送启动电机指令 motor_start(90); // 立即转移到“解锁中”状态 turnstile_state_set(fsm, g_unlocking_state.interface); // 不需要在这里调用 unlock_action } // 在 UNLOCKING 状态的事件处理中响应电机到位事件 void unlocking_on_motor_done(turnstile_state_interface_t *state, turnstile_t *fsm) { turnstile_state_set(fsm, g_unlocked_state.interface); // 此时可以执行一个“解锁完成”动作如响一声提示音 if (fsm-action_thankyou) { fsm-action_thankyou-do_action(fsm-action_thankyou, fsm-ctx); } }4.3 调试与日志记录当状态和动作解耦后调试变得相对容易但也需要一些技巧。为状态和动作添加标识符在状态接口和动作接口结构体中增加一个name或id字段。typedef struct turnstile_state_interface { const char *state_name; // 状态名 void (*on_card_event)(...); // ... } turnstile_state_interface_t; // 初始化时 locked_state_t g_locked_state { .interface.state_name “LOCKED”, // ... };这样在日志中就可以打印“Entering state: %s”, fsm-current_state-state_name非常有助于跟踪流程。动作日志在动作函数的实现里尤其是调试版本第一行就打印日志。这能帮你确认事件是否触发了正确的动作以及动作执行的顺序是否符合预期。4.4 测试策略基于接口的状态机和动作类为单元测试提供了极大的便利。模拟动作Mocking在测试状态机逻辑时你完全不需要真实的硬件。可以创建一组“模拟动作”对象它们不操作硬件只是记录自己被调用的次数和参数。这样你可以编写测试用例模拟发送一系列事件card,pass,coin然后断言状态机的当前状态是否正确以及哪些动作被以何种顺序调用。测试动作本身动作类可以独立测试。你可以为real_hardware_unlock_action编写硬件在环HIL测试验证它是否能正确驱动电磁锁。集成测试最后将真实的状态机对象和真实的动作对象组装起来进行系统级的集成测试。一个常见的坑是循环依赖状态机头文件包含了动作接口头文件动作实现文件又包含了状态机头文件以获取上下文结构。这会导致编译错误。解决方法是使用前向声明forward declaration并在.c文件中包含必要的头文件确保依赖关系是单向的。5. 从闸机到复杂系统设计模式的扩展思考闸机是一个经典的入门例子但理解了状态模式和动作类解耦的精髓后我们可以将其应用到极其复杂的系统中。通信协议栈解析比如解析一个自定义的串口通信协议。状态可以是WAIT_FOR_SYNC,READ_HEADER,READ_LENGTH,READ_PAYLOAD,CHECK_CRC。每个状态下收到一个字节事件后进行相应的处理动作可能是将字节存入缓冲区、计算CRC等然后决定下一个状态。将“存入缓冲区”、“验证CRC”这些动作独立出来协议解析的状态机核心会非常清晰更换不同的缓冲区管理算法或CRC校验算法也变得很容易。用户界面交互一个设备上的UI界面。状态可以是MAIN_MENU,SETTINGS,INPUT_PASSWORD,RUNNING。按键事件在不同状态下触发不同的动作更新屏幕、跳转页面、启动任务。动作类可以对应不同的“页面渲染器”或“业务处理器”。设备工作流一台智能咖啡机。状态IDLE,GRINDING_BEANS,HEATING_WATER,BREWING,ERROR。事件button_pressed,water_temp_ready,grinding_done,brew_timeout。动作start_grinder(),start_heater(),open_valve(),display_error()。将动作分离后你可以为不同型号的咖啡机单锅炉/多锅炉不同磨豆机注入不同的硬件驱动动作而工作流逻辑状态机可以复用。最后一点个人体会状态模式配合动作类解耦初期看起来增加了代码量需要定义更多的接口和结构体。但在项目迭代和维护阶段它带来的收益是巨大的。当产品经理提出“在解锁时不仅要亮绿灯还要‘滴滴’响一声”这种需求时你只需要修改或新增一个unlock_action的实现或者创建一个组合了灯光和声音的“复合动作”状态机的代码稳如泰山。这种应对变化的能力正是专业嵌入式软件工程师与业余爱好者代码之间的一道分水岭。记住好的设计不是让代码第一次就能跑而是让代码在第一百次修改时依然能跑并且改起来不费劲。

更多文章