☰
嵌入式 ADC 数据分发:从裸机回调到事件驱动设计
2026/10/27 9:25:24 网站建设 项目流程

我第一次在一份嵌入式需求里看到“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 处理器采集 ADC,B 处理器要拿结果,靠的就是具体的通信链路,而不是内部回调。这时候你应该去设计通信协议,而不是硬套软件设计模式。

它也不适合订阅者数量持续增长、频繁动态变化的场景。如果系统里有几十个订阅者,每次变更都用遍历数组的方式,效率会明显下降。这种情况下,应该考虑更细粒度的事件路由、消息队列和使用 RTOS 组件。

7.2 和硬件方案结合时,还有几个容易忽略的点

ADC 的分发不能只看软件,也要看硬件触发方式。

  • 定时器触发 ADC:适合周期采样。只要定时器周期稳定,采样频率就稳定。
  • PWM 触发 ADC:适合需要把采样点设置到 PWM 特定位置的场景,比如电机控制和开关电源。
  • DMA 搬运转换结果:适合高频连续采样,能大幅减轻 CPU 负担。

如果你使用 DMA 环形采集,分发层的触发点不应该放在“每次单个转换完成”,而应该放在“DMA 半满/全满”中断里。每次把一块连续数据交给分发层,下游再去做均值滤波或特征提取。

这其实从另一个角度验证了前面的判断:ADC 更新的本质不是“寄存器里多了一个值”,而是“一批新的采样数据已经就绪,需要让关心它的人知道”。

嵌入式软件设计模式里,这个场景不过是一小块切片,但它涉及的边界意识、上下文分离和可扩展思路,会渗透到后续很多模块设计里。

最后说一句经验:遇到“ADC 一更新,4 端收到”这类需求,先别急着写回调函数,先画一张图,画出数据从哪里来,要到哪里去,谁可以马上消费,谁必须排队消费。把这几条线理清了,代码怎么写都不会偏到哪里去。以后新增第五端、第六端时,你只会感谢当初没有把所有业务都塞进那个看起来很忙的中断函数。

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

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

立即咨询