
简介本资源是一套面向嵌入式初学者与STM32开发者的PCF8574 I²C扩展芯片驱动代码专为STM32F103系列MCU设计解决GPIO资源不足时需外扩8路双向数字I/O的实际问题。压缩包共4个文件3个C源文件1个头文件总大小仅4KB结构精简C文件分别实现底层I²C通信封装、PCF8574初始化与读写函数、以及典型应用逻辑头文件定义寄存器地址、操作宏及函数接口便于快速集成到HAL或标准外设库工程中。已有717人学习下载适合正在开展智能硬件、工业控制或教学实验项目并需掌握I²C协议应用、STM32外设驱动编写及C语言底层编程的开发者。代码注释清晰、逻辑分层明确可直接编译运行亦可作为理解I²C从机设备驱动开发的优质入门范例。 如果你的项目里用的恰好是STM32F103手头又缺几个IO口PCF8574应该是最快能救场的芯片之一。这颗I2C转8位GPIO的扩展芯片在各类单片机项目里出现频率极高尤其是需要驱动LCD1602、矩阵键盘、继电器阵列或者在主控引脚已经用满的情况下补几个通用输入输出口。我这篇文章直接讲PCF8574在STM32F103上的工程实践从硬件连接、I2C时序、C语言驱动代码到实际踩坑一条线讲透适合正在做STM32项目又不想被IO数量卡住的人。PCF8574本身不算新东西但正因为它简单稳定到现在还是很多产品板子上的常客。它通过I2C总线通信一颗芯片就能扩展出8个IO口地址线有3根理论上同一条总线上最多挂8片也就是说最多能扩展出64个IO。对大部分中小型项目来说这个扩展能力相当充裕。我最初接触这颗芯片是在一个需要同时控制8个LED和读取4个按键的Demo板上当时STM32F103C8T6的引脚几乎全被占用最后就是靠两片PCF8574解决了问题。这篇博文就围绕这个场景展开代码基于STM32标准外设库用模拟I2C和硬件I2C两种方式分别说明重点是让多数人拿过来就能用。1. 为什么我的STM32F103还是缺IO口PCF8574能解决什么1.1 一个真实的需求场景先说我遇到的那个项目。一块STM32F103C8T6最小系统板接了OLED屏占2个IO一个DHT11温湿度传感器占1个IO一个蜂鸣器占1个IO一个USB转串口占用TX/RX还要留出SWD下载口剩下能自由分配的基本就10个左右。可是需求那边突然说要加8个LED指示灯还要加一个4x4矩阵键盘一算引脚差了将近10个。换大容量芯片STM32F103ZET6确实IO多但板子重新画、代码重新适配项目周期完全不允许。这时候PCF8574的优势就出来了。一片芯片只需要占用STM32的两个引脚——SCL和SDA最多能换回8个IO口。如果需求是输出8个LED一片就够如果还要读按键那就再挂一片反正I2C地址可以配置。最终我在总线上挂了两片PCF8574一片全输出驱动LED一片半输入半输出读按键和控制继电器总共只用掉2个引脚换回来16个IO。这就是它最核心的价值用极小的引脚代价换大量的IO扩展能力。1.2 PCF8574到底是什么PCF8574是NXP原Philips推出的I2C总线扩展芯片核心就是一个8位准双向I/O口扩展器。所谓准双向意思是每个IO口都可以独立配置为输入或输出但和真正的双向IO还不太一样——它在输出高电平时驱动能力很弱基本是靠内部上拉电阻输出高电平所以外部接负载时需要特别注意驱动能力。这个细节后面我会专门讲。芯片内部只有几个寄存器使用非常直白向I2C地址写入一个字节这个字节就是8个IO口的输出状态从I2C地址读回一个字节读到的就是8个IO口当前的输入状态。没有复杂的模式寄存器、方向寄存器写0对应输出低电平写1对应输出高电平读的时候如果某个引脚被外部拉低则读到0否则读到1。操作逻辑简单到可以直接用I2C的收发函数完成。它的工作电压范围是2.5V到6VSTM32F103是3.3V系统直接对接就行。I2C时钟频率支持标准模式100kHz和快速模式400kHz对大多数GPIO扩展场景已经够用。因为芯片太常见市面上还有兼容型号PCF8574A区别只在于I2C地址不同代码逻辑完全一样。1.3 为什么不用74HC595或直接换大容量芯片这个问题我在选型时也纠结过。74HC595是SPI接口的串转并芯片也能扩展输出但有几个痛点首先它只能扩展输出不能读输入要读按键还得另想办法其次它是SPI接口虽然只占3个引脚但如果SPI总线已经被其他外设占用比如Flash、SD卡就要考虑片选冲突再有就是595需要额外的锁存引脚接线稍微多一些。PCF8574只需要2根线而且是I2C标准接口多数MCU都有硬件I2C外设可以挂很多设备。换大容量芯片的路径我直接否了原因很简单最小系统板的封装已经固定换芯片意味着硬件改动而且STM32F103C8T6和ZET6的引脚排列不兼容板子得重新画时间成本太高。PCF8574作为增量扩展只需要在原板子上飞线或者加个小转接板就能用对原型验证阶段非常友好。当然如果你的项目还在硬件设计初期IO需求量又确定很大那确实应该考虑选择引脚更多的MCU或者用PCF8574把I2C总线的潜力榨干两者并不矛盾。2. 硬件接线之前先搞懂PCF8574的地址和引脚2.1 引脚定义与地址配置PCF8574的封装常见的有SO-16和TSSOP-16不过市面上好多模块已经把芯片做成了DIP-16或者转接板形式用起来更方便。我们先看芯片本身的引脚VCC、GND电源。SDA、SCLI2C数据线和时钟线。A0、A1、A2地址选择引脚通过接VCC或GND来配置地址。P0~P78个GPIO口准双向。INT中断输出引脚开漏输出当某个输入口状态发生变化时会拉低可以接到MCU的外部中断引脚。I2C地址是7位地址PCF8574的基地址是0100000二进制也就是0x20再加上A0、A1、A2的组合。A0、A1、A2分别对应地址的bit0、bit1、bit2接GND为0接VCC为1。所以PCF8574的地址范围是0x20~0x27PCF8574A的基地址是0111000即0x38地址范围0x38~0x3F。提示I2C通信时7位地址左移一位加上读写位组成8位设备地址。如果你的代码里用的I2C库要求传入8位地址记得把7位地址左移一位。比如PCF8574默认A0A1A2GND7位地址是0x20那么写入方向是0x40读方向是0x41。实际接线中地址选择引脚不能悬空必须明确接高或接低。我见过有人直接把A0、A1、A2悬空结果读回来的地址飘忽不定排查了半天。正确的做法是全部接地作为默认地址或者用排针跳线做成可配置这样在同一总线上挂多片时可以通过跳线区分。2.2 上拉电阻、VCC和电平匹配PCF8574的SCL和SDA是开漏结构I2C总线必须接上拉电阻。很多MCU板子上的I2C引脚已经集成上拉电阻比如一些开发板的PB6、PB7默认有4.7k上拉到3.3V这种情况下就不需要额外再接了。但如果用的是模拟I2C、引脚是普通GPIO或者上拉到的是5V那就要单独处理。STM32F103的GPIO一般容忍5V但PCF8574的VCC通常是3.3V如果总线上有其他5V设备需要注意电平匹配。最简单的方案是统一用3.3V供电上拉电阻也接到3.3V这样所有I2C设备的逻辑电平都是3.3VMCU可以直接驱动。如果PCF8574必须工作在5V比如要驱动5V继电器那SDA和SCL线上建议加电平转换模块或者用两个MOS管做双向电平转换电路避免5V上拉电压灌进STM32引脚。上拉电阻的阻值选择也有讲究。标准模式100kHz用10kΩ没问题快速模式400kHz建议用4.7kΩ或更小因为上拉电阻越大RC充电时间越长上升沿越慢高速通信时会出错。STM32F103的I2C外设最高支持400kHz建议用4.7kΩ尤其是总线电容比较大的时候保证信号边沿足够陡峭。2.3 与STM32F103的接线图我这里以STM32F103C8T6为例使用I2C1外设默认引脚PB6SCLPB7SDA。如果使用模拟I2C可以随便选两个GPIO我这里就用PB6和PB7做兼容。接线如下PCF8574STM32F103C8T6说明VCC3.3V供电GNDGND共地SDAPB7I2C1_SDA或模拟I2C任意GPIOSCLPB6I2C1_SCL或模拟I2C任意GPIOA0/A1/A2GND配置地址0x20INT可接PB0输出变化中断可选另外建议PCF8574的VCC和GND之间加一个100nF去耦电容我习惯在芯片电源引脚附近并联10uF电解电容和100nF陶瓷电容防止继电器、电机动作时电源波动造成I2C通信异常。这个细节在单独用模块测试时可能看不出来但在整个系统联调时非常关键。如果是PCF8574模块通常模块上已经带了一个4.7k的上拉电阻可以直接接。如果是裸芯片就需要自己补两个上拉电阻到3.3V。3. C语言驱动代码的核心从I2C时序到读写操作3.1 使用硬件I2C还是模拟I2C这是STM32新手最容易纠结的问题。硬件I2C由STM32芯片内部外设完成时序控制CPU只要往数据寄存器写数据或者等待中断/标志位就行优点是CPU占用低、通信速率稳定。缺点是在STM32F1系列上硬件I2C有一些历史遗留的坑比如总线忙标志(BUSY)卡死、错误标志不好清除等网上骂声一片。模拟I2C就是用GPIO翻转电平来模拟时序代码简单、可控性强任何GPIO都可以用缺点是要占用CPU时间在高速通信时效率低。但PCF8574这种设备通信速率要求不高100kHz就能跑得很稳。我个人在实际项目中只要不是要求极端性能优先选择模拟I2C。原因很直接模拟I2C代码完全可控不会出现硬件I2C死锁后不知道怎么恢复的问题而且移植到其他MCU时直接把GPIO操作部分换掉就行。当然如果你的项目里已经有许多I2C设备使用了硬件I2C并且跑得很顺那也可以用硬件I2C逻辑上完全一样。后面我会把两种方式的代码都贴出来方便对比。3.2 写操作输出模式怎么配置PCF8574没有方向寄存器写操作就是直接向设备地址发送一个字节数据这个字节数据对应P0~P7的电平状态对应位为0输出低电平为1输出高电平。注意这里的输出高电平实际上是由内部上拉电阻提供的弱上拉只能驱动LED小电流负载比如LED串一个1k电阻接3.3V或者直接接逻辑电平到其他芯片的输入引脚。下面是使用模拟I2C的代码框架。我用的是一个极简的I2C驱动只依赖两个GPIO引脚和延时函数。// 模拟I2C引脚定义 #define I2C_SCL_PORT GPIOB #define I2C_SDA_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_SCL_HIGH() GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN) // 微秒级延时函数根据主频调整 static void I2C_Delay(void) { uint16_t i; for (i 0; i 10; i); }I2C启动、停止、发送字节这些基础函数我就不逐行写了网上很多但有一个关键点在模拟I2C里SDA方向切换必须处理好。发送数据时SDA是输出模式读应答时SDA要切回输入模式。很多人忘记切换方向导致读应答永远读到高电平最终表现为写操作一直失败。// 向PCF8574写入一字节数据 void PCF8574_WriteByte(uint8_t addr7, uint8_t data) { I2C_Start(); I2C_SendByte(addr7 1); // 7位地址左移一位写入方向 I2C_WaitAck(); I2C_SendByte(data); I2C_WaitAck(); I2C_Stop(); }调用非常简单#define PCF8574_ADDR 0x20 // 7位地址 // 点亮连接在P0口的LED PCF8574_WriteByte(PCF8574_ADDR, 0xFE); // P0输出0其他为1如果需要同时控制8个LED可以定义一个数组来映射uint8_t led_state 0x00; led_state | (1 0); // P0亮 led_state ~(1 1); // P1灭 PCF8574_WriteByte(PCF8574_ADDR, ~led_state); // 低电平点亮则取反3.3 读操作输入模式要小心伪读问题读操作看起来就是把写方向改成读方向但从PCF8574读数据时有一个关键细节读之前必须先写一个字节或者至少保证所有引脚处于已知状态。因为PCF8574的IO口在作为输入时如果之前输出锁存器里写入的是0那么该引脚被外部拉高时读到的仍然是0而且外部信号是拉不高的因为内部MOS管在强下拉。这就是所谓的伪读问题。正确的做法是读端口之前先向PCF8574写入0xFF把每个IO口的输出寄存器都置为1此时内部上拉电阻使引脚处于高电平外部按键按下拉低时才能读到0。如果不做这一步上一次输出为低电平的引脚可能一直读不到外部高电平。所以标准操作是// 读PCF8574的引脚状态 uint8_t PCF8574_ReadByte(uint8_t addr7) { uint8_t data; I2C_Start(); I2C_SendByte(addr7 1); // 写方向 I2C_WaitAck(); I2C_SendByte(0xFF); // 先写0xFF释放所有引脚为输入状态 I2C_WaitAck(); I2C_Start(); // 重复起始 I2C_SendByte((addr7 1) | 0x01); // 读方向 I2C_WaitAck(); data I2C_RecvByte(); // 读取8位数据 I2C_SendNotAck(); // 接收最后一字节返回NAK I2C_Stop(); return data; }这个先写0xFF再读的操作就是PCF8574最经典的伪读解法。如果你发现某次读取始终为0但硬件上确实已经拉高了引脚十有八九是漏了这一步。3.4 完整驱动代码基于STM32标准外设库接下来给一份可以直接复制到工程里的驱动文件。我用的是STM32标准外设库如果你用的是HAL库思路一样只需要把GPIO操作换成HAL函数。文件名就叫pcf8574.c头文件里暴露四个接口// pcf8574.h #ifndef __PCF8574_H #define __PCF8574_H #include stm32f10x.h void PCF8574_Init(void); void PCF8574_WriteByte(uint8_t addr7, uint8_t data); uint8_t PCF8574_ReadByte(uint8_t addr7); void PCF8574_WritePin(uint8_t addr7, uint8_t pin, uint8_t level); uint8_t PCF8574_ReadPin(uint8_t addr7, uint8_t pin); #endif// pcf8574.c #include pcf8574.h #include delay.h // 引脚配置使用PB6/PB7模拟I2C #define PCF8574_I2C_GPIO_PORT GPIOB #define PCF8574_I2C_GPIO_CLK RCC_APB2Periph_GPIOB #define PCF8574_I2C_SCL_PIN GPIO_Pin_6 #define PCF8574_I2C_SDA_PIN GPIO_Pin_7 #define I2C_SCL_HIGH() GPIO_SetBits(PCF8574_I2C_GPIO_PORT, PCF8574_I2C_SCL_PIN) #define I2C_SCL_LOW() GPIO_ResetBits(PCF8574_I2C_GPIO_PORT, PCF8574_I2C_SCL_PIN) #define I2C_SDA_HIGH() GPIO_SetBits(PCF8574_I2C_GPIO_PORT, PCF8574_I2C_SDA_PIN) #define I2C_SDA_LOW() GPIO_ResetBits(PCF8574_I2C_GPIO_PORT, PCF8574_I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(PCF8574_I2C_GPIO_PORT, PCF8574_I2C_SDA_PIN) static void I2C_Delay(void) { uint16_t i; for (i 0; i 20; i); } static void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SDA_LOW(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); } static void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SDA_HIGH(); I2C_Delay(); } static void I2C_SendByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) { I2C_SDA_HIGH(); } else { I2C_SDA_LOW(); } dat 1; I2C_Delay(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); } } static uint8_t I2C_RecvByte(void) { uint8_t i, dat 0; GPIO_InitTypeDef GPIO_InitStructure; // 先释放SDA把SDA配成输入模式 I2C_SDA_HIGH(); GPIO_InitStructure.GPIO_Pin PCF8574_I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(PCF8574_I2C_GPIO_PORT, GPIO_InitStructure); for (i 0; i 8; i) { dat 1; I2C_SCL_HIGH(); I2C_Delay(); if (I2C_SDA_READ()) { dat | 0x01; } I2C_SCL_LOW(); I2C_Delay(); } // 恢复SDA为输出模式 GPIO_InitStructure.GPIO_Pin PCF8574_I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(PCF8574_I2C_GPIO_PORT, GPIO_InitStructure); return dat; } static uint8_t I2C_WaitAck(void) { uint8_t ack 0; GPIO_InitTypeDef GPIO_InitStructure; // SDA切输入读从机应答 I2C_SDA_HIGH(); GPIO_InitStructure.GPIO_Pin PCF8574_I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(PCF8574_I2C_GPIO_PORT, GPIO_InitStructure); I2C_SCL_HIGH(); I2C_Delay(); if (I2C_SDA_READ()) { ack 1; // 读不到低电平说明无应答 } I2C_SCL_LOW(); I2C_Delay(); // 恢复SDA输出 GPIO_InitStructure.GPIO_Pin PCF8574_I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(PCF8574_I2C_GPIO_PORT, GPIO_InitStructure); return ack; } static void I2C_SendNotAck(void) { // 主机发送非应答拉高SDA I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); } void PCF8574_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(PCF8574_I2C_GPIO_CLK, ENABLE); GPIO_InitStructure.GPIO_Pin PCF8574_I2C_SCL_PIN | PCF8574_I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(PCF8574_I2C_GPIO_PORT, GPIO_InitStructure); I2C_SCL_HIGH(); I2C_SDA_HIGH(); } void PCF8574_WriteByte(uint8_t addr7, uint8_t data) { I2C_Start(); I2C_SendByte(addr7 1); if (I2C_WaitAck()) { I2C_Stop(); return; } I2C_SendByte(data); I2C_WaitAck(); I2C_Stop(); } uint8_t PCF8574_ReadByte(uint8_t addr7) { uint8_t data 0; I2C_Start(); I2C_SendByte(addr7 1); if (I2C_WaitAck()) { I2C_Stop(); return 0; } I2C_SendByte(0xFF); // 伪读解决 I2C_WaitAck(); I2C_Start(); I2C_SendByte((addr7 1) | 0x01); I2C_WaitAck(); data I2C_RecvByte(); I2C_SendNotAck(); I2C_Stop(); return data; } void PCF8574_WritePin(uint8_t addr7, uint8_t pin, uint8_t level) { uint8_t cur PCF8574_ReadByte(addr7); if (level) { cur | (1 pin); } else { cur ~(1 pin); } PCF8574_WriteByte(addr7, cur); } uint8_t PCF8574_ReadPin(uint8_t addr7, uint8_t pin) { uint8_t cur PCF8574_ReadByte(addr7); return (cur pin) 0x01; }注意一个细节PCF8574_Init里我把SCL和SDA配置为开漏输出这是因为I2C总线本来就要求开漏而且开漏模式下可以安全地读引脚状态。如果你用的是推挽输出读输入方向时还要重新配置GPIO模式多一层麻烦。4. 我踩过的坑读端口、中断和电平不稳4.1 读操作前的伪读问题调试了半天才发现伪读问题前面提过但这确实是我实际调试中花时间最长的一个问题。当时我用PCF8574读取4个按键按键一端接GND另一端接P0~P3按下时引脚变低。我直接调PCF8574_ReadByte发现无论怎么按读回来的都是0xFF。检查接线没问题检查地址也没问题用万用表量引脚按下时确实能拉到0V。最后翻数据手册才意识到PCF8574在作为输入使用时必须先向输出锁存器写1释放引脚。因为复位后锁存器默认值是1理论上应该不用写但我之前用PCF8574_WritePin操作过某个引脚输出0导致该引脚被内部MOS下拉外部按键怎么拉都拉不高自然读不到按键状态。解决办法就是我上面代码里写的每次读操作前先写0xFF。这样无论之前输出锁存器是什么状态读的时候都是干净的输入状态。后来我再看一些开源库它们甚至每次读之前都会重新写0xFF就为了彻底规避这个问题。如果你在同一个总线上有多片PCF8574或者混有输出和输入场景建议养成这个习惯。4.2 中断引脚INT怎么用为什么我建议接上PCF8574有个中断输出引脚INT当任何配置为输入的引脚状态发生变化上升沿或下降沿时INT会从高电平变为低电平直到主机读取设备数据后才会恢复高电平。这个功能在按键扫描场景非常有用可以让MCU进入睡眠模式按键按下时通过INT唤醒而不是一直轮询。我最初没有接INT因为觉得轮询就够了。后来项目里增加了低功耗需求MCU大部分时间要进入STOP模式只有按键能唤醒。这时候INT就派上用场了把INT接到STM32的PB0配置为外部中断下降沿触发在中断服务程序里唤醒并扫描键盘。不过要注意INT是开漏输出需要外部上拉电阻到VCC否则它只会拉低不会拉高MCU检测不到电平变化。还有一个细节INT触发条件是输入引脚状态变化但PCF8574内部并没有消抖按键机械抖动可能会造成一次按下触发多次中断。我处理方式是在中断服务程序里只置一个标志位回到主循环后延时20ms再读按键状态做软件消抖这样能避免误触发。4.3 电平不稳与上拉电阻的选择PCF8574作为输出高电平时内部上拉只有约100uA的驱动能力实际拉出的高电平非常弱。如果你用万用表量可能能测到接近VCC的高电平但一旦接上一个LED、继电器或者任何需要更多电流的负载电压立刻被拉低设备表现为输出高电平却带不动负载甚至输出状态乱跳。我踩过的坑是直接拿PCF8574的P0口去驱动一个5V继电器模块。继电器模块内部带有光耦输入电流要求5~15mAPCF8574的高电平驱动不了导致继电器时而吸合时而断开。排查后发现是驱动能力不足。解决办法有几种输出高电平时只用来控制信号比如接到三极管/MOS管的基极或栅极由三极管去驱动继电器。如果需要直接输出较大电流可以用灌电流方式。PCF8574的低电平灌电流能力比高电平驱动能力强很多手册上写的最大灌电流约25mA所以可以把负载接在VCC和P口之间P口输出0时负载导通。比如LED阳极接3.3V阴极经电阻接P口写0点亮就是这个原理。对需要双向驱动的场景在P口外部加上拉电阻到VCC比如10kΩ提升高电平的驱动能力但注意外部上拉会影响输入检测——外部上拉越强按键拉低时的电流越大需要确保不超过芯片允许的灌电流范围。I2C总线上拉电阻同样重要。之前我用过一个PCF8574模块上面自带2.2k欧上拉电阻然后我的STM32开发板PB6、PB7也有上拉电阻两个并联导致总线上拉电阻只有约1.1kI2C通信时信号边沿仍然正常但功耗偏大在低功耗项目里会有问题。后来我焊掉了模块上的上拉电阻只保留板载的功耗才降下来。4.4 和板载12C还有其他设备共存时的问题PCF8574挂在I2C总线上通常总线上还会有EEPROM如AT24C02、OLED屏、传感器等设备。多个设备共存时必须保证地址不冲突。EEPROM的地址一般是0x50~0x57OLED一般是0x3C或0x3DPCF8574是0x20~0x27或0x38~0x3F通常不冲突但如果你用了两片PCF8574就要把A0/A1/A2配置成不同组合。最容易忽略的坑是当你用模拟I2C读写PCF8574时如果总线上某个设备在通信过程中拉低了SDA或SCL可能导致总线卡死。比如某些OLED模块在初始化异常时会一直占用总线。遇到这种情况先确认总线上所有设备的电源都稳定再逐个排除。我调试时习惯用逻辑分析仪看总线波形能够直接看到是哪一步卡住。还有一个常见问题是地址左移。很多新手在用HAL库或者某些第三方库时会把7位地址直接当作8位地址传入导致设备无响应。如果你发现写PCF8574总是没有ACK先检查地址是不是已经左移了一位。我的驱动函数用的是7位地址内部处理时左移这样调用时不容易错。5. 性能优化与扩展玩法5.1 提高刷新率使用DMA和硬件I2C如果你用PCF8574来刷新LED点阵或者动态数码管刷新率直接决定显示效果。模拟I2C的读写速度受CPU主频和延时函数影响通常一帧8x8点阵扫描可能需要几百微秒如果主循环还有其他任务刷新率可能只有几十Hz肉眼能感到闪烁。这时候可以考虑把PCF8574的I2C通信改成硬件I2CDMA。硬件I2C外设自动生成时钟、移位数据DMA负责搬运内存到I2C数据寄存器CPU完全不用参与。需要注意的是STM32F103的I2C1的DMA请求连接到DMA1通道7使用前要配置好DMA通道。我试过用硬件I2CDMA向PCF8574发送一字节速率能到400kHzCPU占用几乎为零动态刷新8位数码管也没压力。不过硬件I2C的坑前面提过如果遇到BUSY标志清除不掉的问题可以在初始化I2C时先复位一下I2C外设RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C1, DISABLE);这个技巧在STM32F103硬件I2C卡死时非常管用。当然如果项目稳定性和可维护性优先模拟I2C其实足够了。5.2 多片PCF8574级联PCF8574的地址线A0、A1、A2每根接高接低两种状态组合出8个不同地址所以同一条I2C总线上最多可以挂8片PCF8574。如果8片还不够那就再开一条I2C总线用模拟I2C再加一组GPIO理论上可以实现无限扩展但实际没人这么干。多片级联时建议画一个地址分配表避免写程序时混乱。比如设备A2A1A07位地址PCF8574_10000x20PCF8574_20010x21PCF8574_30100x22PCF8574_41000x24我通常用宏定义封装#define PCF8574_LED_ADDR 0x20 // 扩展输出控制LED #define PCF8574_KEY_ADDR 0x21 // 扩展输入读按键 #define PCF8574_LCD_ADDR 0x22 // 扩展输出驱动LCD多片同时工作时要注意总线上拉电阻的等效阻值。因为每片PCF8574的SCL/SDA内部没有上拉外部总线上只有一组上拉电阻所以不用担心并联问题。但如果每片PCF8574模块都自带独立上拉多片并联后等效上拉阻值会明显变小需要核算一下。5.3 驱动LCD1602、按键矩阵等经典组合PCF8574最经典的用法之一就是驱动LCD1602。市面上常见的I2C LCD1602模块背面其实就是一片PCF8574把LCD1602的8根数据线和3根控制线缩成两个引脚。如果你的LCD1602模块是I2C接口的不需要单独驱动PCF8574直接用现成的库就行如果你用裸PCF8574驱动LCD1602只需要把LCD的RS、RW、E、D4~D7分别接到PCF8574的P0~P2、P4~P7通常P3留作背光灯控制然后按照LCD1602的时序通过PCF8574写字节即可。这个过程里PCF8574的写操作频率决定了LCD的刷新速度常规操作没问题。按键矩阵也是常见组合。4x4矩阵键盘需要8个IO口PCF8574正好一个芯片搞定。行线接P0~P3并设置为输出列线接P4~P7并设置为输入。扫描时逐行输出低电平读列线的电平变化通过行列组合判断按键。伪读问题在这里要特别注意每次读取列状态之前先把P4~P7配置为输入写1否则可能读不到按键按下时的低电平。我实际项目中还见过用PCF8574驱动步进电机速度慢但能转、控制多路风扇PWMPCF8574无法产生真正的PWM只能用延时模拟效果一般、扩展SPI片选信号等玩法。核心思路都一样IO不够PCF8574来凑。6. 常用调试方法和经验总结6.1 用逻辑分析仪看I2C时序调试I2C设备工具很重要。我强烈建议备一个24MHz采样率的逻辑分析仪便宜好用。把探头接到SDA和SCL就能看到完整的波形和解码数据。有一次我的PCF8574写操作偶尔失败看波形发现SCL高电平时间不足原来是模拟I2C延时函数的延时在优化等级较高时被编译器跳过了导致时序过快。通过逻辑分析仪才定位到是延时函数出了问题后来改用volatile变量规避开优化。如果手头没有逻辑分析仪也可以用示波器看信号边沿。没有仪器的话还有一个偏方把SCL和SDA引脚同时配成普通GPIO手动翻转它们用串口打印每个步骤的GPIO电平看是不是符合I2C时序。这个办法麻烦但有效。调试时另一点要注意的是代码里在使用模拟I2C时如果开了中断I2C时序可能被中断打断导致读写超时。如果项目里有定时器中断、串口中断在频繁触发建议在I2C通信的关键阶段暂时关闭中断或者用临界区保护。我遇到过一次因为串口中断导致I2C读写卡死的问题加了临界区保护后解决。6.2 常见故障排查表故障现象可能原因解决办法I2C写操作一直无应答地址错误未左移或地址配置不对确认A0/A1/A27位地址左移后发送读端口总是0xFF引脚没有配置为输入写0xFF读前先写0xFF读端口总是0x00输出锁存器为0引脚被下拉先写0xFF释放引脚输出高电平带不动负载PCF8574高电平驱动能力弱改用灌电流或加三极管/MOS驱动INT一直为低未接上拉电阻INT引脚接10k上拉到VCCI2C通信出错率高总线上拉电阻过小或过大400kHz用4.7k100kHz用10k硬件I2C总线忙死锁F1系列硬件I2C的BUSY标志卡死复位I2C外设或改用模拟I2C同一总线多设备时通信异常地址冲突或上拉等效阻值过小检查设备地址核算上拉电阻6.3 一个小技巧用PCF8574同时扩展输入和输出最后分享一个实用技巧。PCF8574的8个IO口虽然是准双向但同一时刻一个引脚只能当作输入或输出使用。如果你8个IO口不够用又要输入又要输出可以外接类似74HC245的缓冲和方向控制但这样就复杂了。更简单的办法是把PCF8574的某个引脚配置为开漏输出比如P7让它在输出高电平时等效于释放引脚然后外部再接一个可切换方向的总线收发器不过这种方案很少见。在实际项目里我一般会让PCF8574专门负责输出类任务LED、继电器、LCD控制因为读操作的伪读问题虽然能解决但每次读前写0xFF会增加通信时间而且输入引脚在外面被拉低时内部上拉会消耗少量电流。如果需要可靠的输入检测我倾向使用独立的PCF8574芯片一块纯输出一块纯输入初始化时输出那块直接写状态输入那块写0xFF后一直在读这样逻辑清晰也避免方向切换的麻烦。PCF8574虽然不是性能最强的扩展方案但它的可靠性和简单性是经过无数项目验证的。如果你现在正在STM32F103上做项目IO口不够用这颗芯片绝对值得一试。上面这些代码和调试经验都是我自己实际跑过的照着做基本不会有大坑。真要遇到奇怪问题优先检查供电、上拉和地址八成能解决。本文还有配套的精品资源点击获取