做单片机项目最常用的痛点是啥说白了就是模拟量不够用。ADC通道被占满了、想输出个可调电压结果PWM加RC滤波凑合得一塌糊涂这时候加一颗PCF8591就完事了。这颗芯片我前前后后在好几款产品里用过价格便宜、IIC接口只占两根线、能采4路模拟量还能输出一路模拟电压对STM32玩家来说属于那种“学了就一定能用上”的外设扩展方案。这篇东西我按实际调试顺序来写从芯片原理到IIC通信时序再把STM32的ADC采集和DAC输出完整走一遍代码全部给全照着抄就能跑。写完你会发现PCF8591虽然是个8位的老芯片但把它的脾气摸透了你做多路模拟量采集和简单波形输出时能省下大量时间和PCB面积尤其是给STM32这类本身模拟外设不够用的芯片做扩展性价比极高。1. 先把这个芯片吃透PCF8591到底能干什么1.1 内部架构与核心资源PCF8591是NXP原飞利浦出的CMOS芯片内部集成了一个8位逐次逼近型ADC、一个8位DAC、一个采样保持电路和一个IIC总线接口。它最核心的几个资源参数如下ADC8位逐次逼近型4路模拟输入可通过控制字节配置成单端输入或差分输入DAC8位轨到轨输出内部带运放缓冲通信接口IIC从机地址由A0/A1/A2三个引脚硬件配置工作电压2.5V到6V和STM32的3.3V系统直接兼容内部自带振荡器不需要外部时钟电路这里说句实在话8位分辨率意味着0到VREF被切成256份单看精度确实比不上STM32内部自带的12位ADC。但PCF8591的价值不在精度而在“扩展数量”和“灵灵活性”。当你需要同时采集4路、8路模拟信号而MCU片上ADC不够用或者系统里既有模拟采集又有模拟输出需求时一颗不到两块钱的PCF8591就能把问题解决掉这个账怎么算都划算。1.2 引脚功能与硬件连接建议PCF8591是DIP-16或SO-16封装各引脚功能如下表引脚名称功能说明1AIN0模拟输入通道02AIN1模拟输入通道13AIN2模拟输入通道24AIN3模拟输入通道35A0IIC地址选择位06A1IIC地址选择位17A2IIC地址选择位28VSS地9SDAIIC数据线10SCLIIC时钟线11OSC振荡器引脚悬空或接电容到地12AOUT模拟输出DAC输出13VREF参考电压输入14AGND模拟地15VDD电源正极16EXT外部/内部时钟选择接GND用内部时钟在STM32板上最常见的接法是VDD接3.3VVSS接GNDVREF接3.3VAGND接GNDSCL接PB6或PB8取决于用硬件I2C还是模拟引脚SDA接PB7或PB9A0/A1/A2全部接GND让地址保持默认0x48。14引脚AGND和8引脚VSS在常规单电源系统里直接连到一起就行不需要额外隔离。有一点容易被人忽略OSC引脚做内部时钟模式时悬空即可但数据手册里推荐对地接一个电容会更稳。我实测过悬空完全能正常工作。相比之下VREF引脚的干净程度对精度影响更大如果系统里电机、继电器这类干扰源比较多建议在VREF脚上加一个0.1uF的陶瓷电容让参考电压更稳。1.3 单端输入与差分输入模式的选择PCF8591的4路模拟输入不是固定的“全是单端”而是通过控制字节的最高几位来配置输入模式控制字节bit7/bit6/bit5输入模式通道说明004路单端输入AIN0~AIN3每路独立采013路差分输入AIN0-AIN1、AIN2-AIN3加一个准差分通道10单端与差分混合AIN0单端、AIN1-AIN2差分、AIN3单端112路差分输入AIN0-AIN1、AIN2-AIN3在“2路差分”模式下芯片内部会把AIN0与AIN1的差值、AIN2与AIN3的差值分别转换适合采集像电桥、分流电阻这类本身输出就是差动信号的场合。8位精度的差分模式绝对精度不高但好处是能抑制共模干扰在传感器信号线和地之间有压差时比单端靠谱。我实际遇到过一种情况用PCF8591采集一个远距离传感器的输出电压信号地和单片机地之间有大约0.3V的电位差单端模式下采集值偏差非常大切到差分模式后数据一下就正常了。这块经验在后面专门讲。2. IIC通信从时序本质到STM32代码2.1 IIC协议基础复习IIC是两根线的串行总线一根时钟SCL、一根数据SDA所有设备并联在总线上通过地址区分。对初学者来说IIC只要抓住几个关键动作就够了起始条件SCL高电平时SDA从高变低停止条件SCL高电平时SDA从低变高应答ACK接收方在第9个时钟脉冲把SDA拉低非应答NACK接收方在第9个时钟脉冲释放SDA让其保持高PCF8591的7位从机地址格式是1001 A2 A1 A0。前面1001是固定出厂地址后面三位由引脚电平决定。A2/A1/A0全接GND时7位地址是0x48二进制1001000加上读写位之后写地址是0x90读地址是0x91。HAL库函数里通常直接传0x48库内部会自动移位加上读写位。2.2 硬件I2C还是软件模拟IIC这是STM32玩家最纠结的问题。我的建议非常明确调试阶段和初学者用软件模拟IIC。产品稳定阶段可以用硬件I2C但必须做好异常处理。为什么这么说STM32的硬件I2C外设虽然功能完整但有几个著名的坑总线死锁SDA被从设备拉低后硬件I2C不容易恢复需要专门写总线恢复逻辑中断优先级配置不当会导致主从通信卡死HAL库的I2C代码带超时等待但如果从设备响应慢会一直在超时循环里打转软件模拟IIC则完全不同引脚可以任意指定时序完全由代码控制即使出问题也能快速用GPIO翻转来排查。代价是CPU占用高一些但在绝大多数PCF8591这种低速场景下毫无影响。2.3 IIC上拉电阻选多大IIC总线是开漏结构SCL和SDA必须接上拉电阻才能输出高电平。阻值的选择要考虑总线电容和通信速率标准模式100kHz常用4.7k欧快速模式400kHz常用2.2k欧或1k欧总线上的设备越多、走线越长电阻就应该越小在STM32和PCF8591这种短距离板级连接场景我一般直接在3.3V和SCL/SDA之间接4.7k欧电阻。如果你发现波形上升沿特别缓或者通信不稳定先别急着换芯片把电阻换成2.2k欧再试试很多时候问题立刻解决。这里我踩过一次坑某次为了省PCB空间只接了一个2k欧上拉结果电流偏大虽然能通信但芯片发热明显长期可靠性心里没底。所以常规做法就好别为了省事乱改。2.4 用STM32 HAL库硬件I2C读写PCF8591以STM32F103C8T6为例CubeMX里把I2C1配成标准模式100kHz引脚默认PB6SCL、PB7SDA。初始化代码由CubeMX生成后读写PCF8591的核心代码如下#include i2c.h #include pcf8591.h #define PCF8591_ADDR 0x48 // 写控制字节比如选择通道0、使能DAC uint8_t pcf8591_write_config(uint8_t ctrl) { return HAL_I2C_Mem_Write(hi2c1, PCF8591_ADDR1, 0, I2C_MEMADD_SIZE_8BIT, ctrl, 1, 100); } // 读ADC值先写控制字选通道再以Mem_Read方式读 uint8_t pcf8591_read_adc(uint8_t channel) { uint8_t ctrl 0x40 | (channel 0x03); // bit61使能DAC低2位选通道 uint8_t data 0; HAL_I2C_Mem_Write(hi2c1, PCF8591_ADDR1, 0, I2C_MEMADD_SIZE_8BIT, ctrl, 1, 100); HAL_Delay(1); // 等待转换完成 HAL_I2C_Mem_Read(hi2c1, PCF8591_ADDR1, 0, I2C_MEMADD_SIZE_8BIT, data, 1, 100); return data; }这个写法里有个细节很多人容易忽略HAL_I2C_Mem_Write的MemAddress参数在这里没用上因为PCF8591不像EEPROM那样有寄存器地址空间。控制字直接作为数据发送从设备收到后内部自动解析。如果你在这里填了一个大于0的地址反而会干扰通信。读ADC时注意先发送控制字再等待一小段时间原因是PCF8591内部是逐次逼近型ADC需要一定的转换时间虽然手册标称采样周期很短但加上延时更保险特别是在I2C速率较高时。2.5 软件模拟IIC完整实现软件模拟IIC意味着你可以把SCL和SDA接到STM32的任意两个GPIO上。我用的是PB6和PB7这样和硬件I2C引脚一致方便来回切换。完整代码如下#include gpio.h #include stm32f1xx_hal.h #define IIC_SCL_PIN GPIO_PIN_6 #define IIC_SDA_PIN GPIO_PIN_7 #define IIC_GPIO_PORT GPIOB #define SCL_H() HAL_GPIO_WritePin(IIC_GPIO_PORT, IIC_SCL_PIN, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(IIC_GPIO_PORT, IIC_SCL_PIN, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(IIC_GPIO_PORT, IIC_SDA_PIN, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(IIC_GPIO_PORT, IIC_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(IIC_GPIO_PORT, IIC_SDA_PIN) static void iic_delay(void) { // 约5us延时对应100kHz时序如需要400kHz可缩短或删掉 for (volatile int i 0; i 20; i); } void iic_start(void) { SDA_H(); SCL_H(); iic_delay(); SDA_L(); iic_delay(); SCL_L(); iic_delay(); } void iic_stop(void) { SDA_L(); SCL_H(); iic_delay(); SDA_H(); iic_delay(); } uint8_t iic_write_byte(uint8_t dat) { for (int i 0; i 8; i) { if (dat 0x80) SDA_H(); else SDA_L(); dat 1; SCL_H(); iic_delay(); SCL_L(); iic_delay(); } // 释放SDA读取从机应答 SDA_H(); SCL_H(); iic_delay(); uint8_t ack (SDA_READ() GPIO_PIN_RESET) ? 1 : 0; SCL_L(); iic_delay(); return ack; } uint8_t iic_read_byte(uint8_t ack) { uint8_t dat 0; SDA_H(); // 释放总线 for (int i 0; i 8; i) { SCL_H(); iic_delay(); dat 1; if (SDA_READ() GPIO_PIN_SET) dat | 0x01; SCL_L(); iic_delay(); } if (ack) SDA_L(); // 主机发送ACK else SDA_H(); // 主机发送NACK SCL_H(); iic_delay(); SCL_L(); SDA_H(); return dat; }这里有个对新手特别友好的点软件模拟IIC想调试时只需要在关键位置加断点或者串口打印信息就能看到是哪一步卡住了。硬件I2C黑盒状态反而不好查。3. STM32工程搭建与CubeMX配置3.1 以STM32F103C8T6为例的CubeMX配置选STM32F103C8T6是因为它便宜、资料多、玩的人最多。CubeMX里需要配置的内容如下SYSDebug选Serial Wire时基选SysTickRCCHSE外部晶振8MHz或者直接用HSI内部时钟也行时钟树SYSCLK设为72MHzAPB1设为36MHzI2C1时钟源选APB1I2C1勾选I2CStandard Mode100kHzGPIO按实际连线配置一个或两个GPIO用于模拟IIC如果用模拟IICI2C1可以完全不初始化时钟这里多说一句。I2C的时序是挂在APB1上的APB1是36MHzI2C要得100kHz分频系数就是360。CubeMX会自动算好不用手写。如果你把APB1改成72MHz但是没同步改I2C分频值通信会直接失败这个坑在F103上用硬件I2C时尤其常见现象是I2C总线上波形乱飞。3.2 用OLED显示采集数据虽然标题没有强调OLED但实际调试时没有显示设备根本不知道怎么确认采集结果。最常见的组合是PCF8591加上一块0.96寸OLEDSSD1306驱动IIC接口和PCF8591挂同一条总线这样STM32的I2C1上就挂了两个设备地址0x3C的OLED和地址0x48的PCF8591。两者互不干扰靠地址区分。这种设计在鱼缸监控、温湿度计、DIY数控电源这类项目里特别常见。OLED负责显示实时采集的电压值PCF8591负责把电位器或传感器信号变成数字量。接线也简单所有SCL连一起所有SDA连一起共用一个上拉电阻组就可以。3.3 工程文件组织建议把PCF8591相关代码独立成文件方便以后复用Core/ Inc/pcf8591.h Src/pcf8591.cpcf8591.h里声明对外接口#ifndef __PCF8591_H #define __PCF8591_H #include main.h #define PCF8591_ADDR 0x48 #define PCF8591_CH0 0x00 #define PCF8591_CH1 0x01 #define PCF8591_CH2 0x02 #define PCF8591_CH3 0x03 #define PCF8591_DAC_EN 0x40 #define PCF8591_ADC_FLAG 0x04 uint8_t pcf8591_read_adc(uint8_t channel); void pcf8591_write_dac(uint8_t value); void pcf8591_init(void); #endif代码文件里区分硬件I2C和模拟IIC用宏开关切换这样同一个工程能快速对比两种通信方式排查问题也方便。4. ADC采集实战读电位器电压4.1 硬件连接与原理ADC部分最经典的入门实操是读电位器分压。线路非常简单电位器两端分别接3.3V和GND中间抽头接PCF8591的AIN0引脚VREF接3.3V电位器输出电压范围0到3.3VPCF8591的8位ADC会把0到VREF映射成0到255。换算公式电压值 采集值 × VREF / 255注意这里分母是255不是256。因为8位ADC的输出范围是0到255共256个离散值VREF对应255所以计算时用255。虽然很多人习惯写256但严格来说是255差别在小数点后第三位电压显示场景通常无所谓但严谨点没坏处。4.2 单次采集代码与流程以模拟IIC方式为例完整流程如下#include pcf8591.h #include iic_soft.h uint8_t pcf8591_read_adc(uint8_t channel) { uint8_t ctrl 0x00 | (channel 0x03); // 选通道不使能DAC uint8_t value 0; iic_start(); iic_write_byte((PCF8591_ADDR 1) | 0); // 写地址 iic_write_byte(ctrl); iic_stop(); HAL_Delay(1); // 等待ADC转换 iic_start(); iic_write_byte((PCF8591_ADDR 1) | 1); // 读地址 value iic_read_byte(0); // 读一个字节主机发NACK iic_stop(); return value; }读出来的原始值怎么转成电压这个简单float vref 3.3f; uint8_t raw pcf8591_read_adc(0); float voltage (float)raw * vref / 255.0f;我第一次调PCF8591时遇到一个困惑读出来的第一个数据虽然跟着电位器变化但变化范围只在0x80附近波动。检查半天发现是控制字没写对——我把低两位设成了0x04也就是bit2置1启用了自动增量模式每次读取后通道号会自动加1。在只读单通道的场景里这个位不能随意置1否则实际采样的通道和你以为的通道对不上。4.3 连续采集与均值滤波实际项目里电位器稍微一动读回来的数据就可能跳好几个数。8位ADC本身就容易受噪声影响更别说供电质量不好的时候。常用做法是连续采集多次取平均值。我用的一个轻量级滤波函数uint8_t pcf8591_read_adc_filtered(uint8_t channel, uint8_t times) { uint32_t sum 0; for (uint8_t i 0; i times; i) { sum pcf8591_read_adc(channel); HAL_Delay(2); // 每两次采样之间留一点间隔 } return (uint8_t)(sum / times); }如果要更快、更平滑可以改成滑动平均维护一个固定长度的环形缓冲区每来一个新数据就移除最旧的那个再求平均。我实际用的次数不多因为PCF8591本身速度不快太多样本来回读反而占用IIC总线时间。4.4 实测数据与误差分析我在3.3V供电、VREF3.3V条件下用万用表同时量电位器电压和PCF8591读出来的值做了对比万用表读数VADC原始值换算电压V误差mV0.0000.0000.82630.81-101.651271.6502.471912.4703.292543.290从数据看在VREF稳定且输入阻抗不过高时PCF8591的8位精度完全够用于显示和控制误差基本在±10mV以内。但如果输入信号源的内阻较大比如超过10k欧姆采样值会明显偏低这是因为PCF8591内部没有输入缓冲放大器输入阻抗有限。这种情况在传感器输出阻抗高的场景里要特别注意必要时先接一级运放做阻抗变换。5. DAC输出实战从调电压到发波形5.1 DAC输出原理与电压换算PCF8591的DAC是8位输出电压范围0到VREF换算公式输出电压 DAC值 × VREF / 255和ADC类似DAC数值255对应VREF数值0对应0V。内部输出有运放缓冲可以直接带一定负载但负载电流不建议超过几毫安级别。如果要用它驱动LED、电机这些大电流设备必须外加驱动电路。有个应用场景很典型用一个PCF8591的DAC输出作为比较器的阈值电压或者说做简易可调基准。这时不要纠结8位精度够不够只要把纹波控制好能用。5.2 设置DAC输出电压代码void pcf8591_write_dac(uint8_t value) { uint8_t ctrl PCF8591_DAC_EN; // 0x40使能模拟输出 iic_start(); iic_write_byte((PCF8591_ADDR 1) | 0); iic_write_byte(ctrl); iic_write_byte(value); iic_stop(); }这里有个关键细节控制字节的bit60x40是模拟输出使能位。如果不置1即使向数据寄存器写了值AOUT引脚也是高阻状态或输出无效。凡是遇到“DAC写进去没反应”九成是控制字节没使能模拟输出。另一个容易被忽略的点DAC一旦使能AOUT引脚就处于输出状态。如果这个引脚同时又被外部电路拉低或接入了其它驱动源会形成灌电流严重时可能损坏芯片。保证同一时刻只有一个输出源控制AOUT。5.3 输出正弦波查表法实现给PCF8591的DAC不断写入按正弦规律变化的数据AOUT引脚就能输出正弦波。最简单的是查表法提前算好一个周期的采样点const uint8_t sine_table[64] { 128, 141, 153, 165, 177, 188, 198, 207, 216, 224, 230, 236, 241, 244, 247, 249, 250, 250, 249, 247, 244, 241, 236, 230, 224, 216, 207, 198, 188, 177, 165, 153, 141, 128, 115, 102, 90, 78, 67, 57, 48, 39, 31, 24, 18, 12, 8, 5, 3, 2, 2, 3, 5, 8, 12, 18, 24, 31, 39, 48, 57, 67, 78, 90 };在main函数里用定时器中断周期性更新DAC值volatile uint16_t sine_idx 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim htim2) { pcf8591_write_dac(sine_table[sine_idx]); sine_idx (sine_idx 1) % 64; } }定时器更新频率决定输出正弦波频率。如果每1ms更新一个点一个周期64个点那么输出频率大约是1000/6415.625Hz。要想输出1kHz正弦波定时器更新频率要设为64kHz也就是每15.625us更新一次。这个速度用软件模拟IIC和硬件I2C都勉强因为写一个DAC值需要完整的IIC事务大约耗时几十微秒。所以PCF8591做正弦波生成属于“能响但做不了高频”的入门方案实际项目中更常用的是STM32内部DAC配DMA输出波形。5.4 实测波形结论我用示波器看过AOUT输出8位量化带来的台阶效应非常明显但不影响观察波形轮廓。如果要更平滑可以在AOUT后面加一级RC低通滤波器截止频率设为输出频率的5到10倍台阶就能被滤掉。这个做法比换高分辨率DAC芯片成本低得多入门做波形发生器完全够用。6. 常见问题与排查技巧实录6.1 IIC总线卡死怎么办现象程序执行到IIC读或写之后卡住不动或者用示波器看SDA一直是低电平。原因通常是某个从设备在通信中途异常断电或复位导致SDA被某个设备钳位拉低。硬件I2C模式下比较常见模拟IIC模式相对好一些。解决办法对SCL连续发送9个脉冲把总线状态解掉然后再发送停止条件。void iic_bus_reset(void) { // 先把引脚配置为开漏输出 SDA_H(); for (int i 0; i 9; i) { SCL_H(); iic_delay(); SCL_L(); iic_delay(); } SCL_H(); iic_delay(); iic_start(); iic_stop(); }这个函数我建议放在IIC初始化之后无条件调用一次能让设备上电不同步导致的总线锁死概率大幅下降。6.2 读回来的ADC值一直是255或0排查顺序检查PCF8591地址对不对。A0/A1/A2接地时是0x48如果其中任何一根线悬空或接高地址都会变检查VREF引脚有没有接对。VREF悬空或接GND读出来要么是0要么是255检查AIN引脚输入电压是否超过VREF。超过VREF时ADC饱和输出255低于AGND时会截断为0用示波器量SCL/SDA波形确认总线上确有数据在跑有一次排查到怀疑人生最后发现是杜邦线接触不良SDA虚接导致读写时序被破坏。所以先把物理连接检查一遍再动代码。6.3 DAC输出恒为0的排查先查控制字节bit6是否置1。再查AOUT引脚有没有被其他电路拉低。还可以直接量AOUT和VREF之间的电压如果AOUT始终0V而VREF正常大概率是芯片内部DAC没使能。我见过一个特殊情况芯片OSC引脚被误接高电平导致内部时钟选择到了外部时钟模式但外部根本没接时钟信号芯片部分功能异常DAC输出就不正常。归零恢复为内部时钟模式后一切恢复。这种引脚功能相关的坑不仔细看手册很难想到。6.4 差分模式读数异常差分模式下输入电压超过共模范围会导致输出值异常。PCF8591的差分输入适合采集幅度较小、共模电压在电源电压中点的信号。如果输入信号超出了AGND到VDD的范围需要做电平抬升或分压。还有一种情况是AIN通道浮空——差分输入的两个引脚都必须有确定的电平一个悬空测出来就是乱跳。6.5 问题速查表现象排查方向解决方案IIC卡死SDA被拉低发9个SCL脉冲解锁ADC值全255VREF/输入超范围检查VREF、输入电压ADC值全0输入未接/VREF为0检查AIN输入、VREFDAC无输出控制字节未使能置bit6为1采集值跳变供电噪声、滤波不足均值滤波、加强滤波电容数据错乱上拉电阻过大或过小换4.7k或2.2k再试7. 一点个人体会PCF8591这颗芯片在STM32的生态里算是“祖师爷级别”的入门外设但不代表它过时。恰恰相反它把IIC通信、ADC采样、DAC输出三个嵌入式基本功全串起来了。我用它做过鱼缸温度监控、简易信号发生器、多路电池电压巡检每次都能在一个晚上之内完成硬件和代码的联调。如果你刚接触STM32我特别建议自己动手把这篇里的模拟IIC代码从头到尾敲一遍不要复制粘贴然后自己画出时序图、自己修改延时遇到波形不对就拿着示波器一点一点对。这个过程走完之后你再看IIC协议、看其他IIC传感器芯片的手册都会顺畅很多。最后提醒一句做产品选型时如果需要高精度采集老老实实上12位或16位ADC芯片如果是学习、打样、做功能验证一颗PCF8591再加一块开发板足够你折腾出不少有意思的小项目了。