观察者模式实现ADC数据多路分发:STM32裸机驱动设计
ADC 采集完成后多个模块同时要这份数据显示屏要刷新、电机控制要计算、串口要上报、超限要报警。最常见做法是每个模块轮询读一次 ADC或者各自直接调采集函数结果就是“同一个采样值4 个模块读到的时刻不一样动作自然就不一致”。这次我们换一种思路把 ADC 变成数据源一有新值就同时推给 4 个接收端。这就是嵌软设计模式第 25 节的内容——观察者模式在 ADC 数据分发中的应用。本文将围绕“ADC 一更新4 端收到”这个实际需求展开先讲清楚观察者模式为什么适合 ADC 数据分发再给出适用于 STM32 HAL 工程的 C 代码实现覆盖 ADC 初始化、中断/DMA 采集、观察者链表管理、4 个订阅模块的回调封装、批量采样滤波和常见问题排查。文末会补上一套资源占用分析和工程化建议方便你直接套用到实际项目里。1. 核心能力速览能力项说明设计模式观察者模式 / 发布-订阅模型核心功能ADC 采样值更新后统一通知多个消费模块示例平台STM32 HAL 库代码结构对 GD32、AT32、APM32 等类似 HAL 工程同样适用采集方式支持中断采集、DMA 批量采集、定时器触发采集接收端数量支持动态注册多个观察者示例中实现 4 个接收端代码语言C11 标准不依赖 C 和 RTOS裸机工程可运行是否支持批量任务支持通过 DMA 批量采样 均值滤波实现是否需要操作系统否纯裸机轮询 中断即可是否支持接口调用是提供 attach/detach/notify 三组接口适合场景数据采集分发、仪表显示、电机保护、日志上报、阈值报警从材料看这套方案最核心的价值不是在“ADC 怎么采集”而是“采集完成之后数据怎么分发”。如果你只需要一个模块用 ADC直接用 HAL 回调即可不需要引入观察者模式。但当显示、控制、通信、报警 4 个模块同时依赖同一份 ADC 数据观察者模式的价值就非常明显了。2. 适用场景与使用边界2.1 典型需求4 个接收端要同一份 ADC 值假设有一台设备ADC 采集的是电源电压或电机电流系统里有 4 个模块同时需要这份数据显示屏模块把电压值刷新到 OLED 或 LCD。电机控制模块根据 ADC 值调节 PWM 输出或者超限停机。串口日志模块通过 UART 把采样值上报给上位机。报警模块阈值超限时触发蜂鸣器或继电器。如果不用设计模式直接写成模块之间互相调用会出现两个问题读取时机不一致。ADC 在持续更新A 模块读到的是第 10 次采样B 模块可能已经读到第 12 次采样显示值和控制值对不上。模块耦合严重。控制模块需要知道显示模块的接口显示模块要调用 UART 的发送函数新增一个模块时还要改已有的代码。观察者模式把“数据产生”和“数据消费”拆开。ADC 采集模块只负责产生新的采样值然后逐个通知已注册的接收端。接收端之间互不相识新增一个接收模块也不需要改动 ADC 采集模块。2.2 不适用什么场景观察者模式虽然适合数据分发但也不是所有 ADC 场景都要硬套只有一个消费模块时直接用 HAL_ADC_ConvCpltCallback 回调即可不需要注册表。消费模块对数据有强烈的时序要求比如电机 FOC 需要精确到微秒级读取电流此时应该走专用的 PWM 触发 DMA 链路而不是通过软件链表逐个分发。观察者数量太多比如几百个且每次都在中断里做完整通知会导致中断执行时间拉长影响实时性。2.3 安全与合规边界ADC 采集本身属于通用嵌入式技术但实际使用时需要遵守几个边界如果 ADC 用于电机保护、电池保护等安全相关场景不能只靠软件回调必须额外设计硬件保护和独立看门狗。涉及到对第三方设备的监控数据或者把采集结果上传到网络平台需要确认数据来源合规不侵犯他人隐私。示例代码用于功能验证量产前需要根据目标硬件的电压范围、采样电阻、分压电路重新校准。3. 环境准备与前置条件3.1 硬件环境示例代码基于常见 MCU STM32 HAL 库编写多数 Cortex-M 系列 MCU 都可以运行。你需要准备一块带 ADC 外设的开发板比如 STM32F103、STM32G474、STM32L476 等。一个可调电压源或电位器用于给 ADC 输入引脚提供变化的模拟电压。一个串口转 USB 工具用于观察日志输出。示波器或万用表用于核对 ADC 采样值是否和真实电压一致。如果你用的是其他厂家的 MCU重点是理解“HAL 转换完成回调”这一个入口然后把回调函数名替换成对应平台的名字。3.2 软件环境编译工具链ARM GCC 或 Keil MDK 均可。工程生成工具STM32CubeMX 或 STM32CubeIDE。HAL 库版本F1/F4/G4/L4 系列对应的 HAL 库即可。调试工具SEGGER RTT 或串口助手。3.3 前置检查清单项目检查内容ADC 引脚是否已在 CubeMX 中正确配置为 Analog 模式时钟ADC 时钟是否在允许范围内采样时间是否足够DMA如果使用 DMA 采集是否开启 DMA 对应中断中断优先级ADC 中断优先级是否低于系统心跳避免影响主循环串口重定向如果不使用 printf日志模块可以不依赖重定向电压范围ADC 输入电压是否在 0~VREF 范围内避免烧坏引脚4. ADC 采集模块设计4.1 设计目标ADC 采集模块不是简单地把 HAL 回调拿来用而是要承担两个职责启动采集并在转换完成后拿到最新值。将新值通知给所有已注册的观察者。为了让代码更清晰我把 ADC 采集模块拆成两个文件adc_driver.c底层 ADC 启动、停止、采样值读取和具体 MCU 平台相关。adc_subject.c观察者链表管理、通知逻辑和平台无关。这样做以后即使你把 MCU 从 STM32 换到 GD32观察者模式的核心代码一行都不用改只需要重写 adc_driver.c。4.2 文件结构app/ ├── adc_driver.h ├── adc_driver.c ├── adc_subject.h ├── adc_subject.c ├── observer.h ├── observer.c └── main.cobserver 部分负责通用观察者链表adc_subject 部分负责 ADC 数据源的注册与通知adc_driver 部分负责底层硬件采样。5. 观察者模式核心代码实现5.1 通用观察者接口观察者模式的核心是一个节点结构体和一个注册链表。每个观察者节点包含一个回调函数指针、一个私有上下文指针和一个链表 next 指针。/* observer.h */ #ifndef OBSERVER_H #define OBSERVER_H #include stdint.h typedef struct Observer Observer; typedef void (*ObserverCallback)(Observer *self, uint16_t value); struct Observer { ObserverCallback callback; void *context; struct Observer *next; }; void observer_init(Observer *obs, ObserverCallback cb, void *context); void observer_register(Observer **head, Observer *obs); void observer_unregister(Observer **head, Observer *obs); void observer_notify_all(Observer *head, uint16_t value); #endifobserver.c 的实现很短核心就是链表的插入、删除和遍历。这里有一个工程细节需要特别注意如果你在中断上下文和任务上下文中同时调用 register 和 unregister需要给链表操作加临界区保护常见做法是临时关闭中断。/* observer.c */ #include observer.h void observer_init(Observer *obs, ObserverCallback cb, void *context) { obs-callback cb; obs-context context; obs-next NULL; } void observer_register(Observer **head, Observer *obs) { obs-next *head; *head obs; } void observer_unregister(Observer **head, Observer *obs) { Observer **cur head; while (*cur ! NULL) { if (*cur obs) { *cur obs-next; break; } cur (*cur)-next; } } void observer_notify_all(Observer *head, uint16_t value) { Observer *cur head; while (cur ! NULL) { if (cur-callback ! NULL) { cur-callback(cur, value); } cur cur-next; } }5.2 ADC 数据源模块ADC 数据源模块持有一个观察者链表头并提供 attach 和 detach 接口。当一个 ADC 采样完成时data_source_update 被调用内部执行“保存新值 遍历通知”。/* adc_subject.h */ #ifndef ADC_SUBJECT_H #define ADC_SUBJECT_H #include observer.h void adc_subject_init(void); void adc_subject_attach(Observer *obs); void adc_subject_detach(Observer *obs); void adc_subject_update(uint16_t value); uint16_t adc_subject_get_last_value(void); #endif/* adc_subject.c */ #include adc_subject.h typedef struct { Observer *head; uint16_t last_value; } AdcSubject; static AdcSubject s_subject; void adc_subject_init(void) { s_subject.head NULL; s_subject.last_value 0; } void adc_subject_attach(Observer *obs) { observer_register(s_subject.head, obs); } void adc_subject_detach(Observer *obs) { observer_unregister(s_subject.head, obs); } void adc_subject_update(uint16_t value) { s_subject.last_value value; observer_notify_all(s_subject.head, value); } uint16_t adc_subject_get_last_value(void) { return s_subject.last_value; }5.3 与 STM32 HAL 回调对接底层驱动只需要做一件事在 ADC 转换完成中断里读取最新值然后调用 adc_subject_update。/* adc_driver.c */ #include adc_driver.h #include adc_subject.h #include main.h extern ADC_HandleTypeDef hadc1; void adc_driver_start_it(void) { HAL_ADC_Start_IT(hadc1); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { uint16_t value (uint16_t)HAL_ADC_GetValue(hadc1); adc_subject_update(value); } }需要注意HAL_ADC_ConvCpltCallback 是在中断上下文中执行的所以观察者回调函数里不要做耗时操作比如大型浮点运算、阻塞式 UART 发送、延时函数等。如果消费模块需要做复杂处理可以只在回调中置一个标志位让主循环去执行。6. 4 个接收端的代码实现6.1 接收端 1显示模块显示模块把 ADC 值换算成实际电压然后刷新屏幕。这里只保存最新电压值真正的屏幕刷新由显示服务在后台完成。/* display_observer.c */ #include observer.h #include display_ui.h static uint16_t s_display_raw; static void display_observer_callback(Observer *self, uint16_t value) { (void)self; s_display_raw value; display_ui_set_voltage(value); } void display_observer_init(Observer *obs) { observer_init(obs, display_observer_callback, NULL); }6.2 接收端 2电机控制模块电机控制模块根据 ADC 值调整 PWM或者执行保护动作。回调函数需要快速判断是否超限避免在中断中做复杂 PID 计算。/* motor_observer.c */ #include observer.h #include motor_ctrl.h #define MOTOR_OVER_CURRENT_THRESHOLD 3200 static void motor_observer_callback(Observer *self, uint16_t value) { (void)self; if (value MOTOR_OVER_CURRENT_THRESHOLD) { motor_ctrl_emergency_stop(); } else { motor_ctrl_set_output(value); } } void motor_observer_init(Observer *obs) { observer_init(obs, motor_observer_callback, NULL); }6.3 接收端 3串口日志模块串口日志模块把每次采样值格式化输出到调试串口。注意这里的 uart_send_string 应该是非阻塞或短字节发送否则长时间占用中断会造成丢数据。/* uart_observer.c */ #include observer.h #include uart_log.h static void uart_observer_callback(Observer *self, uint16_t value) { (void)self; char buffer[16]; int len snprintf(buffer, sizeof(buffer), ADC%u\r\n, (unsigned int)value); uart_log_send(buffer, len); } void uart_observer_init(Observer *obs) { observer_init(obs, uart_observer_callback, NULL); }6.4 接收端 4报警模块报警模块只关心阈值不关心具体电压值。它通过 LED 或蜂鸣器反馈状态。/* alarm_observer.c */ #include observer.h #include alarm.h #define ALARM_HIGH_THRESHOLD 3800 #define ALARM_LOW_THRESHOLD 400 static uint8_t s_alarm_active; static void alarm_observer_callback(Observer *self, uint16_t value) { (void)self; if (value ALARM_HIGH_THRESHOLD || value ALARM_LOW_THRESHOLD) { if (!s_alarm_active) { alarm_on(); s_alarm_active 1; } } else { if (s_alarm_active) { alarm_off(); s_alarm_active 0; } } } void alarm_observer_init(Observer *obs) { observer_init(obs, alarm_observer_callback, NULL); s_alarm_active 0; }6.5 主程序中注册 4 个观察者主程序的初始化流程非常清晰定义 4 个 Observer 对象初始化各自回调注册到同一个 ADC 数据源。之后每次 ADC 转换完成4 个接收端都会按注册顺序被通知。/* main.c */ #include adc_driver.h #include adc_subject.h #include observer.h #include display_observer.h #include motor_observer.h #include uart_observer.h #include alarm_observer.h static Observer g_display_obs; static Observer g_motor_obs; static Observer g_uart_obs; static Observer g_alarm_obs; void app_init(void) { display_observer_init(g_display_obs); motor_observer_init(g_motor_obs); uart_observer_init(g_uart_obs); alarm_observer_init(g_alarm_obs); adc_subject_init(); adc_subject_attach(g_display_obs); adc_subject_attach(g_motor_obs); adc_subject_attach(g_uart_obs); adc_subject_attach(g_alarm_obs); adc_driver_start_it(); } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_USART2_UART_Init(); app_init(); while (1) { // 主循环可以处理低优先级任务 } }7. 批量采样与滤波处理7.1 使用 DMA 批量采集单个样本通知适合要求响应快的场景但如果要减小噪声影响更好的方式是 DMA 连续采集多个样本然后统一处理。这里介绍一种常见做法用 DMA 把一批采样值放入缓冲区然后在“半满”和“全满”中触发数据处理。/* adc_batch.h */ #ifndef ADC_BATCH_H #define ADC_BATCH_H #include stdint.h #define ADC_SAMPLE_COUNT 64 void adc_batch_start_dma(void); uint16_t adc_batch_calc_average(void); uint16_t adc_batch_calc_max(void); #endif/* adc_batch.c */ #include adc_batch.h #include adc_subject.h #include main.h extern ADC_HandleTypeDef hadc1; static uint16_t s_adc_buffer[ADC_SAMPLE_COUNT]; static volatile uint8_t s_sample_ready; void adc_batch_start_dma(void) { s_sample_ready 0; HAL_ADC_Start_DMA(hadc1, (uint32_t *)s_adc_buffer, ADC_SAMPLE_COUNT); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { s_sample_ready 1; } } uint16_t adc_batch_calc_average(void) { uint32_t sum 0; for (uint16_t i 0; i ADC_SAMPLE_COUNT; i) { sum s_adc_buffer[i]; } return (uint16_t)(sum / ADC_SAMPLE_COUNT); } uint16_t adc_batch_calc_max(void) { uint16_t max 0; for (uint16_t i 0; i ADC_SAMPLE_COUNT; i) { if (s_adc_buffer[i] max) { max s_adc_buffer[i]; } } return max; } uint8_t adc_batch_is_ready(void) { return s_sample_ready; }在这种设计下adc_driver 中的 HAL 回调不再逐点通知而是等一批数据采集完成后在主循环中计算平均值再调用 adc_subject_update。这样可以显著降低中断频率并且天然带有滤波效果对应了“adc值滤波函数”这个常见需求。7.2 滑动平均滤波DMA 批量采集适合设备上电或周期测量但连续运行的系统更适合滑动平均。滑动窗口保存最近 N 次采样值每来一个新值就删除最旧的一个。/* adc_filter.h */ #ifndef ADC_FILTER_H #define ADC_FILTER_H #include stdint.h #define ADC_FILTER_SIZE 16 typedef struct { uint16_t buffer[ADC_FILTER_SIZE]; uint16_t index; uint16_t count; uint32_t sum; } AdcMovingAverage; void adc_filter_init(AdcMovingAverage *flt); void adc_filter_push(AdcMovingAverage *flt, uint16_t value); uint16_t adc_filter_get_average(AdcMovingAverage *flt); #endif/* adc_filter.c */ #include adc_filter.h void adc_filter_init(AdcMovingAverage *flt) { for (uint16_t i 0; i ADC_FILTER_SIZE; i) { flt-buffer[i] 0; } flt-index 0; flt-count 0; flt-sum 0; } void adc_filter_push(AdcMovingAverage *flt, uint16_t value) { if (flt-count ADC_FILTER_SIZE) { flt-sum - flt-buffer[flt-index]; } else { flt-count; } flt-buffer[flt-index] value; flt-sum value; flt-index (flt-index 1) % ADC_FILTER_SIZE; } uint16_t adc_filter_get_average(AdcMovingAverage *flt) { if (flt-count 0) { return 0; } return (uint16_t)(flt-sum / flt-count); }滑动平均滤波可以放在 ADC 主回调之前也可以放在 adc_subject_update 之前。整套流程变成ADC 转换完成 → 滤波模块 push → 滤波结果通知观察者。7.3 定时器触发采集如果系统需要严格等间隔采样而不是 ADC 连续转换可以将 ADC 触发源配置为定时器更新事件。这样 ADC 采样频率由定时器决定和主循环运行时间无关。具体 CubeMX 配置步骤在 TIM2 配置中使能 Update Trigger。将 ADC1 触发源选择为 Timer 2 Trigger Out event。使能 ADC 中断或 DMA。定时器触发适合电机控制、电力监控等对采样间隔有严格要求的场景。配合观察者模式后定时器触发不需要直接调用消费模块只负责启动 ADC转换完成后仍然走 HAL 回调代码结构不受影响。8. 接口 API 与批量任务8.1 数据源对外接口本文的“接口”不是 Web API而是嵌入式软件模块之间的函数接口。核心接口如下接口函数作用调用场景adc_subject_init初始化 ADC 数据源清空观察者链表系统初始化adc_subject_attach注册一个观察者模块初始化时adc_subject_detach注销一个观察者模块停止或休眠时adc_subject_update更新 ADC 值并通知所有观察者HAL 回调或批量采集完成后adc_subject_get_last_value获取当前最新 ADC 值主循环或调试observer_register观察者链表注册底层实现adc_subject 内部调用observer_notify_all遍历链表触发回调adc_subject 内部调用这套接口的好处是消费模块只需要知道 observer 结构体和自己的回调函数不需要知道 ADC 数据源内部是怎么采样的。以后把单次 ADC 采集换成 DMA 批量采集观察者代码不用改。8.2 批量任务设计在实际项目中“批量任务”通常指一批 ADC 样本的采集、处理、上报。我建议把批量任务拆成三个阶段启动阶段调用 adc_batch_start_dma启动 DMA 批量采集。处理阶段在 HAL 转换完成回调中置位 ready 标志主循环发现 ready 后计算平均值和最大值。分发阶段将处理结果通过 adc_subject_update 推给所有观察者。主循环中的处理代码可以参考下面这个结构while (1) { if (adc_batch_is_ready()) { uint16_t avg adc_batch_calc_average(); uint16_t max adc_batch_calc_max(); printf(batch avg%u max%u\r\n, avg, max); adc_subject_update(avg); adc_batch_start_dma(); } }这里有个细节启动下一轮 DMA 采集必须在处理完当前批次之后。如果直接放在 HAL 回调里重新启动可能出现缓冲区覆盖数据还没处理就被新一批覆盖掉。稳妥做法是“采集完成 → len 标志置位 → 主循环处理 → 再次启动 DMA”。8.3 队列化和异常重试如果 ADC 数据需要上报给上位机而 UART 发送速度较慢建议在 UART 观察者中维护一个环形缓冲区。ADC 回调只负责把数据写入队列后台任务负责从队列取数据并发送。这样即使上位机来不及接收ADC 采集也不会被阻塞。批量任务中如果连续多次检测到异常值应该先做软件滤波和重复采样确认再触发报警。避免单次毛刺导致误报警。9. 资源占用与性能观察9.1 中断执行时间估算采用观察者模式后ADC 中断要做的事情包括读取 ADC 数据寄存器、遍历观察者链表、调用各个回调。如果 4 个回调都是简单的赋值或状态判断整体执行时间通常在几微秒以内。这个时间对大多数应用来说可以接受。但要注意两个隐患回调里不能做阻塞式 UART 发送否则中断执行时间可能拉长到毫秒级。观察者链表过长时遍历链表会变成线性开销。如果后续注册几十个观察者建议改用数组或哈希表。9.2 内存占用每个观察者节点占用1 个函数指针4 字节。1 个 context 指针4 字节。1 个 next 指针4 字节。一共 12 字节。4 个观察者共 48 字节对 MCU 来说几乎可以忽略不计。如果改用静态数组方式存储观察者可以减少动态分配带来的不确定性但需要固定最大观察者数量。9.3 轮询模式对比轮询模式下4 个模块各自读取 ADC读到的时刻不同而且每次读取都触发一次寄存器访问。观察者模式下一次采样只读一次 ADC 寄存器然后通过内存变量分发减少了重复硬件访问同时保证 4 个模块拿到同一个采样值。9.4 性能观察建议在实际测试时可以这样观察性能用 GPIO 翻转法测量 ADC 中断执行时间进中断后把某个引脚拉高退出前拉低用示波器或逻辑分析仪测量高电平宽度。在 adc_subject_update 前后各插入一次 GPIO 翻转统计通知 4 个观察者需要的时间。通过 UART 日志统计每 100 次采样耗时判断是否有异常卡顿。如果中断耗时超过预期优先检查回调函数里是否出现了延时、浮点运算或阻塞发送。10. 常见问题与排查方法问题现象可能原因排查方式解决方案启动后 4 个接收端都不更新ADC 未启动或中断未开启检查 HAL_ADC_Start_IT 是否被调用在初始化中调用 adc_driver_start_it只有部分接收端更新某个 Observer 未注册或回调被置空检查 attach 是否调用回调是否被设置为 NULL确保 4 个观察者都通过 adc_subject_attach 注册中断里调用 UART 发送导致卡顿阻塞式 UART 发送耗时过长GPIO 翻转测中断耗时回调里只写队列由主循环发送观察者链表操作报错注册表在中断中被修改检查是否在回调里执行 attach/detach对链表操作加临界区保护DMA 批量采集值一直相同DMA 未正常搬运或缓冲区未更新打印缓冲区首地址和内容检查 DMA 配置是否开启循环模式外设地址是否正确采样值毛刺明显采样时间过短或存在高频噪声查看采样稳定度提高采样时间加入滑动平均滤波定时器触发 ADC 不工作触发源配置错误检查 TIM 的 Update Trigger 是否使能在 CubeMX 中重新配置 ADC 触发源进入 HardFault 中断观察者指针或回调地址非法查看 Call Stack检查 Observer 对象生命周期避免栈变量被提前释放重复通知导致逻辑错乱在批量模式同时开启了单次中断检查 HAL 回调中是否重复调用 update统一采集方式不要同时使用两种通路报警阈值频繁触发数据未滤波观察原始 ADC 值波动范围加入滑动平均或滞回比较在排查过程中最有效的手段是把 ADC 原始值和通知链路分开观察。先通过变量监视确认 HAL 回调确实触发再确认 adc_subject_update 被调用最后确认各观察者回调被调用。分三段排查定位很快。11. 最佳实践与使用建议11.1 回调里只做轻量操作观察者回调在中断上下文中执行必须遵循“快进快出”原则。赋值、置标志、写寄存器都可以浮点计算、打印、延时、动态内存分配都不建议。如果消费模块需要做复杂逻辑回调里只设置 ready 标志主循环中处理。11.2 使用静态注册表如果不涉及动态增删观察者可以改成静态数组方式#define MAX_OBSERVER_COUNT 8 typedef struct { Observer *list[MAX_OBSERVER_COUNT]; uint8_t count; } AdcObserverTable;静态数组的好处是内存固定、遍历快不会出现链表指针异常。适合观察者数量固定且系统启动时一次性注册的项目。11.3 滤波放在通知之前还是之后如果所有模块都想看到平滑后的 ADC 值滤波应该放在 adc_subject_update 之前比如在 HAL 回调中 push 到滑动平均滤波然后通知观察者。如果显示模块想看原始值、控制模块想看滤波值就需要拆成两个数据源或让观察者自己选择原始值或平均值。11.4 与RTOS的配合如果项目使用 FreeRTOSADC 中断回调中仍然只做最小处理观察者的具体业务可以放到任务里执行。一个常见设计是ADC 中断里置事件标志。adc_task 任务收到事件后执行滤波和 adc_subject_update。消费模块各自是独立任务通过观察者回调触发任务通知。但要注意FreeRTOS 的 API 如 xTaskNotifyFromISR 可以在中断中使用xTaskNotify 则不行。11.5 安全确认ADC 数据如果用于电机保护或电池保护还需要硬件比较器或独立 ADC 通道做冗余保护。独立硬件看门狗防止软件死循环导致保护失效。阈值参数通过配置接口下放而不是硬编码在回调里。批量任务中的异常数据要重复确认避免单点毛刺误触发。11.6 代码分层建议建议把 ADC 驱动、观察者模式、应用模块分成三个层级底层adc_driver.c只负责硬件寄存器、DMA。中间层adc_subject.c、observer.c只负责数据分发。应用层显示、电机、串口、报警等模块只通过回调接收数据。这样分层后ADC 驱动换了平台应用层不用改应用层需要新增模块只需要注册一个观察者中间层也不用改。12. 总结与下一步这次的设计核心是观察者模式。ADC 从“被多个模块轮流读取的数据源”变成“主动推送的数据主题”4 个接收端在同一个采样周期内拿到同一份值数据一致性、模块解耦性、后续扩展性都比轮询方式好很多。示例代码围绕 STM32 HAL 编写但 observer.c 和 adc_subject.c 两部分完全不依赖平台可以直接移植到其他 MCU。如果你第一次在项目里用这套结构建议从最简单的功能开始验证先让显示模块和串口模块收到 ADC 值确认中断链路跑通再加电机控制和报警模块。初始化顺序也有讲究先初始化观察者再启动 ADC 采集避免在 ADC 还没等到观察者注册时就已经触发了第一次回调。最容易踩的坑是中断回调中做耗时操作。编码规范上建议强制要求观察者回调只做赋值和标志位设置任何需要做显示刷新、串口上报、电机调节的工作都通过主循环或任务队列异步完成。下一步可以扩展的方向包括把观察者调用改成线程安全版本、增加阈值事件对象、加入历史环形缓冲区、使用 DMA 双缓冲配合定时器触发实现等间隔采集。需要这套完整工程模板的可以直接在评论区留下你的 MCU 型号后续可以专门出一期针对 G4 系列多通道 ADC DMA 双缓冲的实战补充。