☰
STM32F103 模拟 IIC 驱动 PCA9555 实现 16 路 IO 扩展
2026/10/20 16:03:49 网站建设 项目流程

简介:这份资源面向使用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 为什么不用硬件 IIC

STM32F103 的硬件 IIC 外设,老生常谈的问题就不重复了。更现实的原因是引脚固定——I2C1 的 SCL 在 PB6、SDA 在 PB7,I2C2 在 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 |= 8<<28;} // 输入模式 #define SDA_OUT() {GPIOB->CRL &= 0X0FFFFFFF; GPIOB->CRL |= 3<<28;} // 开漏输出 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是开漏输出 50MHz,0x8是上拉/下拉输入模式。注意 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 表示 ACK,1 表示 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); } } // 读一个字节,ack=1 时发送 ACK,ack=0 时发送 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 | (A2<<2 | A1<<1 | 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 占用率直接降下来。配置步骤:

  1. PCA9555 的 Port1 配置为输入,极性反转寄存器保持默认
  2. INT 引脚接 STM32 的 EXTI 线,下降沿触发
  3. 中断服务函数里读 PCA9555 输入寄存器,判断哪个引脚变化
  4. 处理完后读一次输入寄存器清除中断标志

注意 PCA9555 的 INT 是开漏输出,需要上拉。中断服务函数里不要做耗时操作,读一下状态置个标志位就退出。

5.4 验证方法:用回环测试确认时序

改完代码怎么确认没问题?最直接的办法是把 PCA9555 的 Port0 和 Port1 用杜邦线短接,Port0 配置输出、Port1 配置输入,然后写 Port0 的值再读 Port1,看是否一致。这个回环测试能覆盖地址、读写、方向配置全链路。我每次改完 IIC 底层都会跑一遍这个测试,比示波器还快。

从那以后我每次调模拟 IIC 都强制走一遍回环测试,确认时序没问题再接实际外设。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询