简介:面向STM32F103开发者的PCA9555模拟IIC驱动完整工程,解决单片机GPIO资源不足、需要外扩16路IO的实际需求。资源采用标准库编写,通过GPIO引脚软件模拟IIC时序,避开硬件I2C的常见配置问题;代码中完整实现了起始/停止条件、ACK应答、7位地址匹配,并封装了输入端口读取与输出端口控制功能,可直接移植到其他STM32型号。压缩包内共127个文件,包含33个.h头文件、32个.C源文件、编译生成的HEX与AXF烧录文件,以及Keil工程配置与使用说明,整体仅2MB,便于快速下载验证。内容还涉及PCA9555的主要寄存器配置与中断功能,适合嵌入式初学者理解IIC协议和IO扩展原理。该资源已有4827人学习,作者标注亲测有效,是学习模拟通信与端口扩展的实用参考。
1. 为什么放着硬件I2C不用,非要模拟
先交代一下背景。最近一个项目里需要扩展32路IO,原本的方案是直接上STM32F103ZET6,管脚足够多,但PCB布线、成本、供货周期都不太理想。后来换成了STM32F103C8T6这颗经典小芯片,管脚一下子紧张起来,于是外挂了两颗PCA9555做16+16路的IO扩展。
PCA9555是NXP(原Philips)出品的一款I2C接口的16位GPIO扩展芯片,最高支持400kHz的I2C速率,供电范围2.3V到5.5V,输出电流单引脚最高25mA,非常适合做按键扫描、LED驱动、继电器控制这类应用。这颗芯片在工业控制板上非常常见,之所以选它而不是用移位寄存器方案(比如74HC595),核心原因有两个:一是PCA9555支持双向输入输出,而595基本只能做输出;二是I2C总线只需要两根线就能挂最多8颗芯片,扩到128路IO也只占两个引脚。
问题来了:STM32F1系列自带的硬件I2C外设口碑一直不太好。总线锁定、时钟极性配置不对导致通信失败、主模式卡死在EV5事件这些坑,我前几年都踩过,网上随便一搜也是一大片求助帖。后来虽然用状态机轮询的方式勉强调通了,但代码量大,排查问题也不直观。这次项目周期紧,我干脆直接用GPIO模拟IIC协议,把SCL和SDA两根线用普通的推挽输出引脚来控制,反而更可控,实测稳定跑了一个多月,每天24小时不间断读写,一次都没卡死过。
如果你也在用STM32驱动PCA9555,或者正准备做类似的IO扩展方案,这篇文章值得看完。我不光会贴完整代码,还会把起始信号、停止信号、应答位判断、寄存器配置顺序这些容易出错的关键点一个一个掰开讲清楚。
2. PCA9555寄存器结构和寻址规则
2.1 四组寄存器,搞懂它们才是关键
PCA9555内部一共有4组8位寄存器,每组对应两个端口(P0端口和P1端口),所以实际是8个寄存器地址。这四组分别是输入寄存器、输出寄存器、极性反转寄存器和配置寄存器。理解这四组寄存器的分工,基本就掌握了这颗芯片的用法。
输入寄存器(地址0x00和0x01)只读,用来读取P0和P1端口当前的电平状态。输出寄存器(地址0x02和0x03)读写,用来设置端口输出高电平还是低电平。极性反转寄存器(地址0x04和0x05)比较特殊,如果某一位写1,那么对应引脚的输入逻辑会反转,也就是外部输入高电平,读回来是0,反之亦然。配置寄存器(地址0x06和0x07)用来决定每个引脚是输入还是输出,写0是输出,写1是输入。
芯片上电默认状态下,所有引脚都是输入模式,配置寄存器全部为1。这个默认值经常让新手困惑:明明我没配置过,为什么读回来的数据全是FF?因为输入模式下引脚悬空,读到的就是不确定的高电平。所以用PCA9555的第一件事,永远是先把配置寄存器写好。
2.2 从机地址怎么算
PCA9555的7位从机地址由固定部分和硬件引脚电平共同决定。固定部分是0100,低三位由A0、A1、A2三个引脚的电平决定,引脚接地为0,接VCC为1。以A0=A1=A2=0为例,7位地址是0100000,换算成16进制是0x20。
这里有个非常容易踩的坑:I2C通信时发送的地址字节是8位的,由7位从机地址左移一位,最低位填读写标志位组成。所以0x20这个7位地址,左移后变成0x40,再加上最低位的读写位,写操作地址是0x40,读操作地址是0x41。不少人直接拿0x20去当寄存器地址发,芯片当然不会应答。我最早调这颗芯片的时候也在这个地方卡了半小时,后来用逻辑分析仪抓波形才发现地址字节不对。
如果你的板子上A0、A1、A2接了不同的电平组合,从机地址要相应变化。多颗PCA9555挂同一条总线时,每颗的地址必须不同,否则会冲突。计算方法是:0x20加上(A0 + A22 + A14),注意A1对应的是bit1还是bit2取决于你板子的接法,这个需要对照原理图确认,别搞反了。
3. 模拟IIC的完整实现代码
3.1 GPIO初始化:两个引脚搞定通信
模拟IIC只需要两个GPIO引脚,我用的是STM32F103C8T6的PB6和PB7,分别作为SCL和SDA。这两个引脚在板上没有复用功能冲突,随便选就行。初始化时把两个引脚都设置为开漏输出模式,这样好处很多:开漏输出不需要切换输入输出方向,想读SDA电平的时候直接读IDR寄存器就行;而且开漏输出配合外部上拉电阻,天然符合I2C总线的电气要求。
#define IIC_SCL_GPIO_PORT GPIOB #define IIC_SCL_PIN GPIO_Pin_6 #define IIC_SDA_GPIO_PORT GPIOB #define IIC_SDA_PIN GPIO_Pin_7 #define IIC_SCL_H() GPIO_SetBits(IIC_SCL_GPIO_PORT, IIC_SCL_PIN) #define IIC_SCL_L() GPIO_ResetBits(IIC_SCL_GPIO_PORT, IIC_SCL_PIN) #define IIC_SDA_H() GPIO_SetBits(IIC_SDA_GPIO_PORT, IIC_SDA_PIN) #define IIC_SDA_L() GPIO_ResetBits(IIC_SDA_GPIO_PORT, IIC_SDA_PIN) #define IIC_SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_GPIO_PORT, IIC_SDA_PIN) void PCA9555_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = IIC_SCL_PIN | IIC_SDA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); // 初始状态:总线空闲,SCL和SDA都为高 IIC_SCL_H(); IIC_SDA_H(); }开漏输出模式唯一需要注意的是,外部必须接上拉电阻。我用的板子上已经集成了4.7k的上拉电阻到3.3V,如果你是自己画板子,记得加上。上拉电阻的取值有个经验范围:3.3V供电用4.7k到10k都行,5V供电建议用4.7k到10k,总线长度较长或者挂载设备较多时,可以适当减小阻值到2.2k,以保证信号边沿够陡峭。I2C标准里对上升沿时间有要求,400kHz速率下上升沿不能超过300ns,上拉电阻太大导致边沿太缓,通信就会不稳定。
3.2 模拟IIC时序核心函数
I2C通信的本质就是操作SCL和SDA两根线的电平变化。下面这组函数是模拟IIC的基础,包含起始信号、停止信号、发送字节、接收字节和应答判断五个部分。
// 起始信号:SCL高电平期间,SDA从高拉低 void IIC_Start(void) { IIC_SDA_H(); IIC_SCL_H(); delay_us(5); IIC_SDA_L(); delay_us(5); IIC_SCL_L(); } // 停止信号:SCL高电平期间,SDA从低拉高 void IIC_Stop(void) { IIC_SDA_L(); IIC_SCL_H(); delay_us(5); IIC_SDA_H(); delay_us(5); } // 发送一个字节,MSB先行 void IIC_SendByte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { if (data & 0x80) IIC_SDA_H(); else IIC_SDA_L(); data <<= 1; delay_us(2); IIC_SCL_H(); delay_us(5); IIC_SCL_L(); delay_us(2); } } // 读取一个字节,MSB先行 uint8_t IIC_ReadByte(void) { uint8_t i, data = 0; for (i = 0; i < 8; i++) { IIC_SCL_H(); delay_us(5); data <<= 1; if (IIC_SDA_READ()) data |= 0x01; IIC_SCL_L(); delay_us(5); } return data; } // 等待从机应答,返回0表示有应答,返回1表示无应答 uint8_t IIC_WaitAck(void) { uint8_t timeout = 0; IIC_SDA_H(); // 释放SDA,让从机可以拉低 delay_us(2); IIC_SCL_H(); delay_us(5); while (IIC_SDA_READ()) { if (++timeout > 200) { IIC_Stop(); return 1; } } IIC_SCL_L(); return 0; } // 主机发送应答(非应答)信号:主机拉高SDA,从机知道读完了 void IIC_SendNotAck(void) { IIC_SDA_H(); delay_us(2); IIC_SCL_H(); delay_us(5); IIC_SCL_L(); }这段代码里的delay_us用的是简单的软件延时,我当时直接在工程里写了个空的for循环,没有用定时器。实际测试下来,只要延时范围在几微秒级别,PCA9555完全能跟上。400kHz的I2C时钟周期是2.5微秒,SCL高电平和低电平各占一半,所以延时5微秒对应的实际通信速率大约是100kHz,稳定有余,速度够用。对于IO扩展这种低频操作场景,完全不是瓶颈。
需要注意IIC_WaitAck函数里的超时机制。我之前看过很多人写的模拟IIC代码,等待应答时没有超时判断,一旦从机没应答就死循环,程序卡死。加一个超时计数,比如200次循环还读不到低电平就放弃,并发送停止信号,这样即使硬件有问题也不会让整个系统瘫痪。这是一个很实用的小细节。
3.3 向PCA9555写寄存器的标准流程
写寄存器的操作步骤是:起始信号、发送从机地址(写方向)、等待应答、发送寄存器地址、等待应答、发送数据、等待应答、停止信号。PCA9555支持自动地址递增,也就是说连续写多个字节的时候,寄存器地址会自动加1,可以一次写完P0和P1两个端口的数据。
void PCA9555_WriteReg(uint8_t reg_addr, uint8_t port0_data, uint8_t port1_data) { IIC_Start(); IIC_SendByte(0x40); // 从机地址0x20左移1位,写标志 if (IIC_WaitAck()) return; IIC_SendByte(reg_addr); if (IIC_WaitAck()) return; IIC_SendByte(port0_data); if (IIC_WaitAck()) return; IIC_SendByte(port1_data); if (IIC_WaitAck()) return; IIC_Stop(); }细心的读者可能发现了,我在每个字节发送后都检查应答,一旦失败就立刻退出。这个习惯是在实际项目中养成的——总线上的设备可能因为供电异常、地址冲突等原因临时掉线,如果不检查应答还继续往下发,数据就写歪了。虽然IIC_Half的容错能力有限,但至少不会把错误的配置写进寄存器。
有一个很多人忽略的细节:写配置寄存器的时候,第二个字节和第三个字节分别对应P0和P1端口还是反的?初始化PCA9555为全部输出模式时,需要向0x06寄存器写0x00,向0x07寄存器写0x00。这看起来理所当然,但如果你用IIC_WriteByte一次只写一个字节,写了0x06寄存器却忘了写0x07,那么P1端口还是保持默认的输入状态,输出不生效。我之前就栽在这上面,当时以为芯片坏了,换了颗新的还是一样的问题,最后一查是0x07没写。
3.4 从PCA9555读输入状态的标准流程
读操作比写操作多一个步骤:先发送从机地址(写方向)和寄存器地址,告诉芯片要读哪个寄存器,然后重新发送起始信号(即重复起始,Restart),再发送从机地址(读方向),最后读取数据。
uint16_t PCA9555_ReadReg(uint8_t reg_addr) { uint16_t data = 0; IIC_Start(); IIC_SendByte(0x40); // 从机地址,写方向 if (IIC_WaitAck()) return 0xFFFF; IIC_SendByte(reg_addr); if (IIC_WaitAck()) return 0xFFFF; IIC_Start(); // 重复起始信号 IIC_SendByte(0x41); // 从机地址,读方向 if (IIC_WaitAck()) return 0xFFFF; data = IIC_ReadByte(); IIC_SendNotAck(); // 读完最后一个字节发非应答 data <<= 8; data |= IIC_ReadByte(); IIC_SendNotAck(); IIC_Stop(); return data; }读寄存器最后一位数据结束后,主机要发送非应答信号(NACK),告诉从机“你不用再发了”,然后发送停止信号。如果忘了发NACK,直接从机继续把数据总线上拉高,通信过程不会报错,但后续的总线操作可能受影响,尤其是连续读操作时,数据会错位。这也是模拟IIC容易忽略的细节,硬件I2C外设会自动处理这些,但模拟的就得自己记住。
4. 实测配置案例:初始化、输出控制和按键输入
4.1 把PCA9555全部16个引脚配置为推挽输出
实际项目中,我先把PCA9555的16个引脚全部配置为输出模式,用来控制继电器组。初始化代码如下:
void PCA9555_Init_AllOutput(void) { // 配置寄存器0x06和0x07全部写0,所有引脚为输出 PCA9555_WriteReg(0x06, 0x00, 0x00); delay_ms(10); // 等芯片内部稳定 // 输出寄存器全部写0,初始状态所有引脚输出低电平 PCA9555_WriteReg(0x02, 0x00, 0x00); }有个顺序问题值得注意:先写配置寄存器,还是先写输出寄存器?官方手册的建议是先配置方向,再设置输出电平。但更稳妥的做法是先把输出寄存器写成0,再切方向。因为如果引脚在输入模式下悬空,切到输出模式的瞬间,输出寄存器里的随机值会直接反映到引脚上,产生一个不可控的毛刺。先把输出清零,就不会有这个风险。我把先写输出寄存器为0再配置方向的方案,应用在继电器控制上,避免了上电瞬间继电器误动作的问题。
实际测试中,配置完寄存器之后延时10毫秒这个操作很重要。PCA9555内部有上电复位电路,芯片从复位状态到完全就绪需要一点时间,如果配置命令在芯片还没完全起来的时候就发出去,可能丢失。10毫秒是为了确保芯片稳定,实测下来确实有效。
4.2 单独控制某一路输出
全部输出模式的代码有了,但实际项目里不可能只做全开全关,更多时候是单独控制某一路。这个需求有两种实现思路:一种是先读当前输出寄存器的值,修改对应的位后再写回去,这叫读-改-写;另一种是程序里维护一个全局变量保存当前输出状态,每次修改这个变量然后整体写入。
static uint16_t g_pca9555_out = 0; void PCA9555_SetPin(uint8_t pin, uint8_t level) { if (pin > 15) return; if (level) g_pca9555_out |= (1 << pin); else g_pca9555_out &= ~(1 << pin); PCA9555_WriteReg(0x02, (uint8_t)(g_pca9555_out & 0xFF), (uint8_t)(g_pca9555_out >> 8)); }这里有一个关于P0端口和P1端口哪个对应低字节、哪个对应高字节的细节。PCA9555的P0端口对应寄存器地址0x02的低8位,P1端口对应0x03的高8位。但如果你把P0接到单片机的低8位数据总线,P1接高8位,那这个对应关系就是P0是低字节、P1是高字节。具体到自己项目里,看原理图怎么接,别想当然。我在代码里用g_pca9555_out这个16位变量来统一管理,P0对应bit0到bit7,P1对应bit8到bit15,这样程序语义清晰,不容易搞混。
4.3 读取按键输入并做消抖处理
项目里还用了一个PCA9555来读取16路按键。按键接法很简单:一端接PCA9555的引脚,另一端接地,配置为输入模式后,引脚内部没有上拉(PCA9555内部没有可配置的上拉电阻),所以外部要自己接上拉电阻到VCC。按键按下时引脚读到低电平,松开时读到高电平。
void PCA9555_Init_AllInput(void) { // 配置寄存器全部写1,所有引脚为输入 PCA9555_WriteReg(0x06, 0xFF, 0xFF); } uint16_t PCA9555_ReadKeys(void) { uint16_t raw = PCA9555_ReadReg(0x00); return (uint16_t)(~raw); // 取反后,按下的键对应位为1 }读回来的原始值中,按键按下对应0,松开对应1。取反之后变成按下为1,程序逻辑更直观。不过这里只是硬件层读取,真正工程上还要加软件消抖。我一般用10毫秒延时加连续两次读取一致的方式,简单可靠。如果按键数量多、需要的扫描频率高,可以换成定时中断扫描,每2毫秒采一次,连采5次,3次以上相同才认为状态有效,这样消抖效果更好,也不阻塞主循环。
有一个测试中发现的坑:SDA引脚不够用的时候,能不能把PCA9555的INT中断输出引脚接上,用它来检测输入变化?我之前试过直接用轮询,每10毫秒读一次所有按键状态。如果按键数量特别多,轮询效率确实不高,PCA9555有INT引脚,在输入状态变化时会拉低。如果项目中对实时性要求高,建议把INT引脚接上MCU的外部中断,这样可以做到事件驱动,不用频繁读总线。不过需要注意的是,读I2C寄存器之后INT引脚会自动释放,所以中断服务程序里一定要读一次输入寄存器,否则中断会一直触发。
5. 调试过程中的高频问题和排查手段
5.1 芯片无应答:先查地址,再查接线
不管哪个阶段,遇到的最经典问题就是发地址之后收不到ACK。排查思路按概率从高到低排列:从机地址有没有左移一位,对,地址左移这个点再强调一遍;A0、A1、A2引脚有没有虚焊或者接错电平;SDA和SCL有没有接反;上拉电阻是否焊好、阻值是否合理;PCA9555供电VCC是否正常。
我这边遇到过的最隐蔽问题,是用杜邦线连接开发板和PCA9555模块时接触不良。杜邦线这种东西,看起来插紧了,实际上可能只有一半卡稳了。如果你用手捏一下杜邦线,通信就恢复正常,那基本就是接触不良。这种情况用示波器或者逻辑分析仪最直观,能看到SDA引脚上的波形明显变形。没有示波器的话,用万用表量一下引脚的对地电平,也能发现端倪。
5.2 读回来的数据全为FF或者全为00
读数据全为FF的情况,多半是引脚悬空或者芯片没有正常响应,SDA一直保持高电平。另一种可能是配置寄存器没设置成输入模式,引脚作为输出模式去读,读回来的是输出寄存器的值。全为00的情况,大概率是外部引脚都被拉低了,或者读错了寄存器。
让我印象深刻的一次排查,是因为读的寄存器地址不对。PCA9555输入寄存器是0x00和0x01,配置寄存器是0x06和0x07。我一次写代码时手滑,把读输入写成了读配置,自然读回来的是0xFF(因为配置成输入了)。这种低级错误,用逻辑分析仪抓波形,能很清楚地看出来寄存器地址字节发的是什么,一眼就能发现。
5.3 模拟IIC慢半拍:延时过大导致通信速度太慢
模拟IIC的一大问题是通信速度没有硬性保障,完全取决于延时。如果延时设置得太大,比如每格20微秒以上,读一次16路按键状态就得几十毫秒,在实时性要求高的场景下不可接受。我测试时把延时从5微秒调到2微秒,通信速率能提升一倍,但再往下调就有风险了,SCL高电平时间太短,从机可能采样不到正确的电平。
这里可以做个简单估算:一次完整的16位读操作需要发送起始信号(约10微秒)、地址字节(约50微秒)、寄存器地址(约50微秒)、重复起始+读地址(约60微秒)、16个数据位加2个NACK(约130微秒),合计大约300微秒。如果每隔10毫秒扫一次按键,占用的总线时间大约3%,完全能接受。
5.4 总线卡死:SDA被拉低无法释放
模拟IIC偶尔会遇到总线卡死的情况,表现是SDA线一直为低,程序卡在某个读操作里出不来。这个问题的根源多半是时序错误导致从机进入了异常状态,比如在发送过程中丢失了停止信号。解决办法有两个:一是在等待应答的超时函数里加上总线复位逻辑,检测到超时后,手动产生9个SCL时钟脉冲,让从机复位状态机;二是直接调用Stop函数,把总线状态恢复成空闲。
void IIC_Bus_Reset(void) { uint8_t i; // 手动产生9个时钟脉冲,恢复总线状态 for (i = 0; i < 9; i++) { IIC_SCL_H(); delay_us(5); IIC_SCL_L(); delay_us(5); } IIC_Stop(); delay_ms(10); }这个总线复位函数在调试过程中救过我很多次。特别是在热插拔PCA9555模块之后,总线状态经常被打乱,调IIC_Bus_Reset一下,通信就恢复了。
6. 最后一件事:模拟IIC和硬件I2C怎么选
文章写到这里,核心内容基本讲完了。关于模拟IIC和硬件I2C的选择,我说一下自己的真实体会:STM32F1的硬件I2C确实能调通,但需要花时间处理各种边界情况,而模拟IIC接上就能跑,代码复用性也好,换个芯片平台改一下底层的GPIO操作就能移过去。如果你的项目对I2C速率有硬性要求,比如要跑400kHz甚至1MHz,那得用硬件I2C外设,模拟的很难做到那么高的速率还保持稳定。但像PCA9555这种IO扩展芯片,本身就不是高速设备,模拟IIC完全够用。
最后分享一个调试效率提升的小技巧:如果手头有逻辑分析仪,调试I2C的时候一定要接上,不用太高端,几十块钱的24MHz采样率的就够用。它能直接解码出起始、停止、地址、数据、应答这些信息,比对着示波器波形数格子快得多。我这次调PCA9555,从写代码到全部功能正常,不到半天时间,逻辑分析仪功不可没。没有逻辑分析仪的,也可以把某个GPIO翻转作为调试信号,用示波器双通道同时看,一个通道看SCL,一个通道看SDA,同样能分析问题。
如果你按上面的代码和思路,配好自己的电路,大概率一次就能跑通。万一没跑通,优先检查从机地址左移、寄存器地址、上拉电阻这三个地方,十有八九是它们的问题。
本文还有配套的精品资源,点击获取