
简介这份资源面向使用STM32F103进行嵌入式开发的工程师与学习者聚焦于用GPIO软件模拟IIC时序来驱动PCA9555十六位IO扩展芯片解决单片机引脚不足、需要扩展输入输出端口的问题。压缩包共127个文件约2MB以32个c源文件与33个h头文件为核心涵盖STM32标准外设库、PCA9555读写驱动及工程配置另含o、d、crf等编译中间文件与uvprojx、uvoptx工程文件、hex与axf可执行产物以及sct、map、lst等链接与列表文件便于直接编译验证。资源完整呈现了起始条件、器件地址、寄存器读写、应答处理与停止条件的实现思路并涉及输入、输出、中断使能及中断状态等寄存器的操作可帮助读者掌握模拟IIC时序调试与IO扩展的工程方法。目前已有4838人学习下载适合需要快速上手或排错参考的开发者。1. 用两根普通 IO 口把 PCA9555 拉起来这事比想象中稳手头一块 STM32F103 的板子IO 口被外设占得七七八八偏偏还要再挂 16 路按键和指示灯。硬件同事甩过来一颗 PCA9555说“IIC 的两根线搞定”。结果一查原理图这俩脚没接硬件 IIC 外设只能软件模拟。当时心里咯噔一下——模拟 IIC 时序全靠 GPIO 翻转稍不留神就翻车。但实际调通之后发现STM32F103 的 72MHz 主频跑模拟 IIC 绰绰有余PCA9555 的时序容忍度也比数据手册上写的宽松。这套方案适合那些 IO 资源紧张、又不想动 PCB 改硬件 IIC 引脚的场景尤其是工控板、多路 IO 扩展这类需求。下面把整个从时序到读写的链路拆开讲代码可以直接抄。2. 模拟 IIC 的时序底座GPIO 翻转怎么做到不丢步2.1 为什么不用硬件 IICSTM32F103 的硬件 IIC 外设老生常谈的问题就不重复了。更现实的原因是引脚固定——I2C1 的 SCL 在 PB6、SDA 在 PB7I2C2 在 PB10/PB11。如果 PCB 已经打样PCA9555 挂在别的脚上硬件 IIC 根本够不着。模拟 IIC 的好处是任意两个 GPIO 都能当 SCL/SDA布局灵活。代价是时序要自己控但 PCA9555 的 IIC 速率支持到 400kHz我们模拟到 100kHz 左右完全够用CPU 开销在可接受范围内。2.2 GPIO 模式配置的讲究模拟 IIC 的 SCL 和 SDA 都需要在开漏输出和输入之间切换。STM32F103 的 GPIO 配置成开漏输出、使能内部上拉或外部上拉这样总线空闲时能被拉高。SDA 在发送 ACK 或读数据时要切成输入模式。// iic_gpio.h - 模拟 IIC 引脚定义与初始化 #define IIC_SCL_PIN GPIO_Pin_6 #define IIC_SCL_PORT GPIOB #define IIC_SDA_PIN GPIO_Pin_7 #define IIC_SDA_PORT GPIOB // 引脚方向宏输出模式用于驱动总线输入模式用于释放总线 #define SDA_IN() {GPIOB-CRL 0X0FFFFFFF; GPIOB-CRL | 828;} // 输入模式 #define SDA_OUT() {GPIOB-CRL 0X0FFFFFFF; GPIOB-CRL | 328;} // 开漏输出 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_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(IIC_SCL_PORT, GPIO_InitStructure); IIC_SCL 1; // 总线空闲两根线都拉高 IIC_SDA 1; }这里直接操作 CRL 寄存器切换 SDA 方向比调GPIO_Init快得多。CRL 控制 Pin0~Pin7每个引脚占 4 个 bit。0x3是开漏输出 50MHz0x8是上拉/下拉输入模式。注意 SDA 切输入时不要配成浮空否则读到的电平不稳定。2.3 起始、停止、应答的时序实现IIC 的起始条件是 SCL 高电平期间 SDA 从高变低停止条件是 SCL 高电平期间 SDA 从低变高。这两个信号必须严格保证。// 起始信号SCL 高时 SDA 拉低 void IIC_Start(void) { SDA_OUT(); IIC_SDA 1; IIC_SCL 1; delay_us(4); IIC_SDA 0; // SCL 高电平期间 SDA 下降沿 delay_us(4); IIC_SCL 0; // 钳住总线准备发送数据 } // 停止信号SCL 高时 SDA 拉高 void IIC_Stop(void) { SDA_OUT(); IIC_SCL 0; IIC_SDA 0; delay_us(4); IIC_SCL 1; IIC_SDA 1; // SCL 高电平期间 SDA 上升沿 delay_us(4); } // 等待应答返回 0 表示 ACK1 表示 NACK u8 IIC_Wait_Ack(void) { u8 ucErrTime 0; SDA_IN(); // SDA 切输入释放总线 IIC_SDA 1; // 上拉等待从机拉低 delay_us(1); IIC_SCL 1; delay_us(1); while(READ_SDA) // 读 SDA 电平 { ucErrTime; if(ucErrTime 250) { IIC_Stop(); return 1; // 超时无应答 } } IIC_SCL 0; return 0; }delay_us(4)在 72MHz 下大约对应 4~5 微秒SCL 周期约 10 微秒对应 100kHz 速率。这个速率对 PCA9555 来说很安全。IIC_Wait_Ack里的超时计数是后悔药——如果从机没接好或者地址错了没有超时机制会死循环卡死整个程序。2.4 字节收发与 ACK 处理// 发送一个字节高位先发 void IIC_Send_Byte(u8 txd) { u8 t; SDA_OUT(); IIC_SCL 0; for(t 0; t 8; t) { IIC_SDA (txd 0x80) 7; // 取最高位 txd 1; delay_us(2); IIC_SCL 1; delay_us(2); IIC_SCL 0; // 拉低 SCL准备下一位 delay_us(2); } } // 读一个字节ack1 时发送 ACKack0 时发送 NACK u8 IIC_Read_Byte(unsigned char ack) { unsigned char i, receive 0; SDA_IN(); for(i 0; i 8; i) { IIC_SCL 0; delay_us(2); IIC_SCL 1; receive 1; if(READ_SDA) receive; // 采样 SDA delay_us(1); } if(ack) IIC_Ack(); // 主机发送应答 else IIC_NAck(); // 最后一个字节发 NACK return receive; }发送时先拉低 SCL 再改 SDA保证数据在 SCL 低电平期间变化高电平期间稳定。读取时在 SCL 高电平中间采样这是 IIC 协议规定的数据有效窗口。IIC_Ack和IIC_NAck就是拉低或拉高 SDA 一个时钟脉冲代码简单不展开。3. PCA9555 驱动层寄存器映射与读写封装3.1 PCA9555 的寄存器结构PCA9555 是 16 位 IO 扩展器分成两个 8 位端口Port0 和 Port1。内部有 8 个寄存器每对寄存器控制一个端口寄存器Port0 地址Port1 地址功能输入寄存器0x000x01读引脚电平输出寄存器0x020x03写输出电平极性反转0x040x05翻转输入极性配置寄存器0x060x071输入0输出设备地址由 A0/A1/A2 三个引脚决定7 位地址范围是 0x20~0x27。写操作时地址字节是0x40 | (A22 | A11 | A0)读操作是0x41 | ...。3.2 写寄存器配置 IO 方向// 写 PCA9555 寄存器 // addr: 设备地址(0x20~0x27), reg: 寄存器偏移, data: 写入值 u8 PCA9555_WriteReg(u8 addr, u8 reg, u8 data) { IIC_Start(); IIC_Send_Byte((addr 1) | 0x00); // 写地址 if(IIC_Wait_Ack()) { IIC_Stop(); return 1; } IIC_Send_Byte(reg); // 寄存器地址 if(IIC_Wait_Ack()) { IIC_Stop(); return 1; } IIC_Send_Byte(data); // 数据 if(IIC_Wait_Ack()) { IIC_Stop(); return 1; } IIC_Stop(); return 0; } // 初始化Port0 全部输出Port1 全部输入 void PCA9555_Init(u8 addr) { PCA9555_WriteReg(addr, 0x06, 0x00); // Port0 配置为输出 PCA9555_WriteReg(addr, 0x07, 0xFF); // Port1 配置为输入 PCA9555_WriteReg(addr, 0x02, 0xFF); // Port0 输出全高 }配置寄存器的逻辑要记牢写 1 是输入写 0 是输出。这个和很多人的直觉相反我第一次用的时候就把方向搞反了读回来的数据全是 0xFF排查了半天。初始化时先把输出寄存器写成 0xFF这样配置成输出的引脚默认高电平避免上电瞬间外设误动作。3.3 读寄存器获取输入状态// 读 PCA9555 寄存器 u8 PCA9555_ReadReg(u8 addr, u8 reg) { u8 data; IIC_Start(); IIC_Send_Byte((addr 1) | 0x00); // 写地址 IIC_Wait_Ack(); IIC_Send_Byte(reg); // 寄存器地址 IIC_Wait_Ack(); IIC_Start(); // 重复起始条件 IIC_Send_Byte((addr 1) | 0x01); // 读地址 IIC_Wait_Ack(); data IIC_Read_Byte(0); // 读一个字节NACK IIC_Stop(); return data; }读操作的关键是重复起始条件Restart而不是 Stop 之后再 Start。PCA9555 支持这种复合格式。最后一个字节读完后主机发 NACK告诉从机“我不再需要数据了”然后发停止条件。3.4 位操作封装按位读写 IO实际用的时候很少整字节读写更多是按位操作。封装两个函数// 写单个输出引脚 // port: 0 或 1, pin: 0~7, level: 0 或 1 void PCA9555_WritePin(u8 addr, u8 port, u8 pin, u8 level) { u8 reg (port 0) ? 0x02 : 0x03; u8 val PCA9555_ReadReg(addr, reg); // 读回当前输出寄存器 if(level) val | (1 pin); else val ~(1 pin); PCA9555_WriteReg(addr, reg, val); } // 读单个输入引脚 u8 PCA9555_ReadPin(u8 addr, u8 port, u8 pin) { u8 reg (port 0) ? 0x00 : 0x01; u8 val PCA9555_ReadReg(addr, reg); return (val pin) 0x01; }这里有个细节写引脚时先读回输出寄存器的值再改而不是直接写。因为 PCA9555 的输出寄存器是可读的读回的是上次写入的锁存值不是引脚实际电平。这样操作不会影响同端口其他引脚的状态。如果直接写整个字节会把其他位覆盖掉。4. 避坑与排查模拟 IIC 调 PCA9555 最容易翻车的几个点4.1 上电后读回全是 0xFF 或 0x00现象初始化后读输入寄存器不管引脚怎么拉返回值不变。原因大概率是设备地址不对。PCA9555 的 A0/A1/A2 如果悬空内部有弱上拉地址会变成 0x27。但有些板子设计时直接接地地址就是 0x20。另外写地址时忘了左移一位也是常见错误。解决先用万用表量 A0/A1/A2 的电平确定实际地址。然后在IIC_Send_Byte后加一句IIC_Wait_Ack的返回值判断如果返回 1 说明从机根本没应答地址或接线有问题。4.2 能写不能读或者读一次就卡死现象写输出正常LED 能亮但读输入时程序卡在while(READ_SDA)里出不来。原因SDA 方向切换后没有正确释放总线。SDA_IN()宏配置的是输入模式但如果没有使能上拉SDA 可能浮空读到的电平不确定。另外读操作最后没有发 NACK 和 Stop从机会一直拉住 SDA 等待下一个时钟。解决确认SDA_IN()配置的是上拉输入或外部有上拉电阻4.7k 到 10k。读最后一个字节时IIC_Read_Byte(0)参数传 0发 NACK。检查IIC_Stop是否在所有返回路径上都调用了。4.3 时序太快导致偶发失败现象单独测试正常但加入其他任务后偶尔读写失败重启又好了。原因delay_us的精度受中断影响。如果系统里有高优先级中断delay 会被拉长导致 SCL 高电平时间过长。PCA9555 倒不会因此出错但如果从机是其他对时序敏感的器件就可能出问题。解决模拟 IIC 的延时不要用系统滴答定时器用空循环__NOP()或者简单的 for 循环。另外在 IIC 传输前后关中断保证时序原子性。传输数据量小的话关中断几十微秒对系统影响可以忽略。4.4 输出寄存器读回值和引脚实际电平不一致现象写 Port0 输出 0x00读回输出寄存器也是 0x00但万用表量引脚是高电平。原因PCA9555 的输出是开漏结构需要外部上拉才能输出高电平。如果配置成输出但外部没有上拉写 1 时引脚是浮空的写 0 才能拉低。读回的是输出锁存器的值不是引脚实际电平。解决确认输出引脚外部有上拉电阻。如果驱动 LED建议低电平点亮这样 PCA9555 灌电流能力足够。读引脚实际电平要用输入寄存器不是输出寄存器。4.5 多字节连续读写时地址自动递增问题现象想连续读 Port0 和 Port1 的输入寄存器发了一次寄存器地址后连续读两个字节结果第二个字节不对。原因PCA9555 支持地址自动递增但前提是读操作中间不能发 Stop。如果每次读都重新 Start寄存器地址不会自动加。解决连续读的时候用重复起始条件中间不插 Stop。或者干脆分两次独立读代码简单不容易错。我一般用后者多几个微秒的事换代码可读性。5. 二次开发改造从能跑到好用5.1 把模拟 IIC 抽象成可复用层上面代码里 SCL/SDA 的宏定义是写死的。如果板子上有多路模拟 IIC或者要换引脚改起来麻烦。我一般会定义一个结构体把引脚和延时参数封装进去typedef struct { GPIO_TypeDef *scl_port; uint16_t scl_pin; GPIO_TypeDef *sda_port; uint16_t sda_pin; uint8_t dev_addr; // PCA9555 7 位地址 } SoftIIC_Bus; // 使用示例 SoftIIC_Bus bus1 {GPIOB, GPIO_Pin_6, GPIOB, GPIO_Pin_7, 0x20};然后把IIC_Start、IIC_Send_Byte这些函数改成接受SoftIIC_Bus *bus参数。这样同一套代码可以驱动多路 IIC 总线换引脚只改初始化结构体。代价是每次操作多一层指针解引用但在 100kHz 速率下完全无感。5.2 增加超时重试机制工业现场干扰大IIC 通信偶尔失败很正常。与其让程序卡死不如加一层重试u8 PCA9555_WriteReg_Retry(u8 addr, u8 reg, u8 data, u8 retry) { while(retry--) { if(PCA9555_WriteReg(addr, reg, data) 0) return 0; delay_ms(1); } return 1; // 重试耗尽返回错误 }重试次数一般设 3 次间隔 1ms。如果 3 次都失败说明硬件真有问题该报错报错该亮故障灯亮故障灯。这个习惯是从以前做电机控制时养成的——IIC 读编码器芯片偶尔被 PWM 干扰重试一次就好了。5.3 用输入中断替代轮询PCA9555 的 INT 引脚在输入变化时会拉低可以接到 STM32 的外部中断引脚上。这样按键检测不用轮询CPU 占用率直接降下来。配置步骤PCA9555 的 Port1 配置为输入极性反转寄存器保持默认INT 引脚接 STM32 的 EXTI 线下降沿触发中断服务函数里读 PCA9555 输入寄存器判断哪个引脚变化处理完后读一次输入寄存器清除中断标志注意 PCA9555 的 INT 是开漏输出需要上拉。中断服务函数里不要做耗时操作读一下状态置个标志位就退出。5.4 验证方法用回环测试确认时序改完代码怎么确认没问题最直接的办法是把 PCA9555 的 Port0 和 Port1 用杜邦线短接Port0 配置输出、Port1 配置输入然后写 Port0 的值再读 Port1看是否一致。这个回环测试能覆盖地址、读写、方向配置全链路。我每次改完 IIC 底层都会跑一遍这个测试比示波器还快。从那以后我每次调模拟 IIC 都强制走一遍回环测试确认时序没问题再接实际外设。希望帮到你。本文还有配套的精品资源点击获取