ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ADC一更新,四端同时收到:观察者模式在嵌入式C中的应用

ADC一更新,四端同时收到:观察者模式在嵌入式C中的应用 嵌入式开发中ADC 采集结果被多个业务模块同时消费是一个非常常见的需求。比如一块板子上ADC 采样出来的电压值既要送给控制算法做闭环调节又要送给显示模块刷新屏幕还要写进日志同时通过通信接口上报到上位机。如果每个模块都自己去轮询 ADC 寄存器代码不仅乱还会因为采样时序不一致导致几个模块拿到的数据不一致。理想的做法是ADC 一旦更新完成所有关心这个数据的模块立刻同时收到新值。这正是嵌入式软件设计模式里观察者模式的典型应用场景。本文按“第 25 节”的思路完整拆解“ADC一更新4端收到”的代码实现并给出可直接复制的 C 语言示例。无论你是刚开始接触嵌软设计模式的新手还是已经在做 STM32、NXP S32K、瑞萨 RH850 这类 MCU 开发的工程师这篇文章都可以帮助你理解如何在 ADC 驱动层和业务模块之间解耦。读完你会掌握ADC 采样的常见方式、观察者模式在嵌入式 C 工程中的落地写法、如何让多个端同时收到 ADC 更新事件以及中断上下文和回调函数相关的高频坑点。1. 为什么要让 ADC 一更新4 端同时收到1.1 一个真实的多模块消费场景先看一个典型的嵌入式数据流。某个传感器输出 0~3.3V 电压信号MCU 通过 ADC 通道采样得到 12 位或 16 位数值。这个数值往往不只是给一个模块用控制模块把 ADC 值转换为实际物理量参与 PID 计算显示模块把数值格式化为字符串显示在 LCD 或 OLED 上日志模块按固定周期记录采样值方便调试和故障分析通信模块把采样值打包成协议帧通过串口、CAN 或以太网上报。如果代码写成“ADC 转换完成后在中断里依次调用控制函数、显示函数、日志函数、通信函数”那么 ADC 驱动就和这四个业务模块强耦合了。今天多增加一个 5G 模块需要修改 ADC 中断函数明天想临时关闭日志模块也需要修改 ADC 驱动。这明显违背了嵌入式软件设计模式里要求的“开闭原则”对扩展开放对修改关闭。1.2 轮询方案的缺点有些工程会选择轮询方案主循环里每隔一段时间读取一次 ADC 值然后广播给各模块。这种方式最大的问题是采样时机和业务处理时机绑在一起。如果你在做 PWM 触发 ADC 采样或者 ADC 与电机控制同步轮询很难保证数据更新的实时性。而且多个模块都去操作同一个全局变量时很容易出现读到的值一半是上次采样、一半是本次采样的“撕裂数据”。1.3 观察者模式的价值观察者模式可以很好解决上述问题。ADC 驱动只负责“采样完成”这一件事然后通过一组注册好的回调函数把新数据分发给所有关心它的模块。接收端不需要知道 ADC 内部怎么采集ADC 驱动也不需要知道每个接收端内部怎么处理。这样 ADC 一旦更新所有已注册的端都会收到通知数量和类型都可以灵活变化。2. ADC 采样的基础与常见方式2.1 ADC 是什么ADCAnalog-to-Digital Converter也就是模数转换器负责把连续变化的模拟电压转换成数字量。常见的 MCU 内部 ADC 多为 SAR 型比如 STM32F4 的 12 位 ADC、NXP S32K 的 12/16 位 ADC。SAR ADC 的特点是转换速度快、结构简单在电机控制、电源管理、传感器采集领域非常常用。嵌入式开发中使用 ADC 一般关注几个参数分辨率比如 12 位 ADC满量程输出范围是 0~4095参考电压比如 3.3V那么 1 LSB 对应 3.3 / 4096 ≈ 0.806 mV采样时间决定 ADC 内部采样电容充电时间源阻抗越高需要越长的采样时间转换模式单次转换、连续转换、注入转换、扫描转换等。2.2 常见采样方式轮询采样是最简单的方式主循环中启动转换然后等待标志位置位。这种方式 CPU 开销大适合低速场景。中断采样是在 ADC 转换完成产生中断在中断服务函数中读取数据。这种方式响应快但要注意中断服务函数不能做复杂处理。DMA 采样是 ADC 转换完成后由 DMA 硬件自动把数据搬运到内存缓冲区适合连续采大量数据的场景。比如采集一个周期的三相电流波形。定时器触发 ADC 采样适合需要固定采样间隔的场景比如 PWM 中心对齐触发 ADC让采样点精准落在 PWM 周期的某个时刻。这在电机控制和数字电源中非常常见。不管哪一种采样方式最终都会面临同样的问题数据出来了怎么送给多个业务模块接下来进入本文的核心嵌软设计模式第 25 节观察者模式。3. 嵌软设计模式第 25 节观察者模式拆解3.1 模式定义观察者模式Observer Pattern定义了一种一对多的依赖关系当一个对象主题 Subject的状态发生变化时所有依赖它的对象观察者 Observer都会得到通知并自动更新。在嵌入式 C 代码里我们通常不直接使用类继承而是用“结构体 函数指针”来实现。主题就是 ADC 驱动模块观察者就是控制模块、显示模块、日志模块、通信模块等。ADC 每次转换完成会调用注册表中的所有回调函数。3.2 模式角色在这个 ADC 场景里有四个角色主题接口定义注册、注销、通知方法。在 C 语言中通常表现为ObserverManager结构体和相关函数。具体主题就是 ADC 处理模块。它维护一个观察者列表在转换完成时触发通知。观察者接口通常是一个回调函数指针类型。具体观察者接收 ADC 数据并处理的具体业务函数比如control_system_update、display_system_update。3.3 为什么 C 语言非常适合这种模式C 语言虽然没有 class但函数指针可以实现非常灵活的多态。观察者模式在 C 语言里的实现成本很低一个结构体数组保存回调函数一个注册函数把回调函数指针放进数组一个通知函数遍历数组逐一调用。这样既不依赖动态内存分配也方便规划最大观察者数量很适合资源受限的 MCU 环境。3.4 与消息队列的对比有些工程师会问既然要“一更新4端收到”为什么不用消息队列消息队列适合跨任务、异步传递数据每个端从队列里读取数据。但消息队列需要引入 RTOS 或队列模块而且每个端需要额外维护自己的数据缓冲。观察者模式更适合同步、同优先级、数据更新后需要立刻处理的小规模场景。当然如果接收端处理逻辑很重比如需要写 Flash 或者发网络包我们可以在观察者回调里只把数据复制到一个任务邮箱再唤醒对应的 RTOS 任务去处理。这也是观察者模式和 RTOS 结合的最佳实践。4. 实战ADC 数据分发代码实现下面进入核心部分。我们用一个可独立编译运行的 C 工程来演示“ADC 一更新4 端同时收到”。工程思路是模拟一个 ADC 驱动每次adc_handler_start_convert()调用后都认为转换完成并触发观察者通知。四个接收端分别是控制、显示、日志、通信。4.1 项目结构adc_observer_demo/ ├── observer.h ├── observer.c ├── adc_handler.h ├── adc_handler.c └── main.c文件职责划分observer.h / observer.c观察者模式核心与具体业务无关adc_handler.h / adc_handler.cADC 驱动层负责数据采集和触发通知main.c注册四个接收端模拟运行。这种结构便于后续把 observer 模块复用到其他传感器数据分发场景。4.2 观察者模块代码首先是 observer.h定义事件数据类型、回调函数指针和观察者管理器结构体。// 文件路径adc_observer_demo/observer.h #ifndef OBSERVER_H #define OBSERVER_H #include stdint.h #define MAX_OBSERVERS 8 typedef struct { uint16_t adc_value; } EventData; typedef void (*ObserverCallback)(const EventData *data); typedef struct { ObserverCallback callbacks[MAX_OBSERVERS]; uint8_t count; } ObserverManager; void observer_init(ObserverManager *mgr); uint8_t observer_register(ObserverManager *mgr, ObserverCallback cb); void observer_notify(ObserverManager *mgr, const EventData *data); #endif这里有几个关键点EventData用于在 ADC 驱动和观察者之间传递数据。本文只包含一个adc_value实际项目中可以扩展为时间戳、通道号、原始值等。ObserverCallback是统一的回调函数签名所有观察者都必须符合这个签名。ObserverManager使用静态数组存储回调函数避免使用malloc更适合嵌入式环境。MAX_OBSERVERS必须根据实际注册数量调整。本文注册 4 个端所以 8 个绰绰有余。接下来是 observer.c实现注册和通知的核心逻辑。// 文件路径adc_observer_demo/observer.c #include observer.h void observer_init(ObserverManager *mgr) { if (!mgr) { return; } mgr-count 0; for (uint8_t i 0; i MAX_OBSERVERS; i) { mgr-callbacks[i] (ObserverCallback)0; } } uint8_t observer_register(ObserverManager *mgr, ObserverCallback cb) { if (!mgr || !cb) { return 0; } if (mgr-count MAX_OBSERVERS) { return 0; } mgr-callbacks[mgr-count] cb; mgr-count; return mgr-count; } void observer_notify(ObserverManager *mgr, const EventData *data) { if (!mgr || !data) { return; } for (uint8_t i 0; i mgr-count; i) { if (mgr-callbacks[i]) { mgr-callbacks[i](data); } } }observer_init必须在使用管理器之前调用把回调数组清零并设置 count 为 0。observer_register返回注册后的观察者数量如果返回 0 则说明注册失败。实际代码中可以用返回值判断上位机是否注册成功。observer_notify遍历所有已注册回调。要注意的是这属于同步通知回调函数执行期间ADC 驱动是被阻塞的。所以回调函数里不能有长时间循环或阻塞延时。4.3 ADC 驱动模块代码adc_handler.h 暴露 ADC 模块的对外接口。// 文件路径adc_observer_demo/adc_handler.h #ifndef ADC_HANDLER_H #define ADC_HANDLER_H #include observer.h void adc_handler_init(ObserverManager *mgr); void adc_handler_start_convert(void); void adc_handler_convert_done(uint16_t value); #endifadc_handler.c 模拟一次 ADC 转换并在转换完成后触发观察者通知。// 文件路径adc_observer_demo/adc_handler.c #include adc_handler.h #include stdio.h static ObserverManager *s_observer_mgr (ObserverManager *)0; static EventData s_adc_event; void adc_handler_init(ObserverManager *mgr) { s_observer_mgr mgr; s_adc_event.adc_value 0; } void adc_handler_start_convert(void) { static uint16_t sim_value 0; sim_value (uint16_t)(sim_value 1); adc_handler_convert_done(sim_value); } void adc_handler_convert_done(uint16_t value) { s_adc_event.adc_value value; printf([ADC] convert done, value %u\n, value); if (s_observer_mgr) { observer_notify(s_observer_mgr, s_adc_event); } }在真实工程中adc_handler_start_convert()内部会启动一次 ADC 外设转换然后通过轮询、中断或 DMA 等待转换完成。转换完成后调用adc_handler_convert_done(value)传入读取到的 ADC 数值。为了演示这里使用一个静态变量模拟数值递增省略了硬件底层操作。这里特别说明一下EventData s_adc_event这个全局实例。ADC 驱动持有这个数据实例转换完成后先更新它然后通过observer_notify把指针传给各观察者。因为观察者们是同步执行的所以所有回调函数看到的是同一个最新实例。如果回调函数里面需要保存数据应该自己复制一份而不是保存指针。4.4 注册 4 个接收端main.c 中最能体现“ADC 一更新 4 端收到”的效果。我们定义四个回调函数分别对应控制、显示、日志、通信模块。// 文件路径adc_observer_demo/main.c #include stdio.h #include observer.h #include adc_handler.h static void control_system_update(const EventData *data) { printf([Control] receive ADC value %u\n,>// 在 stm32f4xx_it.c 或 stm32f1xx_it.c 中 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { uint16_t value (uint16_t)HAL_ADC_GetValue(hadc); adc_handler_convert_done(value); } }如果你的工程使用 DMA 连续采样则可以在 DMA 传输完成回调里调用adc_handler_convert_done()把最新的 DMA 缓冲区值传出去。不同芯片的 HAL 库回调名称略有差异比如旧一些的固件库可能是ADC_IRQHandler新版本可能是HAL_ADC_ConvCpltCallback。实际使用时以你的芯片手册和 HAL 版本为准核心思路是在“ADC 转换完成”这个时间点触发观察者通知。4.6 扩展PWM 触发 ADC 采样与均值滤波在电机控制或数字电源场景中采样时机非常关键。很多工程师习惯用 PWM 定时器中心对齐触发 ADC这样可以在 PWM 周期的固定时刻采集电流或电压。此时可以结合观察者模式PWM 触发 ADC 转换转换完成后由中断回调触发观察者通知。业务模块在回调里读取最新 ADC 值完成控制计算或状态更新。ADC 采样值往往存在噪声尤其是工业现场。为了更好地展示实际工程处理我们可以为 ADC 数据增加一个简单的滑动均值滤波。这个滤波可以放在adc_handler_convert_done()里也可以在观察者回调里做。下面是一个可复用的均值滤波 C 函数// 文件路径adc_observer_demo/adc_filter.h #ifndef ADC_FILTER_H #define ADC_FILTER_H #include stdint.h #define ADC_FILTER_SIZE 8 typedef struct { uint16_t buffer[ADC_FILTER_SIZE]; uint8_t index; uint32_t sum; uint8_t filled; } AdcFilter; void adc_filter_init(AdcFilter *filter); uint16_t adc_filter_process(AdcFilter *filter, uint16_t raw_value); #endif// 文件路径adc_observer_demo/adc_filter.c #include adc_filter.h void adc_filter_init(AdcFilter *filter) { filter-index 0; filter-sum 0; filter-filled 0; for (uint8_t i 0; i ADC_FILTER_SIZE; i) { filter-buffer[i] 0; } } uint16_t adc_filter_process(AdcFilter *filter, uint16_t raw_value) { filter-sum raw_value; filter-sum - filter-buffer[filter-index]; filter-buffer[filter-index] raw_value; filter-index (filter-index 1) % ADC_FILTER_SIZE; if (!filter-filled filter-index 0) { filter-filled 1; } if (filter-filled) { return (uint16_t)(filter-sum / ADC_FILTER_SIZE); } return raw_value; }使用这个滤波函数时ADC 转换完成后的调用顺序可以是void adc_handler_convert_done(uint16_t value) { s_adc_event.adc_value adc_filter_process(s_filter, value); if (s_observer_mgr) { observer_notify(s_observer_mgr, s_adc_event); } }这样四个接收端收到的就是滤波后的值。滑动均值滤波对周期性噪声有很好的抑制作用但会造成一定延迟。若对实时性要求很高需要根据采样率和噪声特性调整滤波器窗口大小。4.7 运行验证方法上面的示例中没有硬件依赖只要有一个 C 编译器就可以运行。在 Linux 或 Windows 命令行下编译gcc -Wall -I. observer.c adc_handler.c main.c -o adc_observer_demo ./adc_observer_demo如果是在 Windows 的 Visual Studio 或 Keil MDK 中直接把四个.c文件加入工程然后编译运行即可。运行后重点观察两点每次 ADC 转换完成后四个端是否都能打印出同一份数据注册回调的顺序是否就是打印顺序。如果你想把观察者数量从 4 扩展到 8只需要在 main.c 中继续调用observer_register并确保MAX_OBSERVERS足够大。这就是观察者模式带来的扩展性。5. 常见问题与排查思路观察者模式代码本身不复杂但在嵌入式环境中落地时会遇到各种问题。下面整理几个高频问题。问题现象常见原因解决思路注册后回调没有被调用未调用 observer_init或注册函数返回 0先初始化 ObserverManager检查 MAX_OBSERVERS 是否足够回调被调用两次同一个回调被重复注册注册前检查是否已存在相同回调或者注册函数返回唯一 ID回调函数里读到的 ADC 值混乱多个任务或中断同时修改 EventData使用临界区保护 notify 过程或者用值传递代替指针传递中断回调里执行时间过长观察者回调里做了打印、延时或复杂计算回调里只做标记和快速复制具体处理放到任务中程序跑飞或 HardFault回调函数指针非法或数据指针悬空注册时做非空检查通知前确认数据有效避免回调函数提前返回开启优化后回调失效回调函数没有正确声明或优化导致函数指针被意外修改使用 volatile 修饰管理器中的函数指针数组5.1 回调函数执行顺序问题观察者模式的通知顺序就是注册顺序。如果你的业务对顺序有要求比如日志必须最早执行、网络上报必须最后执行那么注册顺序需要特殊设计。更好的方式是为观察者增加优先级字段或者在通知函数中对注册表按优先级排序。5.2 中断上下文问题如果 ADC 使用中断模式observer_notify会在中断服务函数中执行。这时所有观察者回调也运行在中断上下文。在这种场景下单板打印、阻塞延时、动态内存分配都是非常危险的操作。建议的做法是在 ADC 中断回调中只复制数据到轻量级缓冲区然后使用“标志位 主循环轮询”或 RTOS 消息队列把实际业务处理放到任务上下文。5.3 数据一致性问题EventData s_adc_event是全局静态变量ADC 驱动和观察者之间通过指针共享。如果其中一个观察者调用了 ADC 启动函数触发下一次采样导致s_adc_event被修改那么后面尚未执行的观察者读到的值就变了。解决方案有两种一是通知期间禁止再次启动采样二是把EventData改为按值传递每个回调拿到一个副本。5.4 动态内存使用问题很多嵌入式初学者喜欢在注册函数里用malloc动态分配节点但在 MCU 裸机环境下动态内存容易产生碎片而且无法预测分配耗时。推荐使用静态数组作为观察者注册表提前规划最大数量。如果确实需要动态增加观察者也建议用静态内存池实现。6. 最佳实践与工程建议6.1 合理设计事件数据类型EventData不一定只包含一个adc_value。实际项目中可以定义成typedef struct { uint8_t channel; uint16_t adc_value; uint32_t timestamp_ms; } EventData;时间戳对日志和诊断非常有帮助特别是在多通道 ADC 采样场景下。增加字段时只需要修改EventData和 ADC 驱动内部的赋值逻辑观察者回调函数不需要都跟着改。当然如果某个观察者只关心通道 0 的数据接收后需要自己判断channel。6.2 为观察者注册返回值增加诊断信息observer_register目前返回注册后的观察者数量但这不利于定位问题。更好的设计是返回一个本次分配的注册 ID或者返回错误码。比如typedef enum { OBSERVER_OK 0, OBSERVER_NULL_PARAM, OBSERVER_TABLE_FULL, OBSERVER_DUPLICATE } ObserverError;这样上层可以知道注册失败的具体原因。6.3 用临界区保护观察者注册表如果在系统运行过程中动态注册或注销观察者而这时恰好有一次 ADC 采样正在触发通知注册表和回调数组可能被同时访问。这种竞态会导致不可预期的行为。处理方式是在注册、注销、通知时都加临界区保护。在裸机环境中可以使用关中断/开中断实现临界区void observer_notify(ObserverManager *mgr, const EventData *data) { uint32_t primask __get_PRIMASK(); __disable_irq(); // 遍历回调 __set_PRIMASK(primask); }在 RTOS 中可以使用互斥锁或调度器锁。需要特别小心的是如果在中断上下文调用observer_notify使用互斥锁可能会死锁这时通常使用任务通知或队列传入中断服务函数。6.4 回调函数要实现“快速返回”观察者模式是同步通知所有回调执行时间会直接累加到 ADC 中断或主循环周期中。因此每个回调都应该遵守“快速返回”原则只做必要的数据复制、置标志位或简单的算法更新不要执行 Flash 擦写、延时等待、协议栈发包等耗时操作。例如日志模块如果要把 ADC 值写入 Flash可以这样设计static void log_system_update(const EventData *data) { g_latest_log_adc >
返回列表