资讯详情

资讯详情

STM32F103模拟IIC驱动PCA9555:16按键扩展实战

简介这份资源面向使用STM32F103进行嵌入式开发的工程师与学习者聚焦于通过软件模拟IIC协议访问PCA9555 IO扩展芯片这一实用场景。PCA9555可提供16个可编程输入输出端口适合需要扩展GPIO的硬件设计资源给出了完整的工程实现亲测有效。压缩包共127个文件约2MB以32个c源文件与33个h头文件为核心涵盖STM32外设驱动与PCA9555读写逻辑另含14个o目标文件、14个d依赖文件、13个crf编译中间文件以及uvprojx工程、hex与axf可执行文件、sct分散加载与map映射文件等构成可直接编译调试的Keil工程。目前已有4838人学习下载。读者可从中获取GPIO推挽输出配置、起始与停止条件、7位地址与读写位处理、寄存器读写时序等关键代码并参考输入输出及中断相关寄存器的操作方式快速将IO扩展能力集成到自己的项目中。1. 用两根线挂满16个按键STM32F103模拟IIC驱动PCA9555实战手上有个STM32F103的板子项目里要接16个按键加8路LEDIO口一算根本不够用。第一反应是换引脚多的型号但板子已经打样了改硬件成本太高。后来翻到PCA9555这颗IO扩展芯片IIC接口两根线就能扩出16个可独立配置方向的IO正好解燃眉之急。但问题来了——STM32F103的硬件IIC外设出了名的玄学时序死锁、总线卡死、ACK丢失踩过的人都知道。所以这次直接用GPIO模拟IIC时序完全可控调试也直观。这篇笔记就把整个模拟IIC驱动PCA9555的过程拆开讲清楚从时序原理到寄存器配置再到实际读写代码新手能照着接线跑通熟手能看到时序参数和边界处理的细节。2. 模拟IIC的时序底子为什么不用硬件外设2.1 硬件IIC的坑与模拟IIC的取舍STM32F103的硬件IIC外设用过的人大概都有血泪经验。它的状态机比较复杂在某些边界条件下——比如从机时钟拉伸、总线仲裁丢失、或者上电时序不对——容易出现BUSY标志卡死的情况。一旦卡死复位IIC外设也不一定能恢复有时候得整个系统重启。更麻烦的是PCA9555这类芯片对时序的容忍度虽然不低但如果硬件IIC的时钟配置和实际走线电容不匹配波形畸变会导致通信不稳定。模拟IIC说白了就是用两个普通GPIO一个当时钟线SCL一个当数据线SDA靠代码控制引脚电平翻转来产生IIC协议要求的时序。好处是时序完全由你掌控想快就快想慢就慢调试的时候拿逻辑分析仪一抓波形清清楚楚。坏处是占用CPU时间高速通信时不如硬件外设省心。但对于PCA9555这种低速IO扩展芯片通信速率100kHz绰绰有余模拟IIC完全够用。选PCA9555还有一个原因它支持2.5V到5.5V宽电压供电和STM32F103的3.3V系统可以直接对接不需要额外的电平转换。16个IO分成两组每组8个每个都能独立配置成输入或输出输入模式下还能触发中断。对于按键扫描和LED控制这种场景简直是量身定做。2.2 IIC协议的核心时序起始、停止、应答IIC协议的本质就是两根线上的电平变化规则。SCL是高电平的时候SDA从高变低就是起始条件SCL是高电平的时候SDA从低变高就是停止条件。数据位在SCL低电平期间准备好在SCL高电平期间保持稳定接收方在SCL高电平期间采样。每传输完8个数据位接收方要拉低SDA一个时钟周期作为应答ACK表示收到了。如果接收方不拉低就是非应答NACK发送方据此判断传输是否成功。模拟IIC的关键在于延时控制。STM32F103主频72MHz一条GPIO翻转指令大概几十纳秒如果不加延时SCL频率可能跑到几MHzPCA9555根本反应不过来。所以需要在每次电平变化后插入微秒级延时。我一般用delay_us(2)这样SCL周期大约4到5微秒频率在200kHz左右留足了余量。// 模拟IIC的GPIO宏定义根据实际接线修改 #define IIC_SCL_PIN GPIO_Pin_6 #define IIC_SDA_PIN GPIO_Pin_7 #define IIC_GPIO_PORT GPIOB #define IIC_GPIO_CLK RCC_APB2Periph_GPIOB // SCL和SDA的高低电平操作 #define SCL_H() GPIO_SetBits(IIC_GPIO_PORT, IIC_SCL_PIN) #define SCL_L() GPIO_ResetBits(IIC_GPIO_PORT, IIC_SCL_PIN) #define SDA_H() GPIO_SetBits(IIC_GPIO_PORT, IIC_SDA_PIN) #define SDA_L() GPIO_ResetBits(IIC_GPIO_PORT, IIC_SDA_PIN) // 读取SDA引脚电平用于应答检测 #define SDA_READ() GPIO_ReadInputDataBit(IIC_GPIO_PORT, IIC_SDA_PIN) // 微秒级延时根据实际主频调整 #define IIC_DELAY() delay_us(2)这段代码定义了模拟IIC的基础操作宏。SCL和SDA分别挂在GPIOB的6号和7号引脚上实际接线时可以改。SDA_READ()用于读取从机的应答信号所以SDA引脚在需要读取的时候要切换成输入模式。IIC_DELAY()是时序控制的核心2微秒的延时对应大约200kHz的SCL频率这个值可以根据实际走线长度和从机特性调整走线长就加大延时。2.3 GPIO初始化与引脚模式切换模拟IIC的GPIO配置有个细节SDA引脚在发送数据时是推挽输出在接收应答时要切换成浮空输入或上拉输入。SCL引脚始终是推挽输出。很多新手在这里翻车SDA一直配成输出模式结果读应答永远读到低电平通信直接失败。void IIC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能GPIOB时钟 RCC_APB2PeriphClockCmd(IIC_GPIO_CLK, ENABLE); // SCL配置为推挽输出 GPIO_InitStructure.GPIO_Pin IIC_SCL_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(IIC_GPIO_PORT, GPIO_InitStructure); // SDA默认配置为推挽输出 GPIO_InitStructure.GPIO_Pin IIC_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(IIC_GPIO_PORT, GPIO_InitStructure); // 初始状态SCL和SDA都拉高总线空闲 SCL_H(); SDA_H(); } // 切换SDA为输入模式用于读取从机应答 void SDA_Input_Mode(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin IIC_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(IIC_GPIO_PORT, GPIO_InitStructure); } // 切换SDA为输出模式用于发送数据 void SDA_Output_Mode(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin IIC_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(IIC_GPIO_PORT, GPIO_InitStructure); }初始化函数先把SCL和SDA都配成推挽输出然后拉高总线进入空闲状态。SDA_Input_Mode()和SDA_Output_Mode()这两个函数是模拟IIC能正常工作的关键每次需要读ACK或者读数据之前必须把SDA切成输入模式发送数据之前再切回输出模式。切换模式会有微秒级的开销但在这个速率下完全可以接受。注意SDA输入模式建议用上拉输入GPIO_Mode_IPU因为IIC总线是开漏结构需要上拉电阻把电平拉高。如果外部已经有4.7kΩ上拉电阻用浮空输入也行但上拉输入更保险。3. PCA9555寄存器配置把16个IO管起来3.1 器件地址与寄存器映射PCA9555的IIC从机地址是7位的高4位固定为0100低3位由A2、A1、A0三个引脚决定。也就是说同一条IIC总线上最多可以挂8片PCA9555扩展到128个IO。地址字节的最低位是读写位0表示写1表示读。所以写地址是0x40加上A2A1A0的组合读地址是写地址加1。PCA9555内部有8个寄存器分成两组每组4个分别对应Port 0和Port 1。每组寄存器的偏移是寄存器Port 0 地址Port 1 地址功能输入寄存器0x000x01读取引脚当前电平输出寄存器0x020x03设置输出引脚电平极性反转寄存器0x040x05反转输入极性方向配置寄存器0x060x07配置引脚为输入或输出方向配置寄存器是关键写1表示对应引脚为输入写0表示输出。上电默认值是0xFF也就是所有引脚都是输入。这个默认值很合理避免上电时引脚意外输出高电平导致外设误动作。极性反转寄存器一般用不到保持默认0x00就行。如果某个输入引脚的有效电平和实际需要相反可以在这里翻转省去软件取反的麻烦。3.2 写寄存器配置方向与输出电平写寄存器的流程是起始条件 → 发送写地址 → 等待ACK → 发送寄存器偏移 → 等待ACK → 发送数据 → 等待ACK → 停止条件。PCA9555支持连续写也就是发送一个寄存器偏移后可以连续写多个字节地址自动递增。// 向PCA9555指定寄存器写入一个字节 // reg_addr: 寄存器偏移地址 // data: 要写入的数据 // 返回值: 0成功1失败 uint8_t PCA9555_WriteReg(uint8_t reg_addr, uint8_t data) { IIC_Start(); IIC_SendByte(PCA9555_WRITE_ADDR); // 发送写地址 if (IIC_WaitAck() ! 0) { // 等待应答 IIC_Stop(); return 1; } IIC_SendByte(reg_addr); // 发送寄存器偏移 if (IIC_WaitAck() ! 0) { IIC_Stop(); return 1; } IIC_SendByte(data); // 发送数据 if (IIC_WaitAck() ! 0) { IIC_Stop(); return 1; } IIC_Stop(); return 0; } // 配置Port 0为输出Port 1为输入 // 方向寄存器1输入0输出 void PCA9555_Init(void) { // Port 0全部输出用于驱动LED PCA9555_WriteReg(0x06, 0x00); // Port 1全部输入用于按键扫描 PCA9555_WriteReg(0x07, 0xFF); // 初始输出全部拉低LED熄灭 PCA9555_WriteReg(0x02, 0x00); }PCA9555_WriteReg()封装了完整的写寄存器时序。每次发送一个字节后都要调用IIC_WaitAck()检查从机是否应答如果不应答就发送停止条件并返回错误码。这种错误处理在实际项目中很重要总线干扰或者从机掉线时能及时发现。PCA9555_Init()里把Port 0配置成输出接LEDPort 1配置成输入接按键。方向寄存器的值0x00表示8个引脚全是输出0xFF表示全是输入。如果某个Port上既有输入又有输出就需要按位组合比如0xF0表示高4位输入、低4位输出。3.3 读寄存器按键状态采集读寄存器的流程稍微复杂一点需要先写寄存器偏移然后重新起始发送读地址再读取数据。PCA9555也支持连续读读完一个字节后如果主机发送ACK从机会继续输出下一个寄存器的值如果主机发送NACK从机释放总线主机发送停止条件。// 从PCA9555指定寄存器读取一个字节 // reg_addr: 寄存器偏移地址 // 返回值: 读取到的数据 uint8_t PCA9555_ReadReg(uint8_t reg_addr) { uint8_t data; IIC_Start(); IIC_SendByte(PCA9555_WRITE_ADDR); // 先写地址 IIC_WaitAck(); IIC_SendByte(reg_addr); // 写寄存器偏移 IIC_WaitAck(); IIC_Start(); // 重新起始 IIC_SendByte(PCA9555_READ_ADDR); // 发送读地址 IIC_WaitAck(); data IIC_ReadByte(); // 读取数据 IIC_SendNack(); // 发送NACK表示不再继续读 IIC_Stop(); return data; } // 读取Port 1的按键状态 uint8_t PCA9555_ReadKeys(void) { return PCA9555_ReadReg(0x01); // 读Port 1输入寄存器 }PCA9555_ReadReg()里有一个容易忽略的点发送读地址后从机返回ACK然后开始输出数据。主机在接收完8位数据后必须发送NACK拉高SDA产生一个时钟脉冲告诉从机“我读够了”然后发送停止条件。如果主机错误地发送了ACK从机会继续输出下一个寄存器的数据虽然不会造成硬件损坏但读取的数据会错位。PCA9555_ReadKeys()直接读Port 1的输入寄存器返回的8位数据对应8个按键的状态。按键按下时对应位为0假设按键接地松开时为1。实际使用中还需要加上消抖逻辑这个在后面讲。3.4 连续读写与地址自动递增PCA9555的连续读写能力在初始化时特别有用。比如要同时配置两个Port的方向寄存器可以一次性写完减少IIC起始停止的开销。// 连续写两个寄存器先写Port 0方向再写Port 1方向 void PCA9555_WriteTwoRegs(uint8_t reg_addr, uint8_t data1, uint8_t data2) { IIC_Start(); IIC_SendByte(PCA9555_WRITE_ADDR); IIC_WaitAck(); IIC_SendByte(reg_addr); // 起始寄存器地址 IIC_WaitAck(); IIC_SendByte(data1); // 写入第一个寄存器 IIC_WaitAck(); IIC_SendByte(data2); // 地址自动加1写入第二个寄存器 IIC_WaitAck(); IIC_Stop(); }连续写的关键在于发送完第一个数据字节并收到ACK后不需要重新发送寄存器偏移直接发第二个数据字节PCA9555内部地址指针会自动加1。这个特性在批量配置时能省不少时间。但要注意地址递增到0x07后会回绕到0x00如果写的数据超过8个字节会覆盖前面的寄存器。4. 避坑排查模拟IIC调不通的五个常见原因4.1 上电后读不到ACK总线一直忙现象初始化后调用PCA9555_WriteReg()IIC_WaitAck()始终返回1逻辑分析仪上看SCL有波形但SDA一直是高电平。原因最常见的是SDA引脚模式没切换。发送完地址后代码里忘了调用SDA_Input_Mode()SDA还是推挽输出状态从机拉低SDA时和主机的输出冲突读回来永远是高。另一个可能是PCA9555的A2A1A0引脚悬空导致器件地址不确定。解决检查IIC_WaitAck()函数里是否先切换了SDA为输入模式读完ACK后再切回输出模式。A2A1A0引脚必须明确接高或接低不能悬空。用万用表量一下SDA和SCL的静态电平正常应该是3.3V左右。4.2 能写不能读读回来全是0xFF现象写寄存器正常LED能控制但读按键状态永远是0xFF按键按下也没反应。原因读时序里重新起始条件没处理好。有些模拟IIC实现里IIC_Start()在SCL高电平期间拉低SDA但如果前一个停止条件后总线没有完全释放重新起始会失败。另外读地址计算错误也会导致从机不应答读回的数据就是总线空闲时的高电平。解决确认读地址是写地址加1。检查IIC_Start()函数里是否先确保SDA和SCL都是高电平再拉低SDA。如果用的是开漏输出配置还需要确认外部上拉电阻是否接好。4.3 通信时好时坏逻辑分析仪上波形有毛刺现象大部分时间能正常读写偶尔出现数据错误逻辑分析仪上SCL或SDA有窄脉冲毛刺。原因延时不够。STM32F103的GPIO翻转速度很快如果IIC_DELAY()的延时太短SCL周期可能只有几百纳秒PCA9555来不及响应。另外如果SCL和SDA走线太长分布电容会导致边沿变缓进一步压缩有效采样窗口。解决加大IIC_DELAY()的值从2微秒加到5微秒试试。走线长的话建议降到100kHz以下。逻辑分析仪上确认SCL高电平和低电平时间都大于2微秒SDA在SCL高电平期间保持稳定。4.4 按键读取抖动严重一次按下读到多个值现象按键按下时读到的Port 1数据在0和1之间跳变偶尔读到多个位同时为0。原因机械按键的抖动时间通常在5到20毫秒如果主循环里直接读寄存器没有消抖处理就会读到抖动期间的不稳定状态。解决在软件里加消抖逻辑。连续读取两次间隔10毫秒两次结果一致才认为有效。或者用定时器中断定期扫描配合状态机做消抖。// 带消抖的按键读取 uint8_t PCA9555_ReadKeysDebounced(void) { uint8_t key1, key2; key1 PCA9555_ReadReg(0x01); delay_ms(10); key2 PCA9555_ReadReg(0x01); if (key1 key2) { return key1; } return 0xFF; // 两次不一致返回无按键 }4.5 多片PCA9555级联时地址冲突现象总线上挂了两片PCA9555单独测试都能通一起接上后只有一片能响应。原因两片PCA9555的A2A1A0引脚配置成了相同的地址。IIC总线上地址冲突会导致两片从机同时应答数据线电平冲突通信失败。解决确保每片PCA9555的A2A1A0组合唯一。比如第一片A2A1A0接000第二片接001。地址配置好之后用PCA9555_WriteReg()分别测试每片的读写确认都能独立响应。5. 进阶技巧用中断引脚做按键唤醒与批量IO刷新PCA9555有一个INT引脚当输入引脚的电平发生变化时INT引脚会拉低通知主控有事件发生。这个功能在低功耗场景下特别有用主控平时可以休眠按键按下时PCA9555拉低INT引脚触发STM32的外部中断唤醒主控去读按键状态。INT引脚是开漏输出需要接上拉电阻。它对应的是Port 0和Port 1所有输入引脚的逻辑与只要有一个输入引脚的电平和输入寄存器里的值不一致INT就拉低。读取输入寄存器后INT自动释放。// 配置STM32外部中断连接PCA9555的INT引脚 void EXTI_Init_PCA9555_INT(void) { EXTI_InitTypeDef EXTI_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // INT引脚接在PA0上配置为上拉输入 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); // 配置EXTI0 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); EXTI_InitStructure.EXTI_Line EXTI_Line0; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; // 下降沿触发 EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); // 配置NVIC NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0x01; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0x01; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); } // 中断服务函数 void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { // 读取按键状态同时清除PCA9555的INT标志 uint8_t keys PCA9555_ReadReg(0x01); // 处理按键逻辑 // ... EXTI_ClearITPendingBit(EXTI_Line0); } }这个中断方案的好处是主控不需要轮询按键按下时才唤醒功耗能降下来。但要注意PCA9555的INT引脚在读取输入寄存器后才会释放所以中断服务函数里必须读一次输入寄存器否则INT会一直拉低中断反复触发。另一个进阶用法是批量刷新输出。如果Port 0的8个LED需要同时更新用连续写的方式一次传输两个字节比分两次写寄存器快得多。在刷新频率要求高的场景下这个优化能明显降低IIC总线的占用率。// 批量更新Port 0和Port 1的输出寄存器 void PCA9555_UpdateOutputs(uint8_t port0_val, uint8_t port1_val) { IIC_Start(); IIC_SendByte(PCA9555_WRITE_ADDR); IIC_WaitAck(); IIC_SendByte(0x02); // Port 0输出寄存器地址 IIC_WaitAck(); IIC_SendByte(port0_val); // 写Port 0 IIC_WaitAck(); IIC_SendByte(port1_val); // 地址自动加1写Port 1 IIC_WaitAck(); IIC_Stop(); }从那以后我每次调模拟IIC都强制先用逻辑分析仪抓一遍起始条件和ACK波形确认时序没问题再往下写业务代码。这个习惯帮我省了很多来回改代码的时间。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →