☰
模拟IIC驱动PCA9555:STM32 GPIO扩展实战与避坑指南
2026/10/16 3:42:35 网站建设 项目流程

简介:面向STM32开发者,提供一份通过软件模拟IIC总线访问PCA9555芯片的完整工程,解决GPIO数量不足时的IO扩展需求。基于STM32F103平台,源码涵盖GPIO模拟SCL/SDA时序、PCA9555的输入、输出、中断等寄存器读写操作,并包含开始条件、地址发送、应答处理等IIC通信细节,附有'亲测有效'的工程验证说明,可直接参考嵌入到按键扫描、LED控制或传感器读取等实际场景中。压缩包共127个文件,大小约2MB,以C源文件与H头文件为主体,另含Keil工程配置、编译生成的目标文件、HEX烧录文件及map/axf调试输出,方便阅读、编译和下载验证。已有4837人学习,资料重点解释了内部上拉电阻配置、7位地址与读写位时序等关键点,适合需要扩展数字IO或入门软件IIC模拟开发的嵌入式工程师。

1. 模拟IIC访问PCA9555:为什么我要绕开硬件I2C

做某个STM32F103的IO扩展项目时,我最初用的硬件I2C外设读PCA9555,调了三天,偶尔会出现SDA被拉死的现象,复位之后有时也不行。后来换成模拟IIC,两个普通GPIO搞定,一次打通,16个IO全部正常读写。所以这篇文章不讲空泛的原理,而是把真正能用的模拟IIC驱动、PCA9555寄存器配置顺序,以及我踩过的五个坑完整写出来。如果你手里有PCA9555等着被单片机访问,或者正在犹豫要不要放弃硬件I2C,这篇就是给你准备的。文中代码基于标准外设库,宏定义换一换就能挪到HAL工程里用。

2. 硬件接线与寄存器地址:动手前先搞清的三件事

2.1 PCA9555的从机地址怎么算

PCA9555的7位从机地址固定前缀是0100 000,后三位由硬件引脚A2、A1、A0决定。A2=A1=A0都接地时,7位地址是0x20;都接高时是0x27,中间还能组合出六档。我一般这样记:A0对应最低位,A1对应中间位,A2对应最高位,地址偏移刚好和引脚编号一致。

IIC总线在传输设备地址时用的是8位地址帧,也就是把7位地址左移一位,最低位表示方向:0是写、1是读。所以A2=A1=A0=0时,写地址是0x40,读地址是0x41。很多新手直接把0x20当作设备地址发出去,结果从机根本不回应,因为0x20这个值在8位地址帧里既不属于写地址也不属于读地址。

A2A1A07位地址写地址读地址
0000x200x400x41
0010x210x420x43
0100x220x440x45
0110x230x460x47
1000x240x480x49
1010x250x4A0x4B
1100x260x4C0x4D
1110x270x4E0x4F

如果A0到A2悬空,实际电平不确定,地址可能漂移。我的习惯是画板时直接把这三位通过电阻固定到地或VCC,不让它在不同板卡之间出现配置差异。代码里我通常用一个宏定义把7位地址定死,写地址和读地址都通过宏计算出来,避免在函数里到处改裸数字。

2.2 引脚分配和上拉电阻不是玄学

模拟IIC只需要两个空闲的GPIO。我建议别选已经被板载外设占用的引脚,比如某些开发板把PB6/PB7默认连了别的器件,你用它做IIC还要先把跳线断开。我常用PB6做SCL、PB7做SDA,具体看你的板子剩余GPIO情况。初始化时模式要选开漏输出,不能选推挽输出——这个原因后面第4章细说。

信号推荐引脚GPIO模式外部上拉
SCLPB6开漏输出 GPIO_Mode_Out_OD4.7kΩ 到 3.3V
SDAPB7开漏输出 GPIO_Mode_Out_OD4.7kΩ 到 3.3V

上拉电阻我建议选4.7kΩ。手册里I2C总线上拉电阻一般在1kΩ到10kΩ之间都能工作,但STM32内部上拉太弱(大约30kΩ到50kΩ),线一长信号边沿会变缓,噪声一大就容易翻车。外部4.7kΩ算是性能和功耗之间比较稳的选择。如果SDA和SCL走线超过10cm,我会把上拉降到2.2kΩ,但要注意总线上拉电流不能超过芯片允许范围。

引脚初始化代码放在这里,注意开漏模式加初始高电平:

GPIO_InitTypeDef GPIO_InitStruct; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD; /* 开漏输出 */ GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStruct); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); /* 初始拉高 */

初始化之后两个引脚都是高电平,这时候总线处于空闲状态。PCA9555那边的SCL和SDA同样也是开漏结构,所以两边都不主动驱动低电平时,总线靠外部上拉维持高电平,这就是IIC能挂多个设备的基础。

2.3 PCA9555寄存器映射与配置流程

PCA9555内部寄存器地址是8位的,重点记住下面这几个:输入端口寄存器0x00/0x01、输出端口寄存器0x02/0x03、极性反转寄存器0x04/0x05、配置寄存器0x06/0x07。每个寄存器对应两个端口,端口0是低8位,端口1是高8位。

寄存器地址名称读/写默认值说明
0x00输入端口0只读1读P0当前电平
0x01输入端口1只读1读P1当前电平
0x02输出端口0读/写0写P0输出电平
0x03输出端口1读/写0写P1输出电平
0x04极性反转0读/写0置1时输入取反
0x05极性反转1读/写0置1时输入取反
0x06配置0读/写1对应位为1输入、0输出
0x07配置1读/写1对应位为1输入、0输出

上电时PCA9555的配置寄存器全部为1,也就是16个IO口默认都是输入方向。这是最容易踩的一个启动陷阱:你可能一上来就写输出寄存器,然后读输入寄存器发现全是1,以为通信失败了,其实只是IO方向还没切过去。正确的顺序是先把配置寄存器写成想要的输入输出方向,再写输出值,最后读输入状态时直接读输入寄存器。

提示:PCA9555没有“先设方向再读”的自动逻辑,配置寄存器里的每一位决定对应引脚是输入还是输出,上电默认输入。

3. 模拟IIC驱动写全:从底层时序到PCA9555读写

3.1 底层时序:起始、停止、ACK与读写一个字节

模拟IIC的本质就是用GPIO的置高低电平配合延时,把SDA和SCL上的波形按协议造出来。我先把最底层的宏和延时给出来,后面的函数全部基于它们。

#include "stm32f10x.h" #define PCA9555_ADDR 0x20 /* A2=A1=A0=0 时的7位地址 */ #define IIC_SCL_PORT GPIOB #define IIC_SDA_PORT GPIOB #define IIC_SCL_PIN GPIO_Pin_6 #define IIC_SDA_PIN GPIO_Pin_7 #define IIC_SCL_H() GPIO_SetBits(IIC_SCL_PORT, IIC_SCL_PIN) #define IIC_SCL_L() GPIO_ResetBits(IIC_SCL_PORT, IIC_SCL_PIN) #define IIC_SDA_H() GPIO_SetBits(IIC_SDA_PORT, IIC_SDA_PIN) #define IIC_SDA_L() GPIO_ResetBits(IIC_SDA_PORT, IIC_SDA_PIN) #define IIC_SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_PORT, IIC_SDA_PIN) static void iic_delay(void) { volatile uint16_t i; for (i = 0; i < 20; i++); }

延时函数里循环次数直接决定SCL频率。在72MHz主频下,这个20次循环出来的SCL大概在100kHz到200kHz之间,符合标准IIC模式。如果后面调试时发现时序余量不够,优先调这个循环次数,不要为了跑高频把延时压得太狠。

起始和停止条件是IIC通信的边界,逻辑很固定:SCL高电平时SDA从高变低是起始,SCL高电平时SDA从低变高是停止。我把这两个函数写成如下形式:

void iic_start(void) { IIC_SDA_H(); IIC_SCL_H(); iic_delay(); IIC_SDA_L(); iic_delay(); IIC_SCL_L(); iic_delay(); } void iic_stop(void) { IIC_SDA_L(); IIC_SCL_H(); iic_delay(); IIC_SDA_H(); iic_delay(); }

这里有个细节:起始条件发出后,SCL要拉低,目的是让后续数据位在SCL低电平期间准备就绪。停止条件则是在SCL高电平期间直接释放SDA。两条时序缺一不可,少了那一步延时,波形就会不完整,从机没法判断边界。

发送一个字节时,数据要在SCL低电平期间放到SDA上,然后在SCL高电平期间保持稳定,最后拉低SCL为下一个位做准备。接收ACK时,主机必须释放SDA让出总线控制权,从机才能在第9个时钟把SDA拉低表示应答。

void iic_send_byte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { if (data & 0x80) IIC_SDA_H(); else IIC_SDA_L(); data <<= 1; iic_delay(); IIC_SCL_H(); iic_delay(); IIC_SCL_L(); iic_delay(); } } uint8_t iic_wait_ack(void) { uint8_t ack = 0; IIC_SDA_H(); /* 释放SDA,等待从机拉低 */ iic_delay(); IIC_SCL_H(); iic_delay(); ack = IIC_SDA_READ(); /* 0表示收到ACK,1表示NAK */ IIC_SCL_L(); iic_delay(); return ack; }

wait_ack返回0表示成功,返回1表示从机没有应答。实际调试时如果一直在返回1,先看地址对不对,再看SDA上拉有没有接,别上来就怀疑时序频率。

读一个字节比写稍微复杂一点,因为主机要根据字节位置决定在最后一个字节前发ACK还是NAK:

uint8_t iic_read_byte(uint8_t ack_en) { uint8_t i; uint8_t data = 0; for (i = 0; i < 8; i++) { data <<= 1; IIC_SCL_H(); iic_delay(); if (IIC_SDA_READ()) data |= 0x01; IIC_SCL_L(); iic_delay(); } if (ack_en) IIC_SDA_L(); /* 主机回ACK:继续读下一个字节 */ else IIC_SDA_H(); /* 主机回NAK:表示这是最后一个字节 */ iic_delay(); IIC_SCL_H(); iic_delay(); IIC_SCL_L(); iic_delay(); IIC_SDA_H(); /* 释放SDA */ return data; }

ack_en为1时主机拉低SDA,从机知道要继续发;ack_en为0时主机释放SDA,从机知道传输要结束了。很多模拟IIC教程只给了读函数没讲最后这个NAK,直接照抄就会出现连续读错位的问题。

3.2 PCA9555寄存器的读写函数

PCA9555的寄存器大多按16位处理,写寄存器时要先发低字节(端口0对应值),再发高字节(端口1对应值)。读寄存器时需要通过重复起始条件切换方向。我封装的读函数如下:

uint8_t pca9555_write_reg(uint8_t reg, uint16_t val) { iic_start(); iic_send_byte(PCA9555_ADDR << 1); /* 写地址 */ if (iic_wait_ack() != 0) { iic_stop(); return 1; } iic_send_byte(reg); /* 寄存器地址 */ if (iic_wait_ack() != 0) { iic_stop(); return 1; } iic_send_byte((uint8_t)(val & 0xFF)); /* 低字节,端口0 */ if (iic_wait_ack() != 0) { iic_stop(); return 1; } iic_send_byte((uint8_t)((val >> 8) & 0xFF)); /* 高字节,端口1 */ if (iic_wait_ack() != 0) { iic_stop(); return 1; } iic_stop(); return 0; }

写函数里每次都检查ACK,一旦中间哪一步没回应就立刻停止条件收场,不留半截波形在总线上。这样做的好处是出错时不会让总线一直处于异常状态,后面复位再通信时干净利落。

读操作的差别在要发两次起始条件,第一次是写寄存器地址,第二次把设备地址最低位置1变成读地址:

uint8_t pca9555_read_reg(uint8_t reg, uint16_t *val) { iic_start(); iic_send_byte(PCA9555_ADDR << 1); if (iic_wait_ack() != 0) { iic_stop(); return 1; } iic_send_byte(reg); if (iic_wait_ack() != 0) { iic_stop(); return 1; } iic_start(); /* 重复起始 */ iic_send_byte((PCA9555_ADDR << 1) | 0x01); /* 读地址 */ if (iic_wait_ack() != 0) { iic_stop(); return 1; } *val = iic_read_byte(1); /* 读低字节,回ACK */ *val |= (uint16_t)iic_read_byte(0) << 8; /* 读高字节,回NAK */ iic_stop(); return 0; }

这里最关键的逻辑是重复起始不能省略。有些新手会把读流程写成“起始→写地址→写寄存器地址→读数据”,看起来好像没问题,但总线方向还没切过来,从机不会往SDA上送数据,主机会一直读到外部上拉拉高的电平,也就是0xFF。我第一次调的时候就是在这里浪费了大半天。

3.3 基于读写函数补上应用层封装

有了write_reg和read_reg,剩下的事情就是按PCA9555的寄存器定义封装几个高层接口。我用配置寄存器0x06/0x07设置方向,用0x02/0x03写输出,用0x00/0x01读输入:

void pca9555_config_io(uint8_t dir0, uint8_t dir1) { /* dir0对应端口0的IO0~IO7,dir1对应端口1的IO0~IO7 */ /* 某位为1表示输入,为0表示输出 */ pca9555_write_reg(0x06, (uint16_t)(dir0 | (dir1 << 8))); } void pca9555_write_output(uint16_t out_val) { /* out_val低8位输出到P0,高8位输出到P1 */ pca9555_write_reg(0x02, out_val); } uint8_t pca9555_read_input(uint16_t *in_val) { /* in_val低8位来自P0输入,高8位来自P1输入 */ return pca9555_read_reg(0x00, in_val); }

需要注意dir0和dir1的单位是“位”,不是整个端口。比如想让P0的低4位输出、高4位输入,dir0应该写0xF0;想让P1全输入,dir1写0xFF。PCA9555的配置寄存器每个bit只控制一个引脚,没有“按端口批量配置”的捷径。

3.4 实测效果:完整调用顺序与结果

下面这个流程是我在实际板子上跑通的,主循环的逻辑比较典型:先配置方向,再写输出,最后周期读输入,通过串口打印出来。

int main(void) { uint16_t in_val = 0; /* 初始化系统时钟、GPIO、USART */ pca9555_config_io(0x0F, 0xFF); /* P0低4位输出,P0高4位输入,P1全输入 */ pca9555_write_output(0x0005); /* P0输出0x05,P1输出0x00 */ while (1) { if (pca9555_read_input(&in_val) == 0) { printf("input=0x%04x\r\n", in_val); } delay_ms(500); } }

执行这个流程后,P01和P03引脚会输出高电平,其他输出脚为低电平,P1的8个引脚全部作为输入使用。接上按键接地,读回相应位就是0,悬空或接高就是1。如果串口打印出来的输入值符合预期,说明模拟IIC的时序、PCA9555地址和寄存器配置全部正确。

我在调试中还发现一个细节:读输入寄存器得到的是当前引脚电平的实时状态,不是锁存值,所以按键按下到读取之间不要做太长的延时,否则电平变化可能被漏掉。如果需要边沿检测,最好在输入变化时加外部中断或者轮询,这点和PCA9555本身的硬件特性有关。

4. 避坑:模拟IIC访问PCA9555最容易踩的五个坑

4.1 GPIO配置成推挽输出导致SDA被“抢”

现象:SCL和SDA用示波器看都有波形,但ACK检测总是失败,PCA9555看起来完全不响应。

原因:SDA是双向引脚,主机读数据时要从机把SDA拉低。如果主机把SDA配置成推挽输出,它会强行保持当前输出电平,从机根本拉不动这条线,ACK自然回不来。这是模拟IIC最典型的翻车点。

解决:把SCL和SDA都配成开漏输出,输出高电平实际上是释放总线,依赖外部上拉拉高。这样从机才能在需要时把SDA拉低。改成开漏后,我在同一个板子上重新跑,通信就没再出问题。

4.2 忘了外部上拉电阻,数据偶尔错位

现象:常温下通信正常,用手摸一下排线或者把线加长到十几厘米,读回来的数据就开始随机跳变。

原因:我最初只用了STM32内部上拉,内部上拉阻值太大,线缆电容一上来,信号上升沿就变缓,SDA在SCL采样时还没稳定到高电平。IIC总线是高电平有效的空闲状态,上升沿太慢等于把噪声窗口放大。

解决:在SCL和SDA上各加一只4.7kΩ外部上拉到3.3V。走线较长时换成2.2kΩ或1kΩ,但要确认总线上挂的所有设备能承受对应的灌电流。从那以后我的IIC板子再也不把“省一个电阻”当成优化项了。

4.3 读最后一个字节时没发NAK,连续读数据错位

现象:第一次读PCA9555输入寄存器是对的,第二次再读,数据高低字节对调,或者在几个值之间反复横跳。

原因:读多字节时,从机要等主机在第9个时钟给出ACK才能继续发下一个字节。如果最后一个字节主机还回ACK,从机会认为你还要继续,依然驱动SDA,主机这边读到的是一个“没有意义”的字节,整个同步就乱了。

解决:读操作中,前N-1个字节调用iic_read_byte(1)回ACK,最后一个字节调用iic_read_byte(0)回NAK,然后立刻发停止条件。我封装pca9555_read_reg时已经把这个逻辑写进去了,你自己写读函数时注意这个顺序。

4.4 少了重复起始,读回来的数据全是0xFF

现象:读PCA9555输入端口,串口打印出来固定是0xFFFF,一开始以为芯片坏了,换了块板子也一样。

原因:IIC读操作的结构是“起始→写设备地址→写寄存器地址→重复起始→读设备地址→读数据”。我把寄存器地址发完后直接进入读模式,没有重新发起始条件,总线仍然处于写方向,从机不会在SDA上送数据,SDA一直保持上拉高电平。

解决:在读完寄存器地址后调用iic_start(),再发送读地址,也就是repeated start。这是协议层面的硬要求,模拟IIC不能省。我在代码里给pca9555_read_reg加了重复起始,后面再也没遇到读全FF的问题。

4.5 上电默认全是输入,写输出“没反应”

现象:上电后直接调用pca9555_write_output写0x55,引脚电平完全没有变化,读输入寄存器却始终是0xFF。

原因:PCA9555上电时配置寄存器0x06和0x07每一位都是1,代表所有IO都是输入方向。输入方向下引脚呈高阻状态,外部没有驱动时读到的就是1,写输出等于无效。你以为芯片没工作,其实芯片根本还没被配置成输出。

解决:先调用pca9555_config_io把要用的引脚对应位置0,再调用pca9555_write_output。顺序错了不会报错,但表现就是“写啥没反应”。我后来把配置和输出两个操作封装成一个初始化函数,确保每次上电先走一遍方向配置。

注意:PCA9555没有内部非易失存储器,每次上电都要重新配置方向,掉电再上电不会记住你上次的设置。

5. 一个值得保留的设计:给模拟IIC加上超时保护

模拟IIC最怕的不是波形不对,而是从机把SDA拉低后一直不释放。我最早写的iic_wait_ack是死等,某个IIC设备在调试时出了故障,程序直接卡在while循环里,整个主循环假死,在线调试都停不下来。从那以后我养成了习惯,凡是等待类的循环全部加超时计数。

以下是带超时的ACK等待函数,核心是给SDA的读取加一个计数器,超时后主动退出并发送停止条件:

uint8_t iic_wait_ack_with_timeout(void) { uint16_t timeout_cnt = 0; IIC_SDA_H(); /* 释放SDA */ iic_delay(); IIC_SCL_H(); iic_delay(); while (IIC_SDA_READ()) /* 等待从机拉低SDA */ { if (++timeout_cnt > 2000) { IIC_SCL_L(); iic_stop(); return 1; /* 超时按NAK处理 */ } iic_delay(); } IIC_SCL_L(); iic_delay(); return 0; }

2000次循环在当前延时参数下大约对应十几毫秒,对正常IIC通信来说足够宽裕。超时不能只返回错误码,还必须先把SCL拉低,再发送停止条件释放总线,否则总线会一直处于一个半激活状态,下次通信没法正常起始。这个设计对后续挂其他IIC器件同样适用,遇到设备不在线时,调用方可以立刻感知并选择重试或上报错误。

如果你想让超时时间更可控,可以在函数里加一个参数表示重试次数,比如iic_wait_ack_with_timeout(5000),这样不同速率的设备可以单独调。我目前习惯把超时计数和延时循环次数做成宏,放在文件顶部统一维护,改起来不用翻代码。

模拟IIC访问PCA9555的核心其实就三件事:地址算对、方向配好、时序里不省重复起始和NAK。剩下的问题基本都出在细节上,比如开漏配置、上拉电阻、超时保护。从那以后我每次在写模拟IIC驱动,都强制先把超时保护写好,再开始调时序,因为和从机打交道最怕的就是一条while把整个工程带进假死。希望帮到你。

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

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

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

立即咨询