上礼拜调试一块多路采集板,MCU内置ADC只有8个通道,传感器却有14路,逼得我把吃灰的CD74HC4067翻出来扩通道。4067这种16选1模拟开关,在嵌入式里算是最朴素的ADC扩展方案,几毛钱一颗、控制逻辑简单,但真把它调通调准,坑也不少。这篇笔记就记录这次用CD74HC4067扩展16路ADC采集的完整过程,从电路连接、软件驱动写到两个折腾到半夜的调试坑,希望对正在扩通道的你有参考价值。
1. 为什么非要用CD74HC4067:16路ADC的方案对比
1.1 需求背景
这次的项目是一块环境监测板,需要采集16路模拟量,包括温湿度传感器输出、光敏电阻分压、电位器位置反馈等。MCU选的是STM32F103系列,片上ADC有16个外部通道,但资源被分配了,实际可用的只剩8路。而且这16路传感器分布在不同的接口板上,走线长度差别很大,信号类型也不统一,有的输出阻抗高,有的带一点容性负载。
当时摆在面前的路有三条:换MCU、外挂独立ADC芯片、用模拟多路开关扩展。换MCU意味着重新画板、重新评估成本,周期太长;外挂ADS1115这类16位ADC芯片,精度确实高,但一路芯片只能处理4路,16路得4颗,成本直接上去了。最后选了CD74HC4067,一颗芯片解决16路采集,配合MCU内置ADC,整体成本几乎没有增加。
1.2 方案选型的逻辑
模拟多路开关的本质是一个单刀多掷开关:通过地址线选择16路输入中的某一路,把它连接到公共输出端,再由MCU内置ADC完成采样。CD74HC4067在这个场景下的优势很明显:
| 对比项 | CD74HC4067扩展 | 外挂ADC芯片 | 换多通道MCU |
|---|---|---|---|
| 成本 | 极低,一颗几毛钱 | 中高,按路数翻倍 | 高,需换型号 |
| 电路复杂度 | 简单,只需地址线和公共线 | 中,需配置I2C/SPI | 中,需重新评估 |
| 采样精度 | 受导通电阻影响 | 高 | 取决于型号 |
| 通道数扩展 | 可级联,扩展性强 | 受限 | 受限 |
| 软件复杂度 | 低,切换地址读ADC即可 | 中,需驱动协议 | 低 |
当然,4067也有短板,导通电阻和切换时间决定了它不适合高频采样场景,但用在几十赫兹到几百赫兹的低速多路采集上,完全够用。如果你做的是高速采集或者对精度要求极高的工业测量,还是得老老实实上独立ADC。
提示:选型时一定要明确采样速率需求。4067适合信号变化不快、对采样率要求不高的场景,ADC每个通道的采样率往往只有几十到几百Hz,这正是模拟开关的舒适区。
2. 4067的工作原理与关键参数
2.1 内部结构与地址选择逻辑
CD74HC4067内部是16路模拟开关,每一路开关由一对互补的CMOS传输门组成,地址线S0到S3组合出16种状态,EN引脚是整个芯片的使能端,拉低时正常工作,拉高时所有开关断开。
真值表非常简单,S3S2S1S0从0000到1111分别选中C0到C15通道,公共端COM就是被选中的那一路。比如S3=0,S2=0,S1=1,S0=0,也就是地址0010,选中C2通道。实际接线时,把传感器信号接到C0~C15,公共端COM接MCU的ADC引脚,S0~S3接四个GPIO,EN接一个GPIO拉低。
有一点要特别提醒:4067的COM端是双向的。你可以把多路信号汇入COM然后接ADC,也可以反着用,把一路信号通过COM分发给16路输出,做多路DAC输出或LED驱动。这次我们只用前者,但理解双向特性对排查问题有帮助。
2.2 导通电阻与切换时间的实测感受
数据手册上CD74HC4067的导通电阻典型值是70Ω左右,在5V供电下,但这是理想值,实际会随电源电压和信号电平变化。3.3V供电时,导通电阻能到120Ω到200Ω,如果输入信号接近电源轨,电阻还会更大。
这个参数在纯数字开关选型时容易被忽略,但在模拟信号链里影响非常大。举个例子,传感器源阻抗如果是100kΩ,经过4067后,ADC看到的是100kΩ串联200Ω再对采样电容充电。200Ω本身不大,但如果源阻抗本身低,这200Ω就变成了分压的一部分,直接造成读数偏低。
切换时间这边,数据手册标的是几十纳秒,但那是开关本身的机械动作时间,不代表采样系统能立刻稳定。实际上开关切换后,信号源内阻、PCB走线寄生电容、ADC内部采样电容三者构成的RC回路需要时间充电,这才是决定采样稳定性的关键。这个坑后面详细说。
3. 硬件电路设计与PCB注意事项
3.1 最小系统接线
这次用的最小系统接线是这样的:
- VCC接3.3V,GND接电源地,电源引脚旁边放一颗100nF去耦电容,位置尽量靠近芯片。
- S0~S3接MCU的四个GPIO,例如PA0到PA3。
- EN接一个GPIO,比如PA4,软件里拉低使能。
- C0~C15接16路传感器信号。
- COM引脚接MCU的ADC输入引脚,比如PA5(ADC1_IN5),中间不要串电阻。
注意:COM到ADC引脚之间的线越短越好,而且不要在这条线上加π型滤波电容。因为模拟开关的输出有导通电阻,后面再并电容会跟这个电阻组成低通滤波器,导致信号建立时间变长,高速切换时会出现严重的通道串扰。
3.2 去耦与地线处理
4067虽然是个低速器件,不代表它不需要好电源。开关动作瞬间内部所有传输门都会经历短暂的导通过渡,会产生一个微小的电流尖峰,如果电源去耦不好,这个尖峰会耦合到模拟开关的输出端,表现为切换瞬间的毛刺。
地线处理上,模拟地和数字地建议单点连接,传感器信号走线的下方要保持完整的地平面。这次做的是双层板,我把4067附近的顶层走线尽量加粗,底层保持完整覆铜接地,实测噪声比第一版有明显改善。
还有一个容易踩的坑是悬空引脚。CN74HC4067的C0~C15如果有通道没用到,建议直接接地。原因后面在第二个坑里会详细讲,这里先记住结论。
3.3 信号源阻抗的匹配
这部分的经验值很重要。经过这次调试,我总结出一个原则:进入4067的信号源阻抗最好控制在5kΩ以下,如果超过10kΩ,必须采取措施。
对高阻抗信号,最简单的处理是每个通道加一级电压跟随器。比如光敏电阻分压这种,源阻抗几十kΩ,直接用电压跟随器缓冲后再进4067。成本会高一点,但换来的是稳定性和精度,值得。如果不想加运放,还有一个折中办法:在每个通道对地并联一个100nF电容,把源阻抗转换成对电容充电的问题,这样开关导通后虽然是缓慢建立,但稳定后读数不受分压影响。
4. 软件实现:驱动代码与完整采集流程
4.1 GPIO与ADC初始化
软件部分用的STM32标准库,开发环境是Keil。首先初始化控制GPIO和ADC:
void MUX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // S0~S3 + EN,推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 默认使能4067 GPIO_ResetBits(GPIOA, GPIO_Pin_4); } void ADC_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); // PA5作为ADC输入,模拟输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); // 采样时间调长,这个参数很关键 ADC_RegularChannelConfig(ADC1, ADC_Channel_5, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); }4.2 核心通道切换与采样函数
通道切换和采样的核心逻辑:
void MUX_SelectChannel(uint8_t ch) { if (ch > 15) return; GPIO_WriteBit(GPIOA, GPIO_Pin_0, (ch & 0x01) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOA, GPIO_Pin_1, (ch & 0x02) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOA, GPIO_Pin_2, (ch & 0x04) ? Bit_SET : Bit_RESET); GPIO_WriteBit(GPIOA, GPIO_Pin_3, (ch & 0x08) ? Bit_SET : Bit_RESET); // 切换后延时,等待信号建立 Delay_us(200); } uint16_t MUX_ReadChannel(uint8_t ch) { MUX_SelectChannel(ch); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); }这个简单的函数里藏着这次调试最大的两个坑,下面详细展开。
4.3 轮询采集完整逻辑
完整采集16路数据时,我希望每路采集4次取平均,同时做一次滑动滤波。这里我把切换和采样的逻辑合在一起:
#define CHANNEL_COUNT 16 #define SAMPLE_TIMES 4 uint16_t raw_values[CHANNEL_COUNT] = {0}; uint16_t filtered_values[CHANNEL_COUNT] = {0}; void PollAllChannels(void) { uint16_t sum, val; uint8_t i, j; for (i = 0; i < CHANNEL_COUNT; i++) { MUX_SelectChannel(i); // 采集4次,去掉最大最小再平均 sum = 0; uint16_t min_val = 0xFFFF, max_val = 0; for (j = 0; j < SAMPLE_TIMES; j++) { val = MUX_ReadChannel(i); if (val < min_val) min_val = val; if (val > max_val) max_val = val; sum += val; } sum -= min_val + max_val; raw_values[i] = sum / (SAMPLE_TIMES - 2); // 简单滑动平均 filtered_values[i] = (filtered_values[i] + raw_values[i]) / 2; } }每次切换后延时200us是为了让信号充分建立,但实际使用中发现,这个延时并不是固定的,需要根据信号源阻抗动态调整。这个等坑一里细说。
5. 调试坑一:通道切换后采样值飘移,延迟不够不是小问题
5.1 现象描述
第一版代码里,MUX_SelectChannel函数只写了切换GPIO,没有加延时,切换后立刻读ADC。结果就是:读出来的数值在初始几次不稳定,尤其是从高通道切到低通道时,数值会先偏大,然后慢慢回落,稳定下来需要几十到几百微秒。如果用串口打印看波形,能看到明显的阶梯状爬升。
更麻烦的是,这个不稳定时间和信号源阻抗强相关。源阻抗低的通道勉强能读,源阻抗高的通道怎么读都是错的,而且错的方向还不一样,有的是偏低,有的是偏高,完全看信号路径上的寄生参数。
5.2 排查思路与示波器确认
一开始我怀疑是GPIO切换速度不够,用示波器看了S0~S3的波形,切换速度是纳秒级的,没问题。接着看COM引脚,也就是ADC输入端的波形,问题就出来了:通道切换后,COM端电压不是一下子跳变到目标值,而是按指数曲线缓慢爬升,爬升时间最长的通道要400us才能稳定到最终值的99%。
这个指数曲线就是典型的RC充电曲线。信号源等效电阻Rs加上4067的导通电阻Ron,共同对负载电容CL充电,时间常数τ = (Rs + Ron) × CL。CL包括4067输出端的寄生电容、PCB走线电容、还有STM32内部ADC采样电容,加起来大概在20pF到50pF。如果Rs是100kΩ,τ就是100kΩ × 50pF = 5us,理论上5个τ也就25us,但实测要400us,说明还有别的因素。
后来发现,第一版PCB我把COM引脚的走线拉得很长,中间还走过一个过孔,这部分寄生电容远大于预期。另外测试时用了示波器探头,10×档输入电容约15pF,1×档能达到100pF以上,探头本身就在加重负载,测量出来的建立时间被放大了。
5.3 为什么延时不能一刀切
现在很多人写4067的驱动,喜欢固定Delay几个微秒,这种做法在源阻抗稳定的场景下没问题,但一旦信号源阻抗变化范围大,就必须动态考虑。
从工程角度,最简单的做法是把MUX_SelectChannel的延时参数化。我给这个函数加了一个全局变量mux_settle_us,根据实测每路阻抗单独设置。高阻通道设500us,低阻通道设50us,采集一轮16路也就几毫秒,对低速应用完全能接受。
5.4 最终的解决方案与计算依据
解决这个问题我做了三件事:
把ADC的采样时间从1.5周期改成239.5周期。STM32F103的ADC采样时间寄存器可以配置从1.5到239.5个ADC时钟周期,这个值决定ADC内部采样开关保持的时间,直接影响采样电容能否充满。对高阻信号源,采样时间必须拉长。
MUX_SelectChannel里根据通道号设置不同的建立延时,默认给200us。经验公式是:如果信号源等效阻抗为Rs,负载电容估到100pF,至少要等5倍以上的时间常数,t_settle ≥ 5 × (Rs + Ron) × CL。Rs=10kΩ时,t_settle ≈ 5 × 10k × 100p = 5us,但保守起见我会加10倍余量。
把COM引脚的PCB走线缩短,去掉过孔,让4067的COM输出离STM32的ADC引脚尽量近。这一条其实是治本,硬件改完后,同样的软件配置,建立时间缩短了将近一半。
注意:排查这类问题时,不要上来就怀疑代码。先量一下ADC输入引脚的波形,看信号是从什么时候开始建立的、什么时间到稳态,再对症下药。示波器探头用10×档,1×档会严重影响高阻节点的测量结果。
6. 调试坑二:没选中的通道不是高阻,信号在内部悄悄分压
6.1 这是个让人意外的坑
第二个坑是我印象最深的,也是花时间最多的。现象是:系统看起来一切正常,16路都能读到值,但某些通道的读数系统性偏低。比如用可调电源从0V到3.3V扫一个通道,读数是线性递增的,但斜率只有0.9左右,3.3V输入只读到2.97V,怎么校准都修不回来。
我当时以为是ADC的增益误差,做了单点校准,修正后又发现不同电压段误差不一样,明显不是一个固定偏移能解决的。直到我把输入源改成高精度可调源,再拿万用表量4067公共端COM的电压,才发现问题:开关选通时,COM端电压跟输入不一样,被旁边的通道“拉”低了一截。
6.2 内部原理分析
去翻CD74HC4067的数据手册,慢慢理解了这个芯片的一个关键特性:当选中的通道被导通时,未选中的通道并不是完全断开的高阻态,而是会呈现一个到VSS的低阻路径。换句话说,未选中的通道相当于在每个输入引脚上都接了一个到地的“伪开关”。
这是什么概念?想象一下,你的信号源是一个3.3V经过100kΩ电阻分压出来的电压,等效内阻约50kΩ。进入4067接口后,通道C0被选中,理论上COM端应该是这个分压值。但旁边C1通道有一个到VSS的等效低阻路径,可能是几千欧,也可能是几十千欧,具体取决于芯片内部结构。这样信号源的50kΩ内阻和C1通道的接地路径之间就形成了一个分压,最终COM端的电压比真实值低。
更可怕的是,这个分压比例跟相邻通道的连接状态有关。你把某个相邻通道悬空,和把它接地,结果完全不同。我在测试时发现,把C1接地,C0读数直接掉了一大截,而C1悬空,C0还能保持相对正常。这就是为什么有时候代码怎么调都调不对,因为干扰源完全在硬件链路里。
6.3 工程设计上的应对措施
这个问题从根本上说是CMOS传输门结构的固有特性,解决办法有几个方向:
一是尽量降低信号源阻抗。4067的导通电阻在百欧级别,未选通通道的接地等效电阻也在这个量级附近。如果信号源内阻能控制在1kΩ以下,这个分压效应可以忽略不计。对高阻信号,前面加一级运放跟随器是目前最彻底的方案。
二是给高阻通道并联电容。在传感器输出端对地并一个100nF电容,相当于给信号源提供了一个低阻抗的交流通路。这样开关切换后,电容上保持的电压就是信号源的开路电压,而不是分压后的值。代价是切换后建立时间变长,所以需要配合坑一里的延时策略。
三是在软件上理解并规避。对稳定信号,你可以在一个通道上持续采样多次取稳定值,而不是切换一次读一次。因为在长采样时间下,信号最终会稳定到一个固定值,只是这个值因为分压效应偏低。但这个方法不推荐,因为源头的问题没解决,读出来的值是错的。
6.4 关于“备用通道接地”的讨论
网上很多资料建议未使用的通道接地,我实测下来,这个建议要分情况看。如果备用通道直接接地,确实避免了悬空引脚带来的噪声耦合,但也会让同源高阻通道的分压更明显,因为等效到地的路径更“实”了。
我的建议是:备用通道悬空,但在PCB上预留到地的焊盘。如果量产时发现某一路噪声大,再割线接地调试。对于当前全部16路都用满的场景,这个问题不存在,但万一以后需要扩展,预留这个灵活性很重要。
提示:这颗芯片在纯数字信号场景下,未选通通道的特性基本无感,但一旦进了模拟信号链,内部漏电路径的影响就会被放大。设计时留足阻抗预算,比事后靠校准补救要可靠得多。
7. 校准与滤波:扩展ADC的精读补救方案
7.1 软件滤波不能替代硬件优化
遇到数据抖动,大家先想到的是软件滤波。滑动平均、中值滤波、一阶低通,这些确实能平滑波形,但要注意,滤波只能处理噪声,不能纠正系统误差。比如分压效应读出来的值始终偏低,你滤波再狠,它也是稳定的偏低,治标不治本。
我这次的滤波策略是每通道连续采样4次,去掉最大最小取平均,再加上一阶低通。实际效果是,在无外部干扰的情况下,最终输出稳定在±1个LSB以内,16路全部扫一遍大约耗时20ms,对于温湿度、光照这类慢变量,系统响应速度完全够。
7.2 三点校准的思路
理想情况下,MCU内部ADC的转换结果是线性的,实际用起来会有增益误差和偏移误差。4067的导通电阻还会引入一个附加的增益误差,因为Ron会跟信号源内阻分压。
我做了一个三点校准表:每个通道分别输入0V、1.65V、3.3V三个精密电压,记录ADC原始值,然后线性拟合出每个通道的斜率k和偏移b,公式是实际电压 = k × 原始ADC值 + b。把16个通道的k和b存在Flash里,运行时查表修正。
实测校准效果非常好。校准前某些通道偏差5%以上,校准后全量程误差控制在0.5%以内。但前面也说了,这套校准只对硬件链路固定的情况有效,一旦信号源阻抗变了,校准表就失效。
7.3 实测数据一览
这是我在某几个典型通道上做的原始值和校准对比:
| 通道 | 输入电压(V) | 原始ADC读数(LSB) | 校准前计算(V) | 校准后计算(V) |
|---|---|---|---|---|
| C0 | 0.000 | 2 | 0.001 | 0.000 |
| C0 | 1.650 | 2038 | 1.644 | 1.650 |
| C0 | 3.300 | 4033 | 3.254 | 3.298 |
| C7 | 0.000 | 5 | 0.004 | 0.001 |
| C7 | 1.650 | 2011 | 1.622 | 1.649 |
| C7 | 3.300 | 3987 | 3.216 | 3.301 |
偏差从原来最大的3%降到0.3%以内,肉眼可见的改善。
8. 调试工具与排查技巧
这次的调试过程,用到的工具也值得记一笔。串口调试助手是主力,我在MCU里加了一条调试命令,可以实时把16路ADC原始值和校准后的电压值通过串口打出来,配合上位机软件画成波形,数据变化的趋势一目了然。
另一个得力工具是示波器。排查坑一的时候,示波器一探下去就清楚了。排查坑二的时候,万用表反而比示波器好用,因为分压效应是个直流特性,万用表测得的稳定值最能说明问题。
GDB调试在嵌入式里对这类硬件问题帮助有限,但如果你用的是带MMU的MPU,比如RK3568这类,直接Linux下写个ADC测试驱动,再用GDB看变量,效率会高很多。这次STM32的开发还是Keil MDK加硬件调试器,问题定位主要靠串口打印和示波器。
9. 收尾记录:几点个人心得
这次用CD74HC4067扩展16路ADC采集,前前后后花了三天,最大的收获不是驱动写得多漂亮,而是对这个芯片的内部特性有了切身理解。模拟开关不是理想的机械开关,它有自己的导通电阻、漏电通道和寄生电容,在数字世界里可以忽略的参数,放到模拟信号链里都会被放大。
如果你也准备用这颗芯片,我建议按这个顺序做:先用精密信号源和万用表验证单个通道在低阻抗信号下的精度,再用高阻抗信号源测试通道间相互影响,最后才写完整驱动。顺序反了,一旦数据不对,排查起来成本很高。
另外,如果你有比较充足的预算,可以考虑ADS1220这类多通道ADC专用芯片,它不需要前端搞模拟开关,内部自带输入多路器和可编程增益放大器,省心很多。当然,前提是项目对成本和开发周期的要求没那么敏感。我这块板子最终还是要用4067,毕竟成本摆在那里。