我第一次在一份嵌入式需求里看到“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 端收到”这类需求,先别急着写回调函数,先画一张图,画出数据从哪里来,要到哪里去,谁可以马上消费,谁必须排队消费。把这几条线理清了,代码怎么写都不会偏到哪里去。以后新增第五端、第六端时,你只会感谢当初没有把所有业务都塞进那个看起来很忙的中断函数。