我们项目里有一块要同时采 16 路模拟量的板子,主控是 STM32F103,片上 ADC 只有 10 个通道,去掉内部参考电压和温度传感器之后,真正能自由分配给外部信号的通道不够用。当时接了一堆电位器、温度传感器、分压电阻网络,转念一想,最省事的办法不是换大封装 MCU,也不是加独立 ADC 芯片,而是用一颗 CD74HC4067 模拟开关做通道扩展。
这颗芯片本质上是一个 16 选 1 的模拟多路复用器,通过 4 根地址线选通 16 个通道中的一路,把信号送到公共端 COM,再由 MCU 的 ADC 去采样。就这一颗芯片,把可用模拟输入从 4 路直接变成了 16 路,成本只有几块钱。排查过程中踩了两个典型的调试坑,一个是采样建立时间不足导致的数据偏置,另一个是地址切换毛刺导致的串扰读数。这篇笔记把硬件连接、扫描固件和两个坑的定位过程完整记录下来,给准备用 MUX 扩展 ADC 的读者一个参照。
1. 为什么要在方案里塞一颗多路开关:CD74HC4067的定位
1.1 内置ADC不够时有哪些扩展方案
MCU 内置 ADC 通道不够,通常有三条路可以走。
第一条是换芯片,找 ADC 通道更多的型号。这条路的缺点是牵一发动全身,引脚兼容性、封装、成本、库存全部要重新评估,很可能只为了 4 个模拟量就换了整个平台。
第二条是外挂独立 ADC,比如 ADS1115 这种 16 位、I2C 接口的芯片,分辨率高、接口省引脚,但一片只提供 4 路。如果要做 16 路,要么并 4 片,要么选多通道版本,BOM 成本直线上升,I2C 总线上还要做地址区分,软件复杂度也上去。
第三条就是本文用的方案:保留 MCU 自身的 ADC,前面加一颗 CD74HC4067,用时间换通道。MCU 的 ADC 内核、DMA、中断、滤波逻辑全部不用动,只是在每个采样周期里多了一步“切换开关并等待信号稳定”的动作。
三条路放在一起对比如下:
| 方案 | 成本 | 通道扩展能力 | 信号带宽 | 软件改动量 |
|---|---|---|---|---|
| 换更多通道 MCU | 高 | 受选型限制 | 不损失 | 大,要迁移平台 |
| 外挂独立 ADC | 中高 | 按芯片颗数累加 | 取决于接口速率 | 中,要写驱动 |
| 模拟开关 + 内置 ADC | 低 | 16路起步 | 受开关导通电阻和电容限制 | 小,只加切换逻辑 |
对于环境监控这种低频采样场景,温度、湿度和电池电压变化本身就慢,每秒采几十次已经完全够用,通道切换带来的时间开销完全可以接受。
1.2 CD74HC4067核心参数与选型理由
CD74HC4067 是 TI 出品的 16 通道模拟多路复用器/多路分配器,同类芯片还有 74HC4067、74HCT4067,以及国产的 74HC4067 兼容型号,引脚和功能基本一致。之所以选它而不是其他型号,核心原因是 16 路、低成本、易采购、TTL 电平兼容。
关键参数里最需要注意的是导通电阻 (R_{ON})。在 VCC = 4.5V 到 5V 时,典型值大约是几十欧姆,但 VCC 降到 3.3V 时,(R_{ON}) 会明显增大,可能到 100 欧姆以上。这个电阻本身不会让 ADC 读数产生静态误差,因为 ADC 输入阻抗极高,正常稳态下几乎没有电流。但当 ADC 开始采样时,采样电容要从信号源取电荷,这时 (R_{ON}) 就会和信号源内阻、采样电容一起决定电压建立的快慢。这也是后面第一个调试坑的根源。
另一个重要特性是 EN 引脚低电平有效。EN = 0 时,芯片正常工作,A0~A3 决定哪一路开关闭合;EN = 1 时,所有开关全部断开,芯片呈现高阻态。这个引脚很多人直接接地省事,但在实际调试里,它恰恰是解决地址毛刺问题的关键,后面会专门讲。
供电方面,CD74HC4067 的工作电压范围是 2V 到 6V,信号电压范围是 0 到 VCC。设计时必须注意,被采样的信号不能超过 VCC,也不能低于 GND,否则内部保护二极管会导通,轻则采样值被钳位,重则损坏芯片。
2. CD74HC4067硬件搭建:弄清楚COM、A0-A3和EN再接线
2.1 引脚功能与选中逻辑
CD74HC4067 的内部结构可以理解成一个单刀 16 掷开关加一个 4-16 译码器,16 个通道分别命名为 IO0 到 IO15,公共端叫做 COM,另有 4 个地址引脚 A0、A1、A2、A3 和 1 个使能引脚 EN。
地址线和通道的对应关系就是二进制编码,A0 是最低位,A3 是最高位:
| A3 | A2 | A1 | A0 | 选通通道 |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | IO0 |
| 0 | 0 | 0 | 1 | IO1 |
| 0 | 0 | 1 | 0 | IO2 |
| ... | ... | ... | ... | ... |
| 1 | 1 | 1 | 1 | IO15 |
使用时,把需要测量的 16 路信号分别接到 IO0 ~ IO15,把 COM 引脚接到 MCU 的 ADC 输入引脚。MCU 通过 GPIO 控制 A0~A3 的电平组合,就能让对应的通道与 COM 导通,ADC 采样到的就是那个通道的电压。
模块化的 CD74HC4067 板子现在也很常见,上面一般把 IO0~IO15 做成插针或者接线端子,COM、EN、A0~A3、VCC、GND 各有单独引脚,接线比较方便。如果是自己画板,建议在 VCC 和 GND 之间放一个 0.1uF 去耦电容,位置尽量靠近芯片电源脚。
2.2 参考电路与PCB走线经验
我实际接线的方式是这样的:
- 16 路传感器信号经过各自的前端电路,接到 IO0~IO15
- COM 引脚直接接 STM32 的 PA0,作为 ADC 输入
- A0、A1、A2、A3 接 PA1、PA2、PA3、PA4
- EN 接 PA5
- VCC 接 3.3V,GND 共地
信号0 -----> IO0 信号1 -----> IO1 ... 信号15 -----> IO15 | CD74HC4067 | COM ------> PA0 (ADC输入) A0 ------> PA1 A1 ------> PA2 A2 ------> PA3 A3 ------> PA4 EN ------> PA5PCB 布局上有一条重要经验:模拟通道线要尽量短,而且要和数字地址线拉开距离。CD74HC4067 的地址线是数字信号,切换瞬间会有沿跳变,如果和模拟信号线平行走线几厘米,毛刺可以通过寄生电容耦合到模拟通道上。对于一个 ADC 精度只有 12 位或 10 位的系统,这点耦合不一定致命,但如果后面要追求更稳定的读数,走线分离绝对值得做。
COM 到 MCU 的走线同样要短,并且不要在中间额外串联大电阻。如果板上干扰确实严重,可以在 COM 到 GND 之间加一个 1nF 到 10nF 的电容,和 (R_{ON}) 构成低通滤波器,把高频毛刺滤掉。代价是信号建立时间变长,需要相应增加切换后的等待时间,这是一对需要权衡的矛盾。
3. 轮询扫描固件:先让16路数据流动起来
3.1 引脚与ADC初始化
固件部分,我用的是 STM32 HAL 库。先把地址引脚和 EN 引脚配成推挽输出,初始状态把 EN 拉高,保证芯片上电后所有通道都处于断开状态,避免上电瞬间误采样。
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // EN 初始为高,断开所有通道 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);ADC 配置成单通道单次转换,用起来最简单。实际项目里如果后续要提升吞吐率,可以改成扫描加 DMA,但排错阶段单次转换更清晰,每读一次都能和万用表读数对照。
hadc.Instance = ADC1; hadc.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc.Init.ScanConvMode = DISABLE; hadc.Init.ContinuousConvMode = DISABLE; hadc.Init.DiscontinuousConvMode = DISABLE; hadc.Init.NbrOfConversion = 1; HAL_ADC_Init(&hadc); ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc, &sConfig);这里我把采样时间一开始就选得比较长,239.5 个 ADC 时钟周期。为什么这样选,第四部分会详细算。
3.2 扫描主循环:先断开,再切地址,后采样
扫描 16 路的基本逻辑是每次切换一个通道,读取 ADC,然后切下一个通道。
uint16_t adc_raw[16]; void Mux_Disable(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); // EN=1 全部断开 } void Mux_Enable(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); // EN=0 开关有效 } void Mux_SetChannel(uint8_t ch) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4, (ch & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); // 实际写多引脚时,建议用 ODR 一次完成,避免多次写引脚造成的中间状态 // GPIOA->ODR = (GPIOA->ODR & 0xFFE1) | ((uint16_t)(ch & 0x0F) << 1); } uint16_t ReadMuxChannel(uint8_t ch) { Mux_Disable(); Mux_SetChannel(ch); delay_us(2); Mux_Enable(); delay_us(10); HAL_ADC_Start(&hadc); HAL_ADC_PollForConversion(&hadc, 100); return (uint16_t)HAL_ADC_GetValue(&hadc); } void ScanAllChannels(void) { for (uint8_t i = 0; i < 16; i++) { adc_raw[i] = ReadMuxChannel(i); } }注意Mux_SetChannel里我写了一段注释,建议用 ODR 直接写入地址位,而不是分 4 次调用HAL_GPIO_WritePin。原因在于,如果 A0~A3 分开写,4 次操作之间会产生多个中间电平组合。比如要从通道 3 切到通道 7,先改了 A0,芯片会瞬间选中通道 2,再改 A1 变成通道 0,最后才稳定到通道 7。虽然这段时间极短,但芯片的开关状态会跟着跳好几下,放在高频采样场景里就是隐患。一次性写 ODR 可以让地址线近乎同时变化,尽量缩短中间态。
4. 调试坑一:切换通道后马上启动ADC,采出来的值跟实际对不上
4.1 现象:读数偏低还有相邻通道的影子
第一版固件里我为了图快,切换通道后没有做任何延时,直接启动 ADC。结果发现,用万用表测 IO3 引脚明明是 2.5V,ADC 读出来只有 1.8V;把 IO2 接到 3.3V、IO3 接 GND 时,IO3 的读数又变成了 0.6V 左右。更奇怪的是,如果把 16 个通道全接上固定电压,按顺序扫描,前面几个通道读得还算准,越往后误差越不稳定,像是所有通道都在互相干扰。
这个现象很容易被误判成“多路开关串扰严重”。但串扰通常指高频信号通过寄生电容耦合,这里信号全是直流电压,谈不上高频耦合,问题实际上出在 ADC 采样瞬间的电荷建立过程。
4.2 原理:采样电容没充满就开始转换了
STM32 的 ADC 内部有一个采样保持电容,开始转换前会先接通模拟输入,给这个电容充电。充电时间就是寄存器里配置的 SamplingTime。在充电时间内,输入信号要经过信号源内阻、CD74HC4067 的导通电阻 (R_{ON})、MCU 引脚内部串联电阻,才能给采样电容充上电。
等效电路就是一个 RC 充电回路,时间常数:
τ = R_total × C_total R_total = R_source + R_ON + R_ADC_in(内阻) C_total = C_mux_out + C_ADC_sample + C_parasitic假如信号源是一个 10kΩ 的分压电阻网络,CD74HC4067 的 (R_{ON}) 在 3.3V 下大约 100Ω,STM32 内部采样开关等效电阻大约 1kΩ,总的 R_total 就在 11kΩ 左右。再看电容,CD74HC4067 输出端电容、STM32 采样电容加上 PCB 寄生电容,大概 30pF 到 50pF。取 40pF 算:
τ ≈ 11kΩ × 40pF = 440ns12 位 ADC 要稳定到 1 个 LSB 以内的误差,大约需要 8 到 9 个时间常数,也就是:
t_settle ≈ 9 × 440ns ≈ 4us如果我把 ADC 采样时间配成 1.5 个周期,而 ADC 时钟是 12MHz,实际充电时间只有:
t_sample = 1.5 / 12MHz ≈ 125ns125ns 只有 0.28 个时间常数,采样电容上的电压只能充到目标值的 25% 左右,ADC 读到的自然远远小于真实电压。
这就解释了为什么结果“偏低”。同时,CD74HC4067 切换后,打开的那个开关相当于把一个新电压接到 COM 引脚,而 COM 引脚和 ADC 采样电容上都还残留着上一个通道的电压。如果上一个通道是 3.3V,这次要采 0V,初始状态下电容是从 3.3V 开始放电,充电时间不足就导致读数偏正;反过来,上一个通道是 0V,这次要采 3.3V,读数就偏负。这就是“相邻通道的影子”的来源,本质是残余电荷没有放完或充完。
4.3 解决:长采样周期、延时和丢弃首样本
解决办法有三个层次,在实际项目中我最终用了前两个组合。
第一,把 ADC 采样时间调大。STM32F103 的采样时间有 1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5 个周期这几档。直接用最大档 239.5 个周期,在 12MHz 时钟下大约是 20us,足够让前面的 RC 回路完全建立。代价是单次转换时间变长,但对低频场景毫无影响。
第二,通道切换后延时。在代码里,我切完地址线、拉低 EN 之后,又加了 10us 左右延时才启动 ADC。这一步可以进一步确保 COM 引脚上的模拟电压完全稳定,同时把地址线毛刺消散的时间也算进去。
第三,如果追求极致速度,又不想每个通道都等那么久,可以在代码里切完通道后先做一次虚假转换,丢弃结果,再用第二次转换作为有效值。原理是第一次转换期间,采样电容有足够时间完成大部分的电荷补充,虽然第一次结果不对,但转换结束后电容上的电压已经接近真实值,紧接着第二次采样就能采到比较准的数据。
// 丢弃式方案:用更短的等待换更快的扫描 Mux_Enable(); delay_us(1); HAL_ADC_Start(&hadc); HAL_ADC_PollForConversion(&hadc, 100); HAL_ADC_GetValue(&hadc); // 第一次结果丢弃 HAL_ADC_Start(&hadc); HAL_ADC_PollForConversion(&hadc, 100); adc_val = HAL_ADC_GetValue(&hadc); // 第二次取用当时排查这个坑的时候,我特意用示波器钩住 COM 引脚,观察通道切换瞬间的波形,可以看到一段指数上升过程,从切通道到电压稳定,花了大几十微秒。这比我估算的 4us 大得多,原因是我信号源内阻实际还不止 10kΩ,前面还串了滤波电阻。所以“延时宁多勿少”,别卡着理论值刚好够,留足裕量。
5. 调试坑二:地址切换毛刺导致“抢读”错值,需要把EN用起来
5.1 现象:数据偶尔跳到另一个通道的读数
建立时间的问题解决后,数据大部分时间正常,但偶尔会有那么一两路数据“抽风”,直接从正常值跳成了另一个通道的读数。出现频率不固定,有时候连续扫描几百次都不出问题,有时候连续出好几个。
我开始以为是电源干扰,往 ADC 输入加了 RC 滤波,没用;又以为是滤波算法不够强,把滑动窗口拉大,毛刺还是定期出现。直到某次我用逻辑分析仪同时抓了地址线 A0~A3 和 ADC 转换完成标志,才发现问题不在模拟输入,而在地址切换的瞬间。
5.2 原理:地址线跳变时开关状态不确定
当时我的代码流程是:先写地址线,然后开启 ADC 转换。执行完地址写入后,我理所当然地认为通道已经切好了,但事实上,CD74HC4067 内部通过 4-16 译码器控制 16 个开关管,地址线电平变化后,译码输出并不是瞬间完成状态切换的,内部各种门电路存在传播延迟。
更关键的是,在地址线跳变的极短瞬间,如果旧通道对应的开关还没有完全关断,新通道对应的开关又已经开始导通,就会出现两个开关同时处于半导通状态的窗口。这个窗口只有几纳秒到几十纳秒,但足以让 COM 引脚被两个通道同时驱动。如果此时 ADC 恰好在这个窗口内启动采样,采样电容捕获到的就是这个叠加后的不确定电压,而不是任何一路的准确电压。
在多路复用器的数据手册里,这类开关通常标注为“先断后合”或“先合后断”。如果芯片是“先合后断”的时序,在通道切换瞬间就会存在短暂的通道短路;即使是“先断后合”,地址线和讲码器之间的延迟也可能造成一瞬间的悬空状态,让 COM 引脚电压漂移。无论哪种情况,在通道切换过程中直接启动 ADC 采样都是危险的。
5.3 解决:把EN用起来,严格按照“断开-切地址-接通-采样”时序
正确做法是:切换地址线的过程中,先用 EN 把所有开关全部断开,让 COM 引脚悬空;等地址线稳定后,再拉低 EN,让选中通道的开关闭合;再等模拟信号建立起来,最后才启动 ADC。
这样做的本质是给芯片提供一个“隔离期”。地址线怎么跳都无所谓,因为 EN = 1 时所有开关都不导通,COM 引脚不受地址线变化影响;等到地址线稳定了,再一次性把选中通道接通,COM 上看到的就是一个干净的跳变,不会出现多个开关同时导通的混乱状态。
void SafeReadChannel(uint8_t ch) { Mux_Disable(); // 1. EN=1,断开全部开关,隔离地址毛刺 Mux_SetChannel(ch); // 2. 更新地址线 delay_us(2); // 3. 等待地址稳定和毛刺消散 Mux_Enable(); // 4. EN=0,仅选中目标通道 delay_us(10); // 5. 等待模拟电压建立 HAL_ADC_Start(&hadc); // 6. 开始采样 HAL_ADC_PollForConversion(&hadc, 100); adc_val = HAL_ADC_GetValue(&hadc); }这套流程里,第 1 步是最关键的。如果没有第 1 步,即使后面延时再长,都无法消除地址切换瞬间多个开关同时半导通的窗口;有了第 1 步,那个窗口就被彻底屏蔽了,因为这时候所有开关都被 EN 关断,译码器的输出状态不会造成任何物理通路。
如果硬件上已经把 EN 接地,那这个时序改进就用不了。所以设计阶段一定要把 EN 引到 MCU 的 GPIO,不要直接接地。一个 GPIO 引脚的代价,换来的是整个扫描过程的可控性,非常值得。
同样重要的是,地址线更新那一步最好一次性写入 ODR,而不是分四次写引脚。分四次写导致 A0~A3 之间可能出现多个中间组合,即使 EN 保持断开,也会有更多不必要的内部状态跳变,增加毛刺和功耗。一次性写完之后,再用一个短延时把地址线的电平稳定下来。
6. 数据优化和标定:滤波与校准让16个通道真正可用
6.1 简单滑动平均去抖
解决了建立时间和通道切换毛刺之后,16 路数据已经基本稳定,但 ADC 本身还有量化噪声和外部的轻微干扰,单次采样的抖动依然存在。我在每个通道上做了滑动平均,每路连续采 5 次,去掉最大值和最小值,再把中间 3 次取平均。这种方式对脉冲噪声有很强的抑制能力,又不会像单纯平均那样被一个极端值带偏。
#define FILTER_N 5 uint16_t AdcFilterValue(uint16_t *buf, uint8_t n) { uint32_t sum = 0; uint16_t min = buf[0]; uint16_t max = buf[0]; for (uint8_t i = 0; i < n; i++) { if (buf[i] < min) min = buf[i]; if (buf[i] > max) max = buf[i]; sum += buf[i]; } sum -= min; sum -= max; return (uint16_t)(sum / (n - 2)); }每路采 5 次、中间 3 次取平均,16 路算下来每次全扫描要执行 80 次 ADC 转换。如果一次转换加切换延时约 100us,全扫描一轮约 8ms,也就是 125Hz 的全帧率,单通道更新率约 7.8Hz,完全满足温度、电压监控类应用。
6.2 零点、跨度和三点校准
16 路模拟量还有一个绕不开的问题:每一路的前端电路都不可能完全一致,比如电阻分压的精度、传感器供电的微小差异、CD74HC4067 不同通道之间 (R_{ON}) 的离散性,都会让同一电压在不同通道上读出不同的原始值。
最实用的做法是线性校准,用两点或者三点建立“原始 ADC 值到实际物理量”的映射。很多人的做法是只做零点和满量程两点校准,但我建议有条件的话做三点校准。两点校准假设 ADC 特性是一条完美直线,但实际 ADC 的中段误差和两端误差往往不一致,三点校准(零点、中值点、满量程点)可以做分段线性插值,把中段的非线性误差也修正掉。
具体的做法是:给采样端输入三个已知电压,记录下每个通道对应的 ADC 原始值,然后在代码里建一个查找表,用线性插值把任意原始值映射到实际电压。16 个通道存 16 组系数,每组 3 个点,内存占用很小,但校准效果立竿见影。
6.3 关于采样率、缓冲和后续扩展的收尾建议
很多人拿到这类型扩展方案后,第一反应是“能不能提高扫描速度”。CD74HC4067 本身开关速度是纳秒级的,瓶颈并不在芯片上,而在 ADC 采样建立时间上。如果确实要提速,有几个思路可以组合使用。
第一个思路是缩短信号源内阻。尽量降低每一个通道前端的串联电阻,信号源内阻从 10kΩ 降到 1kΩ,充电时间常数直接下降一个数量级。
第二个思路是在 COM 和 ADC 之间加一级运放缓冲。运放输入高阻,输出低阻,把 CD74HC4067 的 (R_{ON}) 和 ADC 采样电容隔离开,这样 ADC 采样时从运放输出端取电荷,运放输出端能提供足够大的电流,采样电容在极短时间内就能充满。这是所有方案里效果提升最大的一种,代价是增加一颗运放的成本。
第三个思路是并行使用两颗 CD74HC4067,分成两组各 8 路,分别接 ADC1 和 ADC2,用 DMA 同时采集两路。这样全通道扫描时间几乎减半,但软件复杂度会明显上升。
在我的实际项目里,最终是“CD74HC4067 + 运放缓冲 + 每路 5 次滤波 + 三点校准”的组合,16 路传感器数据全部稳定在 ±2mV 以内,对于电池电压、环境温度、位置电位器这类信号,精度完全够用。如果只是玩玩低速率采集,不追求极限精度,直接按本文的“断开-切地址-接通-延时-采样”流程,配合较长 ADC 采样周期,就已经能解决绝大多数问题了。