ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32用模拟IIC驱动PCA9555实现GPIO扩展实战指南

STM32用模拟IIC驱动PCA9555实现GPIO扩展实战指南 简介面向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做1616路的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。以A0A1A20为例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到bit7P1对应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同样能分析问题。如果你按上面的代码和思路配好自己的电路大概率一次就能跑通。万一没跑通优先检查从机地址左移、寄存器地址、上拉电阻这三个地方十有八九是它们的问题。本文还有配套的精品资源点击获取
返回列表