文章目录
- 前言:如果你曾被"复杂"击倒
- 一、为什么需要"订阅通知"这套机制
-
- [1.1 复杂度临界点](#1.1 复杂度临界点)
- [1.2 典型场景画像](#1.2 典型场景画像)
- [二、STM32 项目里"最常见的烂通知设计"](#二、STM32 项目里“最常见的烂通知设计”)
-
- [2.1 第一阶段:直接函数调用](#2.1 第一阶段:直接函数调用)
- [2.2 第二阶段:需求一来,调用链开始膨胀](#2.2 第二阶段:需求一来,调用链开始膨胀)
- [2.3 第三阶段:模块互相"知道彼此"------耦合的网](#2.3 第三阶段:模块互相“知道彼此”——耦合的网)
- [2.4 第四阶段:回调、全局标志、队列混合使用------混乱之治](#2.4 第四阶段:回调、全局标志、队列混合使用——混乱之治)
- [2.5 问题不在 FreeRTOS,而在"通知模型不存在"](#2.5 问题不在 FreeRTOS,而在“通知模型不存在”)
- [三、什么是适合 STM32 + FreeRTOS 的订阅通知机制](#三、什么是适合 STM32 + FreeRTOS 的订阅通知机制)
-
- [3.1 正确的订阅通知,必须满足 4 个硬性条件](#3.1 正确的订阅通知,必须满足 4 个硬性条件)
- [3.2 坏的"订阅设计"长什么样(非常关键)](#3.2 坏的“订阅设计”长什么样(非常关键))
- 四、正确思路:通知本身也是"事件"
- 五、推荐的整体组件架构(全景)
-
- [5.1 第一步:统一定义"系统通知类型"](#5.1 第一步:统一定义“系统通知类型”)
- [5.2 第二步:定义订阅者模型](#5.2 第二步:定义订阅者模型)
- [5.3 第三步:实现通知组件(Broker)](#5.3 第三步:实现通知组件(Broker))
- 严禁传递临时栈变量地址
- [5.4 和事件驱动状态机如何配合(核心价值)](#5.4 和事件驱动状态机如何配合(核心价值))
- [5.5 为什么这套模型比"直接队列"高级一个层级](#5.5 为什么这套模型比“直接队列”高级一个层级)
- 六、最常见的三个翻车点(务必避开)
-
- [6.1 把订阅机制当"万能广播"](#6.1 把订阅机制当“万能广播”)
- [6.2 在 ISR(中断)中直接 publish 大量通知](#6.2 在 ISR(中断)中直接 publish 大量通知)
- [6.3 让订阅者之间产生依赖](#6.3 让订阅者之间产生依赖)
- 七、读到这里,你应该真正明白的一件事
- 八、一句话架构心法
前言:如果你曾被"复杂"击倒
做嵌入式开发,尤其是当项目引入了 FreeRTOS 之后,你是否经历过这样一个时刻:
项目初期,一切都很美好。你有一个按键任务,一个串口任务,逻辑清晰,代码行云流水。
但随着产品经理的一句"这里加个联动",深渊开始凝视你。
- "蓝牙连接成功后,不仅要亮灯,还要在屏幕上弹窗,还要在 Log 里记一条,如果正在播放音乐,还要暂停一下......"
- "哦对了,如果这个时候电池电量低,那个弹窗要换成红色的。"
那一刻,你的手指悬在键盘上,心里涌起一股无力感。你知道怎么写------加个 if,调个函数,甚至设个全局变量。你也知道这么写不对,但你不知道对的写法是什么。
于是,你写下了那行注定会让你在三个月后的深夜痛哭的代码。
本文不是为了教你如何使用 FreeRTOS 的队列或信号量,而是教你如何在 STM32 + FreeRTOS 的资源约束下,设计一个真正能"活很久"、经得起需求蹂躏的订阅通知机制。
这将是一次从"满天飞的回调"到"优雅架构"的救赎之旅。
一、为什么需要"订阅通知"这套机制
1.1 复杂度临界点
很多工程师有一种错觉:"只有大型 Linux 系统才需要架构,单片机这种资源受限的系统,越简单越好。"
这是一个巨大的误区。资源的受限,不代表逻辑的简单。
只要你的 STM32 + FreeRTOS 项目满足以下任意两点,你就已经走在必然重构的路上,或者已经站在了"屎山"的边缘:
- 功能模块超过 3 个: 例如,你有 GUI、蓝牙/WiFi 协议栈、电机控制、数据存储。
- "通感"需求强烈: 多个模块对"同一件事"感兴趣。比如"系统掉电"这件事,文件系统要存盘,电机要急停,UI 要黑屏,Log 要记录。
- 网状通信交织: 通信、控制、状态彼此纠缠,不再是简单的线性流程。
- 需求变更频繁: 新需求往往遵循"某某模块也要知道这件事"的句式。
1.2 典型场景画像
当你发现你的代码中开始充斥着以下逻辑时,警钟已经敲响:
- 串口收到数据后: 解析器要干活,状态机要跳转,甚至还要去喂狗。
- 蓝牙连上后: UI 任务要刷新图标,音频任务要准备缓冲区,电源管理任务要提升主频。
- 错误发生时: 这是一个最典型的"广播"场景------日志、告警、业务逻辑、UI 弹窗,所有人都想知道"出事了"。
如果你现在还能靠 if else 和跨文件的 extern 函数调用硬撑,那只是因为你的系统复杂度还没到达那个崩溃的临界点。一旦突破那个点,每加一行代码,都是在给系统埋雷。
二、STM32 项目里"最常见的烂通知设计"
为了更好的理解,我们先来复盘一下,一个清爽的项目是如何一步步堕落的。请对号入座,不要不好意思,因为我们都曾是"烂代码"的制造者。
2.1 第一阶段:直接函数调用
这是梦开始的地方,也是噩梦的温床。
c
// uart_task.c
void uart_rx_done(void)
{
// 收到数据了,直接调用解析函数
protocol_parse();
// 顺便更新一下业务
business_update();
// 还要刷新屏幕
ui_refresh();
}
初学者的内心独白:
"看,多直接!多清晰!代码运行效率最高,没有任何中间商赚差价。这有什么问题?"
潜伏的危机:
此时,UART 模块(底层驱动)直接依赖了 Protocol、Business、UI 三个上层模块。底层包含了上层的头文件,依赖倒置已经发生。但因为代码少,我们还感觉不到痛。
2.2 第二阶段:需求一来,调用链开始膨胀
产品经理说:"我们需要把所有通信数据存到 SD 卡里,另外,如果解析出错,要蜂鸣器响两声。"
于是代码变成了这样:
c
// uart_task.c
void uart_rx_done(void)
{
protocol_parse();
business_update();
ui_refresh();
// 新增需求
log_record_to_sd();
buzzer_beep(2);
}
我们的内心独白:
"稍微有点长了......而且
uart_task.c里面包含的头文件好像有点多?sd_card.h,buzzer.h都加进来了。编译时间变长了,但还能接受,反正逻辑能跑通。"
这一阶段的特征:
UART 任务的栈空间开始捉襟见肘,因为 sd_card 的写入操作可能很耗时,直接阻塞了串口接收中断或任务。我们开始调整任务优先级,试图掩盖问题。
2.3 第三阶段:模块互相"知道彼此"------耦合的网
这时候,业务逻辑变复杂了。业务模块在处理数据时,发现了一个错误,它需要通知 UI 和告警模块。
c
// business_task.c
void business_update(void)
{
// 处理逻辑...
if (error_detected)
{
// 业务模块直接调用了告警模块
alarm_notify();
// 业务模块又直接调用了UI模块
ui_show_error();
}
}
危险点已经出现:
- Business 模块开始操心 UI: 业务逻辑不再纯粹,它包含了 UI 的细节。如果换个屏幕,Business 代码竟然要改?
- 模块边界溃散: 你已经无法单独测试 Business 模块,因为一运行它就报错"Undefined reference to ui_show_error"。
- 循环依赖: 如果 UI 模块里按键按下又要调用 Business 的函数,恭喜你,编译器都要疯了。
2.4 第四阶段:回调、全局标志、队列混合使用------混乱之治
为了解决耦合,你引入了 FreeRTOS 队列和全局标志位。你觉得你用上了 RTOS,应该是先进了。
c
// main.c
if (ble_connected) // 全局变量
{
notify_ui = 1; // 标志位
// 直接往队列塞数据,不管队列满没满
xQueueSend(log_queue, &msg, 0);
}
系统的典型特征:
- "能跑": 只要不长时间运行,看起来是正常的。
- 逻辑黑洞: 你已经说不清:
- 到底是谁先通知了谁?
- 为什么 UI 有时候没更新?(哦,原来是那个标志位被别人清除了)
- 为什么系统死机了?(哦,原来是那个队列满了,发送函数阻塞在中断里了)

2.5 问题不在 FreeRTOS,而在"通知模型不存在"
这一类系统之所以必然失控,不是因为代码量太大,也不是因为 FreeRTOS 不好用。根本原因在于:
系统中发生的"事实",没有被建模。
在上述的所有阶段中,模块之间都是靠"顺手调用"、"顺手判断"来维持联系的。开发者关注的是"动作 "(去更新UI、去写日志),而不是"事件"(发生了什么)。
这类系统缺少的不是 RTOS 的使用技巧(队列、信号量谁都会用),缺少的是一个核心的架构组件:
订阅通知模型(Publish--Subscribe Pattern)
三、什么是适合 STM32 + FreeRTOS 的订阅通知机制
在 Web 开发或 Linux 应用开发中,观察者模式或发布订阅模式非常常见。但在资源受限的嵌入式系统(MCU)中,我们不能照搬。
先说一句非常重要的话:
在嵌入式系统中,订阅通知 ≠ 动态回调注册
在上位机,我们可以用 malloc 创建回调列表,可以用 Lambda 表达式。但在 STM32 上,我们要的不是"高级玩法",我们要的是工程确定性(Determinism)。
3.1 正确的订阅通知,必须满足 4 个硬性条件
- 通知的"事实"是统一定义的: 并不是随便谁都能发个消息,消息类型必须枚举,全系统统一话语体系。
- 发布者不关心谁在订阅: UART 驱动只管喊"我收到数据了",至于谁听到了,谁去处理,UART 驱动完全不知道,也不应该知道。
- 订阅关系是显式、可追踪的: 我不希望在十几个
.c文件里去查找谁注册了回调。我希望打开一个文件,就能看到一张表,上面写着"谁订阅了什么"。 - 不破坏实时性和可预测性: 不能因为订阅者多了,发布者的耗时就线性暴增;更不能因为某个订阅者处理慢了,就把发布者卡死。
如果做不到这四点,就不要引入,否则就是引入了新的复杂度。
3.2 坏的"订阅设计"长什么样(非常关键)
在给出正解之前,必须先扫雷。很多想做架构优化的工程师,往往倒在以下两种"伪架构"上。
错误示例一:函数指针注册表(回调地狱)
c
typedef void (*notify_cb_t)(int evt);
// 动态数组或链表
static notify_cb_t cb_list[5];
void register_cb(notify_cb_t cb) { ... }
为什么是坏设计?
- Debug 极度痛苦: 当程序崩在回调函数里时,调用栈会断层或者极度混乱。你很难追踪到是哪次注册导致的。
- 线程安全噩梦: 在遍历
cb_list进行调用时,如果另一个任务调用了register或unregister,链表就炸了。加锁?那你就要小心死锁。 - 执行上下文不可控: 回调函数是在"发布者"的任务上下文中执行的。如果 UI 注册了一个耗时回调,而发布者是高优先级的电机控制任务,电机任务就会被 UI 拖死。这是实时系统的大忌!
错误示例二:每个模块一个队列(点对点轰炸)
c
// 发布者必须知道所有人的队列句柄
xQueueSend(ui_queue, &msg, 0);
xQueueSend(log_queue, &msg, 0);
xQueueSend(cloud_queue, &msg, 0);
为什么是坏设计?
- 发布者全知全能: 发布者模块必须包含所有订阅者的头文件。
- 扩展性为零: 新增一个"存储模块"订阅消息,你必须去修改发布者的
.c文件,重新编译发布者模块。这违反了开闭原则(对扩展开放,对修改关闭)。

四、正确思路:通知本身也是"事件"
这里是第一个真正的顿悟点,请放慢阅读速度。
订阅通知不是一个独立于 RTOS 之外的新机制,它只是事件驱动系统的自然延伸。
在 FreeRTOS 系统中,我们通常建议每个任务有一个"事件队列"来接收外部指令。那么:
- 通知 ≠ 直接函数调用
- 通知 = 事件分发
当"事件"发生时,订阅通知组件(Broker)负责把这个"事件"复制一份,塞进所有订阅者的"事件队列"里。
订阅者醒来,从队列取出一个事件,发现是"UART数据包",于是开始处理。它根本不知道这个事件是 Broker 发来的,还是谁发来的。
五、推荐的整体组件架构(全景)
为了实现上述理念,我们需要构建一个三层架构。

架构详解:
- 上游(发布者): 产生事实。例如 UART 中断、按键扫描任务、低电量检测逻辑。
- 中游(Broker): 路由中心。它持有一张静态的"路由表"。
- 下游(订阅者): 消费者。通常是各模块的
xQueue或轻量级回调(仅用于置标志位)。
关键原则: 通知组件是独立模块(例如 sys_notify.c/h),它不依赖任何业务模块,只依赖 FreeRTOS。
5.1 第一步:统一定义"系统通知类型"
我们要建立全系统的"官方语言"。
代码实现:通知类型枚举
c
// sys_notify_def.h
typedef enum
{
// 硬件层事件
NOTIFY_UART_FRAME_RX, // 收到一帧数据
NOTIFY_KEY_PRESS, // 按键按下
// 协议层事件
NOTIFY_BLE_CONNECTED, // 蓝牙已连接
NOTIFY_BLE_DISCONNECTED, // 蓝牙断开
// 业务层事件
NOTIFY_ERROR_OCCUR, // 发生错误
NOTIFY_STATE_CHANGED, // 系统状态改变
// 必须有的结尾
NOTIFY_MAX_COUNT
} notify_type_t;
设计原则:
- 描述事实(Fact): 命名要是过去式或状态描述,如
CONNECTED,RX_DONE。 - 不带处理意图: 绝对不要命名为
NOTIFY_NEED_UPDATE_UI。因为"需要更新UI"是订阅者决定的,不是发布者决定的。发布者只负责说"蓝牙连上了"。 - 纯粹性: 这个枚举文件应该被所有模块引用,所以不要在里面包含任何其他头文件。
5.2 第二步:定义订阅者模型
在 STM32 上,我们追求静态分配 。我们不希望在运行时 malloc 一个订阅者节点。
代码实现:订阅结构与订阅表
c
// sys_notify_cfg.c
// 引用各模块的队列句柄(通常在各模块头文件中声明为 extern)
#include "ui_task.h"
#include "protocol_task.h"
#include "log_task.h"
// 订阅者结构体
typedef struct
{
notify_type_t type; // 对什么事件感兴趣
QueueHandle_t target_queue; // 发送到哪个队列
// 可选:可以增加一个 event_id 或 command_id 来区分队列里的不同消息类型
uint16_t msg_id;
} notify_subscriber_t;
// 【核心】静态订阅表
// 这里是全系统唯一需要修改"谁订阅了谁"的地方
static const notify_subscriber_t subscribers[] =
{
// 谁订阅了 UART 帧? UI 和 协议栈
{ NOTIFY_UART_FRAME_RX, ui_queue, UI_MSG_UART_DATA },
{ NOTIFY_UART_FRAME_RX, protocol_queue, PROT_MSG_PROCESS },
// 谁订阅了 错误发生? 日志 和 UI
{ NOTIFY_ERROR_OCCUR, log_queue, LOG_MSG_ERROR },
{ NOTIFY_ERROR_OCCUR, ui_queue, UI_MSG_SHOW_ERR },
// 蓝牙连上,UI 要知道
{ NOTIFY_BLE_CONNECTED, ui_queue, UI_MSG_BLE_ICON_ON },
};
#define SUBSCRIBER_COUNT (sizeof(subscribers) / sizeof(subscribers[0]))
这一点非常重要:
所有订阅关系,在一个地方(数组里)一览无余。
当你需要排查"为什么 UI 会突然弹窗"时,不需要去 grep 几十个文件,直接看这个表,你就知道 UI 订阅了哪些事件。这就是架构的可维护性。
5.3 第三步:实现通知组件(Broker)
Broker 的逻辑非常简单,简单到令人发指,而这正是它的强大之处。
代码实现:发布接口
c
// sys_notify.c
void notify_publish(notify_type_t type, void *data, uint16_t data_len)
{
// 定义一个通用的消息结构体,用于塞入队列
sys_msg_t msg;
// 遍历静态订阅表(因为是静态数组,CPU 只是在读Flash,极快) 通知类型几十个以内 每个事件订阅者 ≤ 5~8 个
for (int i = 0; i < SUBSCRIBER_COUNT; i++)
{
if (subscribers[i].type == type)
{
// 组装消息
msg.id = subscribers[i].msg_id;
// 注意:这里涉及数据拷贝或指针传递,下文会细说
msg.param_ptr = data;
msg.param_len = data_len;
// 发送给订阅者
// 0 表示不阻塞,发布者发完就走,不管订阅者满没满
// 实际工程中可能需要判断返回值并统计丢失率
// 仅允许在 Task 上下文调用
// ISR 中请使用 notify_publish_from_isr() 或先投递到中间任务
xQueueSend(subscribers[i].target_queue, &msg, 0);
}
}
}
架构顿悟:
此时你完成了一件非常关键的事:解耦。
uart_task.c只需要调用notify_publish(NOTIFY_UART_FRAME_RX, buf, len)。- 它不需要引用
ui_task.h,也不需要引用log_task.h。 - 哪怕明天你要加一个"云端上传模块",你只需要在
sys_notify_cfg.c的数组里加一行,UART 任务的代码一行都不用动!
msg.param_ptr = data; msg.param_len = data_len;
使用规则:
小数据(≤8/16 字节):直接值拷贝进 sys_msg_t
大数据:只允许传递 静态缓冲区 / 消息池指针
严禁传递临时栈变量地址
严禁传递临时栈变量地址
5.4 和事件驱动状态机如何配合(核心价值)
很多读者会问:"有了这个,还需要状态机吗?"
答案是:必须配合使用,简直是天作之合。
我们把系统的逻辑分为两类:
- 纵向控制流(核心业务): 比如 协议解析 -> 数据处理 -> 硬件控制。这部分逻辑严密,适合用状态机或直接调用。
- 横向信息流(旁路业务): 比如 UI 显示、LED 状态灯、蜂鸣器、日志记录。这部分逻辑是"观察者"。
Broker 负责横向,状态机负责纵向。
- 状态机只关心: 那些会改变系统核心运行状态的事件(如"收到关键指令")。
- 订阅通知负责: 把状态机的变化广播出去(如"我现在进入故障态了"),让 UI 和 LED 自己去响应。

5.5 为什么这套模型比"直接队列"高级一个层级
让我们用一张表来做最终的裁决:
| 特性 | 直接函数调用 | 多队列直发 | 订阅组件 (Broker) |
|---|---|---|---|
| 模块耦合度 | 极高 (像胶水一样粘在一起) | 高 (需引用对方头文件) | 极低 (只依赖中间件) |
| 扩展新需求 | 修改旧代码 (风险极大) | 修改旧代码 | 只改配置表 (零风险) |
| 调试难度 | 低 (单步调试容易) | 中 | 中 (需追踪消息) |
| 可读性 | 差 (逻辑分散在各处) | 差 | 极高 (上帝视角看表) |
| RTOS 友好度 | 差 (可能导致优先级反转) | 良 | 优 (天然的任务解耦) |
六、最常见的三个翻车点(务必避开)
架构是好的,但落地时往往有坑。作为过来人,给你三个锦囊。
6.1 把订阅机制当"万能广播"
误区: 把高频数据(如 1kHz 的 ADC 采样数据)放入通知机制。
后果: 你的队列会瞬间爆满,CPU 全在做 memcpy 和 xQueueSend。
正解: 通知只适合低频、控制面的消息(如状态变化、低速通信帧、错误事件)。高频数据流应该用专门的 DMA + 环形缓冲区或专用数据管道。
6.2 在 ISR(中断)中直接 publish 大量通知
误区: 在串口中断里调用 notify_publish,而这个函数里遍历了 5 个队列进行发送。
后果: 中断执行时间过长,甚至导致中断嵌套错误。
正解: ISR 只能发最小必要的事件(通常发给一个名为 Driver_Task 的中间层),或者使用 xQueueSendFromISR 的专用版本,且 Broker 需针对 ISR 环境做适配(检测 xPortIsInsideInterrupt)。更推荐的做法是:中断只置标志或发给单一任务,由该任务去 Publish。
6.3 让订阅者之间产生依赖
误区: UI 收到通知后,处理了一半,又发了一个通知给 Log。
后果: 很容易形成"死循环"或者复杂的时序依赖。
正解: 订阅关系必须是扁平的。发布者 -> Broker -> 订阅者。绝不允许反向引用,也尽量避免链式反应。
七、读到这里,你应该真正明白的一件事
如果你之前的系统混乱、难以维护,这通常:
- 不是因为 RTOS 太难用;
- 不是因为业务太复杂;
- 而是因为模块之间没有统一的**"边界语言"**。
事件(Event),是纵向的时间切片。
订阅(Sub),是横向的空间解耦。
这两者一旦区分清楚,你会发现,你以前写的那坨"面条代码",通过这个 Broker 中间件,突然变得井井有条。
你会体验到那种感觉:
当你把最后一行旧代码重构进这个框架,编译通过,烧录运行。按下按键,日志、UI、蜂鸣器同时响应,但它们的代码却互不相识。
那一刻,你会感觉到作为架构师的掌控感。
八、一句话架构心法
在 STM32 + FreeRTOS 中,没有订阅通知模型的系统,迟早会退化成"函数互咬"的泥潭;而好的架构,就是让每个模块都以为自己是世界上唯一的模块。