ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32外扩ADC/DAC:PCF8591驱动开发与IIC通信实战

STM32外扩ADC/DAC:PCF8591驱动开发与IIC通信实战 做单片机开发的朋友大概率都绕不开ADC和DAC这两件事。测个温度、读个电压需要ADC输出个波形、调个运放偏置又需要DAC。如果你的MCU片内外设不够用或者手头正需要一个低成本的模拟前端扩展方案PCF8591几乎是最快能跑起来的选择。这颗芯片价格便宜、引脚少、接口就一个IIC不管你要做多路环境监测、鱼缸水温采集、还是毕业设计里的信号发生器它都能顶上。这篇文章我会从IIC时序讲到STM32的完整配置把ADC采样和DAC输出的代码都贴出来适合正在学STM32、或者做项目需要快速上手PCF8591的读者。全文不摆花架子全部按我实际调试板子时的流程来写保证你照着做能出数、能出波形。1. 先认识PCF8591这个芯片到底能干什么1.1 芯片内部结构和引脚功能PCF8591是NXP飞利浦半导体推出的一片CMOS单电源低功耗器件里面集成了一个8位逐次逼近型ADC、一个8位DAC、一个采样保持电路以及IIC总线接口。它最常用的封装是DIP-16和SO-16引脚不复杂核心就几个AIN0~AIN3是四路模拟输入AOUT是模拟输出SCL和SDA走IIC总线A0/A1/A2是硬件地址引脚VREF是参考电压输入AGND是模拟地VDD供电从2.5V到6V都能跑。先说ADC这边。8位分辨率意味着它能把参考电压VREF均匀切成256份也就是说如果用5V参考电压理论分辨率大约是5V/25519.6mV。这个精度当然比不上STM32内部的12位ADC但在很多场景下已经够用——测个温度、检测光照变化、监测电池电压误差几十毫伏完全能接受。DAC这边同样是8位输出电压由VREF决定后面我会具体给计算公式。有一点容易被忽略PCF8591的AIN输入范围是0到VREF不能接负压也不能超过VREF。如果你要测的电压超过了VREF就必须加分压电阻把信号先衰减进来如果信号本身是负压就得先用运放做电平抬升。这和大部分MCU内置ADC的用法是一致的不算什么限制但新手经常在这里踩坑。1.2 为什么项目里经常选它而不是直接换MCU碰到“ADC不够用”的问题最简单的应对方案是换一颗ADC通道更多的MCU但实际的嵌入式项目里硬件方案不是说换就换的。很多时候板子已经定型MCU引脚也已经规划完了剩下几个IIC口还能用这时候外扩一片PCF8591就是成本最低的补充方案。一颗芯片几毛钱到一块多钱DIP封装还能直接插面包板调试阶段改起来也方便。另外一个很典型的场景是多路传感器同时上板。比如你要做一套鱼缸监测系统需要同时读水温、水位、光照强度、水质TDS值四路模拟信号刚好对应AIN0~AIN3。如果全用MCU内部ADC可能引脚不够分配还可能因为模拟信号线走线过长引入干扰。用PCF8591把传感器信号集中到一处再统一通过IIC总线送进MCU整个系统结构会清爽很多。而且IIC总线本身就是多设备总线同一组SCL/SDA上可以同时挂PCF8591、OLED显示屏、温湿度传感器SHT30、气压计BMP280各设备用不同的7位地址区分互不干扰。1.3 数据手册里最容易被忽略的三个细节我读PCF8591的数据手册读了很多遍有几个细节是新手基本不会注意到的。第一A0/A1/A2三个地址引脚可以在板子上用跳线帽或者焊盘跳线控制让同一根IIC总线上最多挂载8片PCF8591。这个设计在普通项目里可能用不上但如果你要测电池组的几十串电压一个总线上挂多片就是成本很低的做法。第二PCF8591内部自带振荡器不需要外部晶振也不需要从MCU引脚输入时钟。这意味着它的转换速度是内部决定的数据手册上给的ADC转换时间是逐次逼近所需的周期你没办法像操作STM32的ADC那样去设置采样周期。这个特性在绝大多数应用里是好事少了一个时钟源就能少一个故障点。第三PCF8591有一个比较器功能外部引脚EXT输入会和内部DAC输出做比较比较结果放在状态字里。这个功能平时没人用但它可以在不做模拟比较器搭建的情况下完成简单的阈值判断比如雨滴传感器检测到降雨、光敏电阻检测到天黑了这类应用完全不需要MCU参与直接读状态字就行。2. IIC通信协议与时序细节2.1 从物理层理解起始、停止和应答IIC总线只有两根线SCL时钟线和SDA数据线两根线都是开漏结构外部需要接上拉电阻到VCC。空闲时两根线都是高电平谁把线拉低谁就占据了总线。通信的时候一共就五种基本信号起始条件、停止条件、数据位0、数据位1、应答信号。不要被“协议”两个字吓到你把它理解成人说话时的停顿和点头就行。起始条件SCL保持高电平时SDA产生一个由高到低的跳变。停止条件SCL保持高电平时SDA产生一个由低到高的跳变。这两个条件的时序约束很明确SCL高电平期间SDA不能变化否则会被误判为起始或停止。数据位的传输规则是SDA上的数据只能在SCL低电平期间变化SCL高电平期间必须保持稳定。主设备每发送8位数据从设备会拉低SDA作为应答ACK如果不拉低就是NACK表示从设备没收到或不希望继续收。2.2 PCF8591的地址字节和控制字节到底怎么填IIC通信第一步是发送地址字节。PCF8591的7位设备地址是固定格式0b1001 A2 A1 A0最低位是读写标志位。假设你把A2A1A00那么写地址就是0x90读地址就是0x91如果三个引脚都接高电平写地址就变成0x9E。实际项目里很多人喜欢直接查数据手册表但我更建议你记住固定头1001这个规律因为8片并联的时候你自己就能推算地址不用每次查手册。第二步是发送控制字节这个字节控制PCF8591的工作模式。控制字节的默认格式我整理成了表格这样比插图更直观位名称含义D7DADAC使能位1允许DAC输出0关闭D6X保留位通常写0D5X保留位通常写0D4X保留位通常写0D3X保留位通常写0D2X保留位通常写0D1X保留位通常写0D0CH通道选择位00通道001通道110通道211通道3实际上控制字节不是这么简单的排列完整定义应该是最高位D7是DAD6保留D5和D4是模拟输入配置位D3是自动增量标志D2是保留位D1和D0是通道选择。常用的配置就是我下面要写的这些组合。比如控制字0x40二进制是0100 0000表示DAC使能、四路单端输入、通道0。控制字0x41就表示通道1。0x44表示通道0且自动增量这样连续读的时候芯片会自动切换通道在一些扫描场景下能少发几次控制字节。2.3 上拉电阻到底选多大IIC的上拉电阻选择很多教程直接丢一句“接4.7kΩ”就完事了。这个值在大部分板子上确实能跑但如果你追求稳妥还是要根据总线电容和通信速率算一下。IIC标准模式100kHz要求上升时间不超过1000ns快速模式400kHz要求不超过300ns。上升时间近似等于0.8473 × R_pullup × C_bus。假设总线电容是50pF线不长、设备不多的情况走4.7kΩ上拉上升时间就是0.8473×4700×50e-12约等于199ns这个值在400kHz模式下也够用。但如果总线上的设备比较多或者线缆比较长总线电容涨到200pF以上4.7kΩ的上升时间就会变成约800ns100kHz勉强能用400kHz就比较悬了。这时候建议换成2.2kΩ甚至1kΩ。反过来如果总线电容很小你可能希望上拉大一点来降低静态功耗10kΩ也完全可以跑100kHz。我自己调板子的时候习惯先在原理图里把上拉位置空出来焊盘兼容0603电阻调试时根据实际波形换上料。不过要强调一句上拉电阻不是越小越好太小的上拉会增大总线电流在低功耗场景里这个电流不可忽略。比如3.3V供电系统1kΩ上拉意味着每根线都有3.3mA静态电流两根就是6.6mA对于电池供电设备这个代价有点大。3. STM32环境搭建与IIC连接方案3.1 硬件接线我推荐你这样连少走弯路我用STM32F103C8T6举例也就是最常见的“蓝色药丸”板子。PCF8591的引脚和STM32引脚的对应关系可以参考PCF8591引脚连接到VDD3.3V或5V注意VREF不能超过VDDVREF3.3V如果VDD是5V最好单独用3.3V基准AGNDGNDSCLPB6硬件I2C1_SCL或任意普通GPIO模拟IICSDAPB7硬件I2C1_SDA或任意普通GPIOA0/A1/A2全部接GNDAIN0~AIN3传感器/被测电压信号AOUT负载或测量点高阻负载我特意把VREF单独列出来是因为很多新手在这上面反复出错。如果VDD接5VVREF也接5V那么ADC的满量程就是5VDAC输出范围也是0到5V。但STM32的ADC输入范围一般是0到3.3V如果你的传感器输出0到5V信号已经直接进了STM32那早就出问题了。反过来PCF8591用3.3V供电、VREF接3.3V那它的模拟输入只能到3.3V不会损坏但分辨率会略微降低6.6mV/LSB左右。我建议VDD和VREF都直接接3.3V和STM32系统共用一个电源轨这样信号链路的逻辑电平完全一致省去一堆电平偏移的麻烦。3.2 硬件IIC还是软件模拟IIC我的选择和建议这个问题可以说是STM32社区的经典论题。STM32的硬件I2C外设确实有一段时间口碑不好常见的问题是总线忙检测异常、中断标志处理繁琐、出错后状态机卡死。虽然ST后来在新版本库和HAL库里已经做了不少修补但很多从老工程师带出来的习惯仍然是“不到万不得已不用硬件IIC”。我个人的态度是如果你用的是HAL库、固件版本比较新而且总线上设备不多、通信频率固定硬件IIC可以用但如果你是新手、在调入门项目我建议直接用软件模拟IIC原因有三条。第一软件模拟IIC的代码完全在你的掌控中出问题可以直接单步调试看哪一步时序不对而硬件外设出错时往往要翻一堆状态寄存器的说明。第二软件模拟IIC可以灵活选择任意两个GPIO引脚不用受硬件外设引脚复用的限制画PCB的时候走线方便。第三PCF8591这种低速设备通信速率本来就在100kHz到400kHz之间软件模拟用几条GPIO翻转指令完全跟得上性能瓶颈根本不在这。等你吃透IIC时序之后再回去用硬件IIC会发现理解完全不一样。3.3 CubeMX配置步骤软件模拟IIC只需要配GPIO如果你决定用软件模拟IICCubeMX里根本不需要开启I2C外设只要把两个GPIO配置成开漏输出模式就行。开漏输出在这里很关键开漏模式下单片机只能把引脚拉低释放高电平时引脚阻抗很高靠外部上拉电阻把电平拉高。这样才能正确实现IIC总线的线与逻辑。如果配置成推挽输出两个设备同时对一个引脚输出不同电平时会直接短路。所以记住软件模拟IIC的引脚设置一定要选Output Open Drain速度选Low就行因为这个总线通信速率不高低速模式能减少噪声。具体步骤是在CubeMX里打开PC13之类的任意IO这里用PB6和PB7举例把GPIO mode选为Output Open DrainGPIO Output speed选为LowGPIO output level默认为High也可以不勾选上拉因为外部已经有上拉电阻。其他配置就是常规的时钟树HSE外部晶振或者内部晶振APB1和APB2总线时钟默认即可。生成工程之后接下来所有代码都可以在user code区域里写后续重新生成工程也不会丢失。4. 软件模拟IIC的核心代码实现4.1 GPIO初始化与微秒延时函数软件模拟IIC的底层是GPIO电平翻转所以需要一个可靠的微秒级延时。在STM32F103上最简单的做法是使用DWTData Watchpoint and Trace模块做us级延时。DWT是Cortex-M3内核自带的不需要额外的定时器精度很稳定。你也可以用SysTick做延时但SysTick一般被HAL库占着如果系统里已经有其他延时逻辑再叠加可能会互相影响。DWT的初始化代码很简单void DWT_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } void delay_us(uint32_t us) { DWT-CYCCNT 0; while(DWT-CYCCNT us * (SystemCoreClock / 1000000)); }这里SystemCoreClock是系统时钟频率如果是72MHz那么us * 72就是微秒对应的cycle数。DWT计数器的精度很高用起来放心。有一点要注意如果你用软件模拟IIC实现400kHz通信速率单bit的翻转时序要求就是微秒级别的精确控制用这个延时函数能保证时序不会跑偏。如果只是做100kHz慢速通信延时函数稍微粗糙一点问题也不大。4.2 起始、停止、应答信号的C语言实现先把IIC的底层硬件操作封装好。这里我用的是开漏输出模式所以SDA线的读取和写入要分开#define IIC_SCL PBout(6) #define IIC_SDA PBout(7) #define IIC_SDA_READ PBin(7) void IIC_Start(void) { IIC_SDA 1; IIC_SCL 1; delay_us(5); IIC_SDA 0; delay_us(5); IIC_SCL 0; delay_us(5); } void IIC_Stop(void) { IIC_SCL 0; IIC_SDA 0; delay_us(5); IIC_SCL 1; delay_us(5); IIC_SDA 1; delay_us(5); }起始条件必须是先SCL高然后SDA拉低停止条件则是先SCL低然后SDA拉低再SCL拉高最后SDA释放。这个顺序一旦颠倒总线上的设备就会误判常见问题表现为读回的数据全是0xFF或者总线一直被某个设备占用。我刚开始写代码的时候就是把停止条件的顺序写反了导致整个总线上挂的OLED也一起死掉排查了很久才发现。字节发送和接收函数是核心。发送函数要把每一位依次放到SDA上SCL产生一个高电平脉冲让从设备采样接收函数则是SCL低电平时读SDASCL高电平时数据稳定读出的位拼成字节。应答信号则是在第9个时钟脉冲时判断SDA是否被拉低。4.3 读写PCF8591的完整函数有了基础时序读写PCF8591就顺理成章了。读ADC的函数先发送地址字节和控制字节然后重新发送读地址循环读取两次数据丢掉第一次无意义的字节返回第二次的结果uint8_t PCF8591_ReadADC(uint8_t channel) { uint8_t dat; IIC_Start(); IIC_SendByte(0x90); // 器件地址写操作 IIC_WaitAck(); IIC_SendByte(0x40 | channel); // 控制字节使能DAC选择通道 IIC_WaitAck(); IIC_Start(); IIC_SendByte(0x91); // 器件地址读操作 IIC_WaitAck(); dat IIC_RecvByte(); // 第一次读丢弃 IIC_SendAck(1); dat IIC_RecvByte(); // 第二次读有效数据 IIC_SendAck(0); IIC_Stop(); return dat; }这里有一个很容易被忽略的点PCF8591在收到读地址后输出的第一个字节其实是上一次转换的结果或者说是一个无效数据必须再读一次或者多读几次才能得到当前通道的实际数据。很多第一次用这颗芯片的人会发现读到的数据要么是0要么是固定值就是忽略了第一次读的字节需要丢弃。更稳妥的做法是连续读三次取最后一次或者连续读四次然后取平均值这样可以有效降低毛刺的影响。写DAC的函数更简单发送地址字节后直接发DAC的控制字节和数据值void PCF8591_WriteDAC(uint8_t value) { IIC_Start(); IIC_SendByte(0x90); IIC_WaitAck(); IIC_SendByte(0x40); // 控制字节DA1使能DAC IIC_WaitAck(); IIC_SendByte(value); // DAC输出值 IIC_WaitAck(); IIC_Stop(); }注意这个控制字节写了0x40它的实际效果是只开了DAC不切换ADC通道。也就是说调用这个函数不会影响已经配置好的ADC读取状态。如果你先读通了通道0的数据然后又调用DAC输出再回来读通道0不需要重新配置控制字节芯片会保持原来的通道选择。这个细节在写混合读AD和写DA的代码时非常有用。5. ADC多通道采样与数据处理实战5.1 多通道切换从单通道到四通道扫描PCF8591有四个输入通道但并不支持一次性把所有通道的数据全部返回每次读取只有当前控制字节选中的通道数据能出来。要想采集四路信号就必须在每次读取前重新发送控制字节切换通道然后读取。比如下面这个四通道扫描函数void PCF8591_ReadAllChannels(uint8_t *buf) { for (uint8_t ch 0; ch 4; ch) { buf[ch] PCF8591_ReadADC(ch); } }每个通道读取都包含一次完整的IIC读序列包括起始条件、地址字节、控制字节、重新起始、读地址、读数据、停止条件整个过程大概需要几十个微秒到上百个微秒。如果你调用了这个函数然后紧接着用这些数据做PID运算或者显示刷新整体循环周期需要根据实际耗时来估算。我曾经用逻辑分析仪量过这个函数在100kHz的IIC速率下完整跑一遍大约需要3ms左右在大部分传感器采样场景里这个速度完全够用。自动增量模式是减少IIC事务次数的一种方式。设置控制字节的bit2为1之后每次读操作完成后通道号会自动加1从0到3循环。这样你只需要发送一次地址和控制字节然后连续读四次数据就能拿到所有通道的值。这个模式的好处是减少了重复发送控制字节的开销坏处是你必须严格按照0-1-2-3的顺序读取而且通道返回值是“上一轮”的还是“当前轮”的要仔细理清楚。我个人建议新手先用最普通的方式别碰自动增量等逻辑跑通了再优化性能。5.2 模拟电压怎么换算从ADC值到物理量拿到8位的ADC原始值之后换算成真实电压的公式很简单float voltage (float)adc_value / 255.0f * VREF;这里VREF是实际的参考电压。如果你用3.3V供电且VREF接3.3V那么adc_value208对应的电压就是208/255*3.32.691V。如果你的传感器输出的是线性比例信号比如分压后的电池电压就把电压再乘以分压比。这里有一个需要注意的地方普通万用表量出来的3.3V和基准源供给的3.3V在精度要求高的系统里是不同的。PCF8591的精度受限于VREF的稳定性如果VREF直接用数字电源的3.3V而这个3.3V是从降压芯片输出的开关电源的纹波会直接叠加到ADC结果上。所以高精度应用一定要给VREF加一个LDO或者专门的基准电压源。我做过一个电池电压监测项目一开始直接把PCF8591的VREF接到了板子的3.3V电源上结果测出来电压总是跳大约有±10mV左右的波动。后来在VREF引脚附近加了一个10uF电容和0.1uF高频电容波动降到了±3mV左右。如果要求更高应该考虑外部基准芯片比如REF3030这类3.0V基准把VREF稳定在3.0V而不是3.3V。5.3 软件滤波滑动平均滤波与中值滤波8位ADC的分辨率不高所以输出数据如果有毛刺显示的曲线会非常难看。我常用两种简单的软件滤波方法成本低、效果好。滑动平均滤波的思路是维护一个长度为N的窗口每次新数据进来窗口整体向后移动一格输出窗口内数据的平均值。这个滤波对随机噪声抑制效果最好但响应速度会变慢。在STM32上实现如下#define FILTER_LEN 8 uint8_t filter_buf[FILTER_LEN]; uint8_t filter_index 0; uint32_t filter_sum 0; uint8_t PCF8591_ReadADC_Filtered(uint8_t channel) { uint8_t raw PCF8591_ReadADC(channel); filter_sum - filter_buf[filter_index]; filter_buf[filter_index] raw; filter_sum raw; filter_index (filter_index 1) % FILTER_LEN; return (uint8_t)(filter_sum / FILTER_LEN); }注意这里sum变量和buffer长度以及原始数据位宽的关系。如果FILTER_LEN是8filter_sum用一个uint8_t刚好够如果窗口长度超过16建议用uint16_t防止溢出。如果你测量的是缓慢变化的信号比如室温、水温窗口长度可以加大到16甚至32如果是快速变化的电流信号窗口太大会让真实信号被平滑掉响应变慢要权衡。中值滤波则是对连续N次采样排序后取中间值对脉冲型毛刺特别有效。比如你电路附近有继电器吸合、电机启停ADC数据偶尔会冒出一个很大的尖峰中值滤波能完美过滤掉这种干扰。我的做法是两种都保留在代码里用宏定义切换这样在不同的项目里可以针对性的选滤波策略。6. DAC输出与波形生成6.1 从数字到模拟PCF8591的DAC是怎么工作的PCF8591的DAC部分是一个8位电阻梯形网络把数字值0~255转换成对应的模拟电压。输出电压的计算公式是Vout VREF × (DAC值 / 255)。比如VREF3.3VDAC写入128输出电压就是3.3×128/2551.656V。和ADC一样实际输出的精度受VREF影响所以高精度应用同样要给VREF用好电源。有一点很多人不知道PCF8591的AOUT引脚输出能力非常有限数据手册里给出的典型值是驱动能力很弱只能接高阻负载。如果你想用这个输出驱动一个小喇叭或者电机调速必须加一级运放缓冲或者功率放大否则电压会被负载拉低波形直接畸变。我见过有同学把AOUT直接接到了耳机上结果声音很微弱还伴随巨大失真其实是负载阻抗太低导致DAC输出拉不动。6.2 手写一个简单的DAC输出电压计算器写DAC之前先算清楚目标电压对应的数字量。假设VREF是3.3V你想输出2.0V那么DAC值 2.0 / 3.3 × 255 ≈ 154。写成C代码uint8_t voltage_to_dac(float vout, float vref) { if (vout vref) return 255; if (vout 0) return 0; return (uint8_t)(vout / vref * 255.0f 0.5f); }加0.5做四舍五入比直接取整更准确。实际测的时候由于运放偏置或者电阻误差输出值可能和计算值有几点偏差。比如我实测某一片PCF8591写入0xFF时输出2.87VVREF是3.0V换算下来实际VREF等效2.87V。所以标定的时候直接用万用表量一下AOUT在不同DAC值下的实际输出然后在代码里做两点或三点校准比单纯靠公式计算靠谱得多。6.3 生成正弦波查表法实现与频率估算用DAC生成正弦波最常见的做法就是查表法。先离线算出一组正弦波采样点存在单片机Flash里然后用定时器中断不断查表更新DAC输出。采样点越多波形越平滑但需要的Flash空间和每秒中断次数也越多。PCF8591是8位DAC所以表项用uint8_t存就行。我一般取64个点生成一周期的正弦波能满足大多数演示需求。C语言可以这样生成const uint8_t sine_table[64] { 128, 140, 153, 165, 177, 188, 198, 207, 215, 222, 228, 233, 237, 240, 242, 244, 245, 245, 244, 242, 240, 237, 233, 228, 222, 215, 207, 198, 188, 177, 165, 153, 140, 128, 115, 102, 90, 78, 67, 57, 48, 40, 33, 27, 22, 18, 15, 13, 11, 11, 12, 14, 16, 19, 23, 28, 33, 40, 48, 57, 67, 78, 90, 103 };这个表生成的一个周期正弦波以128为中心幅值约117对应0V到约2.4V范围VREF3.3V时峰值大概在245、谷值在11左右。如果你要满幅输出可以把表里的最小值和最大值分别调到0和255。输出频率的计算公式是Fout 更新频率 / 表长度。假设你用一个1kHz的定时器中断每次中断更新一个表项那么正弦波频率就是1000/6415.625Hz。如果想提高频率就提高中断频率或者减少表长度。但PCF8591通过IIC写入DAC需要时间在100kHz的IIC速率下单次DAC写入地址字节控制字节数据字节至少需要16个时钟周期加上起始停止和应答大概200us左右。也就是说纯靠IIC更新DAC的速率上限大约是4kHz到5kHz。64点表要实现20Hz正弦波需要1280Hz的中断频率IIC带宽完全够用但如果想生成1kHz正弦波就要把更新频率提到64kHz这是IIC无法实现的。这种情况下要么用更高速的SPI接口DAC要么直接用STM32内部DAC。这也是PCF8591作为低速DAC的天然限制选型的时候心里要有数。如果想生成三角波或者锯齿波改动就非常容易了。锯齿波就是DAC值从0线性增加到255然后跳回0三角波则是0到255再255到0。这两类波形很适合做扫描源或者调制信号代码逻辑也很简单在中频信号测试时经常用到。7. 常见问题与排查技巧实录7.1 IIC总线读不到数据从哪里开始查这是我在各个技术社区看到最多的问题几乎每天都有新手发帖说“PCF8591读回来全是255”或者“OLED和PCF8591一起挂IIC就死机”。遇到这个问题我的排查顺序很固定。先检查地址是否匹配。PCF8591的7位地址是1001A2A1A0如果你把A0/A1/A2全部接地那么地址是0x90写和0x91读。用示波器或者逻辑分析仪抓起始条件后面的第一个字节看是不是0x90。如果你在CubeMX里面用的地址宏是0x90但代码里IIC_SendByte(0x90)和IIC_SendByte(0x91)确实也发了那么就看有没有ACK回复。在IIC_SendByte之后、调用IIC_WaitAck之前如果SDA被从设备拉低说明地址正确、设备在响应如果SDA一直保持高说明设备没被唤醒或者地址错误。其次检查设备供电和参考电压。PCF8591的VDD和VREF如果没接或者接错到GND整颗芯片等于没工作表现在总线上就是完全没有任何ACK。还有一个坑VREF不能为0或者比AGND低太多否则ADC和DAC都无法正常工作。最后的排查点是上拉电阻和接线。很多开发板上的PB6和PB7已经自带上拉电阻这时候你外接的上拉电阻可以省略但如果你的板子没有自带上拉而你又忘记接上拉总线会一直处于低电平或者浮动状态因为开漏模式下没人把线拉高。用万用表量SCL和SDA对地电压空闲时应该是VCC电压如果是0V基本就是上拉缺失或者有设备把总线拉死了。7.2 电压读不准误差来源和校准方法ADC读到的数值和万用表量的电压对不上这个问题的来源通常有三个层面。第一是参考电压精度。PCF8591是8位ADC50mV的误差可能来自VREF本身不稳定。第二是输入阻抗匹配。PCF8591在采样时会有一个内部采样电容如果信号源阻抗过高采样瞬间电压会被拉低导致读数偏低。这种情况下你需要在信号源和PCF8591之间加一个运放跟随器作为缓冲或者尽量降低信号源阻抗。第三是GND的干扰。模拟地和数字地如果共用一根线数字电路的开关噪声会通过地回路耦合到模拟输入。校准的方法也很直接在ADC输入端加一个已知的精准电压比如用基准源输出1.0V、2.0V、3.0V分别读ADC值然后用两点拟合法算出实际斜率和偏移量。代码里做float adc_calibrated_value(uint8_t raw) { // 假设校准得到: 实际电压 raw * 0.0132 - 0.021 return raw * 0.0132f - 0.021f; }这样处理之后读数误差可以从±30mV压到±5mV以内。我在正式项目里基本都是开机自校准用内部已知电压源做一次零点和满量程校正再挂到业务逻辑里去。7.3 一些从实战里沉淀下来的经验最后分享几个我在实际项目中反复栽过跟头之后总结的经验。第一个是关于模拟IIC的GPIO模式。我见过很多人把GPIO配置成推挽输出发现也能读出一些数据但偶尔会不稳定。原因是推挽输出在总线空闲时主动输出高电平这时候如果从设备想拉低总线两个输出就会经历短暂的冲突轻则产生干扰尖峰重则损坏IO口。开漏输出是IIC的标准做法别图省事抄那些用推挽的代码。第二个经验是关于时序延时的取值。软件模拟IIC对延时不是越短越好。如果你把延时设成1usIIC时钟频率就是500kHz左右超过了PCF8591的快速模式上限400kHz长时间工作可能偶发读取失败。但如果你把延时设成10us虽然完全稳定通信速率就掉到了50kHz读四通道ADC的周期会比100kHz时翻倍。我一般把高低电平时长各设5us合成时钟100kHz速度和稳定性比较均衡。第三个是调试工具的选择。光靠串口打印ADC数据你只能看到结果看不到通讯过程。IIC这样的总线协议波形和时序才是真正的答案。逻辑分析仪现在很便宜20MHz采样率以上的就行把SCL和SDA两个通道探针接上抓一段波形起始、停止、应答、数据位一目了然问题定位速度会提高很多倍。没有逻辑分析仪的话用示波器也可以只是触发条件要设得细致一些。第四个是关于“第一次读数据是无效数据”这个坑前面已经提到了但这里要再强调一次如果你发现PCF8591读回来的数据总是不变或者和你期望的电压明显不符多半是只读了一次而没把第一次的数据丢弃。我见过太多人在这上面浪费了一个下午的时间。连续读两次取第二次或者干脆读三次取中间值。实在不行就在读操作前加一个小延时例如delay_ms(1)让片内的ADC完成一次完整的采样转换周期。PCF8591的单次转换时间大约是几十微秒如果控制字节刚发完就立刻读数据芯片还没有转换出结果这时返回的字节自然不对。第五个是关于DAC输出和ADC采样共用一个IIC总线的问题。有些人先调用DAC写入紧接着立刻读ADC发现ADC结果受到了干扰。这个现象的本质是DAC的建立需要时间DAC内部输出的模拟信号还没稳定下来如果你在同一个时间片内频繁切换AD和DA模拟部分会互相影响。解决方法是让DAC输出一个值之后等待一两个毫秒再去读ADC或者干脆在时间安排上避让不要在同一毫秒内密集操作AD和DA。这个在音频和波形合成的场景里很少遇到但在传感器系统里还是很常见的。7.4 做一个完整的综合示例四路采集和DAC正弦波输出最后放一个完整的综合示例把前面讲的内容串起来。这个示例能在STM32F103上运行定时器2产生1kHz中断每次中断把正弦波表里的一个值写进DAC主循环里每500ms读一次四路ADC并通过串口打印。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); DWT_Init(); uint16_t sine_index 0; uint8_t adc_vals[4]; HAL_TIM_Base_Start_IT(htim2); // 1kHz中断 while (1) { PCF8591_ReadAllChannels(adc_vals); printf(CH0:%d CH1:%d CH2:%d CH3:%d\n, adc_vals[0], adc_vals[1], adc_vals[2], adc_vals[3]); HAL_Delay(500); } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { PCF8591_WriteDAC(sine_table[sine_index]); sine_index (sine_index 1) % 64; } }这个工程跑起来之后用万用表量AOUT引脚可以看到近似正弦波用串口看四路ADC能实时监测四路传感器数据。整个系统占用的MCU资源很少——一个定时器中断、一个串口、两个GPIO剩余资源还能跑显示逻辑和通信协议栈。我在实际使用PCF8591的过程中最大的体会是它的性能参数在今天的半导体产品里确实不算亮眼8位精度、低速IIC放在2024年的背景下都有点平平无奇。但它最大的价值是“够用且简单”——让你在集成一颗ADC/DAC时不需要担心复杂的配置寄存器、不需要专门画模拟电路、不需要花太多时间在调试上。在你需要快速验证一个想法、做一个原型板、或者给老旧MCU扩展模拟通道时它永远是最快能跑通方案的伙伴。最后再分享一个我不太会写在正式文档里的小技巧PCF8591的AOUT输出端并一个10uF电容到GND可以让DAC纹波明显降低对很多传感器阈值判断场景非常有效。这个改动对输出波形会有一点相位延迟但对电压输出类应用几乎没影响强烈建议你试一下。
返回列表