1. 方案选型:为什么是CD74HC4067,而不是直接用MCU多路ADC
1.1 需求背景:16路模拟量,MCU却只有一路ADC
我这次做的是一个环境监测用的数据采集板,需要同时采集16路电压信号,范围大致在0~3.3V,精度要求不高,但是通道数变不了。MCU用的是STM32L4系列,原本自带多个ADC通道,但实际布板时大部分引脚被通信接口、按键、指示灯占掉了,真正能留给ADC的外部引脚只有一两个。为了不改主控、不重新画板,最简单的方案就是外挂模拟多路复用器,把一路物理ADC扩展成16路逻辑通道。
很多人第一反应是改用带更多ADC引脚的MCU,或者直接上外部ADC芯片如ADS1115之类的。但这两个方案在本项目里都不合适:换MCU牵扯到整套固件迁移,工期不允许;接独立ADC芯片虽然性能好,但是I2C或SPI通信又多了一层协议和时序问题,传输速度也受限。CD74HC4067属于纯硬件开关,控制只需要4个GPIO,不占通信外设,读到的数据本质上还是你原来那一路ADC的数据,不需要改采集协议,对现有代码侵入最小。这正是我选它的核心理由:少改动、快验证、成本几乎可以忽略。
1.2 CD74HC4067的工作原理:16选1模拟开关
CD74HC4067是一款16通道单端模拟多路复用器/多路分配器,内部结构可以理解成一个单刀16掷的开关,公共端叫COM(Common),另外16个引脚是通道0到通道15。通过S0~S3四个地址引脚的电平组合,决定COM端与哪一个通道接通。EN引脚是使能端,低电平有效,EN为高时所有通道全部断开,这在多片级联或复位时需要用到。
芯片本质上是CMOS模拟开关,信号通路上的导通电阻典型值在70Ω左右,不同通道之间略有差异,但偏差不大,对高阻抗负载影响有限。电源范围很宽,2V~12V都可以工作,3.3V和5V系统都能直接使用,不用额外电平转换。对于0~3.3V的模拟信号,供电3.3V完全满足要求。
关键是它支持双向传输,既可以做多路选一,也可以做一分多,如果是采集传感器信号,把它放在信号源和ADC之间就行。需要强调的一点:它只解决通道切换,本身没有放大、滤波、采样保持能力,所以说到底还是靠MCU的ADC外设完成量化,芯片只是把不同信号源轮流“接入”到同一个ADC输入引脚。
1.3 方案选型时的几个考虑
网上有人提到用CD4051,那是8选1的,需要两片才能扩到16路,地址线和使能控制会更繁琐,两片之间的切换时序也需要额外处理。CD74HC4067单颗就16路,控制逻辑只有4个GPIO + 1个EN,软件上就是一个switch-case的事情。还有一点,4067的工作带宽足够高,至少几十MHz级别,采集慢速传感器信号完全不存在频率压力。
我实际对比过硬件成本,CD74HC4067一颗散件不到两块钱,相比独立ADC芯片动辄十几二十块的价差,在大批量场景下优势明显。当然,它也有不足,最大的问题是导通电阻会带来轻微压降,如果信号源内阻本身很大,分压效应就会明显,后续我会讲怎么规避。
2. 硬件电路搭建:从面包板到PCB的细节
2.1 最小电路连接框架
CD74HC4067的封装有DIP和SOP两种,我做实验时用的DIP,方便面包板验证,确认没问题后再画到PCB里用SOP。电路连接并不复杂,核心节点就几个:
- VCC接3.3V,GND接系统地;
- S0、S1、S2、S3接MCU的4个GPIO;
- COM接MCU的ADC输入引脚;
- EN接低电平,或者接一个GPIO控制;
- 16个通道引脚分别接各路待测信号。
听着很简单,但实际接板子时有两个小地方非常关键。第一个是芯片的电源去耦电容,必须在VCC和GND之间靠近电源引脚位置放一个0.1μF陶瓷电容。CMOS芯片在切换瞬间会有电流尖峰,对这个容性噪声不加处理,ADC采集值的跳动会非常明显。第二个是COM端到ADC引脚之间的走线要尽量短,这段线上承载的是模拟信号,走线过长不仅容易串进干扰,还会增加分布电容,影响信号建立时间。
2.2 未使用通道的处理:宁可短接,不要悬空
这是我在这次调试中学到的最深的一课。第一次焊好板子测试时,我只接了8路传感器,剩下8个通道什么都没接,悬空放在那里。结果发现ADC读到的数据不稳定,有几个通道的值会周期性漂移,用万用表测传感器输出明明是稳定的2.5V,ADC读出来有时是2.5V,有时是1.8V,完全没有规律。
问题就出在悬空输入上。CMOS模拟开关内部有ESD保护二极管和寄生电容,悬空引脚的电位会受外界电磁干扰、电路板漏电以及芯片内部衬底耦合影响,产生不确定的电压浮动。当通道被选通时,这个不确定的电压就会通过导通电阻传递到COM端,和真实信号叠加。更麻烦的是,随着外部环境变化,这个浮动电平也在变,所以你看到的ADC值就是随机跳的。
处理办法很简单:所有未使用的通道引脚全部直接接到GND上,或者通过一个10kΩ下拉电阻接地。如果你不确定后续会不会用到某个通道,先统一接GND,等真正接传感器时再断开。这样选到未用通道时,ADC读到的就是0V附近的一个确定值,不会干扰其他通道。
2.3 传感器信号源的阻抗匹配
前面说到导通电阻70Ω会造成分压误差,这是模拟开关躲不开的问题。如果信号源内阻比较小,比如运放输出,那点电阻误差可以忽略。但如果直接接的是电位器分压电路、高阻传感器如光敏电阻,信号源内阻可能到几十kΩ,这时70Ω导通电阻和源内阻串联分压,就会在信号源端产生一个不可忽略的电压损失。
举个例子,一个10kΩ电位器分压电路,输出内阻约5kΩ,4067导通电阻70Ω,分压比约98.6%,也就是100mV信号会变成98.6mV,误差1.4%。如果系统精度要求2%以内,这就很危险了。
解决办法有两个方向:一是对每个通道或者公共端加一级运放缓冲,用运放跟随器把高阻抗信号变成低阻抗,再接4067;二是对信号源内阻做校正,在软件里按比例补偿。我这次因为传感器都是低阻抗输出,所以没有加运放,但如果你的信号源阻抗高,千万别偷懒,加一级跟随器是正路。
3. 软件实现:通道切换、ADC采样与数据滤波
3.1 整体采集流程设计
先理一下流程:MCU选择通道0,等待信号稳定,触发ADC转换,读取结果;再选择通道1,等待,转换,读取;如此循环16次,完成一轮扫描。均匀性和实时性要平衡,所以我用一个定时器以固定频率触发一轮扫描,每轮扫描结束后更新16路数据缓存,上层应用只读缓存,不直接操作ADC。
代码上分两层。底层是“选择通道 + 延时 + 读ADC”的函数,上层是“扫描16路”的循环。这里有一个点必须注意:ADC的采样和扫描周期不能太快,因为模拟开关切换后信号需要时间建立。这正好对应我要提到的第二个调试坑,后文详细展开。
3.2 驱动函数参考
基于STM32 HAL库,通道切换和ADC读取代码大致如下:
#define MUX_S0_PIN GPIO_PIN_0 #define MUX_S1_PIN GPIO_PIN_1 #define MUX_S2_PIN GPIO_PIN_2 #define MUX_S3_PIN GPIO_PIN_3 #define MUX_PORT GPIOA static void MUX_SetChannel(uint8_t ch) { if (ch > 15) ch = 15; HAL_GPIO_WritePin(MUX_PORT, MUX_S3_PIN, (ch & 0x08) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(MUX_PORT, MUX_S2_PIN, (ch & 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(MUX_PORT, MUX_S1_PIN, (ch & 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(MUX_PORT, MUX_S0_PIN, (ch & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); delay_us(200); } static uint16_t MUX_ReadADCChannel(uint8_t ch) { MUX_SetChannel(ch); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); uint16_t adc_val = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return adc_val; } void MUX_ScanAllChannels(uint16_t *adc_values) { for (uint8_t i = 0; i < 16; i++) { adc_values[i] = MUX_ReadADCChannel(i); } }注意我调用ADC时使用了“单次转换 + 每次启停”的方式。很多人会习惯开启ADC的连续转换模式,但多路复用时连续模式很容易读到上次的采样值,造成通道间数据窜位。单次转换虽然每次多花一点时间,但逻辑上每个值都对应明确的通道和时刻,排查问题更省心。
3.3 为什么非要等200μs:两个时间常数的工程权衡
MUX_SetChannel里那个delay_us(200),是实验试出来的最小可靠延时。模拟开关芯片本身的切换速度是纳秒级的,CD74HC4067传播延迟只有几十纳秒,真正慢的是后级电路:信号源内阻、导通电阻、PCB分布电容、ADC内部采样电容,会共同构成一个RC低通回路。
典型情况下,传感器输出内阻1kΩ,4067导通电阻70Ω,MCU的ADC输入引脚还会并联一个几pF到十几pF的采样电容。把总电容粗算成10pF,那RC时间常数约(1kΩ+70Ω)×10pF≈10.7ns,也就是信号建立到99%只需要50ns左右。但真实板子上的走线电容和引脚电容远不止这些,而且有些传感器输出端还加了RC滤波,比如100Ω串阻加0.1μF电容,那时间常数直接到10μs级别,稳定需要70μs以上。
所以我给了200μs余量,保证即使信号源阻抗稍微大一点也足够建立。如果你追求极致的扫描速度,可以缩短到50μs试,但要先确认你的信号源和板上分布电容不会导致建立时间不够。这里没有“标准答案”,只有结合你实际电路的测量结果。
3.4 数据滤波:滑动平均消除随机抖动
慢速传感器信号还会有随机噪声。我在没滤波前测试,同一通道连续采100次,值能波动十几个LSB。对精度要求不高的环境监测来说,这个波动可以接受,但如果你对稳定度有要求,建议在扫描循环里连续采多次取平均。我用的是4次滑动平均:
uint16_t MUX_ReadFilteredChannel(uint8_t ch) { uint32_t sum = 0; for (uint8_t i = 0; i < 4; i++) { sum += MUX_ReadADCChannel(ch); } return (uint16_t)(sum / 4); }每路采4次,16路总共64次ADC转换,以每次转换加延时约300μs计算,一轮完整扫描差不多20ms,对温度、湿度、液位这类变化缓慢的物理量完全够用。如果信号变化比较快,可以减少滤波次数或者改用滑动窗口滤波。
4. 踩坑实录:折腾一下午排掉的两个大坑
4.1 坑一:通道间串扰,数据像“得了传染病”
现象非常典型:第0通道接了一个2.5V的标准电压,第1通道接了一个0.5V的电压。我只读第0通道时,数值很稳定;但当我开启全通道扫描时,第0通道读出来的值变成了2.3V,明显被第1通道带低了。更诡异的是,第1通道电压较高时,其他通道的值也会偏高,就像通道之间互相串门。
排查过程从硬件开始。先用示波器看COM端的波形,发现第0通道选通时,COM端除了2.5V直流信号,还有一个明显的交流纹波,频率和芯片切换到其他通道的动作完全同步。这基本实锤了通道间的串扰。
最终定位到三个原因叠加:首先,未使用的通道悬空,悬空引脚的电平受邻近通道影响,而这个影响会通过芯片内部寄生电容耦合到公共端;其次,我没有在信号源和4067之间加任何滤波电容,高频噪声直接进入模拟开关;最后,ADC的采样时间偏短,导致采样电容没有充分充电,采到的值其实是公共端和采样电容分压后的电压,信号被“平均”了。
解决办法分三步走:一是把没用的通道全部接地,阻断悬空耦合路径;二是每个接入的信号源都对地并联一个0.1μF电容,把高频干扰就地旁路;三是把ADC采样时间适当加长,让内部电容能够充到最终值。做完这三步之后,串扰现象基本消失,通道间独立读数互不影响。
4.2 坑二:切换后立刻读取,拿到的是上一个人留下的“残影”
这个坑比第一个更隐蔽,也更坑。因为我一开始在MUX_SetChannel里没有加延时,心想芯片切换速度是纳秒级,GPIO设置完马上启动ADC转换不就行了。结果就是:每次切换后读到的ADC值,总是上一次那个通道的值。
举个例子,第0通道接2.5V,第1通道接1.0V。扫描顺序是先读第0通道再读第1通道。理论上第1通道应该读到1.0V,但我实际读到的还是2.5V左右,而且第0通道永远读的是上一次扫描的最后一通道的值。整个数据看起来就是循环错位了一个通道,非常迷惑。
根本原因是:GPIO翻转完成的是数字电平,但模拟开关内部的MOSFET导通需要时间建立导电沟道,更重要的是公共端以及ADC采样电容上的电压,还没来得及从旧的电压变为新的电压。如果立即启动ADC转换,ADC采到的采样电容上残留的旧电压,还不是新通道的信号。这个“残影”时间随电容值和源阻抗变大而线性增加。
当时我用示波器挂在COM引脚上观察,切换后COM端电压从2.5V降到1.0V大概花了120μs,而我一共才延时了几微秒就开始采样,等于是在电压还在下降的半路上截了个值。加上150μs延时后,波形下降到1.0V并稳定,ADC读数也就正确了。这就是为什么我的代码里强制加了200μs,这不是我拍脑袋定的,是根据实际波形测出来的。
4.3 排查方法的总结
这两个坑排查了差不多一个下午,事后回头看,其实都可以通过更严谨的测试流程提前发现。
我总结了一个可复用的排查顺序:先固定所有通道为同一个已知电压,确认每一路单独读数一致,排除地址线或焊接问题;然后只接两路,一路高电压一路低电压,交替读取,看是否有串扰;再拿示波器探针挂在公共端,直接观察通道切换瞬间的波形是否稳定建立。这个方法对任何模拟开关类芯片都适用,建议你调试时也按这个顺序来,能省很多弯路。
5. 扩展:更快的扫描速度与多片级联
5.1 扫描周期的进一步压缩
我前面给出的200μs延时是针对低速传感器信号的保守值。如果你的项目追求更高扫描速率,比如要采音频信号或者振动信号,那200μs就完全不够用了。此时需要考虑三点优化:
第一,降低信号源阻抗。在4067前面加一级运放缓冲跟随器,把源阻抗压到几欧姆量级,充放电时间常数成百倍下降,延时可以压到10μs以内。第二,用MCU的DMA配合多通道ADC,让硬件自己完成连续采样,而不是每次用CPU去启动-等待-读取。第三,在公共端和地之间并联一个小电容,比如100pF,可以在不显著降低信号速率的前提下,滤掉一部分开关瞬态噪声。
我实测过优化之后的效果:信号源内阻约100Ω,片内采样时间调到最大,公共端加100pF电容,切换后只需要15μs就能读到稳定值,一轮16路扫描大约240μs。这个速度对绝大多数工业传感场景已经绰绰有余。
5.2 多个4067组成更大规模采集矩阵
一片4067是16路,两片就可以做32路,方法就是共用S0~S3地址线,但每片的COM分别接一个ADC引脚,或者通过EN引脚分时选通后接到同一个ADC引脚。后者只需要额外占用一个GPIO做片选,两片交替使能,逻辑上就是在原有通道号基础上加上偏移量。
实际项目中如果超过16路,我更建议每片单独接一路ADC,因为共用一个ADC还得处理两片之间切换时的建立时间,32路乘以200μs那就是6.4ms一轮,实时性变差。而MCU内部多路ADC复用DMA的话,两片同时采样还能提升吞吐率。当然,具体怎么选还是要看你信号的实时性要求。
5.3 4067的一些替代升级方向,我顺便说清单
这个方案能覆盖大量场景,但不是万能的。如果你需要差动输入或者更高精度,可以考虑用ADG726这类双通道模拟开关,或者直接用8通道24位ADC芯片解决。但就通用性、成本和代码易维护度而言,CD74HC4067依然是目前最稳的方案。我自己后续如果遇到需要扩展差分信号的项目,可能还会优先考虑纯模拟开关加仪表放大器的组合,而不是强行上高集成度采集芯片,模块化设计在调试和更换上更灵活。
6. 实测数据对比与常见问题速查
6.1 修正后的实测数据
修完上面两个坑后,我用标准电压源做了16路一致性测试。每一路输入一个已知电压,连续采样100次取平均,统计误差。这里列几组典型数据:
| 理论输入电压 | 通道号 | 平均ADC原始值 | 换算电压 | 绝对误差 |
|---|---|---|---|---|
| 0.500V | CH0 | 778 | 0.498V | 2mV |
| 1.000V | CH2 | 1558 | 0.997V | 3mV |
| 1.500V | CH5 | 2340 | 1.498V | 2mV |
| 2.000V | CH8 | 3119 | 1.997V | 3mV |
| 2.500V | CH10 | 3901 | 2.498V | 2mV |
| 3.300V | CH15 | 5150 | 3.297V | 3mV |
MCU参考电压3.3V,12位ADC满量程4095,但我在程序里把实际采样值映射到了12位量程内,所以3.3V时原始值5150是因为软件内部转换时用了不同的量程刻度。补充说明一下,上面原始值是经过4次平均后的结果。整体误差在3mV以内,对16路低速采集来说,这个精度完全够用。
6.2 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 某通道读数跳变,其他通道正常 | 该通道信号源虚焊或接触不良 | 重新焊接,检查连接线 |
| 所有通道读数都偏高或偏低 | 参考电压不准确或电源纹波大 | 用万用表实测Vref,加去耦电容 |
| 通道切换后数据滞后一个周期 | 切换后未等待信号建立 | 增加MUX_SetChannel延时 |
| 通道之间互相影响 | 未用通道悬空、信号源无滤波电容 | 未用通道接地,信号线加0.1μF电容 |
| 某个固定通道读数始终为0或4095 | 通道引脚对地短路或信号超量程 | 检查焊接,确认输入电压范围 |
6.3 几条实战建议
最后按顺序罗列几条我在实际调试中验证过、对别人一定有用的建议。
第一,如果是第一次用这类芯片,先不要接全部传感器,只接两路电压源做最小系统验证,先把通道切换逻辑和稳定性调明白再上全负载。第二,调试时把ADC原始值通过串口打印出来,不要直接换算成物理量,因为很多误差在原始值阶段更容易看出规律。第三,不同批次芯片的导通电阻会有些差异,但这个差异在低速采集场景下基本可以忽略。第四,如果后续发现某一路读数突然异常,优先怀疑通道对应引脚的接触问题,然后才是芯片本身损坏,因为CMOS模拟开关的损坏概率远低于焊接问题。
我在实际使用中发现,4067这类模拟开关最大的坑不在芯片本身,而在外围电路的处理习惯。很多人觉得芯片便宜、电路简单,随便焊上就完事,结果调试时间全耗费在排除莫名其妙的干扰上。花十分钟把未用通道处理干净,比花一晚上查噪声值值太多了。希望这篇笔记能帮你直接跳过这两个最折腾的阶段。