CD74HC4067扩展ADC通道:硬件搭建、驱动逻辑与两个隐蔽调试坑
2026/9/8 7:52:54 网站建设 项目流程

做采集类项目,最容易卡住工程进度的地方往往不是算法,而是ADC采集通道不够用。我前阵子做的环境监测板需要采集16路0~3.3V的模拟信号,手里的MCU只有5个ADC输入脚,第一反应是换芯片,但评估完成本、供货周期、PCB改版和旧代码迁移的工作量之后,决定先用CD74HC4067这颗16选1模拟开关把通道扩出来。方案本身不复杂,驱动也就几十行代码,但真正让我耗了两个晚上的,是后面两个隐蔽的调试坑:一个让采集到的数据永远是上一路的残留值,另一个让整块板的ADC读数跟着环境噪声乱跳。这篇文章把硬件搭建、驱动逻辑和两段完整的排错过程都记录下来,给同样被“ADC通道不够”卡住的朋友做参考。

1. 为什么偏偏是CD74HC4067:三种扩展方案的取舍

1.1 模拟通道扩展的根本矛盾

ADC输入通道不够用时的第一反应,通常是“换一颗ADC脚更多的MCU”。但实际评估下来会发现这条路往往走不通:同系列更高规格的芯片可能现货短缺,封装和引脚分布变化意味着PCB要重新设计,固件里所有ADC相关的初始化代码都得跟着改,更不用说采购和BOM成本的变化。一个很简单的功能扩展,最后变成了整个项目的改版。

另一种做法是外挂独立ADC芯片,像ADS1115这类带I2C接口的器件。它的优点是精度高、不占用MCU内部ADC资源,但通道数通常只有4路,16路信号至少要4颗芯片,成本和软件复杂度都会翻几倍,而且I2C在这种低速模拟采集场景下能不能跑得过来也需要评估。如果只是把模拟量变成数字量送进MCU,这条路有点杀鸡用牛刀。

CD74HC4067的定位完全不同:它本身不负责模数转换,只做模拟开关的“通道选择”。MCU的ADC引脚仍然是原来那颗,只是通过开关矩阵把1个ADC口复用成16个输入位置。价格低、电路简单、没有通信协议,本质上就是4根地址线加1个公共端,用最粗暴的方式解决了通道数问题。

1.2 4067的关键参数决定电路设计

在选型之前,必须先搞清楚CD74HC4067的几项关键参数,因为它们直接决定了外围电路怎么做:

参数典型值设计影响
通道数量16选14根选择线S0~S3可寻址16路
导通电阻RDS(on)约70Ω@4.5V信号源内阻越高,分压误差越大
开关切换时间ton/toff约40ns芯片本身很快,但外部RC会拖慢
模拟输入范围VEE~VCC不能输入负压,也不能超过VCC
数字逻辑电平CMOS兼容3.3V MCU可直接驱动
使能端EN低电平有效EN=1时所有通道断开

这里我最想强调的是导通电阻。很多人把4067当成一根理想导线,忽略了RDS(on)的存在。如果信号源内阻很低,比如运放输出,那70Ω分压可以忽略;但如果信号直接来自一个10kΩ电位器或传感器高阻输出,这70Ω就会和源阻抗分压,导致ADC读到的电压偏低。这也是后面调试坑里会碰到的隐患之一。

2. 硬件连接与最小系统搭建

2.1 引脚功能与接线方法

CD74HC4067的引脚功能很清楚:S0~S3是通道选择地址线,EN是使能端,低电平有效,COM是公共端,I/O0~I/O15是16个模拟输入输出端口。这个芯片是双向的,但从ADC扩展的角度看,信号流向就是“I/O引脚进,COM出”。

建议的接线方式是这样的:

  • 16路传感器信号分别接到I/O0~I/O15引脚
  • COM引脚直接连到MCU的ADC输入脚
  • S0~S3接到MCU任意4个空闲GPIO,最好集中在同一个GPIO端口
  • EN引脚在不需要关断所有通道时直接接地,如果需要省电场景可以接一个GPIO控制
  • VCC接3.3V或5V,GND共地

我在电源引脚旁边加了一个0.1μF的陶瓷去耦电容,实测对高频噪声抑制有帮助。还有一个细节:如果ADC输入信号变化非常缓慢,建议在COM到GND之间并联一个几nF的电容,能够稳定到达ADC引脚的电压,但代价是增加了通道切换的建立时间——这正好是下文第一个调试坑要展开的矛盾点。

2.2 信号源内阻对精度的约束

接线看起来简单,但精度问题藏在细节里。4067的导通电阻会随供电电压和工作温度变化,4.5V供电时典型值70Ω,3.3V供电时会更高一些。如果信号源输出阻抗是10kΩ,那采样点实际电压就是:

V_actual = V_source × R_adc / (R_source + R_switch)

假设R_source=10kΩ,R_switch=70Ω,V_source=3.3V,则:

V_actual = 3.3 × 10000 / (10000 + 70) ≈ 3.277V

12位ADC下的误差大约是9个LSB。也就是说,高内阻信号源接进来,不用等采样,电压就已经被分压分掉了。

所以我的硬件设计里加了一条原则:凡是信号源输出阻抗高的传感器,后面必须加一级运放电压跟随器,让4067看到的信号源阻抗降到几十欧姆级别,再进模拟开关。对于本来就由运放驱动的信号,可以直接进。

3. 驱动代码的合理设计:查表、时序与滤波

3.1 通道选择的底层操作

驱动4067并不需要复杂的协议,本质上就是往S0~S3四个引脚写对应的二进制值。比如要选通第3通道,就把S0=1、S1=1、S2=0、S3=0写到GPIO上。为了方便维护,我建议用一张16项的表来做映射,而不是在代码里到处散落直接赋值。

#define MUX_SEL_PINS (GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7) void mux_select_channel(uint8_t ch) { uint8_t sel = ch & 0x0F; uint32_t odr = GPIOB->ODR; odr &= ~MUX_SEL_PINS; odr |= ((sel & 0x01) ? GPIO_PIN_4 : 0); odr |= ((sel & 0x02) ? GPIO_PIN_5 : 0); odr |= ((sel & 0x04) ? GPIO_PIN_6 : 0); odr |= ((sel & 0x08) ? GPIO_PIN_7 : 0); GPIOB->ODR = odr; }

这里有一个很多教程不会提的操作细节:为什么我要直接操作ODR寄存器,而不是用HAL库的HAL_GPIO_WritePin逐个写4个引脚?因为逐条写GPIO会带来“中间态问题”。例如从0000切到1111,如果先写了S0=1,那么瞬间S=0001,选中了通道1而不是目标通道15,虽然只有几微秒,但如果此时ADC正在采集,或者外部电路对这个短脉冲敏感,就会凭空多出一个毛刺。用一句ODR赋值同时更新4个引脚,从软件逻辑上就避免了中间态。

3.2 采样循环的完整流程

单通道采样函数不是“选通道,读ADC”两步就完了,完整的流程应该是:选通道 -> 等待建立时间 -> 启动ADC转换 -> 多次采样取中位值平均 -> 进入下一通道。

我实际使用的核心循环大概是这样的:

#define ADC_SETTLE_DELAY_US 500 uint16_t read_mux_channel(uint8_t ch) { mux_select_channel(ch); delay_us(ADC_SETTLE_DELAY_US); uint16_t value = adc_read_filtered(); return value; }

这里500μs的建立时间看似很长,但由于4067毕竟没有采样保持电路,通道切换后COM端电压不会瞬间稳定,后面我会详细说这个坑。如果你对采样速度要求更高,可以严格测量自己的电路实际需要的建立时间,在保证精度前提下适当缩小。

滤波方面,我没有用简单的一次采样,因为多路复用环境下噪声来源更复杂。每一路连续采7次,去掉最大和最小值,剩余5次取平均。代码实现很直接:

#define ADC_SAMPLE_CNT 7 uint16_t adc_read_filtered(void) { uint16_t buf[ADC_SAMPLE_CNT]; for (int i = 0; i < ADC_SAMPLE_CNT; i++) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); buf[i] = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); delay_us(20); } for (int i = 0; i < ADC_SAMPLE_CNT - 1; i++) { for (int j = i + 1; j < ADC_SAMPLE_CNT; j++) { if (buf[i] > buf[j]) { uint16_t tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } } } uint32_t sum = 0; for (int i = 1; i < ADC_SAMPLE_CNT - 1; i++) { sum += buf[i]; } return sum / (ADC_SAMPLE_CNT - 2); }

另外还有一个小细节:在STM32的HAL库中,ADC采样周期决定了内部采样电容的充电时间。如果信号源经过模拟开关后等效阻抗较高,建议把采样周期调长,我配置的是55.5个ADC时钟周期,而不是默认的最短采样时间,这能在源头减少采样电容充不满的问题。

4. 调试坑第一个:切换后立刻采集,数据永远是上一路的残留

4.1 现象:只有第一路数据是对的

这个坑是我在写完基础驱动之后第一个遇到的。烧录固件后,我用信号发生器给前4路分别灌了0.5V、1.0V、1.5V、2.0V的直流电压,然而串口打印出来的是:

CH0: 0.50V OK CH1: 0.50V ERROR CH2: 0.50V ERROR CH3: 0.50V ERROR

更诡异的是,如果只单独测试CH3,把地址线手动改成0011,它又能正确读出2.0V。也就是说硬件本身没有问题,问题出在“按顺序切换”这个动作上。我当时第一个怀疑的是GPIO地址线配置或延时不够,因为在某些STM32型号上面,GPIO翻转是有性能瓶颈的。我甚至怀疑是不是ODR寄存器操作出错了。

4.2 排查链路:从地址线电平到建立时间

排查过程值得记录一下。第一步,用万用表量S0~S3的静态电平,发现切换CH0到CH1时,引脚电平确实从0000变成了0001,地址选择逻辑正确。第二步,把示波器探头接到4067的COM引脚和MCU的ADC输入脚之间,观察切换瞬间的波形,结果发现了一个非常明显的现象:从CH0(0.5V)切到CH1(1.0V)时,COM电压不是立刻跳到1.0V,而是先冲到接近1.0V,然后慢慢爬升,整个过程持续了大约几百微秒。同理,从高电压切到低电压时会看到电压从高向低慢慢放电。

问题定位到这里就清楚了:模拟开关从内部逻辑上看确实已经把“触点”接通了,但MCU的ADC引脚对地存在等效电容,包括引脚寄生电容、走线寄生电容,以及ADC内部的采样保持电容。切换通道后,这个电容上还保留着上一路的电压,它必须通过“信号源内阻 + 4067导通电阻”这条路径充放电,才能到达新通道的电压值。这个过程的时间和RC乘积直接相关。

我用最慢的场景估算一下:

R_source = 10kΩ(信号发生器输出阻抗或传感器内阻) R_switch = 70Ω(4067导通电阻) C_load = 10nF(引脚电容+走线电容+采样电容总和) τ = (10k + 70) × 10nF ≈ 100μs 5τ ≈ 500μs

在RC电路里面,5倍时间常数才能让电压误差降到1%以内。而我当时的代码里,通道切换后只延时了1~2μs就启动ADC转换,电容上的残留电压根本来不及变化,读到自然就是上一路的值。

4.3 修复:让通道切换后有足够的建立时间

修复方式直观但又容易矫枉过正。我在mux_select_channel()之后加延时,一开始直接给了5ms,数据确实全对了,但16路扫描一圈要80ms,输出刷新率低得没法用。后来用示波器逐步缩短延时,发现我的板子在实际走线和10kΩ内阻信号下,500μs左右就已经足够稳定。

最终我采取了一种半自适应做法:在电路设计阶段就考虑这个因素,COM端不额外加大电容,把寄生电容控制在几nF以内,然后在固件里预留统一定义的建立延时宏,方便根据实际硬件调整。

#define ADC_SETTLE_DELAY_US 500 void delay_us_for_settle(uint32_t us) { for (volatile uint32_t i = 0; i < us; i++) { // 实际项目里用定时器或SysTick实现 } }

这个坑给我最深的教训是:任何模拟开关器件的数据手册里写的“切换时间”,都只是开关触点本身的机械动作时间,不代表外部电路电压能够在这个时间内稳定。真正决定ADC建立时间的是外部RC网络,这一点在设计之初就该考虑到。

5. 调试坑第二个:悬空通道和负压把整路ADC拉偏

5.1 诡异现象:单独测每路正常,整机却频繁乱跳

解决了建立时间问题之后,16路信号在严苛调试环境下都能稳定输出了。但把板子装进整机,接上各种传感器和外部设备之后,问题又来了。ADC数据开始出现毫无规律的跳变:某些通道偶尔读到满量程值,某些通道从1.2V直接跳到超过3.3V,还有些时候采集结果完全变成另一路传感器的值。

最让人头疼的是,每路传感器单独接到4067的对应引脚、单独测试时都是正常的,只有全部接入并运行时才出问题。我最初判断是电源噪声,换了一台更干净的线性电源,数据依然乱跳。这个现象说明问题一定出在4067本身或者它的周边电路上,而不是MCU的ADC配置。

5.2 使用示波器定位:悬空引脚变天线

我再次拿出示波器,不过这次不是测COM端,而是测那些“没有被选中”的输入引脚。结果让我豁然开朗:未被选中的I/O引脚在示波器上能看到明显的50Hz工频干扰波形,手靠近时幅度还会变大,完全就是一副天线的样子。

原因要从4067的内部结构说起。模拟开关未选中的通道,在内部确实是断开的,但I/O引脚在外部悬空时,引脚对地阻抗极高,噪声电流在这个高阻节点上会产生明显的电压波动。更关键的是,CMOS芯片的输入/输出引脚到电源轨之间都有一对寄生二极管,用于ESD保护。如果悬空引脚感应出的电压超过VCC+0.5V或低于GND-0.5V,这些二极管就会正向导通,干扰电流会直接灌入VCC或GND电源网,最终整个模拟前端的电位都会被拉偏。

另外还有一个更隐蔽的问题。我的整机里有两个来自外部设备的信号,其中一个在设备启动瞬间会产生一个负电压毛刺,这个毛刺虽然只有几十微秒,但已经足够让4067的寄生二极管导通。一个通道的异常电压,通过芯片内部电源网耦合,把其他通道全部污染了。这就是“单独测正常、整机乱跳”的根源。

5.3 工程修复:下拉电阻加限幅保护

修复工作分三层做,这里按优先级从高到低说明。

第一层:每个I/O引脚对地加10kΩ下拉电阻。作用是把悬空引脚的对地阻抗从MΩ级降到10kΩ,让感应电荷通过电阻泄放掉,破坏天线效应的形成条件。前提是信号源输出阻抗不能太高,否则下拉电阻会和信号源分压。

第二层:对输入信号做RC低通滤波。我在每个输入引脚前串联一只100Ω电阻,并在I/O引脚与GND之间并联一只100nF电容,截止频率大约是16kHz,既能滤掉高频干扰,又能吸收快速尖峰。代价是通道切换时的建立时间会变慢,但因为之前已经把建立延时做成可调宏,所以只需要重新调整数值即可。

第三层:在输入级加二极管钳位保护。如果信号有可能超过VCC或低于GND,用肖特基二极管BAV99做一个“正钳位到VCC、负钳位到GND”的保护电路,把引脚电压严格限制在电源轨以内,确保寄生二极管永远不会导通。

这三层措施都做完之后,我重新测试整机工作状态,ADC数据恢复稳定。而且我很确定这个坑不是个案——凡是使用模拟开关扩展多路模拟输入的方案,只要输入信号来自外部设备,早晚会遇到同样的问题。


聊到这儿,顺手分享一个个人经验。我后来做这套4067方案时,把“建立时间”和“悬空通道的泄漏路径”这两件事直接当成PCB设计阶段的必查项,不再放到调试阶段去碰运气。如果你也用这颗芯片做多路采集,建议在硬件阶段就先把输入端的RC保护和下拉电阻画进去,固件里预留好可调的建立延时,后面会省掉很多烦心事。至于更进一步的精度提升,我打算在下一次实践里做一次基于外部ADC三点校准思路的线性补偿,到时候再单独写一篇。

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

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

立即咨询