ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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路LED,IO口一算根本不够用。第一反应是换引脚多的型号,但板子已经打样了,改硬件成本太高。后来翻到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频率可能跑到几MHz,PCA9555根本反应不过来。所以需要在每次电平变化后插入微秒级延时。我一般用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配置成输出接LED,Port 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波形,确认时序没问题再往下写业务代码。这个习惯帮我省了很多来回改代码的时间。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进