资讯动态

STM32模拟I2C访问PCA9555:GPIO时序替代硬件I2C的完整方案

发布时间:2026/10/16 6:59:07 来源:尧图企业网站定制
简介面向STM32F103嵌入式开发者的PCA9555 IO扩展实战资源包聚焦软件模拟IIC协议访问PCA9555芯片解决GPIO数量不足的常见需求。压缩包内是一套完整可编译的Keil工程共127个文件包含33个头文件、32个c源文件以及Keil工程配置文件、生成的hex与axf文件大小仅2MB导入MDK即可查看修改。已有4837人学习下载适合需要扩展IO、理解IIC时序的嵌入式初学及进阶者。工程代码完整覆盖模拟IIC的关键环节GPIO推挽输出与内部上拉配置、起始停止条件、7位地址加读写位的发送、应答位处理以及对PCA9555输入输出寄存器、中断使能寄存器的读写流程。压缩包中还包含LED相关驱动文件与批处理脚本可直接对照演示效果便于逐段理解STM32与PCA9555的数据交互细节在此基础上快速移植到实际项目中提高IO扩展开发效率。1. 放着硬件 I2C 不用我为什么改成模拟 IIC 访问 PCA9555STM32F103 访问 PCA9555 这颗 16 位 IO 扩展芯片常规做法是挂硬件 I2C 外设。我上一个项目就这么干结果调试占了两天半事件标志各种等不到、总线忙标志清了又置位、一个从机异常拖死整条总线复位外设、翻转时钟线都试过能不能跑通全看运气纯粹玄学。后来改成两个普通 GPIO 软件模拟 IIC也就是 I2C时序当晚点亮第一排 LED连续跑一周没卡死一次。这篇把这次改造完整写出来PCA9555 地址寄存器怎么对齐、模拟时序每一步在干什么、完整可跑代码、调试踩过的五个典型坑。新手能直接抄代码熟手能对照时序设计和排查依据。2. PCA9555 与模拟 I2C 先对齐这些地址、寄存器、时序、上拉2.1 地址换算7 位还是 8 位最容易搞混PCA9555 的从机地址由硬件引脚 A0/A1/A2 决定7 位地址范围是 0x20~0x27二进制 0100 A2 A1 A0。注意 I2C 协议里地址是以 8 位形式在总线上传输的即 7 位地址左移一位最低位是读写方向写 0、读 1。所以 A0/A1/A2 全部接地时7 位地址是 0x208 位写地址是 0x408 位读地址是 0x41。提示很多现成代码里定义#define PCA9555_ADDR 0x40这个值其实已经是 8 位写地址不是 7 位地址。调试时先确认手上板子 A0~A2 的实际接法再按左移规则换算能少走一半弯路。2.2 寄存器地图配置、输出、输入分开记PCA9555 有 8 个寄存器两个端口各一套。调试时最常用的就三个配置寄存器决定引脚方向输出寄存器锁存输出电平输入寄存器反映引脚实际电平。寄存器名称读/写默认值含义0x00输入端口 0只读—IO0_0~IO0_7 输入电平0x01输入端口 1只读—IO1_0~IO1_7 输入电平0x02输出端口 0读/写0x00IO0 输出锁存值0x03输出端口 1读/写0x00IO1 输出锁存值0x04极性反转 0读/写0x00置 1 则对应输入位取反0x05极性反转 1读/写0x00同上0x06配置 0读/写0xFF位为 1输入0输出0x07配置 1读/写0xFF同上上电默认所有引脚是输入配置寄存器为 0xFF这是很多人忽略的一点直接写输出寄存器无效必须先把对应配置位写成 0输出。反过来如果某脚没配成输入就去读输入寄存器读回来的是输出锁存值不是外面真实电平这个坑后面专门讲。2.3 时序拆解模拟 I2C 每一步在干什么I2C 总线就两根线模拟的本质是在正确时刻翻转 GPIO 并采样。四个基本动作起始条件SCL 为高时SDA 从高拉到低通知从机“主机要开始通信了”。停止条件SCL 为高时SDA 从低抬到高通知从机“这帧结束”。数据位SDA 只能在 SCL 为低时变化SCL 拉高后 SDA 必须稳定从机就在这个稳定的高电平窗口里采样。应答位每收完 8 个数据位第 9 个时钟里发送方释放 SDA接收方拉低表示 ACK。主机读数据时最后一个字节要回 NACK 通知从机“不用再发了”。标准模式 100kHz 的要求是 SCL 高电平不小于 4µs、低电平不小于 4.7µs。模拟代码里我就用一个统一延时函数半周期按 5µs 量级做比标准略宽一点更稳。400kHz 快速模式也能跑但纯软件时序容易被中断打断、被优化等级影响我建议先按 100kHz 跑通功能再考虑提速。2.4 硬件连接开漏、上拉和电平域SDA 是双向线主机要发送也要接收最省事的接法是 STM32 的 SDA 配成开漏输出外部接一个 4.7kΩ 上拉到 3.3V。开漏模式下写 1 等于释放总线由外部上拉拉高写 0 是强拉低读引脚电平直接用输入数据寄存器因为开漏模式下输入通路始终有效不用切换方向。SCL 由主机独占配推挽输出即可。PCA9555 供电范围是 2.3V~5.5V和 STM32F103 一起用 3.3V 供电最省事两边同一个电平域不需要电平转换。如果板子上已经按 5V 供电设计SDA/SCL 的电平兼容问题会在后面避坑章里展开。另外模拟 I2C 对引脚没有特殊要求换引脚只改宏定义和 GPIO 初始化这正是我弃用硬件外设的原因之一。3. 完整代码从 GPIO 初始化到 PCA9555 寄存器读写的全部细节3.1 引脚宏、基础延时与 GPIO 初始化我用 PB6 做 SCL、PB7 做 SDA。PB6/PB7 默认没有被调试接口占用可以直接使用。宏定义把引脚操作收敛到一处换引脚时只改这一块/* 引脚定义PB6 作 SCLPB7 作 SDA */ #define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_Pin_7 #define SCL_H() GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SCL_L() GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SDA_H() GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define SDA_L() GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN) /* 约 5us 量级延时实际时长取决于主频和优化等级跑起来后用示波器校一次 */ static void I2C_Delay(void) { volatile uint16_t i; for (i 0; i 100; i); } void I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); /* SCL主机独占推挽输出 */ GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_SCL_PORT, GPIO_InitStructure); /* SDA双向开漏输出 外部上拉 */ GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_SDA_PORT, GPIO_InitStructure); SCL_H(); SDA_H(); }I2C_Delay 里我故意写了 volatile 变量防止编译器在开优化时把整个循环优化掉。延时次数的“正确值”不是一个固定数字72MHz 主频、不同优化等级、不同编译器最终延时都不一样。我的校准方法很简单逻辑分析仪挂上 SCL看高低电平宽度保证各不小于 4µs 就行。3.2 起始、停止、字节收发与应答判断这一层是模拟 I2C 的核心四个函数把协议拆到最小粒度。起始和停止的顺序是关键起始是“SCL 高时 SDA 拉低”停止是“SCL 高时 SDA 抬升”两者的前提都是 SCL 先在高位不能搞反。static void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); /* SCL 为高时 SDA 由高到低起始条件 */ I2C_Delay(); SCL_L(); } static void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); /* SCL 为高时 SDA 由低到高停止条件 */ I2C_Delay(); } static void I2C_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) SDA_H(); else SDA_L(); data 1; I2C_Delay(); SCL_H(); /* SCL 高电平期间 SDA 必须稳定 */ I2C_Delay(); SCL_L(); I2C_Delay(); } } static uint8_t I2C_WaitAck(void) { SDA_H(); /* 释放 SDA从机应答时会主动拉低 */ I2C_Delay(); SCL_H(); I2C_Delay(); if (SDA_READ()) /* 高电平说明没有 ACK */ { SCL_L(); return 1; } SCL_L(); I2C_Delay(); return 0; } static uint8_t I2C_ReadByte(uint8_t ack) { uint8_t i, data 0; SDA_H(); /* 释放 SDA交给从机驱动 */ for (i 0; i 8; i) { SCL_H(); I2C_Delay(); data 1; if (SDA_READ()) data | 0x01; SCL_L(); I2C_Delay(); } if (ack) SDA_L(); /* ack1 继续读ack0 回 NACK 结束 */ else SDA_H(); I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); SDA_H(); /* 释放总线 */ return data; }I2C_WaitAck 返回 1 表示没收到应答调用方要立刻 I2C_Stop 并且不要继续发后续字节否则从机的状态机可能错乱。I2C_ReadByte 的 ack 参数是给连续读多字节用的读最后一个字节传 0前面字节传 1。这里每个函数都比较短调试时可以在关键位置加 GPIO 翻转做软件示波器但先保证逻辑对再谈调优。3.3 寄存器写入与读取地址、寄存器号、数据三段式PCA9555 写寄存器是三段式先发从机写地址再发寄存器号最后发数据。读寄存器是四步发写地址、发寄存器号、重复起始、发读地址然后读回数据。重复起始的作用是“不停止总线就切换读写方向”比 StopStart 更规范也少占总线时间。/* A0/A1/A2 全接地7 位地址 0x20写地址 0x40读地址 0x41 */ #define PCA9555_ADDR_W 0x40 #define PCA9555_ADDR_R 0x41 #define PCA9555_REG_IN0 0x00 #define PCA9555_REG_IN1 0x01 #define PCA9555_REG_OUT0 0x02 #define PCA9555_REG_OUT1 0x03 #define PCA9555_REG_POL0 0x04 #define PCA9555_REG_POL1 0x05 #define PCA9555_REG_CFG0 0x06 #define PCA9555_REG_CFG1 0x07 /* 成功返回 0失败返回 1 */ uint8_t PCA9555_WriteReg(uint8_t reg, uint8_t value) { I2C_Start(); I2C_SendByte(PCA9555_ADDR_W); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(reg); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(value); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Stop(); return 0; } /* 读失败返回 0xFF */ uint8_t PCA9555_ReadReg(uint8_t reg) { uint8_t value 0xFF; I2C_Start(); I2C_SendByte(PCA9555_ADDR_W); /* 先写寄存器地址 */ if (I2C_WaitAck()) { I2C_Stop(); return 0xFF; } I2C_SendByte(reg); if (I2C_WaitAck()) { I2C_Stop(); return 0xFF; } I2C_Start(); /* 重复起始切读方向 */ I2C_SendByte(PCA9555_ADDR_R); if (I2C_WaitAck()) { I2C_Stop(); return 0xFF; } value I2C_ReadByte(0); /* 只读一字节回 NACK */ I2C_Stop(); return value; }错误返回的语义要跟调用方约定好写接口返回 0/1读接口失败返回 0xFF业务代码里看到 0xFF 要当作异常处理而不是真实数据。PCA9555 的寄存器都是单字节读一字节场景够用如果要连续读两个端口把 I2C_ReadByte 的第一个参数传 1ACK最后一个传 0NACK即可。3.4 参数说明哪些地方按你的板子改换板子最常改三处。第一是引脚宏和 GPIO 初始化里的端口、引脚号PB6/PB7 换成 PA4/PA5 之类的只需改两处。第二是 PCA9555_ADDR_W/RA0~A2 接法不同地址就不同按 2.1 节的换算规则重新算。第三是 I2C_Delay 的循环次数判断标准是 SCL 高低电平不小于 4µs而不是死记某个数字这个一定要用示波器或逻辑分析仪确认一次。注意如果板子上 PCA9555 的 A0~A2 不是全接地0x40/0x41 这两个地址必须重新算。最快的办法是写一个总线扫描后面避坑章会详细讲。4. 实测流程8 个按键 8 个 LED 的接入与验证4.1 硬件准备与初始化顺序实测场景我给一个最常见的接法P0 的 8 个引脚各接一个 LEDLED 阳极经 1kΩ 限流电阻到 3.3V阴极接 P0 引脚输出低电平点亮P1 的 8 个引脚各接一个按键到 GND按下读回低电平。这样输出和输入分开在两个端口寄存器操作也直观。初始化顺序有讲究先配方向再配输出初值。如果先写输出再配方向配置寄存器写完后端口默认值可能闪一下。我一般这样写void PCA9555_Init(void) { /* 先配方向P0 全输出P1 全输入 */ PCA9555_WriteReg(PCA9555_REG_CFG0, 0x00); PCA9555_WriteReg(PCA9555_REG_CFG1, 0xFF); /* 再给输出初值全高LED 全灭 */ PCA9555_WriteReg(PCA9555_REG_OUT0, 0xFF); }配置 P1 为全输入那行其实可以省略因为芯片上电默认就是全输入但我习惯显式写出来防止有人改过上电时的默认行为、或者代码在别处改过配置寄存器显式声明让初始化自文档化。4.2 主循环读按键、翻转 LED主循环逻辑非常简单读 P1 的输入寄存器按键按下对应位是 0直接把读到的值写到 P0 输出寄存器输出为 0 的位点亮对应 LED。0 和 0 正好映射不需要额外取反逻辑。int main(void) { uint8_t keys, led; I2C_GPIO_Init(); /* 初始化模拟 I2C 引脚 */ PCA9555_Init(); /* 配置 PCA9555 方向与初值 */ while (1) { keys PCA9555_ReadReg(PCA9555_REG_IN1); /* 按下为 0 */ led keys; /* 输出 0 点亮 LED */ PCA9555_WriteReg(PCA9555_REG_OUT0, led); } }这里没做按键消抖因为演示主逻辑用真实项目里按键按下或释放后延时 10~20ms 再读一次确认或者加个简单的软件滤波。一次轮询包含两次读、一次写按 100kHz 时序算大约 1ms轮询频率足够按键检测用。4.3 验证方法回读、量引脚、看波形代码跑起来后按下面三步验证顺序别乱。第一步回读配置PCA9555_ReadReg(0x06)应返回 0x00PCA9555_ReadReg(0x07)应返回 0xFF这一步同时验证了读方向和寄存器映射。第二步量外部引脚万用表量 P0 某个引脚按下对应按键后电平翻转量 P1 某个引脚按下时读到 0V。第三步上逻辑分析仪抓 SCL/SDA确认起始条件出现在 SCL 高电平期间、地址字节是 0x40 或 0x41、每字节后第九个时钟 SDA 被拉低ACK。这一整套验证下来基本能确定问题在协议层还是应用层。模拟 I2C 相比硬件外设最大的好处就是这个所有信号都是普通 GPIO 电平逻辑分析仪直接看没有外设内部状态这种黑匣子问题定位快得多。5. 避坑模拟 I2C 访问 PCA9555 的五个典型翻车点以下五个问题是我实际调试和帮别人排查时反复遇到的每个都是现象、原因、解决三段式按排查顺序排列。5.1 完全没波形先查时钟、上拉、引脚占用现象逻辑分析仪挂上去一个跳变都抓不到程序仿佛卡死在某个 while 里。原因最常见是两处。一是 GPIO 时钟没使能RCC_APB2PeriphClockCmd 这行被漏掉引脚配置写进去等于没写二是 SDA 用了开漏模式但外部根本没加上拉电阻引脚悬空读到的电平随机ACK 永远判不到。解决先补时钟使能再确认 SCL 和 SDA 各有一个 4.7kΩ 上拉到 3.3V这是模拟 I2C 最容易漏的一步最后确认所用引脚没被其他外设或调试接口占用PB6/PB7 默认是安全的。5.2 ACK 一直超时地址换算错了现象发出 0x40 后第九个时钟 SDA 一直是高I2C_WaitAck 永远返回 1读地址 0x41 也一个样。原因要么 A0/A1/A2 实际接法和代码里的假设不一致要么把 7 位地址和 8 位地址搞混。常见误用是手册写了“从机地址 0x20”代码里直接 I2C_SendByte(0x20)这发送的其实是 7 位地址 0x20 左移一位后的值 0x20完全对不上。解决先把 A0~A2 的实际接线确认一遍再算 8 位地址。更快的办法是写一个简单的总线扫描I2C_Start 后依次发 0x00~0x7F检查哪个地址有 ACK一秒钟就能定位正确的写地址。有 ACK 的地址右移一位就是 7 位地址跟 A0~A2 接线一对照马上能发现问题。5.3 读输入永远 0xFF配置和极性先查一遍现象按键按下PCA9555_ReadReg(0x01) 纹丝不动返回 0xFF或者按下没反应、松开反而读到变化。原因三种情况。一是引脚方向没配对配置寄存器还是 0引脚是输出模式这时读输入寄存器读到的是输出锁存值二是极性反转寄存器 0x04/0x05 被误写过输入电平被取反三是读的寄存器号写错比如把 0x00 当输入、0x01 也是输入但实际接的是另一组端口。解决先回读 0x06/0x07 确认方向配置再读 0x04/0x05 确认极性都是 0x00然后用万用表量引脚电平排除外部信号本身没进来。读输入之前先做一次回读验证能过滤掉一大半“代码没问题”的假象。5.4 偶发卡死时序余量、ACK 处理、中断抢占现象连续跑几小时偶发一次读回 0xFF之后总线像被占住复位单片机才能恢复。原因三个叠加因素。一是时序跑太临界想上 400kHz 快速模式但软件延时抖动大某个沿不满足建立时间二是某次 ACK 失败后没走 Stop 就继续发下一帧从机状态机卡住后续所有通信都错乱三是中断在 SCL 高电平期间抢占 CPU导致这个高电平窗口变短或变长从机采样出错。解决模拟时序主动降到 100kHz 标准模式延时留足余量ACK 失败立即 I2C_Stop 并返回错误调用方做有限次重试读写过程中关中断或保证这段代码不被高优先级中断打断。如果从机已经卡死主机可以在总线上发 9 个 SCL 脉冲再发一个停止条件让从机复位状态机这是 I2C 手册里的标准复位招数。5.5 3.3V 和 5V 混用电平域不对齐现象硬件看起来都连了但读写偶尔错、或者芯片明显发烫按一下复位又好一阵。原因PCA9555 被 5V 供电SDA/SCL 上拉也接到了 5V3.3V 的 STM32 引脚被强行抬到 5V 电平。一次两次没烧但引脚长期过压损伤是累积的通信错误也是电平阈值不匹配导致的。解决PCA9555 的供电范围本身就包含 3.3V最简单是两边统一用 3.3V 供电。如果系统里 PCA9555 必须 5V那就需要电平转换或者退一步上拉电阻改接到 3.3VSDA/SCL 各串一个 100Ω~1kΩ 限流电阻让高电平不超过 3.3V低电平仍能被 5V 侧正确识别做保护。优先保证两边处于同一个电平域这是最省心的方案。6. 收尾每次上电先做这三个验证动作模拟 I2C 跑通只是第一步真正考验是换板子、换芯片批次之后还能不能一次点亮。我的习惯是固定验证三步每次上电先做确认地基稳了再进业务代码。6.1 第一步量 SCL 频率和 SDA 沿逻辑分析仪挂上 SCL 和 SDA先看两件事SCL 高低电平宽度各不小于 4µs起始条件是 SCL 高电平期间 SDA 的下降沿。这两点对了说明延时校准和基本时序没问题协议地基算稳了。6.2 第二步回读配置寄存器写 0x06 0x00再读回来一致说明地址、读写方向、寄存器映射全部正确。这一步不过后面的业务逻辑全是空中楼阁先回头查地址换算和 ACK 处理。6.3 第三步外部短接看输入变化拿杜邦线把 P1 某个引脚短到 GND读 0x01 对应位变 0松开恢复 1。这证明外部信号真正进到了芯片里而不是在寄存器层面自嗨。这三个动作加一起不到五分钟但能把协议层、配置层、信号层三个层面的问题一次性过滤掉。从那以后我每次换引脚、换板子、换芯片批次都强制先跑一遍这三个验证再写业务代码——省下的调试时间远比这五分钟多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑