用GPIO模拟IIC驱动PCA9555:从放弃硬件外设到稳定通信的实战笔记

📅 发布时间:2026/10/16 7:00:29
用GPIO模拟IIC驱动PCA9555:从放弃硬件外设到稳定通信的实战笔记
简介面向STM32F103嵌入式开发者提供一套经过实测的软件模拟IIC访问PCA9555 IO扩展芯片的完整工程源码与Keil配置工程可灵活读写寄存器、扩展16路GPIO。压缩包共127个文件核心为C源码与H头文件覆盖IIC时序、GPIO模拟、寄存器操作同时含uvprojx/uvoptx工程配置、axf/hex/map/lst等编译产物及sct、s、htm等辅助文件整体仅2MB适合直接打开对照阅读。已有4837人学习下载。代码目录结构清楚读者可从中提取起始/停止条件、应答与寄存器寻址等IIC实现细节也可以直接复用输出端口控制、输入端口读取等完整流程对于需要在不占用硬件I2C的情况下扩展大量输入输出的裸机项目这套资料能显著缩短开发调试时间。1. 模拟 IIC 访问 PCA9555为什么我放弃了硬件 IIC 外设手头一块板子要把 16 路 GPIO 扩展出来选了 PCA9555 挂在 STM32F103 的 PA6/PA7 上。刚开始图省事直接用片上 I2C 外设结果调试时频繁卡在 BUSY 状态总线释放不掉复位、换引脚都没根治。后来我干脆把这两个引脚抠出来用 GPIO 模拟 IIC起始、停止、读写全走软件延时反而一次性就通了。这篇笔记把整套方案拆开讲接线怎么布、初始化怎么配、底层收发和寄存器读写怎么写最后把五个容易翻车的细节按“现象→原因→解决”列出来。适合手里正好有 STM32F103、需要快速扩展一组 IO、又不想在 IIC 总线上反复折腾的开发者照着复现。2. 为什么要模拟 IIC硬件外设被占用后的三个替代理由2.1 硬件 IIC 外设不顺手的地方STM32F103 的 I2C 外设在多设备、长走线、电平匹配不理想的环境下偶尔会出现 BUSY 标志拉不回来的情况。网上很多开发者都遇到过复位外设、切换 GPIO 复用功能、甚至整板断电才能恢复。这并不是说硬件外设一定有问题而是它内部状态机比软件模拟复杂出错之后排查路径长。模拟 IIC 的本质是把 SCL 和 SDA 当普通 GPIO 操作按数据手册的时序图把波形一位一位做出来整个时序完全可控出问题直接看波形就能定位。引脚复用是另一个现实原因。硬件 I2C1 和 I2C2 的引脚是固定的如果 PCB 走线把这几组引脚占了或者引脚要同时用于其它功能重映射也要受限制。软件模拟 IIC 的 SCL 和 SDA 可以放在任意两个空闲 GPIO 上改一下宏定义就能换引脚这在改板、飞线、验证阶段特别有用。2.2 IIC 时序参数如何转成延时宏PCA9555 是标准 IIC 从设备时钟 100kHz 的标准模式完全够用。模拟 IIC 不需要精确到微秒级只需要保证最小时序参数满足。标准模式关键参数如下参数符号标准模式最小值说明起始条件保持时间tHD;STA4.0usSCL 为高后 SDA 保持低起始条件建立时间tSU;STA4.7usSCL 拉高前 SDA 必须稳定数据建立时间tSU;DAT250nsSCL 高电平期间 SDA 保持停止条件建立时间tSU;STO4.0usSCL 高后 SDA 释放总线空闲时间tBUF4.7us停止到下一次起始的间隔软件模拟时延时宁长勿短慢不会导致通信失败最多就是吞吐量低一点。我一般把 IIC_Delay 调成 5us 左右在 72MHz 主频下就是几十个空循环完全能满足上面所有参数。把延时做短反而容易出现起始条件保持时间不够波形抓出来像一团毛刺。2.3 开漏输出与上拉电阻怎么选模拟 IIC 的 SDA 建议配置成开漏输出SCL 用推挽输出在单主机场景下也能工作但我习惯两个都开漏再分别接 4.7k 上拉到 3.3V。开漏的好处是切换输入输出方便主机拉低时从机也能拉低应答不会出现两个输出口互相打架的问题。上拉电阻 4.7k 在 100kHz 下没问题如果板子上已经挂了多个 IIC 设备总线电容变大可以把上拉降到 2.2k上升沿会更陡。还有一些板子为了省事不接外部上拉靠 STM32 内部上拉。STM32 内部上拉大约 30k 到 50k阻值偏大在长走线和多设备场景下上升沿会变缓极端情况下通信不稳定。我调过的板子里凡是模拟 IIC 出问题的排查到最后一半以上是上拉电阻缺失或阻值太大。3. 硬件接线与引脚初始化开漏、上拉与配置代码3.1 最小接线与器件地址PCA9555 的 16 路 IO 分成 P0 和 P1 两组每组 8 位。IIC 地址由 A0、A1、A2 三个引脚的电平决定默认全部接地时 7 位地址是 0x20加上读写方向位后8 位写地址是 0x408 位读地址是 0x41。如果地址线接到 VDD 或组合接法地址会变驱动里的宏要跟着改。引脚接法说明A0 / A1 / A2GND7 位地址 0x20SCLPA6 4.7k 上拉时钟线SDAPA7 4.7k 上拉数据线VDD3.3V供电INT悬空或接 GPIO开漏中断输出本方案不用接线时注意 SCL 和 SDA 不要跨过长距离飞线超过 20cm 建议用双绞线或杜邦线绑在一起走否则信号反射会在上升沿上叠加毛刺。PCA9555 的 VDD 旁路电容 0.1uF 放在电源脚附近这在批量板上已经是常规做法。3.2 GPIO 初始化与 SDA 方向切换PA6 做 SCLPA7 做 SDA。初始化代码如下void PCA9555_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; // PA6 - SCL GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_7; // PA7 - SDA GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出读之前切成输入 GPIO_Init(GPIOA, GPIO_InitStructure); /* 初始电平都拉高保证总线空闲 */ GPIO_SetBits(GPIOA, GPIO_Pin_6); GPIO_SetBits(GPIOA, GPIO_Pin_7); }SCL 用开漏或推挽差异不大但 SDA 开漏是必须的因为读 ACK 和读数据时要释放 SDA 让从机驱动。如果你在标准库工程里已经有自己的时钟使能代码把 RCC 那行删掉即可。GPIO_Speed_50MHz 不是必须只要配置比 IIC 时钟高就行10MHz 也够。SDA 在收发过程中要频繁切换输入输出如果每次都用 GPIO_Init 切模式库函数开销太大。我一般直接操作 CRL 寄存器针对 PA7 的写法如下#define SDA_IN() { GPIOA-CRL ~(0xF 28); GPIOA-CRL | (0x8 28); } #define SDA_OUT() { GPIOA-CRL ~(0xF 28); GPIOA-CRL | (0x3 28); }CRL 寄存器里 PA7 对应 bit28 到 bit31先清零再写模式。0x8 对应输入模式带上拉0x3 对应推挽输出。这里有个细节SDA 在输入模式下ODR 对应位要写成 1内部上拉才生效所以初始化时 GPIO_SetBits 已经把 PA7 拉高切到输入后仍然维持高电平。如果切输入后发现 SDA 被拉低先检查 ODR 位是不是被谁清掉了。3.3 验证波形逻辑分析仪抓起始条件没有示波器也能验证时序。手头有逻辑分析仪的话采样率设 8MHz 以上触发条件选 SDA 下降沿。正常波形应该是SCL 高电平期间SDA 由高变低这是起始条件SCL 高电平期间SDA 由低变高这是停止条件。起始条件之后 SCL 开始翻转每个 SCL 高电平采样一位数据。实际调试时我会写一个只发起始条件就停住的测试函数用逻辑分析仪看这一段的保持时间是否满足 tHD;STA。如果 SDA 在 SCL 下降沿附近还在跳变说明 IIC_Delay 太短把延时加大即可。没有逻辑分析仪就先执行一次完整的写寄存器操作读回 ACK 位WaitAck 返回 0 说明总线上有设备应答了基本可以认定时序没大问题。4. 驱动源码拆解底层收发、寄存器读写与 PCA9555 操作4.1 底层信号函数与位收发模拟 IIC 的核心就是 Start、Stop、写字节、读字节四个函数外加一个 ACK 处理。先定义引脚操作宏#define IIC_SCL_H() GPIO_SetBits(GPIOA, GPIO_Pin_6) #define IIC_SCL_L() GPIO_ResetBits(GPIOA, GPIO_Pin_6) #define IIC_SDA_H() GPIO_SetBits(GPIOA, GPIO_Pin_7) #define IIC_SDA_L() GPIO_ResetBits(GPIOA, GPIO_Pin_7) #define IIC_SDA_READ() ((GPIOA-IDR GPIO_Pin_7) ? 1 : 0) void IIC_Delay(void) { uint8_t i 20; while (i--); }IIC_Delay 里的 i 值决定每一位的时长20 在 72MHz 主频下大约几微秒刚好满足 100kHz 标准模式。如果板子主频不同或延时偏短调这个数即可不需要改其它代码。接下来是 Start 和 Stopvoid IIC_Start(void) { SDA_OUT(); // SDA 切到输出 IIC_SDA_H(); IIC_SCL_H(); IIC_Delay(); IIC_SDA_L(); // SCL 高电平期间 SDA 拉低 - 起始条件 IIC_Delay(); IIC_SCL_L(); // 拉低 SCL准备传数据 } void IIC_Stop(void) { SDA_OUT(); IIC_SCL_L(); IIC_SDA_L(); IIC_Delay(); IIC_SCL_H(); IIC_Delay(); IIC_SDA_H(); // SCL 高电平期间 SDA 拉高 - 停止条件 IIC_Delay(); }Start 和 Stop 的时序和手册一一对应起始条件是 SCL 高时 SDA 由高到低停止条件是 SCL 高时 SDA 由低到高。注意 Stop 后要留出 tBUF 空闲时间代码里的 IIC_Delay 已经覆盖了如果后面马上开启新事务也没问题。写字节从最高位开始void IIC_WriteByte(uint8_t data) { uint8_t i; SDA_OUT(); for (i 0; i 8; i) { if (data 0x80) IIC_SDA_H(); else IIC_SDA_L(); data 1; IIC_Delay(); IIC_SCL_H(); // SCL 高电平期间 SDA 必须稳定 IIC_Delay(); IIC_SCL_L(); } }读字节类似但 SDA 要切输入uint8_t IIC_ReadByte(void) { uint8_t i, data 0; SDA_IN(); // 释放总线让从机驱动 SDA for (i 0; i 8; i) { IIC_SCL_H(); IIC_Delay(); data 1; if (IIC_SDA_READ()) data | 0x01; IIC_SCL_L(); IIC_Delay(); } return data; }读字节时 SCL 低电平期间把数据左移再拉高 SCL 采样。这里 SCL 高电平期间 SDA 不能变化所以读完一位后立刻拉低 SCL。要注意 SDA_IN() 之后不能再调用 IIC_SDA_H() 去强拉高否则又把总线抢回来了正确做法是依赖外部上拉让 SDA 回到高。ACK 处理uint8_t IIC_WaitAck(void) { uint8_t err 0; SDA_IN(); // 释放 SDA等待从机拉低应答 IIC_SDA_H(); // 这里只是确保 ODR 位为 1不是强推挽驱动 IIC_Delay(); IIC_SCL_H(); IIC_Delay(); if (IIC_SDA_READ() 1) err 1; // SDA 为高说明无应答 IIC_SCL_L(); IIC_Delay(); return err; } void IIC_Ack(void) { SDA_OUT(); IIC_SDA_L(); IIC_Delay(); IIC_SCL_H(); IIC_Delay(); IIC_SCL_L(); IIC_Delay(); } void IIC_Nack(void) { SDA_OUT(); IIC_SDA_H(); IIC_Delay(); IIC_SCL_H(); IIC_Delay(); IIC_SCL_L(); IIC_Delay(); }WaitAck 在主机发送完一个字节后调用从机应答时会把 SDA 拉低。如果返回 1说明总线上没有设备应答先查地址对不对再查电源和接线。4.2 PCA9555 寄存器映射与读写封装PCA9555 的控制寄存器不多16 位 IO 分成 P0 和 P1 两组各寄存器地址如下寄存器地址名称复位值说明0x00输入端口 0只读读取 P0 引脚电平0x01输入端口 1只读读取 P1 引脚电平0x02输出端口 00xFF写入 P0 输出电平0x03输出端口 10xFF写入 P1 输出电平0x04极性反转端口 00x001 表示输入取反0x05极性反转端口 10x001 表示输入取反0x06配置端口 00xFF1 为输入0 为输出0x07配置端口 10xFF1 为输入0 为输出重点记住配置寄存器的方向定义1 是输入0 是输出复位值全 1意味着上电后所有 IO 都是输入高阻态。写输出寄存器之前必须先把对应配置位改成 0否则输出不会被驱动。寄存器写封装void PCA9555_WriteReg(uint8_t reg, uint8_t value) { IIC_Start(); IIC_WriteByte(0x40); // 7 位地址 0x20 写方向位 0 IIC_WaitAck(); IIC_WriteByte(reg); // 目标寄存器地址 IIC_WaitAck(); IIC_WriteByte(value); // 写入的数据 IIC_WaitAck(); IIC_Stop(); }0x40 是 8 位写地址0x20 左移一位得到。如果你改了 A0/A1/A2 的接法这个地址要重新算。读寄存器的流程和写不一样中间要用重复起始条件不能直接 Stop 再 Startuint8_t PCA9555_ReadInput(uint8_t port) { uint8_t value 0; IIC_Start(); IIC_WriteByte(0x40); // 先写寄存器地址 IIC_WaitAck(); IIC_WriteByte(port); // 0x00 读 P00x01 读 P1 IIC_WaitAck(); IIC_Start(); // 重复起始 IIC_WriteByte(0x41); // 读方向位 1 IIC_WaitAck(); value IIC_ReadByte(); IIC_Nack(); // 读完最后一字节发 NACK IIC_Stop(); return value; }读流程里的重复起始很多人第一次写会漏直接 Stop 再 Start从机状态机不会认读回来的数据全是 0xFF。另外主机读最后一个字节必须发 NACK告诉从机不用再发下一个字节了否则从机还会继续驱动 SDA总线释放不干净。4.3 典型应用8 位输出加 8 位输入假设使用场景是 P0 全部做输出控制 LEDP1 全部做输入读按键初始化流程如下void PCA9555_Init(void) { /* 上电后所有 IO 是输入先配方向 */ PCA9555_WriteReg(0x06, 0x00); // P0 全输出 PCA9555_WriteReg(0x07, 0xFF); // P1 全输入 PCA9555_WriteReg(0x02, 0xFF); // P0 输出默认全高 } void PCA9555_SetOutput(uint8_t port, uint8_t value) { uint8_t reg (port 0) ? 0x02 : 0x03; PCA9555_WriteReg(reg, value); } uint8_t PCA9555_GetInput(uint8_t port) { uint8_t reg (port 0) ? 0x00 : 0x01; return PCA9555_ReadInput(reg); }方向配置写在 Init 里之后每次读写只操作对应的输入或输出寄存器。P0 输出写 0x02P1 输出写 0x03。注意极性反转寄存器 0x04/0x05 默认是 0x00表示输入不做反转如果之前有人改过它读回来的电平会和引脚相反。5. 模拟 IIC 调 PCA9555 常见问题排查五个翻车现场5.1 现象WaitAck 永远返回 1从机不应答地址没错、接线没错但每次发完地址后从机就是不拉低 SDA。这个现象多半出在 SDA 的端口模式上。如果 SDA 是推挽输出而不是开漏主机在释放 SDA 等 ACK 时推挽输出会把 SDA 强拉到高从机根本拉不动这条线。原因就是推挽输出的驱动能力太强从机开漏拉不赢。解决方法是把 SDA 配成开漏输出或者按前面的 SDA_IN() 宏在释放总线前切到输入模式。检查方法很简单用万用表测 SDA 静态电平总线空闲时应该是高如果主机不操作时 SDA 被某个输出口锁死为低或高先查模式配置。5.2 现象起始信号一出现SCL 和 SDA 同时跳变用逻辑分析仪抓波形时看到 SCL 下降沿附近 SDA 也在变化或者起始条件之后 SDA 没有稳定下来。原因是 IIC_Delay 太短数据建立时间 tSU;DAT 和保持时间 tHD;DAT 没满足。PCA9555 要求 SCL 高电平期间 SDA 稳定模拟代码在写字节时先设 SDA再延时再拉高 SCL这个延时小于数据建立时间的话SCL 拉高瞬间 SDA 还没稳定。解决方式是把 IIC_Delay 里的循环变量加大我一般调到 20 到 50确保每位至少有 5us 的稳定窗口。别担心速度变慢访问 16 位 IO 根本不需要多高的 IIC 速率。5.3 现象写配置寄存器后读回来还是 0xFF方向寄存器 0x06/0x07 明明是刚写的读回来却恢复成复位值。这种往往是读流程里的重复起始没写对或者写配置后立刻读取中间没有做重复起始。还有一种是 PCA9555 上电后 VDD 还没稳定芯片还在复位状态写入被丢弃。解决方法是上电后延时 50ms 再操作读流程严格按“Start→写地址→写寄存器地址→Repeat Start→写读地址→读数据→Nack→Stop”来。调试时可以在写完后读回 0x06确认是否真的写入成功再继续。5.4 现象配置成输出后IO 引脚还是高阻写 0x06/0x07 方向寄存器后对应引脚没有输出外接 LED 也不亮。检查方向寄存器配置位1 是输入0 是输出。不少人习惯性以为 1 是使能输出结果把 0x06 写成 0xFF所有引脚继续做输入。另外输出寄存器 0x02/0x03 的复位值是 0xFF如果只想让某一位置 0用软件维护一个副本不要依赖读寄存器拿当前值。解决方法是先确认方向寄存器写的是 0x00再写输出寄存器。用万用表量引脚电平如果输出模式下引脚电压接近 VDD说明配置没问题再查外部负载。5.5 现象SDA 切成输入后一直为低再也拉不回来这是 CRL 寄存器操作的一个隐蔽坑。SDA_IN() 宏把 PA7 的 CRL 位改成输入模式但输入模式下 SDA 的电平由 ODR 位决定是否启用上拉。如果 ODR 对应位是 0输入模式下引脚被内部下拉SDA 就一直是低。解决方法是切输入之前先把 ODR 对应位置 1也就是调用一次 GPIO_SetBits。另外直接在 CRL 宏里对整个 4 位区域做 ~(0xF 28)再赋值中间会短暂把模式清成模拟输入如果在中断里切换引脚模式可能产生毛刺。稳妥做法是切模式前关中断切完再开。6. 进阶宏封装、回环自检与单 bit 修改的实战细节6.1 引脚宏封装重映射只改四行前面所有代码都假设 SCL 是 PA6、SDA 是 PA7。实际项目换板子时引脚很可能要换。把引脚定义集中在宏里重映射时只改宏定义底层收发代码不用动#define PCA9555_SCL_PORT GPIOA #define PCA9555_SCL_PIN GPIO_Pin_6 #define PCA9555_SDA_PORT GPIOA #define PCA9555_SDA_PIN GPIO_Pin_7 #define IIC_SCL_H() GPIO_SetBits(PCA9555_SCL_PORT, PCA9555_SCL_PIN) #define IIC_SCL_L() GPIO_ResetBits(PCA9555_SCL_PORT, PCA9555_SCL_PIN) #define IIC_SDA_H() GPIO_SetBits(PCA9555_SDA_PORT, PCA9555_SDA_PIN) #define IIC_SDA_L() GPIO_ResetBits(PCA9555_SDA_PORT, PCA9555_SDA_PIN) #define IIC_SDA_READ() ((PCA9555_SDA_PORT-IDR PCA9555_SDA_PIN) ? 1 : 0)换引脚时除了改这四个宏还要改 GPIO 初始化里的 RCC 时钟使能以及 SDA 方向切换的 CRL 位。如果新引脚不在同一个 GPIO 端口CRL 宏要重新计算位偏移。我一般建议方向切换改用 GPIO_Init 标准库函数虽然慢一点但重映射时可读性高很多而且模拟 IIC 本身延时够大这点开销无所谓。6.2 上电自检回环法快速确认 IIC 通路调好驱动后最好先跑一遍回环自检再进业务逻辑。做法是板级设计上把 P0 和 P1 对应位用排针短接或跳线互连。自检函数先配 P0 输入、P1 输出写一组测试码再读 P0 比对uint8_t PCA9555_LoopbackTest(void) { uint8_t fail 0; PCA9555_WriteReg(0x06, 0xFF); // P0 全输入 PCA9555_WriteReg(0x07, 0x00); // P1 全输出 PCA9555_WriteReg(0x03, 0xAA); // P1 输出测试码 0xAA delay_ms(2); if (PCA9555_ReadInput(0x00) ! 0xAA) fail 1; PCA9555_WriteReg(0x03, 0x55); // 再测互补值 delay_ms(2); if (PCA9555_ReadInput(0x00) ! 0x55) fail 1; return fail; }这个函数能一次性验证 IIC 时序、PCA9555 寄存器配置、PCB 走线三个环节。如果 0xAA 和 0x55 都回读正确说明驱动没问题如果读回全 1多半是 P0 输入没拉下来查方向寄存器或外部短接线如果读回的数据在跳变优先怀疑某个 IO 虚焊。上电后先跑这个自检再初始化业务逻辑是我现在调所有扩展 IO 芯片的习惯。6.3 修改单 bit 时用软件副本别依赖读寄存器PCA9555 的输入寄存器读的是引脚电平不是输出锁存器的内部值。如果某个输出引脚接了强负载读回来的电平可能被拉低和输出寄存器里存的值不一致。所以修改某一位输出时我的做法是维护一个软件副本static uint8_t out_cache[2] {0xFF, 0xFF}; void PCA9555_SetBit(uint8_t port, uint8_t bit, uint8_t level) { if (port 1 || bit 7) return; PCA9555_WriteReg(0x06 port, 0x00); // 先配成输出 if (level) out_cache[port] | (1 bit); else out_cache[port] ~(1 bit); PCA9555_WriteReg(0x02 port, out_cache[port]); }先用方向寄存器把对应端口配成输出再改副本最后把整个字节写进输出寄存器。这里有个细节要留意0x06 和 0x07 是连续地址0x02 和 0x03 也是连续地址代码里直接用 0x06 port 和 0x02 port 就能定位到对应端口比写 if else 更简洁但可读性稍差建议加注释说明。从那以后我每次调这类扩展 IO 芯片都先抓一遍起始和 ACK 波形再做一遍回环自检才进业务逻辑踩坑记录也按现象、原因、解决整理下来。希望这份驱动拆解和排错清单能帮到你。本文还有配套的精品资源点击获取