STM32F103实战:PCF8574驱动详解与I2C扩展GPIO

STM32F103实战:PCF8574驱动详解与I2C扩展GPIO 简介本资源是一套面向嵌入式初学者与STM32开发者的PCF8574 I²C扩展芯片驱动代码专为STM32F103系列微控制器设计解决GPIO资源不足时需外扩8路双向数字I/O的实际问题。压缩包共4个文件3个C源文件1个头文件总大小仅4KB结构精简C文件分别实现底层I²C通信、寄存器读写封装及功能级接口调用H文件定义芯片地址、位操作宏及函数声明便于快速集成至HAL或标准外设库工程。已有717人学习下载适用于智能硬件、工业控制等需低成本IO扩展的场景。读者可直接复用该代码完成PCF8574初始化、引脚电平控制、按键状态读取等典型应用并通过源码深入理解I²C协议时序、STM32外设驱动分层设计及C语言在裸机开发中的工程化组织方式。 前阵子从某个下载站拉回来一个 PCF8574 代码的 rar 包标题里写着 stm32、C、stm32f103一看就是从老项目里抠出来的散装代码。解压之后果然没让我失望几个没头没尾的 .c 和 .h 文件注释基本等于没有主函数里堆了一堆 GPIO 宏定义能编译过但换块板子就没法用。这种包我见得太多了关键代码能用但你要是直接往自己工程里塞十有八九要踩坑。这篇文章我就以这个 rar 包为引子把 PCF8574 在 STM32F103 上的驱动从原理到代码完整梳理一遍。为什么需要这颗芯片、软件 I2C 和硬件 I2C 怎么选、地址到底怎么算、那个著名的伪读问题是怎么回事、中断怎么接最后附一份可以直接抄走的 C 语言驱动。适合用 STM32F103 做项目、GPIO 不够用或者第一次接触 I2C 扩展 GPIO 的同学参考。1. PCF8574 项目整体设计与方案选型1.1 为什么非要用 PCF8574STM32F103 的 GPIO 资源其实不算少C8T6 有 37 个 IO但真做起项目来根本不够用。接一个 1602 液晶要 6 个 IO四个按键要 4 个几个 LED 要 3 个再加个蜂鸣器、继电器还没接传感器呢引脚已经见底了。换大封装芯片能解决一部分问题但 PCB 面积、成本、布线复杂度都上去了。这个时候用 PCF8574 扩 IO 是最划算的方案。PCF8574 是 NXP 的 I2C 并行 IO 扩展芯片只用两根线SCL、SDA就能扩出 8 个准双向 IO。更有意思的是I2C 总线是可以挂多颗器件的通过 A0/A1/A2 三个地址引脚一条总线上最多挂 8 片 PCF8574也就是 64 个 IO代价只有两根线。如果混用 PCF8574 和 PCF8574A还能挂到 16 片。这个扩展能力对大多数嵌入式项目来说绰绰有余。对比一下其它扩展方案74HC595 是 SPI 移位寄存器只能输出不能输入做不了按键扫描74HC165 正好相反只能输入不能输出MCP23017 功能强双 8 位端口还带中断但价格贵、封装大对简单场景属于杀鸡用牛刀。PCF8574 价格一两块钱SOP-16 封装不大输入输出都能用还带一个中断输出脚性价比确实高。1.2 软件模拟 I2C 和硬件 I2C我选软件STM32F103 自带硬件 I2C 外设为什么我还要用软件模拟这个问题在嵌入式圈子里争议很大网上流传着各种硬件 I2C 有 bug的说法。实际用下来的感受是F103 的硬件 I2C 不是不能用是事件处理太繁琐。标准外设库的 I2C 驱动要处理一堆事件标志SB、ADDR、BTF、TxE、RXNE中断里一个事件没处理对就可能卡在某个等待标志的死循环里。相比之下软件模拟 I2C 的时序完全由自己控制出问题也好排查。我的建议很简单读写 PCF8574 这种低速器件用软件模拟 I2C如果是要大批量传输数据比如读传感器 FIFO、刷 OLED 显示缓冲再考虑硬件 I2C 加 DMA。PCF8574 一次就读写一两个字节100kHz 的标准模式完全够用CPU 开销可以忽略不计。软件模拟还有两个额外好处一是任意两个 GPIO 都能用PCB 布线灵活二是代码可移植性极强从 F103 换到 GD32、换到 8 位单片机底层函数几乎不用动。提示网上很多硬件 I2C 卡死的案例多半是初始化时序不对或者中断事件处理有遗漏。实在想用硬件 I2C建议直接上 HAL 库ST 官方把状态机封装得比较完善比自己用寄存器写省心得多。1.3 标准库还是 HAL 库标题里带了个c这基本说明 rar 包里的代码是标准库风格。F103 这颗芯片目前仍然大量使用标准外设库原因很简单资料多、例程全、网上随便一搜都是现成代码。而新项目我更推荐 HAL 库ST 官方已经停止标准库的更新CubeMX 生成的代码结构清晰换芯片平台也方便。这篇文章的代码按标准库写因为软件模拟 I2C 本质上只用到 GPIO 的操作和用哪个库关系不大。如果你是 HAL 库用户把底层那几个 GPIO 读写函数替换成 HAL_GPIO_WritePin 和 HAL_GPIO_ReadPin 就行驱动层完全不用改。2. PCF8574 芯片原理与关键细节2.1 引脚、地址与总线挂载PCF8574 的 SOP-16 封装有 16 个引脚实际用到的没几个VDD、VSS 接电源工作电压 2.5V~6V直接和 STM32F103 的 3.3V 供电对接没问题SCL、SDA 是 I2C 引脚注意这两个是开漏结构必须接上拉电阻A0、A1、A2 是地址选择引脚接 VDD 或 VSSP0~P7 是 8 个准双向 IOINT 是中断输出开漏、低有效。地址换算是新手最容易栽跟头的地方。PCF8574 的 7 位地址是 0b0100A2A1A0也就是说 A0/A1/A2 全部接地时7 位地址是 0x20。而在 I2C 总线上发送时这个 7 位地址要左移一位变成 8 位最低位填 R/W 位。所以 A0/A1/A2 接地时8 位写地址是 0x408 位读地址是 0x41。PCF8574A 的 7 位地址是 0b0111A2A1A0即 0x38~0x3F换算成 8 位就是 0x70~0x7F。买芯片的时候一定看清丝印PCF8574 和 PCF8574A 的地址不一样混用的时候要分开计算。A0/A1/A2 引脚不建议悬空。这仨引脚内部没有下拉悬空的时候电平不确定很可能导致地址漂移。老老实实接 VDD 或 GND一根线的事能省掉一晚上的排查时间。2.2 准双向 IOPCF8574 最坑也最实用的特性准双向 IO 是理解 PCF8574 的核心也是它和 STM32 普通 GPIO 最大的区别。STM32 的 GPIO 有方向寄存器可以配置输入还是输出PCF8574 没有方向概念每个引脚只有一个输出锁存器和一个输入缓冲器。你要让引脚输出高电平就往锁存器写 1输出低电平就写 0要读外部状态得先把锁存器写 1再读输入缓冲器。这个结构带来两个非常实际的坑。第一个坑是伪读问题。如果某个引脚之前输出过 0锁存器里存的是 0输出级的 MOS 管还在强制下拉这个引脚。这时候就算外部电路已经把它拉高了输入缓冲器读到的仍然是 0。原因是输出级的下拉能力比外部上拉能力强得多电平被拉不上去。解决办法是读端口之前先对整个芯片写 0xFF把所有锁存器置 1再执行读操作。这就是我代码里单独写了一个 PCF8574_ReadWithPreWrite 函数的原因。第二个坑是输出高电平的驱动能力极弱。PCF8574 输出高电平靠内部一个约 100μA 的电流源只能带很轻的负载。你要是直接拿它驱动 LED 阳极就是经典的输出高电平点亮结果 LED 要么不亮要么暗到怀疑人生。正确做法是灌电流驱动LED 阳极接 VCC阴极经限流电阻接 PCF8574 引脚引脚输出低电平时 LED 点亮。PCF8574 的灌电流能力能到 20mA 以上但注意所有引脚总灌电流最好别超过 80mA否则芯片发热不说还可能永久损坏。用大白话类比准双向 IO 就像一个带弱上拉的输出口输出低电平是重拳出击输出高电平是虚张声势。点亮负载靠低电平去吸电流读取状态靠内部弱上拉配合外部电路。2.3 读写时序和中断引脚PCF8574 的读写时序在 I2C 器件里算是最简单的一档因为整个芯片就 8 个 IO一个字节就能表达全部状态所以它没有寄存器地址的概念。这一点和 EEPROM、各种传感器芯片完全不同很多人第一次用会习惯性按发寄存器地址→发数据的流程操作结果发现发完设备地址之后直接发数据就行。写操作时序起始条件→发设备写地址8 位→等 ACK→发 1 字节数据→等 ACK→停止条件。这 1 字节数据就是 P0~P7 的输出状态对应位写 1 输出高电平写 0 输出低电平。读操作时序起始条件→发设备读地址8 位→等 ACK→读取 1 字节数据→主机发 NACK→停止条件。读到的字节就是 P0~P7 的当前电平状态。中断引脚 INT 是一个很实用的功能。当任意输入引脚的电平状态和上一次读到的状态不一致时INT 会从高电平拉低直到 MCU 执行一次读操作后恢复。这个信号可以直接接到 STM32 的 EXTI 外部中断引脚实现按键唤醒、传感器状态变化检测等功能不用 MCU 一直轮询。但要注意两点一是 INT 是开漏输出必须接上拉电阻二是它触发的是电平变化不是边沿机械按键的抖动会在 INT 上产生毛刺建议输入侧加 RC 滤波或者在中断服务函数里做 10~20ms 的软件去抖。3. 代码实现与核心环节实操3.1 先搭一个干净的软件 I2C 底层软件模拟 I2C 的底层代码核心就四个操作起始条件、停止条件、发送一个字节、接收一个字节。SCL 始终作为输出SDA 需要双向切换。先看引脚定义和宏封装#include stm32f10x.h #define I2C_GPIO_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_SCL_H() GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SCL_L() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SCL_PIN) #define I2C_SDA_H() GPIO_SetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_L() GPIO_ResetBits(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN) #define I2C_SDA_OUT() \ do { GPIO_InitTypeDef GPIO_InitStructure; \ GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN; \ GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; \ GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; \ GPIO_Init(I2C_GPIO_PORT, GPIO_InitStructure); } while(0) #define I2C_SDA_IN() \ do { GPIO_InitTypeDef GPIO_InitStructure; \ GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN; \ GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; \ GPIO_Init(I2C_GPIO_PORT, GPIO_InitStructure); } while(0)SDA 方向切换是这个底层最容易出错的地方。发送数据时 SDA 必须配置为推挽输出接收 ACK 或读取数据时必须切换成上拉输入。切晚了或者忘了切要么读不到正确的 ACK要么数据全是 0xFF。我见过太多人卡在这里。延时函数和初始化static void I2C_Delay(void) { uint8_t i 8; while (i--) ; } void SoftI2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(I2C_GPIO_PORT, GPIO_InitStructure); I2C_SDA_OUT(); I2C_SDA_H(); I2C_SCL_H(); }标准模式 100kHz 要求 SCL 半周期大约 5μs在 72MHz 主频下这个空循环次数差不多能到 1~2μs实际跑起来没问题。如果外设是 400kHz 快速模式或者换到其它主频的芯片把 I2C_Delay 里的循环次数调整一下就行。起始、停止、收发字节void SoftI2C_Start(void) { I2C_SDA_OUT(); I2C_SDA_H(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_L(); } void SoftI2C_Stop(void) { I2C_SDA_OUT(); I2C_SCL_L(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_H(); I2C_Delay(); } uint8_t SoftI2C_WriteByte(uint8_t data) { uint8_t i; I2C_SDA_OUT(); for (i 0; i 8; i) { if (data 0x80) I2C_SDA_H(); else I2C_SDA_L(); data 1; I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); } I2C_SDA_IN(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); if (I2C_SDA_READ() 0) { I2C_SCL_L(); return 0; } I2C_SCL_L(); return 1; } uint8_t SoftI2C_ReadByte(uint8_t ack) { uint8_t i, data 0; I2C_SDA_IN(); for (i 0; i 8; i) { I2C_SCL_H(); I2C_Delay(); data 1; if (I2C_SDA_READ()) data | 0x01; I2C_SCL_L(); I2C_Delay(); } I2C_SDA_OUT(); if (ack) I2C_SDA_L(); else I2C_SDA_H(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_SDA_H(); I2C_Delay(); return data; }写字节函数返回 1 表示收到 NACK返回 0 表示收到 ACK。读字节函数的参数 ack 为 1 时主机回 ACK表示还要继续读为 0 时主机回 NACK表示读完了。PCF8574 一次只读一个字节所以调用时传 0。3.2 PCF8574 驱动层封装底层搞定之后PCF8574 的驱动层非常薄。头文件里把地址和引脚掩码定义好#ifndef __PCF8574_H #define __PCF8574_H #include stdint.h #define PCF8574_WR_ADDR 0x40 #define PCF8574_RD_ADDR 0x41 #define PCF8574_P0 0x01 #define PCF8574_P1 0x02 #define PCF8574_P2 0x04 #define PCF8574_P3 0x08 #define PCF8574_P4 0x10 #define PCF8574_P5 0x20 #define PCF8574_P6 0x40 #define PCF8574_P7 0x80 void PCF8574_Init(void); uint8_t PCF8574_Write(uint8_t addr, uint8_t data); uint8_t PCF8574_Read(uint8_t addr); uint8_t PCF8574_ReadWithPreWrite(uint8_t addr); #endif实现文件#include pcf8574.h void PCF8574_Init(void) { SoftI2C_Init(); } uint8_t PCF8574_Write(uint8_t addr, uint8_t data) { uint8_t ret; SoftI2C_Start(); ret SoftI2C_WriteByte(addr); if (ret) { SoftI2C_Stop(); return 1; } ret SoftI2C_WriteByte(data); SoftI2C_Stop(); return ret; } uint8_t PCF8574_Read(uint8_t addr) { uint8_t data; SoftI2C_Start(); if (SoftI2C_WriteByte(addr | 0x01)) { SoftI2C_Stop(); return 0xFF; } data SoftI2C_ReadByte(0); SoftI2C_Stop(); return data; } uint8_t PCF8574_ReadWithPreWrite(uint8_t addr) { PCF8574_Write(addr, 0xFF); return PCF8574_Read(addr); }注意 PCF8574_Read 接收的 addr 参数是 8 位地址在内部把它或上 0x01 变成读地址。PCF8574_ReadWithPreWrite 先写 0xFF 再读这是规避伪读问题的标准姿势。我在看那个 rar 包里代码的时候发现原作者的读函数就是直接 PCF8574_Read没有先写 1这基本上就是很多人读按键不灵的根源。3.3 实际应用LED 控制、按键读取和中断应用一灌电流驱动 8 个 LED。LED 阳极接 VCC阴极经 1kΩ 限流电阻接 PCF8574 的 P0~P7。输出 1 时 LED 灭输出 0 时 LED 亮。uint8_t led_state 0xFF; // 全灭 // 点亮 P0、P3 对应的 LED led_state ~(PCF8574_P0 | PCF8574_P3); PCF8574_Write(PCF8574_WR_ADDR, led_state); // 再熄灭 P0 led_state | PCF8574_P0; PCF8574_Write(PCF8574_WR_ADDR, led_state);这个例子看一眼就明白准双向 IO 的驱动逻辑要亮的位清零要灭的位置一。如果是从 51 单片机转过来的朋友这个操作模式和 51 的 P1 口几乎一模一样。应用二读取按键。按键一端接 GND另一端接 P0。读取前先写 1 使内部上拉生效然后读整个端口uint8_t key_status; key_status PCF8574_ReadWithPreWrite(PCF8574_WR_ADDR); if ((key_status PCF8574_P0) 0) { // P0 被拉低按键按下 }这里要提醒一下内部上拉电流源只有约 100μA抗干扰能力一般。按键连接线如果比较长或者现场有电机、继电器之类的干扰源建议在按键旁边加一个 10kΩ 外部上拉电阻到 VCC稳定性会好很多。应用三按键中断。PCF8574 的 INT 引脚接 STM32F103 的 PA0配置成 EXTI 下降沿触发。INT 是开漏输出必须外部接上拉电阻到 3.3V。void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line0); // 延时去抖然后读取引脚状态 volatile uint8_t keys PCF8574_ReadWithPreWrite(PCF8574_WR_ADDR); // 根据 keys 的值处理按键逻辑 } }一个细节PCF8574 的读操作会清除 INT 信号。所以在中断处理函数里读取 PCF8574 之后 INT 会自动拉高不需要额外处理。这个机制很方便但也意味着如果中断处理函数里忘了读INT 就会一直保持低电平触发中断的条件一直成立MCU 会被中断风暴淹没。再看看如果项目需要逐位操作某个引脚可以加一层带缓存的操作接口static uint8_t pcf8574_output 0xFF; void PCF8574_SetPin(uint8_t pin_mask, uint8_t level) { if (level) pcf8574_output | pin_mask; else pcf8574_output ~pin_mask; PCF8574_Write(PCF8574_WR_ADDR, pcf8574_output); }这种带输出缓存的写法在实际项目里很实用每次修改只更新需要变化的位不用反复读写芯片也避免了多字节输出状态不同步的问题。4. 常见问题与排查技巧实录4.1 I2C 总线卡死与软件复位软件模拟 I2C 有一个经典问题如果传输过程中主从时序不一致、或者从机异常掉电SDA 可能被某个设备拉低总线处于忙状态后续所有通信都无法进行。硬件上这个问题要靠总线超时复位来解决软件上也有一个常用的急救办法让 SCL 翻转 9 个时钟周期把总线上的从机状态机复位然后发一个停止条件释放总线。我习惯在每次读写之前调一个总线检测函数uint8_t SoftI2C_BusCheck(void) { uint8_t i; I2C_SDA_IN(); if (I2C_SDA_READ() 1) return 0; for (i 0; i 9; i) { I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_Delay(); } SoftI2C_Stop(); return 1; }这个函数能在 SDA 被拉低时尝试恢复总线。虽然不能保证 100% 救回来但在大多数软件模拟 I2C 的卡死现场都能起作用。遇到过几次莫名其妙死机的问题加上这个函数之后再也没犯过。4.2 伪读问题为什么读到的永远不对伪读问题是 PCF8574 使用中排名第一的坑值得单独拎出来再讲一遍。现象是按键明明已经松开了读到的却是按下状态或者传感器输出高电平读回来是 0。用万用表量引脚电平确实已经是高电平了但代码读出来的就是 0。问题就出在输出锁存器上引脚之前被写过 0输出级的下拉 MOS 管还在工作外部上拉根本拉不动输入缓冲器读到的是被拉低的电平。解决办法就四个字先写再读。读操作之前对整个端口写 0xFF让所有引脚的输出级关断这时候外部电平才能真实反映到输入缓冲器上。我把这个逻辑封装成了 PCF8574_ReadWithPreWrite 函数项目里所有读操作都走这个函数再没出过问题。注意写 0xFF 这个动作本身会改变 P0~P7 的输出状态。如果你的电路里某些引脚正用于输出控制比如驱动继电器写 0xFF 会把它们全部置高可能导致误动作。这种情况建议用带缓存的方式先记住当前输出状态读之前写 0xFF读完再恢复原状态。代价是每次读操作变成两次本文还有配套的精品资源点击获取