恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从阻塞到事件驱动:观察者模式在嵌入式串口通信中的实战
首页
资讯中心
/
从阻塞到事件驱动:观察者模式在嵌入式串口通信中的实战
从阻塞到事件驱动:观察者模式在嵌入式串口通信中的实战
发布时间:2026/10/18 6:26:31
我入行嵌入式这几年最有感触的一个瞬间是用观察者模式重构完一个被 if-else 塞满的串口协议解析模块之后原本动不动就“卡住”的通信链路一下子变得极其丝滑。硬件通信领域本来就充满各种时序约束、中断优先级、缓冲区溢出问题观察者模式能成为很多老工程师口中的 yyds不是因为它听起来高级而是它真正解决了嵌入式通信里最顽固的两个问题同步阻塞带来的响应延迟以及模块之间高耦合带来的修改恐惧。这篇文章我把整个思路完整梳理一遍从“为什么通信会卡”的根源讲起到观察者模式在 MCU 平台上的工作原理再到一个可以抄作业的轻量级 C 语言实现最后用 UART 命令解析的实战案例做前后对比。如果你正在写裸机程序、跑 RTOS或者刚把一块传感器数据接到主控上这篇内容应该能帮你少走不少弯路。1. 通信“卡顿”的根源同步等待与回调耦合1.1 阻塞轮询为什么让人头疼很多新手写串口收发第一个版本长这样uint8_t get_response(uint8_t cmd) { // 假设串口已经初始化 send_cmd(cmd); uint8_t resp 0; while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) RESET) { // 死等接收完成 } resp USART_ReceiveData(USART1); return resp; }这段代码功能上完全正确但它把 CPU 锁死在等待循环里。假如外设响应需要 50ms这 50ms 里你什么都干不了LED 不能闪、按键不能扫、另一路总线上的传感器数据也没人处理。更麻烦的是如果对方协议里有一条“不应答”的分支while 循环直接变成死循环整机只能靠看门狗复位。阻塞轮询的本质问题是通信时序占用 CPU而不是 CPU 占用通信时序。硬件通信本应是异步的数据到了自动触发没有数据就去做别的事结果你把它写成了同步等待原本属于硬件的能力全被浪费了。1.2 中断回调算不算观察者模式为了不阻塞大家普遍会用中断加回调函数void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); handle_uart_byte(data); // 直接调用某个全局处理函数 } }handle_uart_byte()里可能做了很多事解析协议帧、更新状态机、写日志、计算校验和、回复应答。这在功能上能用但很快你就会发现三个问题第一handle_uart_byte和中断处理函数强耦合。以后要加一个“串口收到数据后同时驱动蜂鸣器”的功能你得跑进这个中断函数里改代码或者祈祷 Global callbacks 的设置别冲突。第二中断处理函数里做耗时运算会拉高中断优先级占用时间。如果后面又接了 SPI Flash、CAN 总线、ADC每个外设都往中断里塞逻辑系统响应时间会变得越来越不可控。第三如果两个模块都对同一份 UART 数据感兴趣你只能在handle_uart_byte里挨个调用调用顺序写死想动态增删模块基本不可能。你发现没有中断回调本质上是观察者模式的雏形外设是被观察的主题中断函数是事件源handle_uart_byte是订阅者。只不过它缺少了最关键的“注册”“注销”和“多对多分发”机制导致每个回调都是一个孤岛扩展全靠人肉维护。1.3 一张表看清三种方式的差距实现方式阻塞情况代码扩展性中断占用时间适合场景阻塞轮询CPU 完全被锁差新增功能要改主流程无中断但CPU利用率低极简单、无实时性要求的指令交互普通中断回调不阻塞但回调内可能耗时差所有功能堆在一个函数里高取决于回调逻辑复杂度功能单一的单外设处理观察者模式不阻塞事件异步分发好新模块订阅即可不动旧代码低中断里只做入队或快分派多模块共享数据、协议栈扩展、多外设混合系统所以观察者模式的真正价值不在于“不用 while 了”而在于它让事件分发变成了一个可管理的订阅系统。中断里只负责标记“事件发生了”到底谁来处理这个事件、处理到什么程度交给各个订阅者自己决定。2. 观察者模式到底在解决硬件通信里的哪几个问题2.1 主题、观察者、事件三个角色的分工观察者模式在嵌入式通信上下文里角色非常清晰。主题Subject是数据产生者比如 UART 外设、SPI 从设备、CAN 控制器的接收中断。它不关心数据最终被谁使用只负责在事件发生时向订阅表广播。观察者Observer是数据消费者比如命令解析器、日志模块、状态指示模块、传感器融合算法。每个观察者告诉主题“我对哪类事件感兴趣”然后等主题通知。事件Event是通信过程中产生的信号比如“收到一个字节”“收到一帧完整数据”“发送完成”“校验失败”。事件可以带负载也就是我们常说的数据指针或数据拷贝。一个经典的场景一块主控板上的 UART1 连接着 GPS 模块同一份数据需要被导航算法、数据记录器、LED 指示灯三个模块使用。如果不用观察者模式你大概率会在串口中断里写三行调用navigation_update(gps_data); log_store(gps_data); led_indicate(gps_data);看起来问题不大但哪天你要临时屏蔽日志功能就要改中断函数哪天想加一个远程遥测模块又得改中断函数。用观察者模式GPS 这个主题根本不关心谁在订阅三个模块各自注册哪天不想看日志了注销一行代码就行中断处理函数一行都不用动。2.2 观察者模式与普通回调的边界在哪里很多文章说观察者模式就是“函数指针 注册表”我不太认同。函数指针没错注册表也没错但观察者模式还隐含了三个管理维度事件类型管理不是所有事件都发给所有订阅者观察者要能选择自己关心的事件类型生命周期管理订阅者可以随时注册、注销系统启动时静态注册也可以运行时动态增减也可以分发策略管理是同步分发还是异步分发是在中断上下文分发还是投递到任务上下文分发这决定了整个系统的实时性表现。这三个维度才是观察者模式区别于“简单回调”的关键。简单回调是“谁写了就调谁”观察者模式是“谁注册了才通知谁”。前者面向实现后者面向接口这也是它能降低模块耦合的根源。2.3 一个比喻食堂取餐和电话订餐把观察者模式讲给硬件同事听我常用食堂做比喻。轮询方式就像你站在食堂窗口前死等菜没好就一直站着后面人全被你堵住普通中断回调就像一个窗口做好菜后喊一声所有号码牌拿菜但喊话的人得认识每一个来取餐的人不然就把菜给错人观察者模式则是食堂建立一个订餐档案谁订了什么菜菜好了就按档案通知谁。窗口这个“主题”只需要维护一份档案不需要认识每个人。硬件通信中UART 中断频率往往很高如果每个字节都触发一次完整分发流程就要求分发代码足够轻量。所以观察者的落地实现一定要围绕“轻量”和“可控速度”来设计这也是下一章要写的核心。3. 一个最小可用的观察者分发模块C 语言实现3.1 数据结构设计事件类型与订阅节点在单片机上做观察者模式我最常用的方式是基于静态数组的订阅表不用动态内存分配。每个事件类型对应一个订阅链表节点里保存回调函数指针和可选的上下文参数。typedef enum { EVT_UART_RX_BYTE 0, EVT_UART_RX_FRAME, EVT_UART_TX_DONE, EVT_CAN_RX_FRAME, EVT_TIMER_TICK, EVT_COUNT, } event_id_t; typedef struct observer_node { void (*callback)(void* ctx, const void* data, uint16_t len); void* ctx; struct observer_node* next; } observer_node_t; static observer_node_t* subscriber_head[EVT_COUNT];这里事件类型用 enum好处是编译期检查类型避免把事件 id 写成魔法数字。订阅表用数组加链表EVT_COUNT 就是事件总数每个事件维护一张链表。有人会问为什么不用二维数组固定容量因为不同的模块可以被同一个事件触发多次比如两个显示器都想看 CAN 数据链表的灵活度更高。3.2 注册与注销接口给观察者一个“身份”注册接口不复杂核心只是把节点挂到对应事件链表的尾部int observer_subscribe(event_id_t event, observer_node_t* node) { if (event EVT_COUNT || node NULL || node-callback NULL) { return -1; } node-next NULL; if (subscriber_head[event] NULL) { subscriber_head[event] node; } else { observer_node_t* cur subscriber_head[event]; while (cur-next ! NULL) { cur cur-next; } cur-next node; } return 0; }注销接口稍微麻烦一点需要遍历链表找到目标节点并摘除int observer_unsubscribe(event_id_t event, observer_node_t* node) { if (event EVT_COUNT || node NULL) { return -1; } observer_node_t** pp subscriber_head[event]; while (*pp ! NULL) { if (*pp node) { *pp node-next; node-next NULL; return 0; } pp (*pp)-next; } return -1; }提示静态数组 链表的实现里节点内存是调用方自己的静态变量或结构体成员。不要在函数栈上定义节点然后注册如果函数退出这个订阅节点就悬空了后面事件分发必然踩内存。这个设计最实用的一点是同一个观察者节点可以同时订阅多个事件吗可以的只要这个节点结构体里的next字段只属于一条链表。如果你要让一个节点出现在多条链表里需要给每个事件单独分配一个节点。实际项目中我一般这么用一个模块结构体里内嵌多个 observer_node分别对应不同的事件。3.3 事件分发中断上下文里的安全通告事件分发是整个框架的心脏也是最容易出事的地方。基本思路是遍历订阅链表依次调用回调void observer_notify(event_id_t event, const void* data, uint16_t len) { if (event EVT_COUNT) { return; } observer_node_t* node subscriber_head[event]; while (node ! NULL) { observer_node_t* next node-next; // 先保存next防止回调中注销自己 if (node-callback) { node-callback(node-ctx, data, len); } node next; } }那段“先保存 next”的代码是踩过坑才加上的。假设一个观察者收到“UART 收到帧”事件后觉得不再需要订阅了直接调用 observer_unsubscribe把当前节点从链表中摘除并释放。如果分发函数还继续用 node-next 去遍历轻则漏掉后面的观察者重则访问悬空指针。先保存 next再执行回调就能保证即使回调里注销了自己遍历指针仍然有效。如果这个分发函数要从中断里调用还有一个关键问题注册表操作和遍历分发可能并发访问链表。裸机环境下注册动作往往发生在 main 上下文分发动作发生在中断上下文。这时候需要在操作链表时做临界区保护。static uint32_t irq_lock_save(void) { uint32_t primask; __asm volatile(MRS %0, PRIMASK : r(primask)); __asm volatile(CPSID i); return primask; } static void irq_lock_restore(uint32_t primask) { __asm volatile(MSR PRIMASK, %0 : : r(primask)); }在 Cortex-M 上处理这种短暂链表操作最合适的做法就是关闭全局中断那么几个周期然后把注册、注销操作包进去。注意不要在临界区里执行观察者回调函数。回调可能跑到数毫秒甚至更久关闭中断这么久会破坏整个系统的实时性。应该只保护链表操作的瞬间遍历过程里如果需要动态修改链表就采用先保存 next 的方法同时约定“回调里允许注销自己但不允许向同一事件注册新节点”这样并发风险完全可控。3.4 内存和实时性的权衡看到这里你可能会问每个事件都要保存一份 head 指针才几KB的 RAM 会不会不够在主流 MCU 上这个开销通常小到可以忽略假如有 8 个事件head 数组才 8 个指针32 位平台上就是 32 字节订阅节点一般也就几十个字节一个。真正要关心的是分发延迟。分发延迟取决于两个因素链表长度和回调耗时。链表越长遍历越慢回调越重每个观察者的处理时间越长。硬件通信事件尤其是中断里触发的我建议分发函数只负责“快通知”把真正耗时的处理放到主循环或者 RTOS 队列里。这里给一个经验值中断处理函数总耗时应该尽量控制在单次中断周期的一半以下。比如 UART 在 115200 波特率下一个字节中断间隔约 86 微秒那在你的分发流程里面最好不要超过 30 微秒。3.5 扩展异步分发和优先级机制如果你的系统里观察者数量很多或者存在某个观察者处理特别慢还有一种改进是“延迟分发”。做法是在中断里不调用回调只是把事件信息丢到一个无锁环形队列主循环或专用处理任务再统一取出并分发。这样中断函数耗时极短所有业务逻辑都在低优先级上下文执行。typedef struct { event_id_t event; const void* data; uint16_t len; } pending_event_t;环形队列和消费者配合本质上就是生产者-消费者模型它能解耦“事件产生速度”和“事件处理速度”之间的不匹配。对高速 DMA 接收尤其有效后面第五章我再展开。4. 实战案例用观察者模式重构 UART 命令解析模块4.1 原始代码嵌套 if-else 和 12 个模块的纠缠之前接过某设备的一个固件里面 UART 命令解析函数是我见过最典型的“意大利面条代码”。一个handle_frame()函数里面大概有十几个 if-else 分支每个分支都对应一种命令类型。看起来是不是还可以但问题是每个分支内部还直接调用了底层硬件操作、日志模块、状态修改、反馈发送正反五行夹杂着读写寄存器出问题时根本不知道是谁改了状态。这类代码最让人绝望的地方不是能跑而是“不能改”。你想加一个新命令必须找到那个大函数然后在最后的 else 后面继续堆 if-else你想删掉一个旧命令又必须在十几个分支里精确找到相关的隐式调用稍不留神就会影响另一个分支的共享变量。4.2 重构目标命令注册、协议解析、业务处理三层分离要把这块拆开我第一件事是把命令处理函数抽成统一的回调签名typedef struct { uint8_t cmd_id; int (*handler)(void* ctx, const uint8_t* payload, uint16_t len); void* ctx; } cmd_handler_t;然后定义一组事件每种命令 ID 就是一个事件类型。协议解析层只负责“从字节流里识别出一帧完整命令”识别成功的帧被封装成事件通过观察者框架分发出去。至于哪个命令由谁来处理协议解析层完全不知道。static observer_process_t uart_rx_byte_node; static const uint8_t* frame_ptr NULL; static uint16_t frame_len 0;实际项目中我会把完整帧数据放到一个帧缓冲里然后调用 observer_notify(EVT_UART_RX_FRAME, frame_ptr, frame_len)。每个命令处理器在系统启动时注册自己关心的事件。比如温控模块注册EVT_CMD_SET_TEMP电机模块注册EVT_CMD_MOTOR_CTRL版本查询模块注册EVT_CMD_GET_VERSION。4.3 重构后的代码长什么样系统初始化阶段是这样static observer_node_t temp_cmd_node { .callback temp_cmd_handler, .ctx temp_state, }; static observer_node_t motor_cmd_node { .callback motor_cmd_handler, .ctx motor_state, }; static observer_node_t version_cmd_node { .callback version_cmd_handler, .ctx NULL, }; void app_init(void) { observer_subscribe(EVT_CMD_SET_TEMP, temp_cmd_node); observer_subscribe(EVT_CMD_MOTOR_CTRL, motor_cmd_node); observer_subscribe(EVT_CMD_GET_VERSION, version_cmd_node); }协议解析部分变成简洁的框架代码void uart_frame_dispatch(const uint8_t* frame, uint16_t len) { if (len 2) { return; } uint8_t cmd_id frame[0]; uint16_t payload_len (frame[1] 8) | frame[2]; const uint8_t* payload frame[3]; if (cmd_id CMD_SET_TEMP) { observer_notify(EVT_CMD_SET_TEMP, payload, payload_len); } else if (cmd_id CMD_MOTOR_CTRL) { observer_notify(EVT_CMD_MOTOR_CTRL, payload, payload_len); } else if (cmd_id CMD_GET_VERSION) { observer_notify(EVT_CMD_GET_VERSION, payload, payload_len); } }可能有较真的读者说这不是还有 if-else 吗没错解析层仍然需要做“命令 ID 到事件 ID”的映射这是必要的协议分派但注意它和业务逻辑已经彻底分开了。以后再新增命令协议解析层的 if-else 里加一行映射同时新增一个模块注册自己的观察者即可。相比原来在同一个函数里既做映射又做处理改动面小了一个量级。4.4 实测效果代码量和响应时间的变化重构完我做了个小统计指标重构前重构后处理函数代码总行数约 1200 行约 450 行含框架和所有命令回调新增命令耗时约 1 小时改大函数易出错约 20 分钟新增一个模块即可单帧平均处理耗时3.2ms1.1ms中断中耗时占比40%因为解析逻辑太多8%中断里只做字节入队分发响应时间提升很明显但更值钱的其实是维护性的改善。之后我加了一个远程升级命令没去碰任何既有模块的回调就实现了版本确认、固件写入、重启校验三个过程的解耦。5. 中断、RTOS 和 DMA 混用时的三个大坑5.1 坑一中断上下文直接执行慢回调毁掉整个实时性观察者模式最容易让人产生误解的地方是以为“把逻辑都放回调里就完事”。如果分发函数在 UART 中断里被调用所有订阅者回调也会在中断上下文中执行。一个日志模块的订阅者把 32 字节写到 SPI Flash写 Flash 可能要 10ms 到几十毫秒这段时间里其他中断优先级低于 UART 的外设全部被饿死高优先级的中断如果嵌套又会带来新的重入问题。解决思路是分层中断里只通知“有数据到达”这个轻量事件真正的协议解析和业务处理放到主循环或者专用任务里。我常用的一种做法是给观察者框架配置“上下文标志”typedef enum { OBSERVER_CTX_ISR 0, OBSERVER_CTX_TASK 1, } observer_ctx_t;分发函数带一个 ctx 参数观察者如果发现自己是在 ISR 上下文被调用就不做耗时操作只是把数据放入队列然后返回。主循环再调用一次专门的observer_dispatch_pending()来处理队列。5.2 坑二RTOS 下直接操作共享链表不加锁就是找死跑 RTOS 后除了中断另一个并发源是多个任务同时访问同一个订阅表。比如任务 A 注册一个观察者任务 B 同时触发同一个事件链表操作就可能出现交叉写轻则丢事件重则链表循环系统直接跑飞。我的做法是给订阅库加上一层互斥锁。如果是 FreeRTOS中断里用taskENTER_CRITICAL_FROM_ISR任务里用taskENTER_CRITICAL但注意它们的实现不同。更通用一点的做法是在发布接口里区分调用环境void observer_notify_from_isr(event_id_t event, const void* data, uint16_t len) { // 遍历时只处理注册表不做锁 // 所有需要修改注册表的操作都必须在任务上下文做 }实际工作中最稳的策略其实是注册表只能被一个固定上下文修改通常是初始化阶段一次性注册完运行期间不做动态注册。如果确实需要动态订阅就在主循环里做不要放在中断里做。把集权机制设计在架构层远比靠锁来救场更可靠。5.3 坑三用了 DMA 之后“收到数据”事件不再是逐字节的很多工程师第一次上手 DMA 接收容易把观察者模式照搬过去结果发现中断频率从每字节一次变成了一帧一次回调触发频率一下子低了但每次数据量大了。这种情况下观察者事件需要改变语义原来EVT_UART_RX_BYTE是逐字节的细粒度事件现在应该改成EVT_UART_RX_DMA_HALF、EVT_UART_RX_DMA_FULL这类粗粒度事件。为什么因为 DMA 中断只会在半满、全满或者传输完成时触发如果想把“新的字节到了”这种事件精确地通知出去就必须自己维护一个环形缓冲区并且每次都要处理“上次读到哪里”的游标。这个条件下线程安全的环形缓冲区比观察者事件本身更重要观察者只是消费这个缓冲区的通知口而已。一个可行组合是DMA 接收 无锁环形队列 观察者事件。DMA 中断负责把数据搬运到环形队列中断安全地更新写指针主循环读取观察者事件收到EVT_UART_RX_BLOCK_READY后把环形队列数据取出来做协议解析。注意环形队列的读写指针如果是各自上下文独立更新的可以做到无锁。比如写指针在中断里更新读指针在主循环里更新不交叉操作同一个变量数据同步靠 volatile 和内存屏障保证这在 Cortex-M 上通常问题不大。6. 观察者模式并不是银弹适用边界与我踩过的教训6.1 不适合的场景高频控制回路和极端资源受限观察者模式能解决解耦问题但引入的是间接调用。对于 CPU 万分紧张的控制内核比如电机 FOC 电流环跑在 20kHz你要用观察者模式去分发每一个电流采样点事件一次函数指针调用多出的开销可能就几个周期看似不多但如果订阅链表里有 5 个观察者一次采样就多了 5 次间接跳转、参数传递、返回恢复累积起来对硬实时的杀伤力不可忽视。这种场景我建议用回调数组或者干脆直接展开写不要为了设计模式牺牲确定性时序。你要是跑在只有 2KB RAM 的 8 位单片机上且通信频率也不高上个简单的中断回调就够了强行引一套观察者框架只会让代码比业务逻辑还复杂。6.2 调试技巧给订阅表加一套“可视化”观察者模式最难调试的情况是“明明订阅了但事件没触发”。常见原因有几个注册的节点内存被覆盖、节点在错误上下文被注册、事件 ID 映射写错。我后来在框架里加了两样东西大大缓解了这些问题。第一是订阅计数。每次 register / unregister 都更新一个全局计数调试时读一下就知道系统启动后哪些事件被哪些模块订阅了。第二是事件触发次数统计。分发函数的每次调用都累加对应事件的 trigger count如果协议层明明发了事件trigger count 却没变问题一定出在之前的上层调用如果 trigger count 在涨但某个回调没执行那大概率是订阅链表断裂或者节点悬挂。我还会在注册和注销接口里放断言检查 node 是否在合理的 RAM 范围内避免传入野指针。嵌入式系统里内存越界是家常便饭有断言和没有断言排查时间差好几倍。6.3 我个人总结出的三个选型判断标准经历多了之后我每次设计通信模块都会先问自己三个问题。一是“这个事件有几个消费者”只有一个消费者用普通中断回调就够两个以上考虑观察者模式未来可能增加到三个以上直接上观察者模式。二是“这些消费者是否会动态增删”运行期间要开关日志、插拔外设模块那就是必须用观察者模式如果整个生命周期里消费者列表固定不变静态注册甚至直接函数表都够用。三是“事件处理耗时是否差异巨大”如果有一个观察者极慢其他观察者都极快就需要设计异步分发或者给慢观察者单独起任务如果所有观察者都很轻量同步分发是好选择因为代码更简单。有一次我把一个温度传感器的事件分发设计成 ISR 同步执行三个观察者分别做显示刷新、数据记录、状态检测。后来记录模块升级成写 SD 卡一次写卡时间远超显示刷新的允许时长整个显示都跟着卡顿。后来我把记录模块改成内部队列SD 卡写操作放后台执行显示模块的响应立即恢复。这个案例让我理解了观察者模式本身只解决“通知谁”的问题解决不了“通知之后回调里花多少时间”的问题。时间预算仍然需要在系统设计时单独规划。6.4 最后一个建议从最痛苦的那段代码开始尝试如果你现在还不太确定观察者模式到底能在自己项目里带来多少收益我的建议是从最让你痛苦的那个通信模块开始试水也许是那个每次新增协议命令都要改半天的串口解析器也许是那个被多个模块共用的外设数据分发器。用两三天时间把那段代码按观察者模式重写一版先不追求完美只追求“新增功能时不需要改动已有代码”这一条原则。跑通后你自然会体会到硬件通信丝滑感的背后不是某个神奇的循环或中断参数而是事件流动的方向变得清晰了、模块之间的牵绊变少了。这份感觉才是观察者模式真正让人上瘾的地方。