尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

嵌入式 ADC 数据分发:从裸机回调到事件驱动设计

嵌入式 ADC 数据分发:从裸机回调到事件驱动设计 我第一次在一份嵌入式需求里看到“ADC 一更新4 端同时收到”这种描述时第一反应是去中断回调里多写几行调用把数据发给串口更新到界面塞进控制算法再写一份日志。看起来直接但很快就会发现代码开始变得相互牵连改一个模块的初始化顺序都可能破坏另一处的数据链路。这个问题的表面是 ADC 采样但真正值得讨论的是嵌入式软件设计模式里的数据分发。它要解决的不是“怎么测准电压”而是“当一个硬件事件发生时如何让多个相关模块都被通知到同时又不会把彼此绑死”。1. 先搞明白一个 ADC 更新为什么会牵扯出四个端1.1 从寄存器到回调裸机代码最初的耦合方式很多做嵌入式的人刚开始接触 ADC 时都经历过这样一个过程查参考手册配置时钟选择通道配置采样时间和分辨率启动一次转换然后轮询等待标志位读到数据打印出来。这个流程对“只想看一眼值”的需求完全够用。但一旦进入真实产品情况就不一样了。真实产品里一个 ADC 采样值通常不会只服务一个模块。以最常见的电压或温度采集为例。上位机监控需要看到实时数据本机屏幕或指示灯需要根据阈值切换状态控制算法需要拿到经过滤波后的稳定值日志系统需要把每次变化记录下来用于后续排查报警模块还可能需要判断是否超出上下限。如果把这些逻辑全部写进 ADC 的中断回调里代码会变成什么样子void adc_isr(void) { uint16_t raw read_adc(); send_to_pc(raw); update_display(raw); control_algorithm(raw); write_log(raw); check_alarm(raw); }这种方法最大的问题不是代码长而是耦合程度过高。adc_isr必须知道所有下游模块的存在而且必须知道它们各自怎么处理数据。一旦控制算法需要改成接收“滤波后的值”而不是原始值就得回去改中断一旦上位机协议改变又得回去改中断一旦新增一个接收端还得回去改中断。这就是为什么很多经验不那么足的项目会在后期出现一种奇怪现象ADC 的更新频率不高但是中断函数越来越长优先级越调越乱调试时无从下手。1.2 “4 端收到”的真正含义事件分发而不是功能堆叠“4 端同时收到”这个说法真正对应的概念并不是“把一个函数复制到四个地方”而是“一次 ADC 转换完成四个订阅方都能获得通知”。用事件驱动视角理解就是三层结构事件源ADC 转换完成事件通道负责把“更新”这一信息广播出去事件消费者串口监控、显示、控制算法、日志等。这种结构和平时直接调用函数最大的差别在于事件源不关心下游有几个消费者也不关心消费者内部如何处理数据。它只负责发出一条通知。下游是否响应、怎么响应由消费者自己决定。这也是为什么观察者模式、发布-订阅模式在嵌入式软件里非常常见。单片机资源有限不能像 PC 端那样引一个完整的企业级框架但核心思路完全可以借鉴。2. 不要在每个模块里复制 ADC 调用先搭一个“分发层”2.1 一个简单的订阅登记表在裸机环境下最稳妥、最容易理解的做法就是维护一个“订阅者”注册表。先定义一个统一回调类型typedef void (*adc_event_handler_t)(uint16_t raw, uint16_t mv);然后定义一个订阅结构体typedef struct { adc_event_handler_t handler; uint8_t active; } adc_subscriber_t;预留固定订阅数量。对多数 MCU 场景4 到 8 个足够#define ADC_MAX_SUBSCRIBERS 4 static adc_subscriber_t s_adc_subs[ADC_MAX_SUBSCRIBERS];注册函数负责把回调指针放进表里int adc_subscribe(adc_event_handler_t handler) { for (int i 0; i ADC_MAX_SUBSCRIBERS; i) { if (s_adc_subs[i].active 0) { s_adc_subs[i].handler handler; s_adc_subs[i].active 1; return 0; } } return -1; // 表满了 }与之对应的注销函数int adc_unsubscribe(adc_event_handler_t handler) { for (int i 0; i ADC_MAX_SUBSCRIBERS; i) { if (s_adc_subs[i].active s_adc_subs[i].handler handler) { s_adc_subs[i].active 0; s_adc_subs[i].handler 0; return 0; } } return -1; }分发层只做一件事遍历订阅表逐个调用有效 handler。static void adc_dispatch(uint16_t raw, uint16_t mv) { for (int i 0; i ADC_MAX_SUBSCRIBERS; i) { if (s_adc_subs[i].active s_adc_subs[i].handler) { s_adc_subs[i].handler(raw, mv); } } }这样一来ADC 中断或回调处只需要调用一次void adc_update(uint16_t raw, uint16_t mv) { adc_dispatch(raw, mv); }如果你用的是 STM32 HAL可以直接在转换完成回调里调用void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { uint16_t raw (uint16_t)hadc-Instance-DR; uint16_t mv adc_raw_to_mv(raw); adc_dispatch(raw, mv); }这段代码的意义不在于省几行代码而在于把“ADC 采样”和“业务处理”解开了。以后新增第五个接收端只需要在初始化时多调用一次adc_subscribe完全不用改 ADC 中断。以后的adc_dispatch是唯一的发布点。2.2 为什么固定数组比链表更适合 MCU有人可能会问既然要支持多个订阅者为什么不写一个动态链表答案很简单在裸机环境里动态内存分配是风险源。malloc可能产生碎片也可能在中断上下文中引发不确定行为。固定数组的问题是需要预设上限。但 ADC 分发这类场景消费者通常不会超过 8 个。把上限写死换来的是编译期确定的内存占用、稳定的遍历行为和极低的运行开销。这是一种典型的工程取舍。嵌入式里很多看起来“不优雅”的写法恰恰是为了把确定性放在第一位。注意注册表方案虽然简单但别忽略“注销”。如果某个模块在运行过程中会失效、休眠或重新初始化不做注销就会留下悬空回调结果是“莫名其妙的 ADC 更新又把一个已关闭的外设唤醒了”。3. 真正要发布的不只是原始值还有“数据质量”3.1 原始值不能直接给控制模块用ADC 的原始值也就是寄存器里的数字本质上是一个“未加工状态”。影响它的因素很多采样时序不稳电源纹波PCB 走线耦合参考电压波动通道切换后的建立时间不足。所以同一个引脚连续采 10 次结果大概率是不一样的。如果直接把原始值广播给四个端可能出现这样的情况串口端每帧数据都在跳显示端数字不停闪烁而控制算法拿着抖动的数据去计算输出也会跟着抖。在真实项目里分发层更适合发布“处理后的稳定结果”而不是原始值。3.2 常见滤波代码移动平均与一阶滞后先看移动平均。它适合周期性采样窗口长度不宜太大否则滞后明显。#define ADC_SAMPLE_WINDOW 8 static uint16_t s_history[ADC_SAMPLE_WINDOW]; static uint8_t s_index; uint16_t adc_filtered_average(uint16_t raw) { uint32_t sum 0; s_history[s_index] raw; s_index (s_index 1) % ADC_SAMPLE_WINDOW; for (int i 0; i ADC_SAMPLE_WINDOW; i) { sum s_history[i]; } return (uint16_t)(sum / ADC_SAMPLE_WINDOW); }再看一阶滞后滤波。它适合采样频率快、需要跟踪趋势的场景写法也更简洁。uint16_t adc_filtered_lowpass(uint16_t raw) { static uint32_t filtered 0; if (filtered 0) { filtered raw; } else { filtered (filtered * 7 raw) / 8; } return (uint16_t)filtered; }7/8和1/8的比例决定了滤波强度。比例越大结果越平滑但响应越慢。实际使用时可以根据采样周期调整并不存在一个“所有环境都正确”的系数。3.3 不同接收端需要的数据可能不一样分发层不一定要发布同一个值。串口上位机想看原始值或者轻微滤波后的值便于观察信号抖动显示端需要更平滑的数值避免 UI 闪烁控制算法需要快速响应但又不能太抖日志系统需要记录原始值和最终值便于对比。一个简单做法是在分发层内部准备两个版本原始值和滤波值。然后在调用回调时一起传出去或者让订阅者自己选择签名。如果不想改变回调签名也可以把滤波结果放进一个结构体typedef struct { uint16_t raw; uint16_t filtered; uint16_t mv; } adc_event_t; typedef void (*adc_event_handler_t)(const adc_event_t *evt);这样每个接收端都拿到一份事件快照自己按需取数据。这里要提醒一句不要在分发层的循环里做太耗时的事。尤其是当分发层被 ADC 中断回调调用时任何一个订阅者执行了浮点运算、打印日志或者操作外部 Flash都会把整个中断处理时间拉长直接影响采样周期。4. 四端收到以后各自该做什么4.1 四个典型消费端的职责划分“4 端收到”并不一定是字面意义上的 4 个物理端口更常见的是 4 类逻辑模块。我见过一个比较典型的分发场景订阅者需要的数据处理动作运行位置串口监控原始值 电压值组包发送到上位机主循环或低优先级任务状态指示滤波后的值和上下限比较控制 LED/蜂鸣器中断或主循环控制算法滤波后的值做 PID/阈值判断定时任务日志存储原始值 滤波值写入环形缓冲区后台任务或 DMA在这个表里可以看到一个关键点不同消费者对实时性的要求不同。状态指示和控制算法通常希望越快越好串口和日志则可以略微延迟。分发层如果坚持“统一同步调用”就会被迫按最严苛消费者的标准执行。这其实是另一种隐患。4.2 对实时性要求极高的场景分解成“产生事件”和“消费事件”以状态指示为例如果它只是一个阈值比较确实可以在中断上下文快速完成static void on_adc_event(const adc_event_t *evt) { if (evt-filtered 3000) { led_on(); } else { led_off(); } }但如果消费端要做浮点运算、协议解析或外设操作就不能直接在 ADC 中断上下文里跑。更稳妥的方式是“事件产生和消费分离”。ADC 转换完成 - 分发层把数据放入一个环形缓冲区主循环或 RTOS 任务再从这个缓冲区读取并执行实际业务。这和前面订阅注册表不冲突。注册表负责“通知”环形缓冲区负责“缓冲”。两者组合能同时兼顾实时性和安全性。简单示意#define ADC_RING_SIZE 16 static uint16_t s_ring[ADC_RING_SIZE]; static uint8_t s_head; static uint8_t s_tail; int adc_event_post(uint16_t value) { uint8_t next (s_head 1) % ADC_RING_SIZE; if (next s_tail) { return -1; // 满了 } s_ring[s_head] value; s_head next; return 0; } int adc_event_get(uint16_t *value) { if (s_head s_tail) { return -1; // 空 } *value s_ring[s_tail]; s_tail (s_tail 1) % ADC_RING_SIZE; return 0; }这种写法在裸机环境里很常见。它的价值在于中断处理只做“投递”而真正的协议解析、日志写入都放到主循环缩短了中断临界区长度。5. 从裸机到 RTOS同一个模式的不同边界5.1 RTOS 里的天然映射消息队列和信号量如果你用操作系统的消息队列来做同一件事思路会更简单ADC 中断或任务把数据osMessageQueuePut()到队列多个接收任务从队列里osMessageQueueGet()。但这并不代表注册表模式就没用了。在一个略微复杂的系统里一个 ADC 任务可能需要把同一份事件分发给多个异构任务而某些任务只需要接收通知不需要消费数据。信号量只负责“唤醒”消息队列负责“传数据”事件标志组负责“多事件同步”。建议按这样一个顺序做判断需求推荐机制多个模块需要同一个值消息队列 多个队列或发布订阅表只关心有没有新数据不马上拿信号量等待“超上限”或“连续 N 次异常”事件标志组需要极其稳定且无阻塞裸机回调注册表 环形缓冲5.2 RTOS 环境下需要额外考虑的问题引入 RTOS 之后最大的变化是上下文切换。订阅者的回调可能运行在普通任务里也可能运行在中断服务函数里。如果回调里访问了同一个全局变量两个上下文就可能发生竞争。经典解法是项目初期就约定好哪些回调允许在中断上下文执行哪些只允许在任务上下文执行。不能等到写完了再补。举例来说如果 ADC 采集在定时器中断里触发DMA 转换完成后产生中断。你在 DMA 中断里调用adc_dispatch那么所有订阅回调都被迫运行在中断上下文。此时任何一个订阅回调调用带阻塞语义的函数都可能导致系统卡死。一个比较稳的约定是中断上下文只做采样、滤波、投递最多做简单的阈值判断业务逻辑全部通过“事件提醒”到任务上下文再执行。6. 实操落地最小可运行流程与排查链路6.1 从零开始跑通“一更新、四端收到”的步骤如果想把这段逻辑落到真实板子上我建议按下面的顺序做不要一上来就写完整功能。第一步先把单通道 ADC 采样跑通确保能稳定读到数据。不接任何分发逻辑。第二步打印原始值和滤波值的对比确认滤波效果符合预期。此时再引入分发层订阅一个串口打印回调。第三步加入第二个订阅者比如状态指示确认 LED 能根据阈值变化。第四步加入控制算法或者日志缓冲并检查整体 CPU 占用和中断耗时。第五步最后做异常注入。模拟采样超限、外部接线松动、订阅表满等情况验证系统表现。这个顺序看似保守但能避免一个常见问题数据源都还没稳定就在多个模块里去排查 bug最后很难分清是采样问题还是业务逻辑问题。6.2 常见现象与排查顺序“4 端收到”在实际调试时会有几种典型症状。逐个说。症状一所有端都没收到数据先查 ADC 转换流程本身。看看转换完成标志有没有置位中断有没有进入DMA 有没有搬数据。如果这些都没问题再检查分发层有没有被调用。症状二只有部分端收到数据这通常不是采样问题而是注册表问题。要么是订阅数量超过上限要么某个订阅者初始化失败后没有建立回调。打印一下订阅表状态很快能定位。症状三数据会跳动但不是周期性跳动先看滤波是否生效。如果滤波已经生效但仍跳动检查是不是多个通道复用了同一个外设导致上一次转换结果干扰了下一次。症状四中断执行时间过长影响其他任务把所有耗时操作从中断里挪走。至少把日志、协议解析、Flash 写入移到主循环或任务。症状五某个模块正在运行中突然不再收到更新优先检查这个模块的订阅者是否在运行中被反初始化、注销或者覆盖。尤其是把“订阅”写在模块的初始化函数里但模块又被其他模块重复初始化时很容易踩到。6.3 一套可复用的四步判断框架把这个场景抽象一下可以沉淀为一套比较通用的嵌入式事件分发框架第一识别事件源明确“哪一层产生数据”。第二定义事件结构体统一数据格式。第三选择事件通道。简单场景用注册表复杂场景用消息队列或事件标志组。第四为每个消费者定义运行上下文区分中断上下文和任务上下文。这套框架不只适用于 ADC。按键扫描、定时器更新、传感器中断、通信帧接收都可以用同一套思路。只要把“事件源”换成对应硬件或协议层“消费者”换成实际业务模块逻辑依然成立。7. 别忘了适用边界一个注册表不是万能药7.1 它适合什么不适合什么订阅注册表这招最适合同一个 MCU 内部的多个软件模块。比如 STM32、ESP32、S32K312 这类芯片上的裸机项目或者 RTOS 下的轻量组件都很实用。但它不太适合跨处理器场景。如果 A 处理器采集 ADCB 处理器要拿结果靠的就是具体的通信链路而不是内部回调。这时候你应该去设计通信协议而不是硬套软件设计模式。它也不适合订阅者数量持续增长、频繁动态变化的场景。如果系统里有几十个订阅者每次变更都用遍历数组的方式效率会明显下降。这种情况下应该考虑更细粒度的事件路由、消息队列和使用 RTOS 组件。7.2 和硬件方案结合时还有几个容易忽略的点ADC 的分发不能只看软件也要看硬件触发方式。定时器触发 ADC适合周期采样。只要定时器周期稳定采样频率就稳定。PWM 触发 ADC适合需要把采样点设置到 PWM 特定位置的场景比如电机控制和开关电源。DMA 搬运转换结果适合高频连续采样能大幅减轻 CPU 负担。如果你使用 DMA 环形采集分发层的触发点不应该放在“每次单个转换完成”而应该放在“DMA 半满/全满”中断里。每次把一块连续数据交给分发层下游再去做均值滤波或特征提取。这其实从另一个角度验证了前面的判断ADC 更新的本质不是“寄存器里多了一个值”而是“一批新的采样数据已经就绪需要让关心它的人知道”。嵌入式软件设计模式里这个场景不过是一小块切片但它涉及的边界意识、上下文分离和可扩展思路会渗透到后续很多模块设计里。最后说一句经验遇到“ADC 一更新4 端收到”这类需求先别急着写回调函数先画一张图画出数据从哪里来要到哪里去谁可以马上消费谁必须排队消费。把这几条线理清了代码怎么写都不会偏到哪里去。以后新增第五端、第六端时你只会感谢当初没有把所有业务都塞进那个看起来很忙的中断函数。
返回列表