
做多路模拟量采集的时候最郁闷的不是ADC配置而是“芯片上明明有ADC引脚却不够用”。最近一个新项目要同时监测16路电压信号MCU还是定好的STM32F103片上只有一个ADC模块、十几个引脚的方案不够分换芯片又不现实。最后的解法就是在ADC输入前面加一片CD74HC4067十六选一模拟开关把一路ADC扩展成16路。整体跑通并不难真正耗时间的是后面两个调试坑一个在软件时序一个在上电瞬间。这篇笔记把完整接线、驱动思路和排查过程都记下来给同样准备用4067扩ADC的朋友做个参考。1. CD74HC4067方案选型与工作原理1.1 它到底是个什么样的芯片CD74HC4067本质上是一个16选1模拟开关也叫多路复用器/MUX。它内部就是16个受控开关一端分别引出IO0到IO15另一端全部汇到一个公共端COM用4根地址线S0到S3来控制到底导通哪一路。地址线组合是二进制编码0000对应IO00001对应IO1依此类推到1111对应IO15。这个芯片一个关键特性是双向导通信号从IOx进COM出可以反过来从COM进IOx出也行所以它不光能扩展ADC采集也能用来扩展DAC输出、复用UART、I2C这类数字信号。不过实际做ADC扩展时我建议只把它当成“模拟通道切换”用别拿它去切高速数字信号它的导通延迟和通道间串扰虽然对模拟量不是大问题对讲究时序的协议信号就没那么友好了。芯片还有个EN使能引脚低电平有效。EN为高时所有通道全部断开公共端悬空EN为低时芯片正常工作按S0到S3的组合选通对应通道。很多国产手册会把EN标成“禁止端”或者“片选端”本质一样。1.2 为什么不用多ADC单片机或并接多片芯片接到需求时我第一反应也是“要不要直接换个多ADC的型号”评估了一圈还是放弃了。换MCU的成本不光是芯片差价引脚封装、PCB布局、现有代码库都要跟着动对一个只增加一路采集功能的产品来说动静太大。拿STM32F103这类芯片来说片上ADC虽然灵活但多通道分配受限于特定引脚16路模拟信号全部直连根本不现实即便部分型号有ADC1、ADC2两个ADC通道数加起来也就十几个还得手动去布线和处理交错采样复杂度一点不比加外挂芯片低。另一种思路是两片CD4051级联但逻辑复杂。CD4051八选一两片需要两套地址线或通过EN做片选控制引脚反而比4067多成本也没省多少。用独立ADC芯片比如ADS1115、ADS1015I2C接口确实方便但多路扩展受限于器件地址4路要两片才能凑8路16路得上4片成本一下就上去了而且逐次逼近型ADC芯片在低速多路场景下优势不明显。相比之下CD74HC4067单价便宜、单芯片解决16路选通地址线只需要4根外加1个公共端接MCU的ADC引脚是成本最低、逻辑最简单的方案。1.3 影响调试的关键参数而不是手册里那些花哨指标CD74HC4067手册里有几个参数调试时才真正体会到它们的意义。导通电阻R_ON3.3V供电下典型值大概65欧左右5V供电下会小一些四五十欧。单独看这个值不大但它会和信号源内阻叠加如果信号源是高阻分压器或电位器再加上后面ADC采样电容就会构成一个RC充电环节。这个在后面坑一里是主要导火索。EN引脚虽然有内置下拉上电悬空时芯片会处于“使能”状态但地址线在MCU复位期间可能是浮空的这就可能导致随机导通某个通道。我一开始就吃了这个亏坑二会详细说。通道间馈通和关断泄漏这类参数平时不用太在意真正影响体验的是“切换建立时间”和“输入电压范围”。输入电压范围千万别超VCC加0.3V否则内部ESD二极管会把多余电流导到电源上造成串扰这点后面也会反复提到。2. 硬件接线用4根GPIO换16路模拟输入2.1 引脚分配与接线表设计接线时我尽量把地址线放在同一个GPIO端口这样软件里用一个端口赋值就能完成通道切换代码会简洁很多。实际接线如下CD74HC4067引脚连接到说明S0PA0地址线bit0S1PA1地址线bit1S2PA2地址线bit2S3PA3地址线bit3ENPA4使能控制低有效COMPA5接STM32 ADC输入对应ADC1_IN5IO0~IO15外部16路模拟信号对称输入通道VCC3.3V与MCU同电源GNDGND共地注意PA5这里复用了ADC功能不要再把它当普通GPIO去操作。地址线和EN引脚则全部配置成推挽输出。选择PA0到PA5这组引脚还避免了与SWD调试口、HSE晶振引脚冲突调试器能一直挂着后面排查问题时省了不少事。2.2 电源和参考电压处理CD74HC4067的VCC直接和MCU共用3.3V。这个选择不是图省事而是为了避免电平不匹配。4067的开关导通特性受VCC影响如果给芯片供5V当输入信号接近5V时COM端输出也是0到5V范围MCU的ADC输入范围最高3.3V那就必须分压凭空增加误差。芯片VCC引脚旁边我放了两个去耦电容一个0.1uF陶瓷电容靠近电源脚一个1uF钽电容稍远。一开始只放了一个0.1uF在切换大电流通道时实测ADC读数会波动几个LSB加上1uF后明显改善了。别小看这两个电容4067内部模拟开关在导通瞬间会从VCC取充电荷电源不稳采样结果一定不稳。ADC的参考电压Vref我用的也是3.3V与4067共地同电源。多路模拟量采集最怕“地弹”和参考电压漂移所以PCB上所有模拟GND要单独走一段然后在一点连接背板GND不要让4067的GND和MCU的GND隔着一条细长走线。2.3 输入通道上电防浮空所有IO输入通道在没接信号时必须处理不能悬空。悬空状态下通道输入是高阻很容易耦合外部电磁干扰和PCB走线串扰一旦该通道被选通噪声就进ADC了。我的做法是空闲通道全部通过100k电阻拉到GND实际有信号的通道如果信号源本身是低阻输出不用额外处理如果是高阻传感器或分压电阻网络就在4067输入端并联一个0.1uF对地电容做滤波但容值别太大太大了坑一的建立时间问题会更明显。COM引脚到MCU PA5的走线要短而粗尽量远离时钟线、PWM线等骚扰源。4067的COM端是16路信号的公共汇聚点任何耦合噪声都会叠加到所有通道上这条规则比任何软件滤波都重要。3. 软件实现从通道切换到底层ADC采样3.1 地址线初始化和通道选择函数软件部分先做GPIO初始化我用的是HAL库配置PA0到PA4为推挽输出PA5为模拟输入。初始化这里有一个我和不少人都踩过的坑上电瞬间默认状态不能乱所以顺序是先配置好GPIO、把地址线全部输出0再把EN引脚输出高电平让4067保持断开等系统真正进入主循环需要采集时再拉低EN使能。void MX_GPIO_MUX_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* PA0-PA3 地址线PA4使能全部推挽输出 */ GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4; 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); /* 先地址线置0再禁止使能最后再允许采集 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // EN1芯片断开 /* PA5配置为模拟功能用作ADC采集 */ GPIO_InitStruct.Pin GPIO_PIN_5; GPIO_InitStruct.Mode GPIO_MODE_ANALOG; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); }通道选择函数核心就是把通道号拆成4个bit写进PA0到PA3。后面调试时在这个函数里加延时变成了解决坑一的关键位置。void MUX_SetChannel(uint8_t ch) { if (ch 15) ch 15; HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, (ch 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, (ch 0x02) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, (ch 0x04) ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, (ch 0x08) ? GPIO_PIN_SET : GPIO_PIN_RESET); /* 通道切换后需要留出建立时间这里先用10us后面会根据实际调整 */ delay_us(10); }3.2 单通道读取的正确写法有了通道选择函数读取一路信号就很简单先切通道再启动一次ADC转换等转换完成读结果。uint16_t MUX_ReadChannel(uint8_t ch) { uint16_t val 0; MUX_SetChannel(ch); HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); if ((HAL_ADC_GetState(hadc1) HAL_ADC_STATE_REG_EOC) HAL_ADC_STATE_REG_EOC) { val HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); return val; }这里有个细节每次读取完必须调HAL_ADC_Stop。如果一直用连续转换模式而不是单次转换切完通道后直接读HAL_ADC_GetValue拿到的很可能是上一次转换的旧值而不是新通道的实时值。这个很多人第一次写都会踩现象就是“通道数据没更新”或者“每个通道读到的都是上一个通道的值”。我调试时喜欢把采样周期设置长一点用ADC_SAMPLETIME_239CYCLES_5。这样内部采样电容充电时间更充分配合外部高阻信号源更稳。代价是单次转换时间变长但在这个项目里16路全部扫一圈也就几毫秒完全够用。3.3 循环采样轮询和DMA的两种节奏项目需求是周期性地把16路全部扫一遍所以主循环里就很简单从0到15逐通道读取存入数组。uint16_t adc_buf[16]; void ADC_ScanAll(void) { for (uint8_t i 0; i 16; i) { adc_buf[i] MUX_ReadChannel(i); } }这样最简单也好调试。但有一个问题每路都要HAL_ADC_Start、PollForConversion、Stop中间还有通道切换延时整体读取一轮的耗时取决于ADC转换速度和延时大小。如果延时设成1ms16路下来至少16ms对1kHz以上的高速采样就不够看了。另一种更高效的方式是用ADC的DMA功能。思路是先把所有通道切好并让电压稳定然后启动ADC转换DMA自动把结果搬到内存转换完成后切下一个通道再启动一次。循环往复。有人想当然地用“ADC连续转换DMA循环搬运”同时用软件去切4067通道结果发现采出来的数据全是乱的。因为ADC转换速度远快于通道切换速度DMA可能在4067还没稳定时就把中间态的值采进去了。DMA不是不能配而是要让“切换-稳定-转换”变成串行逻辑不能靠Continuous模式一直接着采。4. 调试坑一切换通道后第一次读数总是不准4.1 现象轮询一圈只有每路第一次数据偏程序跑起来后我在串口打印16路ADC原始值发现一个非常规律的现象每次上电或者轮询开始的第一轮数据某些通道的值明显偏离真实电压比如一个稳定在1.25V的信号第一轮读到1.19V但紧接着第二轮、第三轮读到的就完全正确了。一开始我以为是ADC校准或者参考电压的问题。换通道连续测试发现只要不切换通道、连续读同一个通道值就很稳只要一切换通道然后再读第一笔数据就会偏。这基本就把问题锁死在4067的通道切换环节而不是ADC本身。4.2 排查过程示波器看波形才找到真凶我先用万用表测4067的输出引脚在切通道瞬间捕捉不到现象因为万用表太慢换成示波器探头点在4067公共端COM上反复切换通道观察终于看明白了通道切换瞬间COM端波形不是突变的方波而是有一个明显的指数充电过程从小电压爬升到目标电压爬升耗时大约几毫秒。这里的关键在于4067的导通电阻虽然只有几十欧但我的输入信号是用两个100k电阻分压得到的信号源等效内阻约50k。4067导通的几十欧相对于50k可以忽略但50k内阻和4067后面的杂散电容、滤波电容串在一起就构成了一阶RC低通时间常数很大。示波器看到的那条爬升曲线就是RC充电曲线。如果用ADC采样去读这个还没稳定的电压结果肯定偏小因为采样时刻早于稳定时刻。连续读第二次、第三次时电容已经被充得差不多了所以数据又恢复正常。4.3 根因与解决方案根因可以概括为一句话4067的开关速度很快但信号源内阻和外部电容决定的建立时间可能很慢软件只等了开关时间没等建立时间。解决办法有两个思路可以组合使用。第一个思路是加大通道切换后的延时。这是最直接有效的办法。我在MUX_SetChannel里把延时从10us改成1ms第一轮数据就正常了。如果你的输入滤波电容比较大比如0.1uF甚至更大延时还要相应增加到5到10ms。公式上大致是RC时间常数50k乘以1uF等于50ms那延时至少预留三五个时间常数。第二个思路是“废弃第一次采样值”。切换通道后先启动一次ADC转换读完结果直接丢弃再启动一次真正有效的转换。这样不用傻等固定延时只要第一次转换结束后电容基本已经建立得差不多第二次读到的就准了。这种方案对采样速度要求高的场景更友好。我最后实际使用的是延时加废弃首采值双保险因为系统对16路总扫描周期的要求不苛刻几毫秒延时可以接受。这里再强调一次不要迷信手册上的开关时间。CD74HC4067的导通时间确实只有几十纳秒但那只是开关本身机械动作的时间不代表信号经过它之后能立刻稳定你还要把信号源阻抗和负载电容考虑进去。5. 调试坑二系统复位瞬间地址线悬空导致误选通5.1 现象上电瞬间外部设备莫名其妙跳变这个坑是在整机联调时发现的。16路输入中有一路是用来检测外部电源电压的分压电阻后面接了一个较大的滤波电容这根线上还连了一个低压差稳压器的使能脚。每次给MCU重新上电或按复位键外部电源监控电路就会误动作一下表现为输出电压瞬间抖动。第一反应是电源拓扑问题但把万用表和示波器拉出来反复测发现低压差稳压器使能脚上的电压在复位瞬间出现了一个很窄的毛刺。顺着走线查最终发现这个信号和4067的某个IO通道之间只隔了一个10欧的限流电阻而4067在复位瞬间居然导通了。5.2 排查过程找到“随机选通”的真身用示波器同时抓MCU复位引脚的波形和4067的公共端COM波形能看到在MCU复位的瞬间COM端电压跳了一下随后恢复。这说明在复位期间4067确实把某一路通道和COM连起来了。问题就出在MCU复位后的GPIO状态上。STM32在上电复位、内部闪存加载和时钟稳定这段时间里PA0到PA3还处于输入模式引脚状态是高阻浮空。4067的S0到S3全是高阻输入引脚此时收到的是悬空电平于是随机选通了某一通道。如果这个随机选中的通道恰好连接了敏感信号就可能把COM端电平拉过去造成外部电路误动作。还有一个细节CD74HC4067的EN引脚内部有下拉悬空时默认使能。这意味着上电期间4067不是默认断开而是默认随便导通某一通道。EN引脚的内部下拉不足以保证系统安全不能依赖。5.3 解决方案硬件下拉加软件初始化顺序解决这个坑我同时做了硬件和软件两层防护。硬件上在S0到S3四根地址线上各加一个10k下拉电阻到地保证MCU复位期间地址线电平稳定为00004067会固定选通IO0而不是随机选通某一通道。同时给EN引脚加一个10k上拉电阻到3.3V让MCU复位期间使能引脚保持高电平4067处于断开状态。这样即使地址线全部悬空芯片也不会对外输出任何信号。软件上初始化顺序调整为先把PA0到PA3配置成推挽输出并输出0再把PA4配置成输出并先把EN拉高禁止使能等所有初始化完成后主循环开始运行前再把EN拉低放行。这和我在3.1节写的那段初始化代码是一致的。经过这两层处理复位瞬间再抓波形COM端没有异常跳动了外部稳压器使能脚上的毛刺也消失了。这个坑和坑一最大的不同在于坑一是运行期间切换时序问题坑二是上电瞬间的状态问题。很多人在坑一花了很多时间却忽略了坑二这种“只在复位瞬间出现一次”的问题容易被当成偶发故障放过去。6. 数据稳定性优化与常见问题速查6.1 软件滤波不能代替硬件设计数据稳定之后我又顺手加了一层简单的软件滤波。这层滤波的目的不是去修硬件问题而是让最终采集值更平滑方便上位机展示和控制逻辑使用。对于16路NTC温度检测这类缓慢变化的信号我用了“去极值平均滤波”连续采集5次去掉最大值和最小值剩下3个取平均。这个办法对毛刺干扰很有效。去极值平均滤波伪代码 1. 连续采集5次数据 2. 找出最大、最小 3. 丢弃最大最小剩余3个求平均 4. 输出平均值对于需要响应快速变化信号的通道我用了中值滤波或一阶低通滤波。一阶低通滤波计算量小适合循环扫描场景但对突然变化会滞后中值滤波能去毛刺但会丢失波形细节。选哪种看你信号变化速度和毛刺频率没有绝对最优。那组16路数据我每个通道单独保存滤波状态不能所有通道共用一个滤波器否则通道之间会互相污染前一路的余波会串到后一路的输出上。6.2 调试过程中的问题速查表现象可能原因排查方法切换通道后第一笔数据偏小或偏大信号源内阻与负载电容形成RC建立时间不足增大切换延时丢弃第一次无效采样值DMA连续循环采样数据杂乱通道切换和ADC转换不同步改为一对一切换或用停止DMA后再切换的方式某一路读数稳定串入相邻通道的电压未用通道悬空、PCB走线串扰未用输入通道下拉加屏蔽地线上电瞬间外部电路抖动地址线浮空导致随机选通地址线下拉10kEN上拉10k软件按正确顺序初始化全部通道读数整体偏高或偏低供电电压和参考电压不一致4067的VCC与MCU ADC Vref统一通道读数有低频抖动电源纹波或公共端走线引入干扰检查去耦电容调整走线布局4067发热或输入信号被削顶输入电压超过VCCESD二极管导通输入信号分压后再进4067或提高4067供电6.3 关于采样周期和扫描时间的经验值我把最终的参数分享给大家参考STM32F103主频72MHzADC采样周期设为239.5周期单通道转换时间约17us通道切换延时设在1ms。这样16路扫一圈的总耗时约16ms加ADC转换时间约20ms不到采集刷新率50Hz对绝大多数传感器、电压监测完全够用。如果你的项目对实时性要求更高比如每路要1kHz采样率那16路每路最多留60us时间这时延时1ms肯定不行只能走“切换后废弃首次采样”的思路而且尽量降低输入源内阻、减小滤波电容。硬件上还可以考虑把16路分成两组用两片4067分别接到MCU的两个ADC引脚但那就是另一个方案了。另一个经验是PCB布局上4067尽量靠近MCU的ADC引脚公共端走线保持短直S0到S3地址线可以长一点因为它们是数字控制信号对噪声不敏感。真正要保护的是模拟通路COM到ADC引脚这一段以及各路输入信号在进入4067之前的处理。最后再分享一个调试技巧串口打印十六路数据时不要只打印原始值打印之前先算好对应的实际电压值或者直接打印一个“基准电压下的百分数”这样一眼就能看出哪一路异常。我习惯把通道号和电压同时打印出来格式类似“CH0: 1.25V”排查问题比看一堆十六进制原始值直观太多了。这个小板子用了快两个月16路数据一直稳定两个坑也再没犯过。回头再看用CD74HC4067扩ADC这条路本身不复杂难的是理解模拟开关周围的那些“看不见的状态”上电瞬间的浮空、开关切换后的建立时间、输入阻抗对ADC采样的影响。把这些摸透了以后的模拟采集项目会省掉大量排查时间。