嵌入式观察者模式实战:STM32 ADC采集广播到OLED/串口/存储/控制
2026/8/31 2:08:53 网站建设 项目流程

在实际嵌入式开发里,ADC 采集本身并不算难。真正让代码变得难维护的,往往是“采集完成之后”的那段逻辑:同一份采样值,可能要被 OLED 显示、串口上传、历史数据存储、阈值控制四个模块同时使用。如果每个模块都靠主循环轮询标志位,或者由采集代码逐个调用四个处理函数,每增加一个使用方,采集模块就要改一次。这种问题碰到第三次,就该引入设计模式了。这篇文章要讲的,是嵌软设计模式中很经典的一类:观察者模式。对应到实际场景,就是“ADC 一更新,4 端同时收到”。文章会从为什么需要这种模式讲起,用一个基于 STM32 HAL 库的最小 C 工程,把 ADC 采集端的发布逻辑和 4 个订阅端完整写出来,再给出验证步骤和常见问题排查。读完以后,你可以直接把这套结构迁移到传感器数据分发、按键事件通知、网络消息广播等场景。

1. ADC 采集完成后,为什么需要“多端同时收到”

1.1 裸机轮询写法:采集端和应用端耦合在一起

很多刚从单片机入门阶段进入项目开发的人,会先写一个主循环轮询版本。逻辑大概是下面这样:

volatile uint8_t adc_ready = 0; volatile uint16_t adc_value = 0; while (1) { if (adc_ready) { adc_ready = 0; Display_Show(adc_value); Uart_Send(adc_value); Storage_Save(adc_value); Control_Adjust(adc_value); } }

这段代码在实验板子上完全可以工作。问题是,它把“采集完成”和“四个使用方”全部写死在主循环里。后续如果再加一个云端上报端,主循环就要再增加一行Cloud_Report(adc_value)。如果某个模块需要修改参数,主循环也跟着改。时间一长,主循环变成了一个什么都要管的“上帝循环”,ADC 模块和所有应用模块都耦合在一起。

1.2 中断里点对点调用:中断负担会越来越重

有人会想,既然主循环轮询不够及时,那就在 ADC 中断里直接调用四个处理函数:

void ADC_IRQHandler(void) { adc_value = (uint16_t)ADC_Read(); Display_OnAdcUpdate(adc_value); Uart_OnAdcUpdate(adc_value); Storage_OnAdcUpdate(adc_value); Control_OnAdcUpdate(adc_value); }

这种做法比主循环轮询及时,但问题更大。中断里的函数越多,中断服务函数执行时间越长,其他中断和主循环就越容易被饿死。更何况,OLED 刷新、串口打印这类操作通常都耗时不短,根本不该放在中断里。更关键的是,采集模块仍然必须知道“现在有哪几个端在等数据”。

1.3 观察者模式的本质:发布端不关心谁在订阅

观察者模式解决的问题,是让“数据产生者”和“数据消费者”解耦。ADC 采集模块只负责发布一份新的adc_value,至于这个值是被显示、被存储、被上传,还是被用来控制 LED,ADC 模块完全不关心。

使用观察者模式后,采集端只依赖一个统一的通知接口:

Observer_Notify(adc_value);

显示端、串口端、存储端、控制端各自注册自己的回调函数。采集端不直接调用它们,而是通过一个订阅链表统一广播。

三种方式的对比可以看这张表:

实现方式耦合度新增使用方成本中断友好度模块复用度
主循环轮询标志位需要修改主循环
中断里点对点调用需要修改采集中断
观察者模式广播只需新增订阅节点

在实际项目里,这条设计思路对应的是“ADC 一更新、4 端同时收到”的常见需求。它不只是写起来漂亮,更重要的是让后续扩展变得安全。

注意:观察者模式不是银弹。订阅关系少、使用方固定不变的小程序,直接轮询反而更直观。当使用方增长到 3 个以上,并且未来可能继续增加时,再引入观察者模式才划得来。

2. 用最小 C 工程理解观察者模式的四个角色

2.1 四个角色分别是谁

观察者模式在嵌入式 C 语言里落地,通常包含四个角色:

  1. 发布者(Subject):这里是 ADC 采集模块。它产生数据,并调用通知接口。
  2. 订阅者(Observer):需要获取 ADC 数据的各个模块,比如显示模块、串口模块、存储模块、控制模块。
  3. 订阅节点(Node):C 语言里没有对象和接口,必须用结构体保存回调函数指针,并把多个节点串成链表。
  4. 通知接口(Notify):遍历订阅链表,依次调用每个节点的回调函数。

2.2 定义一个可复用的观察者管理器

为了不让 ADC 模块直接操作链表,我习惯单独写一个observer_manager模块。这个模块只负责一件事:管理订阅关系并广播通知。它不关心数据来自 ADC、按键还是网络。

头文件可以写成这样:

#ifndef OBSERVER_MANAGER_H #define OBSERVER_MANAGER_H #include <stdint.h> #include <stddef.h> typedef void (*ObserverCallback)(uint16_t value); typedef struct ObserverNode { ObserverCallback callback; struct ObserverNode *next; } ObserverNode; void Observer_Init(void); uint8_t Observer_Register(ObserverNode *node, ObserverCallback cb); uint8_t Observer_Unregister(ObserverNode *node); void Observer_Notify(uint16_t value); #endif

回调函数统一接收一个uint16_t类型的 ADC 原始值。如果你的 ADC 是 12 位,这个范围完全够用。如果是 16 位,可以把类型改成uint32_t

2.3 单链表实现注册、注销和广播

实现文件里用静态头指针保存订阅链表:

#include "observer_manager.h" static ObserverNode *s_head = NULL; void Observer_Init(void) { s_head = NULL; } uint8_t Observer_Register(ObserverNode *node, ObserverCallback cb) { if (node == NULL || cb == NULL) { return 0; } node->callback = cb; node->next = s_head; s_head = node; return 1; } uint8_t Observer_Unregister(ObserverNode *node) { ObserverNode **pp = &s_head; while (*pp != NULL) { if (*pp == node) { *pp = node->next; node->next = NULL; return 1; } pp = &(*pp)->next; } return 0; } void Observer_Notify(uint16_t value) { ObserverNode *node = s_head; while (node != NULL) { if (node->callback != NULL) { node->callback(value); } node = node->next; } }

这里有几个关键设计点:

  • 新节点插入链表头部,时间复杂度 O(1),不需要遍历整个链表。
  • 节点不是动态分配的,而是由调用方提供静态全局变量。这样避免malloc带来的内存碎片,也方便在编译期检查节点数量。
  • Observer_Unregister使用二级指针遍历,可以统一处理“删除头节点”和“删除中间节点”的逻辑,代码更简洁。

2.4 接口使用约束

函数作用注意事项
Observer_Init初始化链表头系统启动时调用一次,重复调用会丢失订阅关系
Observer_Register注册订阅节点同一个节点不能重复注册,否则链表成环
Observer_Unregister注销订阅节点不要在通知回调里注销当前节点
Observer_Notify广播数据可被 ADC 中断调用,回调函数必须足够快

注意:Observer_Notify是在调用者上下文里执行的。如果它在 ADC 中断里被调用,那么所有回调都不能包含阻塞延时、打印重负载、动态内存分配等耗时操作。

3. 以 STM32 HAL 库 ADC 为例,实现四端同时收到数据

3.1 环境准备与硬件规划

下面的实现基于 STM32CubeMX 生成的 HAL 库工程,使用 Keil MDK 或 IAR 编译。ADC 通道接一个电位器,旋转电位器可以改变采样电压。

项目说明
开发板STM32F103 或 STM32F4 系列
ADC 输入电位器输出接 ADC1_IN0
ADC 模式单通道、单次转换、软件触发
数据使用方OLED 显示、串口打印、环形缓冲存储、LED 阈值控制
编译环境Keil MDK 或 IAR
调试器ST-Link 或 J-Link

CubeMX 中 ADC 配置参数的大致效果如下:

static void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; HAL_ADC_Init(&hadc1); sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_71CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); }

几个参数简单解释一下:

  • ContinuousConvMode设为DISABLE,使用单次转换模式,每启动一次触发一次转换,转换完成后进中断。
  • ExternalTrigConv设为ADC_SOFTWARE_START,由软件启动转换。学习环境用软件触发足够。
  • SamplingTime越大,采样越稳定,但整体转换时间越慢。对于电位器这种源阻抗不高的场景,71.5 周期通常够用。

如果你希望 ADC 每隔固定时间采样一次,可以把触发源改成定时器触发。这样不会占用主循环,也能保证采样间隔稳定。使用 PWM 触发 ADC 采样的原理也类似,核心都是“外部触发源控制转换启动时机”。

3.2 主流程注册四个订阅端

先定义四个静态订阅节点:

static ObserverNode s_displayNode; static ObserverNode s_serialNode; static ObserverNode s_storageNode; static ObserverNode s_controlNode;

然后在main函数里初始化管理器并注册:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_USART1_UART_Init(); MX_I2C1_Init(); Observer_Init(); Observer_Register(&s_displayNode, Display_OnAdcUpdate); Observer_Register(&s_serialNode, Serial_OnAdcUpdate); Observer_Register(&s_storageNode, Storage_OnAdcUpdate); Observer_Register(&s_controlNode, Control_OnAdcUpdate); HAL_ADC_Start_IT(&hadc1); while (1) { } }

注意,注册顺序决定了回调执行顺序。这里不一定符合业务优先级,因此不要在订阅端之间依赖先后顺序。如果某个模块必须最先处理数据,最稳妥的做法是在回调内部通过时间戳或消息队列做同步,而不是依赖注册顺序。

3.3 ADC 采集中断回调:只做发布,不做业务

HAL 库在单次转换完成时会调用回调函数HAL_ADC_ConvCpltCallback。在这里直接读取转换结果并广播:

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc == &hadc1) { uint16_t adc_value = (uint16_t)HAL_ADC_GetValue(hadc); Observer_Notify(adc_value); } }

这样做的好处是,ADC 中断不关心哪个模块需要数据。它只负责三件事:确认是我的 ADC 实例、读取转换值、通知所有订阅者。

在实际项目中,如果你有多个 ADC 实例,一定要通过hadc指针区分,否则一份数据会被不必要的回调重复处理。

3.4 订阅端一:OLED 显示

显示端负责把 ADC 原始值显示到屏幕上。这里不直接搬完整的 OLED 驱动,只给出回调函数片段:

void Display_OnAdcUpdate(uint16_t adc_value) { char buf[16]; OLED_Clear(); sprintf(buf, "ADC:%u", adc_value); OLED_ShowString(0, 0, buf); }

OLED 刷新速度较慢,一次全屏刷新可能需要几十毫秒。如果直接在 ADC 中断里调用,会严重拖慢采样节奏。因此在实际工程中,显示端不应该在回调里直接刷屏,而应该把adc_value存入一个待显示变量,由主循环周期刷新。下面的写法更适合学习环境:

volatile uint16_t s_displayValue = 0; void Display_OnAdcUpdate(uint16_t adc_value) { s_displayValue = adc_value; }

主循环里再根据刷新周期更新 OLED。

第 1 个常见坑就在这里:回调里做了重活,导致 ADC 中断迟迟无法退出。现象是程序整体反应变慢,或者使用调试器时发现中断一直处于忙状态。

3.5 订阅端二:串口打印

串口端可以用于调试和数据观察。在 HAL 库下,可以使用重定向后的printf,也可以直接调用串口发送:

#include <stdio.h> void Serial_OnAdcUpdate(uint16_t adc_value) { printf("[ADC] %u\r\n", adc_value); }

如果你没有重定向printf到串口,可以这样写:

void Serial_OnAdcUpdate(uint16_t adc_value) { uint8_t buf[16]; uint8_t len; len = (uint8_t)snprintf((char *)buf, sizeof(buf), "ADC:%u\r\n", adc_value); HAL_UART_Transmit(&huart1, buf, len, 100); }

串口发送同样是耗时操作。波特率为 115200 时,发送一个 16 字节的报文大约需要 1.4 毫秒。这个时间放在中断里也是不可接受的。学习环境可以接受,生产环境必须改成环形发送缓冲区或消息队列。

3.6 订阅端三:历史数据存储

存储端把每次采样的值写入一个固定大小的环形缓冲区,保留最近 N 个采样点:

#define STORAGE_SIZE 64 static uint16_t s_history[STORAGE_SIZE]; static uint8_t s_writeIndex = 0; static uint8_t s_dataCount = 0; void Storage_OnAdcUpdate(uint16_t adc_value) { s_history[s_writeIndex] = adc_value; s_writeIndex = (s_writeIndex + 1) % STORAGE_SIZE; if (s_dataCount < STORAGE_SIZE) { s_dataCount++; } }

这里使用环形缓冲区的目的是,避免不断移动数组元素。写入索引只会在 0 到STORAGE_SIZE-1之间循环,历史数据在缓冲区满之后自动覆盖最旧的数据。

读取端需要知道当前有多少有效数据:

uint8_t Storage_GetCount(void) { return s_dataCount; } uint16_t Storage_GetAt(uint8_t index) { if (index >= s_dataCount) { return 0; } if (s_dataCount < STORAGE_SIZE) { return s_history[index]; } return s_history[(s_writeIndex + index) % STORAGE_SIZE]; }

这个实现只考虑了单写者单读取者的场景。如果存储模块的读取者来自另一个中断或另一个任务,需要加临界区保护。

3.7 订阅端四:阈值控制 LED

控制端根据 ADC 值判断是否超过阈值,决定是否点亮 LED。这是一个最简单的执行器控制逻辑:

#define ADC_THRESHOLD 2048 void Control_OnAdcUpdate(uint16_t adc_value) { if (adc_value > ADC_THRESHOLD) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); } }

如果 ADC 是 12 位,满量程值是 4095。ADC_THRESHOLD取 2048 相当于 50% 满量程。这个回调非常轻量,放在 ADC 中断里执行也问题不大。

3.8 关键参数速查

参数取值示例作用调大影响调小影响
SamplingTime71.5 周期决定采样电容充电时间结果更稳,速度变慢速度变快,但高阻源误差变大
ContinuousConvModeDISABLE单次转换适合软件或外部触发开启后自动连续转换
ExternalTrigConvADC_SOFTWARE_START触发源选择定时器触发可固定周期软件触发灵活
STORAGE_SIZE64历史缓冲深度保留更多数据,占 RAM 更多覆盖过快
ADC_THRESHOLD2048控制判据更晚触发 LED更早触发 LED

4. 运行验证与结果分析

4.1 编译与下载

在 Keil MDK 中编译整个工程,确认没有报错后,使用 ST-Link 下载固件。打开串口调试助手,波特率设为 115200,然后旋转电位器观察输出。

如果串口端也订阅了数据,旋转电位器时应该不断收到类似下面的输出:

[ADC] 1024 [ADC] 1050 [ADC] 1108 [ADC] 1780 [ADC] 2600

同时 OLED 上显示的值同步变化,LED 在 ADC 值跨过阈值时点亮或熄灭。这就说明四个端都收到了同一份 ADC 数据。

4.2 用调试器检查 4 个回调是否真实执行

串口输出只证明了“串口端收到了”,不能完全证明四个端都收到了。最好的验证方式是在调试器里给四个回调函数各打一个断点:

  • Display_OnAdcUpdate
  • Serial_OnAdcUpdate
  • Storage_OnAdcUpdate
  • Control_OnAdcUpdate

程序运行到断点后,查看调用栈。正常情况下,四个断点都会在执行Observer_Notify后被命中,并且调用栈里能看到Observer_Notify这一层。通过观察adc_value参数,可以确认每个端收到的值是一致的。

4.3 用 GPIO 翻转测量通知耗时

如果想知道Observer_Notify这个广播过程占用了多少时间,可以用一个空闲 GPIO 来测量。在调用Observer_Notify之前把引脚拉高,调用之后拉低:

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc == &hadc1) { uint16_t adc_value = (uint16_t)HAL_ADC_GetValue(hadc); HAL_GPIO_WritePin(DEBUG_GPIO_Port, DEBUG_Pin, GPIO_PIN_SET); Observer_Notify(adc_value); HAL_GPIO_WritePin(DEBUG_GPIO_Port, DEBUG_Pin, GPIO_PIN_RESET); } }

用逻辑分析仪或示波器观察这个引脚的脉冲宽度,就是整个通知接口的执行时间。如果脉冲宽度过大,说明某个回调函数太耗时,需要优先排查。

注意:不要只验证程序能启动,还要验证数据一致、回调命中、耗时指标和异常分支。只有四个订阅端都真实执行过,才算真正跑通了观察者模式。

4.4 数据一致性验证

在调试器的 Watch 窗口添加:

  • s_displayValue
  • s_history[s_writeIndex]
  • adc_value(回调参数)

运行后比较三个值。正常情况它们应该一致,或者显示端和存储端因为更新时机不同存在一次采样的延迟。如果差异很大,说明回调没有按预期顺序执行,或者有订阅者内部修改了数据。

5. 常见问题排查

5.1 只有第一个注册的端收到数据

现象:串口有输出,但 OLED 不更新,LED 不动作。

原因:最常见的原因是 OLED 初始化失败、I2C 通信异常,或者 LED GPIO 配置成了复用模式而不是推挽输出。

检查方式

先看串口是否正常输出。如果串口正常,说明Observer_Notify本身工作正常,问题出在 OLED 和 LED 模块本身。

接着检查两个订阅节点是否注册成功。Observer_Register返回 1 表示成功,返回 0 表示传入参数非法。可以在main里加断点确认。

最后检查 OLED 的 I2C 地址和初始化顺序。显示屏模块初始化必须放在Observer_Register之前。

5.2 ADC 值一直不变

现象:旋转电位器,串口输出的值始终固定。

原因

  • ADC 通道没有正确连接,输入引脚悬空或接地。
  • ContinuousConvMode设成了DISABLE,但主循环里没有再次调用HAL_ADC_Start_IT
  • 电位器接错引脚,实际采集的不是 ADC1_IN0。

解决方案

先测量电位器中心抽头电压,确认电压确实在变化。然后在 ADC 中断回调里打一个断点,确认程序是否持续进入中断。如果不进入中断,检查HAL_ADC_Start_IT是否只调用了一次。

单次转换模式下,每次转换完成后不会自动开始下一次转换。如果想连续采集,可以在回调里重新启动转换:

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc == &hadc1) { uint16_t adc_value = (uint16_t)HAL_ADC_GetValue(hadc); Observer_Notify(adc_value); HAL_ADC_Start_IT(hadc); } }

或者直接把ContinuousConvMode设为ENABLE。但生产环境里更推荐用定时器触发加 DMA,而不是在中断里反复启动转换。

5.3 注册后回调不执行

现象:四个订阅节点都注册了,但其中一个回调函数始终不执行。

原因:节点重复注册,或者节点生命周期出错。

void App_Init(void) { ObserverNode localNode; Observer_Register(&localNode, MyCallback); }

局部变量localNode在函数结束时生命周期结束,链表里保存的是悬空指针,回调触发时访问非法内存,可能直接 HardFault。

解决方案:订阅节点必须使用静态全局变量,或者在模块内用static修饰:

static ObserverNode s_myNode; void App_Init(void) { Observer_Register(&s_myNode, MyCallback); }

5.4 在回调里做延时或重活,导致系统卡死

现象:启用串口或 OLED 回调后,整个程序卡顿,甚至无法进入调试器。

原因:回调在 ADC 中断上下文执行,而中断里调用了阻塞延时、printf或 OLED 刷屏。中断执行时间过长,低优先级任务和调试接口都被阻塞。

解决方案:回调里只保存数据或设置标志位,把耗时操作移到主循环或低优先级任务中。例如串口发送使用 DMA,OLED 刷新通过主循环周期执行。

5.5 通知回调里注册或注销节点,导致链表崩溃

现象:程序运行一段时间后随机死机,调试器定位到Observer_Notify里的链表遍历。

原因:某个回调函数在执行时调用了Observer_RegisterObserver_Unregister,修改了正在遍历的链表。node = node->next可能拿到已经被置空或被释放的节点。

解决方案:不要在通知回调里修改订阅链表。如果确实存在“收到数据后自己取消订阅”的需求,可以增加一个延迟注销列表,等Observer_Notify遍历结束后统一处理。

5.6 多个 ADC 实例共用回调,数据错乱

现象:工程里同时使用 ADC1 和 ADC2,但 ADC2 的值也被广播了,或者两个通道的值互相覆盖。

原因HAL_ADC_ConvCpltCallback是所有 ADC 实例共享的回调函数,必须通过hadc参数区分。

解决方案

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc == &hadc1) { Observer_Notify((uint16_t)HAL_ADC_GetValue(hadc)); } else if (hadc == &hadc2) { /* ADC2 的数据走另一条处理路径 */ } }

6. 生产环境中的实践建议

6.1 在 RTOS 环境里要怎么改

上面的例子是裸机写法。如果工程使用了 FreeRTOS,ADC 采集中断和业务任务通常不在同一个上下文。直接在所有任务里调用Observer_Notify会带来临界区问题。

推荐做法是:ADC 采集回调里不直接通知业务模块,而是把adc_value写入消息队列或事件标志组:

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc == &hadc1) { uint16_t adc_value = (uint16_t)HAL_ADC_GetValue(hadc); BaseType_t xHigherPriorityTaskWoken = pdFALSE; xQueueSendFromISR(s_adcQueue, &adc_value, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }

业务任务从队列里取出数据后,再调用Observer_Notify。这样观察者模式的广播逻辑仍然保留,但上下文从中断切换到了任务,回调里可以放心使用阻塞较短的 API。

在 AUTOSAR 或 MCAL 风格的工程里,ADC 的触发源、转换完成回调和数据分发会被拆得更细。触发源可能是 PWM 或定时器,转换完成通知被封装在 MCU 驱动层,业务模块通过 RTE 层获取数据。概念上仍然是发布-订阅,但代码分层比裸机工程严格得多。

6.2 DMA 多通道场景的适配

当 ADC 要采集多个通道时,每次转换完成通知一次的方式会产生大量中断和广播,CPU 开销明显。更高效的做法是使用 DMA 连续传输,转换完成后一次性得到一组数据。

这种情况下,观察者模式的参数类型要扩展,不能只是一个uint16_t。可以定义一个结构体:

typedef struct { uint16_t adcValues[4]; uint8_t channelCount; } AdcDataPacket; typedef void (*ObserverCallback)(const AdcDataPacket *data);

广播接口也改成传指针:

void Observer_Notify(const AdcDataPacket *data);

在 DMA 传输完成中断里,只需通知一次,四个订阅端会同时拿到整包数据。

6.3 观察者模式在嵌入式项目里的使用边界

观察者模式适合事件频率不极端、订阅端数量有限的场景。它不适合以下情况:

  • 高频数据流,每个回调都必须极短,否则广播时间和 CPU 占用不可控。
  • 严格实时性要求,数据必须保证在固定时间内到达指定端。
  • 订阅端数量少且固定,用直接函数调用更直观。

对于“ADC 一更新、4 端同时收到”这类需求,观察者模式的收益非常明显。采集端代码基本稳定下来,后续新增端不需要改动已有订阅者。

6.4 可复用检查清单

每次使用观察者模式做数据分发,可以参考下面的清单逐项检查:

    1. 订阅节点全部使用静态全局变量,禁止局部变量注册。
    1. 回调函数里不做延时、打印、动态分配等耗时操作。
    1. 多 ADC 实例时,回调函数里通过hadc区分实例。
    1. 回调里不修改订阅链表,注册和注销放到广播结束后执行。
    1. 共享数据变量使用volatile修饰,防止编译器优化后读到旧值。
    1. 发布端只负责通知数据,不关心哪个端需要数据。
    1. 订阅端之间不依赖注册顺序执行业务。
    1. 在 RTOS 环境中,中断里只进队列,不要在中断里直接广播给所有任务。
    1. 生产环境测量Observer_Notify的执行时间,确保广播开销可接受。
    1. 每个订阅端捕获错误时做好日志或状态标记,方便现场排查。

从实践角度看,最推荐的练习路径是:先在一块最小系统板上跑通“ADC + 串口 + LED”三个点,确认观察者模式工作正常后,再加入 OLED 显示和环形存储。每增加一个订阅端,就验证一次数据是否一致。这样即使出现问题,也能快速定位是新增模块本身的问题,还是订阅链表管理的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询