STM32F103 IO口不够用?PCF8574 I2C扩展GPIO实战指南
2026/9/1 21:56:19 网站建设 项目流程

简介:本资源是一套面向嵌入式初学者与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到6V,STM32F103是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、SCL:I2C数据线和时钟线。
  • A0、A1、A2:地址选择引脚,通过接VCC或GND来配置地址。
  • P0~P7:8个GPIO口,准双向。
  • INT:中断输出引脚,开漏输出,当某个输入口状态发生变化时会拉低,可以接到MCU的外部中断引脚。

I2C地址是7位地址,PCF8574的基地址是0100000(二进制),也就是0x20,再加上A0、A1、A2的组合。A0、A1、A2分别对应地址的bit0、bit1、bit2,接GND为0,接VCC为1。所以PCF8574的地址范围是0x20~0x27,PCF8574A的基地址是0111000,即0x38,地址范围0x38~0x3F。

提示:I2C通信时,7位地址左移一位加上读写位组成8位设备地址。如果你的代码里用的I2C库要求传入8位地址,记得把7位地址左移一位。比如PCF8574默认A0=A1=A2=GND,7位地址是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.3V,MCU可以直接驱动。如果PCF8574必须工作在5V(比如要驱动5V继电器),那SDA和SCL线上建议加电平转换模块,或者用两个MOS管做双向电平转换电路,避免5V上拉电压灌进STM32引脚。

上拉电阻的阻值选择也有讲究。标准模式100kHz用10kΩ没问题,快速模式400kHz建议用4.7kΩ或更小,因为上拉电阻越大,RC充电时间越长,上升沿越慢,高速通信时会出错。STM32F103的I2C外设最高支持400kHz,建议用4.7kΩ,尤其是总线电容比较大的时候,保证信号边沿足够陡峭。

2.3 与STM32F103的接线图

我这里以STM32F103C8T6为例,使用I2C1外设,默认引脚PB6=SCL,PB7=SDA。如果使用模拟I2C,可以随便选两个GPIO,我这里就用PB6和PB7做兼容。接线如下:

PCF8574STM32F103C8T6说明
VCC3.3V供电
GNDGND共地
SDAPB7I2C1_SDA,或模拟I2C任意GPIO
SCLPB6I2C1_SCL,或模拟I2C任意GPIO
A0/A1/A2GND配置地址0x20
INT可接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~15mA,PCF8574的高电平驱动不了,导致继电器时而吸合时而断开。排查后发现是驱动能力不足。解决办法有几种:

  • 输出高电平时只用来控制信号,比如接到三极管/MOS管的基极或栅极,由三极管去驱动继电器。
  • 如果需要直接输出较大电流,可以用灌电流方式。PCF8574的低电平灌电流能力比高电平驱动能力强很多,手册上写的最大灌电流约25mA,所以可以把负载接在VCC和P口之间,P口输出0时负载导通。比如LED阳极接3.3V,阴极经电阻接P口,写0点亮,就是这个原理。
  • 对需要双向驱动的场景,在P口外部加上拉电阻到VCC,比如10kΩ,提升高电平的驱动能力,但注意外部上拉会影响输入检测——外部上拉越强,按键拉低时的电流越大,需要确保不超过芯片允许的灌电流范围。

I2C总线上拉电阻同样重要。之前我用过一个PCF8574模块,上面自带2.2k欧上拉电阻,然后我的STM32开发板PB6、PB7也有上拉电阻,两个并联导致总线上拉电阻只有约1.1k,I2C通信时信号边沿仍然正常,但功耗偏大,在低功耗项目里会有问题。后来我焊掉了模块上的上拉电阻,只保留板载的,功耗才降下来。

4.4 和板载12C还有其他设备共存时的问题

PCF8574挂在I2C总线上,通常总线上还会有EEPROM(如AT24C02)、OLED屏、传感器等设备。多个设备共存时,必须保证地址不冲突。EEPROM的地址一般是0x50~0x57,OLED一般是0x3C或0x3D,PCF8574是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通信改成硬件I2C+DMA。硬件I2C外设自动生成时钟、移位数据,DMA负责搬运内存到I2C数据寄存器,CPU完全不用参与。需要注意的是,STM32F103的I2C1的DMA请求连接到DMA1通道7,使用前要配置好DMA通道。我试过用硬件I2C+DMA向PCF8574发送一字节,速率能到400kHz,CPU占用几乎为零,动态刷新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_10000x20
PCF8574_20010x21
PCF8574_30100x22
PCF8574_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驱动步进电机(速度慢但能转)、控制多路风扇PWM(PCF8574无法产生真正的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/A2,7位地址左移后发送
读端口总是0xFF引脚没有配置为输入(写0xFF)读前先写0xFF
读端口总是0x00输出锁存器为0,引脚被下拉先写0xFF释放引脚
输出高电平带不动负载PCF8574高电平驱动能力弱改用灌电流或加三极管/MOS驱动
INT一直为低未接上拉电阻INT引脚接10k上拉到VCC
I2C通信出错率高总线上拉电阻过小或过大400kHz用4.7k,100kHz用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口不够用,这颗芯片绝对值得一试。上面这些代码和调试经验都是我自己实际跑过的,照着做基本不会有大坑。真要遇到奇怪问题,优先检查供电、上拉和地址,八成能解决。

本文还有配套的精品资源,点击获取

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

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

立即咨询