STM32 GPIO模拟IIC驱动PCA9555 IO扩展芯片实战指南
简介STM32模拟IIC访问PCA9555的工程资源包面向嵌入式开发与STM32学习者用于在STM32F103上通过软件模拟IIC时序访问PCA9555 IO扩展芯片解决单片机GPIO端口不足的问题。资源以Keil工程形式组织共127个文件以C源码和头文件为主体另含编译生成的中间文件.o、.crf与工程配置文件压缩包约2MB可直接打开参考或移植。内容覆盖GPIO推挽输出配置、内部上拉处理、IIC起始/停止条件、应答位判断及PCA9555寄存器读写等关键实现并附有程序源码和常见问题咨询方式能帮助读者深入理解软件模拟IIC的工作原理。工程内主程序与驱动函数层次分明便于查找和修改代码适合希望掌握IO扩展技术或排查相关时序问题的开发者。目前已有4827人学习下载。 最近在调一块以STM32F103为主控的板子板载的IO不够用外设又多最后决定挂一片PCA9555做IO扩展。本来想着STM32自带硬件IIC直接调库就行结果查了一圈发现硬件IIC的坑不少网上哀嚎一片。索性改用GPIO模拟IIC半天时间把PCA9555跑通了读写稳定增益、中断、方向配置全部正常。这篇文章就把这次模拟IIC访问PCA9555的完整过程记录下来包括时序原理、寄存器配置、完整代码和调试时踩过的坑给有同样需求的朋友做个参考。这篇内容适合谁看一是手头正好在玩PCA9555、PCF8575这类IO扩展芯片的二是硬件IIC屡屡受挫、想换成模拟IIC图个省心的三是刚接触IIC协议、想通过一个实际案例把时序彻底搞明白的新手。文章不绕弯子直接干活。1. 为什么选模拟IIC而不是硬件IIC1.1 PCA9555到底是什么芯片PCA9555是NXP推出的一款IIC接口的16位IO扩展芯片通过两根线SCL、SDA就能扩展出16个可独立配置的IO口每个IO可以设置为输入、输出输出还能配置成推挽或开漏并且带有中断输出引脚INT#。芯片供电范围1.65V到5.5V和3.3V的STM32直接对接非常方便。这个芯片在实际项目中出镜率很高尤其是需要控制大量LED、读取多个按键、驱动继电器阵列的场景。比如一个智能家居控制板MCU引脚本来就不富裕用一颗PCA9555就能接管16路继电器或者16路按键扫描省下来的引脚留给通讯和调试用。还有基于STM32的毕业设计里用PCA9555扩展IO去控制点阵屏、数码管驱动、步进电机脉冲输出也都是常见玩法。PCA9555内部有8个寄存器通过IIC读写这些寄存器来控制IO的行为。寄存器不多但有输入输出、方向、极性反转三组端口0和端口1各一套配置逻辑很清晰。后面我会把每个寄存器的地址和用途列出来方便对照看代码。1.2 硬件IIC的坑能绕就绕STM32F1系列的硬件IIC被吐槽已经不是一天两天了。它本身是一个比较复杂的状态机外设用寄存器操作很容易陷进各种BUSY、AF应答失败状态里出不来尤其是和不同厂家的从设备通信时时序配合稍有问题就卡死。虽然用HAL库或者标准库封装过后硬件IIC的易用性有所提升但真到了现场调试一旦遇到无响应、卡死、时钟拉低等情况排查起来非常头疼。相比之下模拟IIC的核心逻辑就是软件控制GPIO电平变化来模拟IIC协议时序。它的优势很明显引脚任意随便选两个闲置GPIO就能用时序完全可控要快就快、要慢就慢碰上信号线比较长或者上拉电阻偏大的情况可以手动把延时长拉一点来保证稳定出错之后排查简单逻辑是透明的每一步是什么电平、什么顺序都看得见。有人说模拟IIC的CPU占用率太高无法用于高速传输。这个说法对但不全面。PCA9555本身是IO扩展芯片不是大吞吐量的传感器或存储芯片每次通信的数据量就那么几个字节再加上IO扩展场景本身对实时性要求并不极端。实测下来在72MHz主频的STM32F103上跑模拟IIC单次读写PCA9555的耗时都在微秒级别对项目整体性能几乎没有影响。还有一个很实际的原因模拟IIC的代码是跨芯片可移植的。今天用STM32F103明天换GD32、CH32、N76E003只要改一下引脚定义和延时函数代码几乎不用动。对于经常换平台的朋友来说这套代码就是一份通用的资产省得每次换芯片都要重新啃一遍硬件IIC的用户手册。2. 模拟IIC的核心时序拆解2.1 IIC协议的基本时序组成模拟IIC的代码不难写难的是把时序记清楚。IIC协议上电后总线空闲SCL和SDA都为高电平。一次完整的通信包含以下几个基本时序单元起始信号STARTSCL为高电平时SDA由高电平跳变为低电平。停止信号STOPSCL为高电平时SDA由低电平跳变为高电平。数据发送SCL为低电平期间SDA电平可变化SCL为高电平期间SDA必须保持稳定。一个SCL时钟周期传1位数据高位在前。应答信号ACK主机发送完一个字节后释放SDA输入模式从机在第9个时钟周期拉低SDA作为应答。非应答信号NACK当从机无法应答或主机要结束读取时第9个时钟周期SDA保持高电平。起始和停止是最容易出问题的地方很多刚开始写模拟IIC的新手会把顺序搞反。记住一个口诀起始信号是“高到低”停止信号是“低到高”而且电平跳变都必须发生在SCL高电平期间。这个规则是整个IIC协议的基石后面的所有操作都建立在它之上。数据位传输的规则也需要单独强调。SDA的电平只能在SCL低电平期间做变化SCL高电平期间必须是稳定的。这个约束的意义在于接收方是在SCL高电平期间采集SDA的如果数据电平在SCL高电平时还在跳变接收方无法判断当前读到的是0还是1。所以模拟IIC发送数据时务必先拉低SCL再改变SDA最后拉高SCL让从机采样。2.2 模拟IIC的延时机电取舍模拟IIC的延时直接决定了通信速率。标准IIC模式是100kbit/s快速模式400kbit/s。模拟的方式不需要精确匹配这些数值但最好控制在对应的时间量级内延时太短会超出从机允许的时序范围太长则影响通信效率。以PCA9555的数据手册为例SCL高电平最小时间是4微秒100k模式快速模式则为0.6微秒。为了保证兼容性我通常把模拟IIC的延时设定在5微秒左右这个值实测下来在STM32F10372MHz下用简单的for循环延时就能稳定贴合100k模式的要求。如果项目对速度有追求可以适当压缩延时到2微秒左右此时通信速率大概在100k到200k之间PCA9555能正常响应。延时函数的选择上可以用DWT数据观测点定时器做精确延时也可以用SysTick但最简单的还是for循环加NOP。考虑到不同编译器优化等级导致的延时差异我更喜欢用一个被volatile修饰的局部变量做循环计数这样循环体不会被编译器优化掉延时量相对可控。纯粹做IO扩展的应用就算延时误差达到30%通信也不会出问题因为IIC是同步协议只要满足从机的最小时间要求慢一点完全OK。2.3 ACK/NACK的检测技巧读操作时主机需要检测从机发来的ACK信号以此判断从机是否正常工作。具体做法是在第9个时钟周期前把SDA引脚设置为输入模式然后读取引脚电平。如果读到低电平说明从机应答读到高电平说明从机没反应或者地址不对。有些代码偷懒读数据时不做ACK检测直接用固定延时代替这在通信稳定的情况下也能跑但一旦从机没上电、地址配错、总线被拉死表现出来的现象就是读回来全是0xFF或者随机值排查起来完全没有头绪。我建议还是老老实实做ACK检测多十几行代码换来的却是故障快速定位的能力。STM32的GPIO模拟输入模式和推挽输出模式可以通过标准库的GPIO_InitTypeDef快速切换写入读取之间转换非常方便这也是STM32做模拟IIC轻松的原因之一。51单片机做模拟IIC时SDA需要在读的时候单独配置引脚模式麻烦一点但逻辑相同。3. 实操接线与PCA9555寄存器配置3.1 硬件接线与地址选择这次实验用的是STM32F103C8T6最小系统板PCA9555模块的工作电压是3.3V与MCU电平匹配接线如下STM32引脚PCA9555引脚说明PB6SCL模拟IIC时钟线PB7SDA模拟IIC数据线3.3VVCC供电GNDGND共地-A0地址引脚接GND-A1地址引脚接GND-A2地址引脚接GND-INT#中断输出本例未用PCA9555的7位IIC地址格式是0100 A2 A1 A0A2、A1、A0是硬件引脚通过接高或接低来配置地址。三个地址脚全部接地时7位地址是0x208位写地址是0x408位读地址是0x41。如果模块上的A0、A1、A2有上拉电阻读到的地址会不同代码里要对应修改。关于上拉电阻必须单独说一下。IIC总线是开漏结构SCL和SDA必须有上拉电阻才能正常工作。STM32的GPIO即使配置成开漏输出内部虽然有弱上拉但驱动能力有限信号边沿会变缓。实测下来当总线上只挂PCA9555一个设备、通讯速率100k时使用4.7k欧姆的外部上拉电阻工作最可靠。如果总线上挂了多个IIC设备总线上并联的上拉等效电阻会变小此时可能需要把上拉电阻换成10k甚至更大。初始化GPIO时SCL和SDA都先配置成开漏输出模式开漏的好处是能够实现线与功能也方便在读数据时把SDA切换成输入模式。开漏输出加外部上拉这是IIC总线的标准接法不能偷懒省略。3.2 PCA9555的寄存器地图PCA9555一共8个寄存器每个端口组各占一个地址寄存器地址寄存器名称功能说明0x00Input Port 0端口0输入状态0x01Input Port 1端口1输入状态0x02Output Port 0端口0输出状态0x03Output Port 1端口1输出状态0x04Polarity Inversion 0端口0极性反转配置0x05Polarity Inversion 1端口1极性反转配置0x06Configuration 0端口0方向配置1为输入0为输出0x07Configuration 1端口1方向配置1为输入0为输出配置寄存器0x06、0x07是出场率最高的默认值0xFF表示所有IO都是输入模式。想要让某个引脚输出高电平需要先把对应位置0配置为输出再往输出寄存器0x02、0x03写1。极性反转寄存器0x04、0x05的作用是把输入电平取反后再存入输入寄存器比如引脚上接了常闭开关读取时逻辑就不是直观的这时可以用极性反转来修正。写入配置寄存器的流程是发送从机地址写位0x40接着发送寄存器地址0x06然后发送端口0配置字节如0x00表示全部输出再发送端口1配置字节如0x00。PCA9555支持连续写即发送一个寄存器地址后跟着的多个数据字节会自动写入后续寄存器这一点比PCF8575要灵活PCF8575只能整体写一个字节。读输入寄存器的流程类似发送从机地址写位0x40接着发送寄存器地址0x00然后重新发送从机地址读位0x41连续读两个字节第一个字节是端口0第二个字节是端口1。这就是IIC协议里经典的“先写地址再重新起始读”的复合操作模拟代码里也要对应实现。3.3 完整代码模拟IIC底层时序底层时序这块我用的是ST标准外设库所有代码在STM32F103C8T6上编译测试通过。先看IIC底层实现#include stm32f10x.h #include delay.h #define IIC_SCL_GPIO GPIOB #define IIC_SCL_PIN GPIO_Pin_6 #define IIC_SDA_GPIO GPIOB #define IIC_SDA_PIN GPIO_Pin_7 #define IIC_SCL_H() GPIO_SetBits(IIC_SCL_GPIO, IIC_SCL_PIN) #define IIC_SCL_L() GPIO_ResetBits(IIC_SCL_GPIO, IIC_SCL_PIN) #define IIC_SDA_H() GPIO_SetBits(IIC_SDA_GPIO, IIC_SDA_PIN) #define IIC_SDA_L() GPIO_ResetBits(IIC_SDA_GPIO, IIC_SDA_PIN) #define IIC_SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_GPIO, IIC_SDA_PIN) static void iic_delay(void) { volatile uint32_t i 40; while (i--) ; } static void iic_sda_input_mode(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin IIC_SDA_PIN; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(IIC_SDA_GPIO, GPIO_InitStructure); } static void iic_sda_output_mode(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin IIC_SDA_PIN; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_OUT_OD; GPIO_Init(IIC_SDA_GPIO, GPIO_InitStructure); } void iic_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_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_OUT_OD; GPIO_Init(GPIOB, GPIO_InitStructure); IIC_SCL_H(); IIC_SDA_H(); } void iic_start(void) { iic_sda_output_mode(); IIC_SDA_H(); IIC_SCL_H(); iic_delay(); IIC_SDA_L(); iic_delay(); IIC_SCL_L(); } void iic_stop(void) { iic_sda_output_mode(); IIC_SDA_L(); IIC_SCL_H(); iic_delay(); IIC_SDA_H(); iic_delay(); } uint8_t iic_write_byte(uint8_t data) { uint8_t i; uint8_t ack; iic_sda_output_mode(); for (i 0; i 8; i) { IIC_SCL_L(); if (data 0x80) { IIC_SDA_H(); } else { IIC_SDA_L(); } data 1; iic_delay(); IIC_SCL_H(); iic_delay(); } IIC_SCL_L(); iic_sda_input_mode(); iic_delay(); ack IIC_SDA_READ(); IIC_SCL_H(); iic_delay(); IIC_SCL_L(); return ack; } uint8_t iic_read_byte(uint8_t ack_flag) { uint8_t i; uint8_t data 0; iic_sda_input_mode(); for (i 0; i 8; i) { IIC_SCL_L(); iic_delay(); IIC_SCL_H(); iic_delay(); data 1; if (IIC_SDA_READ()) { data | 0x01; } } iic_sda_output_mode(); if (ack_flag) { IIC_SDA_L(); } else { IIC_SDA_H(); } IIC_SCL_L(); iic_delay(); IIC_SCL_H(); iic_delay(); IIC_SCL_L(); IIC_SDA_H(); return data; }这段代码有几个细节值得展开。首先是iic_sda_input_mode和iic_sda_output_mode的切换读取时SDA必须从开漏输出切换成上拉输入否则无法采集从机拉低的电平主机的ACK发送又要切回开漏输出模式将SDA拉低。这个过程几乎是模拟IIC最容易写乱的地方建议封装成独立函数避免在读写字节的主循环里来回穿插模式切换代码可读性会好很多。其次是开漏输出模式的选择。模拟IIC的SDA和SCL都配置成GPIO_Mode_OUT_OD同时依靠外部上拉电阻保证高电平。开漏输出模式下向寄存器写1时引脚实际是高阻态电平由外部上拉决定写0时引脚拉低。这种配置天然符合IIC总线“线与”的需求也方便在读SDA时直接切换成输入模式不需要改变引脚的其他电气特性。阅读字节时为什么最后一个时钟周期要让SDA保持高这是为NACK做准备。读最后一个字节后主机要发送NACK告诉从机不用再发了接下来主机就会发停止信号。如果读的不是最后一个字节要发送ACKSDA就要在SCL低电平期间被拉低。ack_flag这个参数的命名已经说明了用途调用时传入1表示读完后回ACK传入0表示读完后回NACK。3.4 PCA9555读写函数封装有了IIC底层时序PCA9555的驱动代码就是简单的组合了。先实现单寄存器写、连续写和连续读三个函数#define PCA9555_ADDR_W 0x40 #define PCA9555_ADDR_R 0x41 #define PCA9555_IN0 0x00 #define PCA9555_IN1 0x01 #define PCA9555_OUT0 0x02 #define PCA9555_OUT1 0x03 #define PCA9555_POL0 0x04 #define PCA9555_POL1 0x05 #define PCA9555_CFG0 0x06 #define PCA9555_CFG1 0x07 uint8_t pca9555_write_reg(uint8_t reg_addr, uint8_t data0, uint8_t data1) { uint8_t ack; iic_start(); ack iic_write_byte(PCA9555_ADDR_W); if (ack) { iic_stop(); return 1; } iic_write_byte(reg_addr); iic_write_byte(data0); iic_write_byte(data1); iic_stop(); return 0; } uint8_t pca9555_read_reg(uint8_t reg_addr, uint8_t *buf, uint8_t len) { uint8_t ack; iic_start(); ack iic_write_byte(PCA9555_ADDR_W); if (ack) { iic_stop(); return 1; } iic_write_byte(reg_addr); iic_start(); ack iic_write_byte(PCA9555_ADDR_R); if (ack) { iic_stop(); return 1; } while (len--) { *buf iic_read_byte(len 0 ? 0 : 1); buf; } iic_stop(); return 0; }这里写操作做了一次ACK检测从机无应答时立刻返回错误码方便上层调用函数判断通信状态。很多网上流传的代码在发送从机地址后不管ACK直接接着发寄存器模块在线时没事但模块掉线后整个程序处于一种“假死”的盲目发送状态无法区分错误原因这种错误处理方式不推荐。PCA9555的连续写特性让配置变得非常简单。把方向配置寄存器0和1连在一起写一次调用就能完成16个IO的方向设置。比如要让P00到P07全部为输出P10到P17全部为输入配置字节就是pca9555_write_reg(PCA9555_CFG0, 0x00, 0xFF);要让P00输出高电平P01输出低电平其他输出口保持低电平就写输出寄存器pca9555_write_reg(PCA9555_OUT0, 0x01, 0x00);读16个IO的输入状态uint8_t port_data[2]; pca9555_read_reg(PCA9555_IN0, port_data, 2); // port_data[0] 对应端口0的8个引脚port_data[1] 对应端口1的8个引脚3.5 主函数演示点灯与按键读取为了验证通信可靠我写了一个简单的演示程序P00控制一个LED闪烁P10读取一个按键状态按键按下时把P01置高点亮另一个LED松开时熄灯。代码如下int main(void) { uint8_t port0; uint8_t port1; uint8_t key_state 0; delay_init(); iic_init(); // P0全部输出P1全部输入 pca9555_write_reg(PCA9555_CFG0, 0x00, 0xFF); // 初始输出全部为低电平 pca9555_write_reg(PCA9555_OUT0, 0x00, 0x00); while (1) { // P00翻转输出LED闪烁 pca9555_read_reg(PCA9555_OUT0, port0, 1); pca9555_write_reg(PCA9555_OUT0, port0 ^ 0x01, 0x00); // 读取端口1判断P10按键 pca9555_read_reg(PCA9555_IN1, port1, 1); if (port1 0x01) { // 按键没按下P01输出低电平 pca9555_write_reg(PCA9555_OUT0, 0x00, 0x00); } else { // 按键按下P01输出高电平 pca9555_write_reg(PCA9555_OUT0, 0x02, 0x00); } delay_ms(200); } }这段代码虽然简单但演示了写配置寄存器、写输出寄存器、读输入寄存器、读输出寄存器四种最常用的操作。实际项目中直接在这个框架上替换业务逻辑就可以了。4. 排查技巧与防坑指南4.1 三个最容易踩的坑IIC通信调试中问题往往不是出在协议逻辑上而是出在硬件细节上。我这次调试PCA9555遇到的最值得记录的问题有三类。第一个坑是SDA被从机拉死总线卡死。现象是程序运行到某个IIC操作后再也无法继续用示波器看SDA一直是低电平。这种情况大多是通信过程中发生了错误比如主机时序不正确导致从机进入异常状态SDA被从机占用无法释放。解决办法是先让SCL多翻转几次9个以上的时钟脉冲相当于给从机做一个“软件复位”很多情况下能恢复。更彻底的方式是为PCA9555的复位引脚如果有或者直接断电重启。需要注意的是PCA9555没有专门的复位引脚但IIC总线上挂了其他带复位的芯片时可以先复位那些芯片。第二个坑是读回来的数据全是0xFF。这个现象最能迷惑人看起来总线没有卡死ACK也正常但数据不对。排查思路是先确认从机地址是不是7位0x20、8位写地址0x40A0到A2的硬件电平是否和代码中的地址一致。其次是确认SDA引脚是否配置成了输入模式读取数据时如果SDA一直是推挽输出高电平读回来的自然是0xFF。第三个坑是上拉电阻阻值不合适。总线上只有一个从设备时4.7k上拉工作良好。但一旦在总线上并联了多个IIC设备等效上拉电阻会变小SCL和SDA的低电平时间会变长波形边沿变缓极端情况下导致通信超时。这时需要把上拉电阻增大到10k左右。反过来上拉电阻太大信号上升沿过缓高速通信时会失败。这个问题的排查比较隐蔽建议直接用示波器观察波形看上升沿是否陡峭。4.2 调试工具与排查方法调试模拟IIC最趁手的工具是逻辑分析仪。不需要贵的高端型号几十块钱的8通道逻辑分析仪配合电脑端的PulseView软件就能把IIC时序完整解码出来。把SCL接CH0SDA接CH1设置好采样率触发方式选下降沿然后运行程序就能在软件里看到完整的IIC报文和每一个ACK信号。我在调试中养成的习惯是先在初始化代码里单独调用一次iic_start和iic_stop用逻辑分析仪确认波形是否正常。正常的起始信号是一个高电平SCL下的SDA下降沿停止信号是高电平SCL下的SDA上升沿。如果这两个波形都不对后面的通信就不用看了。确认起始和停止正常后再单独测试发送一个字节的波形数一下是不是正好8个数据位加1个ACK位每个字节的MSB是不是第一个发送的。连逻辑分析仪都没有的时候可以用最简单的“LED法”在IIC通信函数的关键位置翻转另一个GPIO的LED状态比如start之前点亮stop之后熄灭通过LED闪烁频率大致判断通信是否卡住。这种土办法虽然原始但在野外调试、手边没有仪器的时候非常救命。5. 扩展思路从PCA9555到更多IIC设备PCA9555跑通后这套模拟IIC代码几乎可以无缝迁移到其他IIC从设备上比如AT24C02存储芯片、MPU6050六轴传感器、SSD1306 OLED显示屏、BH1750光照传感器等。区别只在于寄存器地址和寄存器含义不同底层的起止信号、读写字节时序完全一模一样。实际操作中如果在同一个项目里挂了多个IIC设备建议把所有设备的地址和寄存器宏定义放在同一个头文件里集中管理这样代码维护起来很清晰。总线上挂的设备多了还需要注意一个问题不同器件的IIC地址不能冲突。比如PCA9555的地址是0100 A2 A1 A0如果板子上还有PCF8563地址是1010 A2 A1 A0两者的高4位不同不会冲突但如果挂了两个PCA9555就必须通过A0、A1、A2引脚的上下拉将地址区分开。这也是PCA9555设计了三根地址引脚的原因最多支持8片PCA9555挂在同一总线上总共128个IO对于绝大多数应用绰绰有余。如果后续想在同一个项目里同时用硬件IIC和模拟IIC也可以这样搭配硬件IIC挂高速传感器模拟IIC挂低速IO扩展芯片两者互不干扰。我自己在实践中更喜欢把IO扩展这类低速设备全部放在模拟IIC上硬件IIC留给对时序要求更严格的新式传感器这个习惯帮我躲过了不少硬件IIC的坑。最后分享一个实际心得模拟IIC调试不要一次性把所有代码写完再上电测试正确的节奏是先移植底层时序用逻辑分析仪验证起始、停止、单字节读写正常再加载PCA9555的寄存器操作代码最后才写应用逻辑。分步验证每一步都有明确结论调试效率反而最高。这次PCA9555的调试从开始搭代码到点灯成功前后只用了半天大部分时间花在了查地址和确认A0到A2的硬件接线上。当你发现代码翻来覆去查不出毛病时多半问题出在硬件或者配置上拿起万用表量一下A0、A1、A2的电平可能比盯着代码看得更快。本文还有配套的精品资源点击获取