简介:面向嵌入式开发者与STM32初学者,这是一套围绕STM32F103软件模拟IIC并访问PCA9555 IO扩展芯片的完整工程资料,可用于GPIO资源不足时的端口扩展实验,也可作为裸机IIC时序学习与移植参考。压缩包共127个文件、约2MB,以32个c源文件和33个h头文件为主体,保留Keil工程配置及hex、axf等编译输出,并含有少量脚本与说明文件,便于直接打开对照。已有4838人学习。资料完整呈现GPIO推挽输出与上拉配置、起始与停止条件、7位地址及读写位、应答处理,以及对输入、输出、中断使能等寄存器的操作思路,读者可据此理解时序调试、寄存器读写和常见通信故障排查,并快速将模拟IIC与PCA9555驱动迁移到自己的项目中。
1. 从两根线到十六个IO:PCA9555 与软件 IIC 的适用边界
STM32F103 的硬件 IIC 外设不是不能用,但在引脚被占死、总线设备地址固定、时序需要微调的场合,用 GPIO 模拟 IIC 访问 PCA9555 更省心。PCA9555 是 16 位 I/O 扩展器,IIC 接口,7 位地址由 A2/A1/A0 决定,两组 8 位端口可独立配置输入或输出,适合按键面板、LED 指示、继电器阵列这类“IO 不够用”的场景。模拟 IIC 的价值不在替代硬件外设,而在于把 SCL/SDA 挪到任意 GPIO、把半周期延时调到自己能接受的档位,并且出问题时能逐位看波形。这套代码面向 STM32F103 最小系统板和 3.3V 供电的 PCA9555 模块,新手能按步骤点亮端口,熟手能对照时序参数和地址算法检查边界。
2. 硬件与地址先对齐:PCA9555 最小系统与 7 位地址算法
2.1 PCA9555 引脚、上拉与供电的底线
PCA9555 的接线不复杂,但先要把几个“底线”钉死。VDD 和 GND 必须和 MCU 共地,模块供电优先用 3.3V,和 STM32F103 的 GPIO 电平一致。SCL、SDA 各接一只 4.7k 上拉到 VDD,INT 是开漏输出,不用中断也要上拉或明确悬空处理。A0、A1、A2 不能悬空,必须接 VDD 或 GND,否则地址会漂,扫描时能看到“有时应答、有时不应答”的玄学现象。P0_0 到 P0_7、P1_0 到 P1_7 是 16 个 I/O,上电默认全部为输入,配置寄存器默认 0xFF,这一点后面写输出时还会碰到。
| 引脚 | 方向 | 接法 | 说明 |
|---|---|---|---|
| VDD | 电源 | 3.3V | 与 MCU 同电平,5V 供电要确认总线电平 |
| GND | 地 | 共地 | 不共地时读到的全是假 ACK |
| SCL | 输入 | MCU GPIO + 4.7k 上拉 | 开漏驱动 |
| SDA | 双向 | MCU GPIO + 4.7k 上拉 | 开漏驱动,读时释放 |
| A0/A1/A2 | 输入 | 接 VDD 或 GND | 决定 7 位地址低三位 |
| INT | 输出 | 开漏,需上拉 | 不用中断可不接,但别让它拉低总线 |
| P0/P1 | I/O | 按负载接 | 输入高阻,输出锁存 |
PCA9555 内部寄存器按命令字节寻址,常用的是下面这几个。写配置之前先读一遍,能排除“模块默认状态和你想的不一样”的低级翻车。
| 命令字节 | 寄存器 | 读/写 | 上电默认 | 作用 |
|---|---|---|---|---|
| 0x00 | 输入端口 0 | 读 | 反映引脚 | P0 引脚实际电平 |
| 0x01 | 输入端口 1 | 读 | 反映引脚 | P1 引脚实际电平 |
| 0x02 | 输出端口 0 | 读/写 | 0xFF | P0 输出锁存 |
| 0x03 | 输出端口 1 | 读/写 | 0xFF | P1 输出锁存 |
| 0x04 | 极性反转 0 | 读/写 | 0x00 | 输入极性反转 |
| 0x05 | 极性反转 1 | 读/写 | 0x00 | 输入极性反转 |
| 0x06 | 配置 0 | 读/写 | 0xFF | 0=输出,1=输入 |
| 0x07 | 配置 1 | 读/写 | 0xFF | 0=输出,1=输入 |
注意:PCA9555 上电默认 16 个口全是输入。只写输出寄存器 0x02/0x03 不会让引脚变输出,必须先把 0x06/0x07 对应位清 0。
2.2 地址字节怎么算:A2/A1/A0 与读写位
PCA9555 的 7 位地址固定高四位是 0x20 的高位部分,低三位由 A2、A1、A0 决定。公式是addr7 = 0x20 | (A2 << 2) | (A1 << 1) | A0。A0 到 A2 全接地时是 0x20,全接 VDD 时是 0x27,一条总线上最多挂 8 片。IIC 实际发出去的是 8 位地址字节:写操作是addr7 << 1,读操作是(addr7 << 1) | 1。很多人在这里翻车,是把模块资料里的“写地址 0x40”又左移了一次,结果发成 0x80,扫描当然全无应答。
| A2 | A1 | A0 | 7 位地址 | 写字节 | 读字节 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0x20 | 0x40 | 0x41 |
| 0 | 0 | 1 | 0x21 | 0x42 | 0x43 |
| 0 | 1 | 0 | 0x22 | 0x44 | 0x45 |
| 0 | 1 | 1 | 0x23 | 0x46 | 0x47 |
| 1 | 0 | 0 | 0x24 | 0x48 | 0x49 |
| 1 | 0 | 1 | 0x25 | 0x4A | 0x4B |
| 1 | 1 | 0 | 0x26 | 0x4C | 0x4D |
| 1 | 1 | 1 | 0x27 | 0x4E | 0x4F |
读 PCA9555 时不是直接发读地址就完事,标准流程是“写地址 + 命令字节 + 重复起始 + 读地址 + 读数据 + NACK + STOP”。命令字节告诉器件从哪个寄存器开始读,读地址里的最低位为 1 表示主机要收数据。写流程则是“写地址 + 命令字节 + 数据 + STOP”。这两个流程在后面的驱动函数里会各出现一次,先把地址字节算对,后面只是时序问题。
2.3 用扫描代码确认总线:0x20 到 0x27 的逐地址探测
在写完整驱动之前,先写一个只发地址字节的探测函数。它不读寄存器,也不写数据,只判断第 9 个时钟上从机有没有把 SDA 拉低。这个动作能把“接线错、地址错、供电错”三类问题先筛掉。
/* 返回 1 表示该 7 位地址上有器件应答,0 表示无应答 */ uint8_t pca9555_probe(uint8_t addr7) { uint8_t ack; iic_start(); /* 起始条件 */ ack = iic_write_byte((addr7 << 1) | 0); /* 只发写地址字节 */ iic_stop(); /* 停止条件 */ return (ack == 0) ? 1 : 0; /* iic_write_byte 返回 0 表示收到 ACK */ } void pca9555_scan(void) { uint8_t addr; for (addr = 0x20; addr <= 0x27; addr++) { if (pca9555_probe(addr)) { printf("PCA9555 found at 0x%02X\r\n", addr); } } }pca9555_probe的入参是 7 位地址,函数内部左移并补读写位,避免调用者把 8 位地址和 7 位地址混着传。iic_write_byte返回 0 表示从机在第 9 个时钟拉低 SDA,返回 1 表示超时或无应答。扫描范围固定 0x20 到 0x27,正好覆盖 A0-A2 的 8 种组合。printf需要工程里已经重定向串口,没有串口时把打印换成点亮 LED 或置一个全局变量也行。
扫描到地址只是第一步,总线上别的器件也可能占 0x20 到 0x27。更稳的确认方式是接着读 0x06 配置寄存器,上电默认应该是 0xFF;读 0x00 输入寄存器,值会随引脚电平变化。如果扫描有 ACK、读寄存器却全 0x00 或全 0xFF,先别怀疑代码,回头量 SDA/SCL 的静态电平和上拉电阻。
提示:扫描前把 SCL、SDA 都释放为高电平,静态量到 3.3V 再上电。静态不是高电平,后面所有时序都无从谈起。
3. GPIO 模拟 IIC 的时序骨架:从 START 到 STOP 的六个原子操作
3.1 开漏输出与释放 SDA:为什么推挽会翻车
模拟 IIC 的 GPIO 必须配成开漏输出,外部加上拉。开漏的“高”是释放总线,由上拉电阻把线拉高;“低”才是真正把线拉低。如果配成推挽输出,主机输出高电平时从机想把 SDA 拉低,两个输出对打,轻则波形畸形,重则引脚发热。STM32F103 的 HAL 里用GPIO_MODE_OUTPUT_OD,同时把内部上拉打开作为兜底,但外部 4.7k 不能省。SCL 只由主机驱动,SDA 在写字节时由主机驱动,在读字节和等 ACK 时主机要写 1 释放,让从机控制。
读 SDA 有两种做法:一是把 SDA 临时切成输入模式,读完再切回输出;二是保持开漏输出,先写 1 释放,再直接读 IDR。后者切换少,时序更干净,我一般用后者。前提是输出寄存器对应位确实写成了 1,否则读到的还是自己拉低的电平。
#define IIC_SCL_PORT GPIOB #define IIC_SCL_PIN GPIO_PIN_6 #define IIC_SDA_PORT GPIOB #define IIC_SDA_PIN GPIO_PIN_7 #define SCL_H() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(IIC_SDA_PORT, IIC_SDA_PIN) void iic_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = IIC_SCL_PIN | IIC_SDA_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; /* 开漏,外部 4.7k 上拉 */ gpio.Pull = GPIO_PULLUP; /* 内部弱上拉兜底 */ gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); SCL_H(); SDA_H(); }这段初始化把 SCL、SDA 都置于释放状态。GPIO_MODE_OUTPUT_OD是关键,GPIO_PULLUP只是内部弱上拉,不能替代外部 4.7k。引脚宏单独抽出来,换板子只改端口和引脚号。初始化最后两行把总线拉高,给起始条件一个干净的静态起点。
3.2 半周期延时与 100kHz 参数表
模拟 IIC 的速度由半周期延时决定。标准模式 100kHz 对应 5us 半周期,快速模式 400kHz 对应 1.25us 半周期。STM32F103 跑 72MHz 时,用 DWT 周期计数器做微秒延时比较稳;没有 DWT 就用定时器或校准过的循环。下面给的是可调宏,先把IIC_DELAY_US设成 5,跑通后再往下压。
| 参数 | 典型值 | 作用 | 调整建议 |
|---|---|---|---|
| IIC_DELAY_US | 5 | 半周期延时,约 100kHz | 先 5us 跑通,再试 2us |
| 上拉电阻 | 4.7k | 上升沿 | 线长可降到 2.2k,功耗会增大 |
| 总线电容 | 小于 400pF | 上升沿 | 线越长越要缩短或降低速率 |
| ACK 超时 | 200 次循环 | 防从机拉死 | 按主频调整,别写成死等 |
| 供电 | 3.3V | 电平匹配 | 5V 器件要确认 MCU 引脚耐受 |
注意:延时不是越小越好。PCA9555 支持 400kHz,但模拟 IIC 的上升沿由外部上拉和线电容决定,延时压到 1us 时如果上升沿还没到高电平,从机可能把高位读错。
3.3 起始、停止、写字节、读字节与 ACK 超时
IIC 的原子操作就六个:起始、停止、写一个字节、读一个字节、等 ACK、给 ACK。起始条件是 SCL 高时 SDA 由高变低;停止条件是 SCL 高时 SDA 由低变高。写字节时先拉低 SCL,再放 SDA,然后拉高 SCL 让从机采样。读字节时主机先释放 SDA,再拉高 SCL,在 SCL 高期间读 SDA。
static void iic_delay(void) { delay_us(5); /* 约 100kHz,按需改成 2 试 250kHz */ } void iic_start(void) { SDA_H(); SCL_H(); iic_delay(); SDA_L(); /* SCL 高时 SDA 下降:起始 */ iic_delay(); SCL_L(); iic_delay(); } void iic_stop(void) { SDA_L(); SCL_L(); iic_delay(); SCL_H(); iic_delay(); SDA_H(); /* SCL 高时 SDA 上升:停止 */ iic_delay(); } /* 返回 0:收到 ACK;返回 1:无应答或超时 */ uint8_t iic_write_byte(uint8_t dat) { uint8_t i; uint8_t ack; uint16_t timeout = 0; for (i = 0; i < 8; i++) { SCL_L(); iic_delay(); if (dat & 0x80) { SDA_H(); } else { SDA_L(); } dat <<= 1; iic_delay(); SCL_H(); iic_delay(); SCL_L(); } SDA_H(); /* 释放 SDA,准备读 ACK */ iic_delay(); SCL_H(); iic_delay(); while (SDA_READ() == GPIO_PIN_SET) { if (++timeout > 200) { SCL_L(); return 1; /* 从机没拉低,超时 */ } iic_delay(); } ack = 0; SCL_L(); iic_delay(); return ack; } /* ack=0 主机回 ACK,ack=1 主机回 NACK;返回值是读到的字节 */ uint8_t iic_read_byte(uint8_t ack) { uint8_t i; uint8_t dat = 0; SDA_H(); /* 释放 SDA,交给从机驱动 */ for (i = 0; i < 8; i++) { SCL_L(); iic_delay(); SCL_H(); iic_delay(); dat <<= 1; if (SDA_READ() == GPIO_PIN_SET) { dat |= 0x01; } SCL_L(); iic_delay(); } if (ack == 0) { SDA_L(); /* 主机回 ACK */ } else { SDA_H(); /* 主机回 NACK */ } iic_delay(); SCL_H(); iic_delay(); SCL_L(); SDA_H(); iic_delay(); return dat; }iic_start和iic_stop里的顺序不能换:SCL 高电平期间 SDA 的跳变才被识别为起始或停止,SCL 低电平期间 SDA 随便变。iic_write_byte先发最高位,发完 8 位后释放 SDA,再给第 9 个时钟读 ACK,超时 200 次是为了防止从机把 SDA 拉死时程序卡在while里。iic_read_byte每读一位都先拉低 SCL 再释放 SDA,读完后根据ack参数决定主机拉低还是释放,最后一个字节必须传ack=1发 NACK,否则从机会继续占用总线。
这套函数没有关中断,也没有做时钟拉伸处理。PCA9555 一般不会拉低 SCL 做时钟拉伸,但在多设备总线上,如果某个从机拉低 SCL,主机读 SDA 之前最好加超时。量产代码里我一般把iic_delay换成 DWT 延时,并在iic_write_byte的 ACK 等待里保留超时返回,别让一个坏器件把整机拖死。
4. 读写 PCA9555 寄存器:配置、输出、输入三步闭环
4.1 命令字节与自动递增:一次写两个端口
PCA9555 的寄存器访问靠命令字节,写操作是“START + 写地址 + 命令字节 + 数据 + STOP”,读操作是“START + 写地址 + 命令字节 + 重复 START + 读地址 + 数据 + NACK + STOP”。命令字节写完以后,器件内部地址指针会指向对应寄存器;如果继续发数据,指针自动递增,所以可以一次写 0x02 和 0x03 两个输出端口,也可以从 0x00 开始连续读两个输入端口。
/* 写一个寄存器:返回 0 成功,1 失败 */ uint8_t pca9555_write_reg(uint8_t addr7, uint8_t reg, uint8_t val) { iic_start(); if (iic_write_byte((addr7 << 1) | 0)) { iic_stop(); return 1; } if (iic_write_byte(reg)) { iic_stop(); return 1; } if (iic_write_byte(val)) { iic_stop(); return 1; } iic_stop(); return 0; } /* 从 reg 开始连续写 len 个字节,利用地址自动递增 */ uint8_t pca9555_write_regs(uint8_t addr7, uint8_t reg, const uint8_t *buf, uint8_t len) { uint8_t i; iic_start(); if (iic_write_byte((addr7 << 1) | 0)) { iic_stop(); return 1; } if (iic_write_byte(reg)) { iic_stop(); return 1; } for (i = 0; i < len; i++) { if (iic_write_byte(buf[i])) { iic_stop(); return 1; } } iic_stop(); return 0; } /* 从 reg 开始连续读 len 个字节,最后一个字节回 NACK */ uint8_t pca9555_read_regs(uint8_t addr7, uint8_t reg, uint8_t *buf, uint8_t len) { uint8_t i; iic_start(); if (iic_write_byte((addr7 << 1) | 0)) { iic_stop(); return 1; } if (iic_write_byte(reg)) { iic_stop(); return 1; } iic_start(); /* 重复起始 */ if (iic_write_byte((addr7 << 1) | 1)) { iic_stop(); return 1; } for (i = 0; i < len; i++) { buf[i] = iic_read_byte((i == (len - 1)) ? 1 : 0); } iic_stop(); return 0; }pca9555_write_reg是最小写单元,三个 ACK 任意一个失败都直接发 STOP 退出,避免总线停在半截。pca9555_write_regs用len控制连续写,写输出端口时传buf[0]到 P0、buf[1]到 P1。pca9555_read_regs里重复起始之后发读地址,最后一个字节传ack=1发 NACK,再 STOP。addr7是 7 位地址,函数内部统一左移,调用时不要传 0x40 这种 8 位写地址。
4.2 配置寄存器先写 0:把默认输入改成输出
配置寄存器 0x06 和 0x07 的每一位对应一个引脚,写 0 是输出,写 1 是输入。上电默认 0xFF,所以第一次用必须写配置。只把 P0 全设输出、P1 全设输入时,0x06 写 0x00,0x07 写 0xFF。如果只想改 P0 的低四位,就先读 0x06,改低四位,再写回去,避免把其他位误设成输出。
/* port: 0 或 1;dir: 0 输出,1 输入 */ uint8_t pca9555_set_direction(uint8_t addr7, uint8_t port, uint8_t dir) { uint8_t reg = (port == 0) ? 0x06 : 0x07; return pca9555_write_reg(addr7, reg, dir); } /* 写一个端口输出锁存:port 0/1,val 8 位 */ uint8_t pca9555_write_port(uint8_t addr7, uint8_t port, uint8_t val) { uint8_t reg = (port == 0) ? 0x02 : 0x03; return pca9555_write_reg(addr7, reg, val); } /* 读一个端口输入寄存器:port 0/1 */ uint8_t pca9555_read_port(uint8_t addr7, uint8_t port, uint8_t *val) { uint8_t reg = (port == 0) ? 0x00 : 0x01; return pca9555_read_regs(addr7, reg, val, 1); }pca9555_set_direction的dir参数是整字节,0x00 全输出,0xFF 全输入,0x0F 表示低四位输出、高四位输入。pca9555_write_port只写输出锁存,不改变方向,所以调用前必须先写配置寄存器。pca9555_read_port读的是输入寄存器,反映引脚实际电平;如果端口被配置成输出,读输入寄存器也能看到引脚电平,但受外部负载影响,不能完全代替输出锁存回读。
4.3 完整驱动函数与主循环示例
把上面的函数拼起来,初始化顺序是“配置 P0 输出、P1 输入,写 P0 初始电平,再读 P1”。下面这段主循环每 500ms 翻转一次 P0,同时把 P1 的按键状态读回来。LED 接 P0_0 到 P0_7 时注意限流电阻,按键接 P1 时用 10k 上拉、按下接地。
#define PCA_ADDR7 0x20 /* A2=A1=A0=0 */ void pca9555_init(void) { uint8_t cfg[2]; iic_gpio_init(); /* P0 全输出,P1 全输入 */ cfg[0] = 0x00; /* 配置 0:0=输出 */ cfg[1] = 0xFF; /* 配置 1:1=输入 */ pca9555_write_regs(PCA_ADDR7, 0x06, cfg, 2); /* 输出端口先给一个安全电平 */ pca9555_write_port(PCA_ADDR7, 0, 0x00); pca9555_write_port(PCA_ADDR7, 1, 0xFF); } int main(void) { uint8_t in0, in1; uint8_t out = 0x00; HAL_Init(); SystemClock_Config(); delay_init(); uart_init(115200); pca9555_init(); while (1) { out ^= 0xFF; pca9555_write_port(PCA_ADDR7, 0, out); if (pca9555_read_port(PCA_ADDR7, 0, &in0) == 0 && pca9555_read_port(PCA_ADDR7, 1, &in1) == 0) { printf("IN0=0x%02X IN1=0x%02X\r\n", in0, in1); } else { printf("PCA9555 read fail\r\n"); } HAL_Delay(500); } }pca9555_init先写两个配置字节,利用自动递增一次写完 0x06 和 0x07,再写输出端口给安全电平。主循环里out ^= 0xFF翻转 P0 的 8 个 LED,读 P1 的按键状态。读失败时打印错误,不继续用旧值,避免把通信失败误判成按键状态。PCA_ADDR7按实际 A0-A2 改,0x20 到 0x27 对应前面表格。
提示:写输出后想确认有没有写进去,读 0x02/0x03 输出寄存器回读,不要读 0x00/0x01 输入寄存器判断。输入寄存器受外部电路影响,读回的值和输出锁存不是一回事。
5. 避坑与排查:模拟 IIC 访问 PCA9555 的五个高频翻车点
5.1 总线无应答:先查上拉、地址和供电
现象:扫描 0x20 到 0x27 全无 ACK,或者偶尔能扫到、复位后又扫不到。原因通常不是代码,而是 SDA/SCL 接反、上拉电阻缺失、PCA9555 的 VDD 没供上、A0-A2 悬空导致地址漂移,或者 MCU 引脚配成了推挽输出。解决顺序是先断电量线:SCL 和 SDA 对 VDD 的电阻应该在 4.7k 左右,静态电压接近 3.3V;再确认 A0-A2 各自接固定电平;最后把 GPIO 改成开漏,扫描代码只发地址字节,不读寄存器。如果静态电平都对、扫描仍无 ACK,用示波器看第 9 个时钟 SDA 有没有被从机拉低,拉低说明地址对了但后续流程有问题,没拉低就继续查地址和供电。
5.2 写成功但端口不动:配置寄存器与输出寄存器混淆
现象:pca9555_write_port返回 0,串口打印成功,但 P0 的 LED 不亮。原因多半是配置寄存器 0x06 还是默认 0xFF,端口仍是输入;或者写的是输入寄存器 0x00 而不是输出寄存器 0x02;也有可能是只写了配置没写输出,或者写完输出立刻读输入寄存器,把外部电平当成写失败。解决是初始化先写 0x06/0x07,再写 0x02/0x03,写完回读 0x02/0x03 确认锁存值。LED 不亮时用万用表量 P0 引脚,不要只看串口返回值。
5.3 输入读数抖动:上下拉、去抖与扫描节奏
现象:P1 接按键,读到的值随机跳,或者未接引脚全 1。原因是 PCA9555 配置成输入后引脚是高阻,外部没有上拉或下拉,线一长就拾取干扰;按键没有硬件消抖,软件又读得太快。解决是按键一端接 10k 上拉到 3.3V、另一端接地,按下为低;软件连续读两次,间隔 10ms 一致才认;IIC 速率先降到 100kHz,电源脚加 0.1uF 去耦。未用引脚不要悬空,配置成输出并给固定电平,或者外部加下拉。
5.4 多设备地址冲突与总线电容
现象:一条总线上挂两片 PCA9555,只有一片响应;或者波形上升沿变圆,读数据偶尔错位。原因是 A0-A2 没有错开,两片地址相同;上拉电阻太大或总线电容超过 400pF。解决是把第二片的 A0 接 VDD,地址改成 0x21;上拉保持 4.7k,线长超过 30cm 时缩短或降低速率;多设备分两条总线,不要把所有模块串在同一组排线上。地址冲突时扫描能扫到地址,但读写数据会互相干扰,表现像“时好时坏”。
5.5 总线死锁与上电顺序:9 个时钟恢复
现象:程序复位后扫描不到 PCA9555,量到 SCL 是高、SDA 被拉低。原因是从机正在输出数据时主机复位,时钟停了,从机一直等下一个时钟;或者上电顺序不对,PCA9555 先上电并拉了 INT/SDA。解决是上电初始化时先发 9 个 SCL 时钟,再发 STOP,让从机把剩余字节吐完。下面这个恢复函数放在iic_gpio_init之后、正式扫描之前调用。
void iic_bus_recover(void) { uint8_t i; SDA_H(); for (i = 0; i < 9; i++) { SCL_L(); iic_delay(); SCL_H(); iic_delay(); } iic_stop(); /* 补一个停止条件,把总线拉回空闲 */ }iic_bus_recover只做一件事:在 SCL 上打 9 个时钟,让从机把可能挂住的字节发完,最后补 STOP。调用前 SDA 要先释放为高,否则从机看到 SDA 一直低,可能不退出。量产代码里还要加超时:任何一次 ACK 等待超过 200 次循环就返回错误,主循环重新初始化总线,不要死等。上电顺序上,PCA9555 和 MCU 尽量同时上电,或者先给 MCU 上电再给扩展芯片上电,减少从机拉死总线的概率。
6. 进阶验证与量产习惯:用回读、示波器和自检表把 IIC 锁死
6.1 上电自检表
模拟 IIC 没有硬件错误标志,通信是否可信要靠回读和自检。我一般在上电初始化后跑一张小表:读 0x06/0x07 配置寄存器,确认等于写入值;写 0x02 输出 0x55,回读 0x02 确认锁存;写 0x03 输出 0xAA,回读 0x03;读 0x00/0x01 输入寄存器,记录当前引脚状态。任何一项失败就点亮错误指示,不进入主循环。这样能把“偶发 ACK 失败”和“配置没写进去”分开,省掉很多黑匣子式的猜。
| 自检项 | 操作 | 期望 | 失败处理 |
|---|---|---|---|
| 配置回读 | 读 0x06/0x07 | 等于写入的 cfg | 重写一次,仍失败报错 |
| 输出回读 | 写 0x02=0x55,读 0x02 | 0x55 | 检查写时序和地址 |
| 第二端口回读 | 写 0x03=0xAA,读 0x03 | 0xAA | 检查自动递增 |
| 输入快照 | 读 0x00/0x01 | 随引脚变化 | 检查外部上下拉 |
6.2 自检代码与波形检查
uint8_t pca9555_self_test(uint8_t addr7) { uint8_t v; if (pca9555_read_regs(addr7, 0x06, &v, 1) || v != 0x00) return 1; if (pca9555_write_reg(addr7, 0x02, 0x55)) return 2; if (pca9555_read_regs(addr7, 0x02, &v, 1) || v != 0x55) return 3; if (pca9555_write_reg(addr7, 0x03, 0xAA)) return 4; if (pca9555_read_regs(addr7, 0x03, &v, 1) || v != 0xAA) return 5; return 0; }返回值直接对应失败步骤,串口打印一个数字就能定位。示波器或逻辑分析仪看两处:起始条件之后第 9 个时钟 SDA 是否被拉低,以及 STOP 之后 SCL/SDA 是否都回到高电平。波形上如果 ACK 位是高的,说明地址或器件没响应;如果 STOP 后 SDA 一直低,查从机是否挂死,用前面的 9 时钟恢复。
从那以后我每次给新板子上电,都先跑一遍地址扫描和这张自检表,再谈业务逻辑。模拟 IIC 的坑大多不在函数本身,而在上拉、地址和配置顺序,先把这三样锁死,后面改引脚、换模块都只是改宏的事。希望帮到你。
本文还有配套的精品资源,点击获取