ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103软件IIC驱动PCA9555:16路IO扩展与排查

STM32F103软件IIC驱动PCA9555:16路IO扩展与排查

简介:面向嵌入式开发者与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/P1I/O按负载接输入高阻,输出锁存

PCA9555 内部寄存器按命令字节寻址,常用的是下面这几个。写配置之前先读一遍,能排除“模块默认状态和你想的不一样”的低级翻车。

命令字节寄存器读/写上电默认作用
0x00输入端口 0读反映引脚P0 引脚实际电平
0x01输入端口 1读反映引脚P1 引脚实际电平
0x02输出端口 0读/写0xFFP0 输出锁存
0x03输出端口 1读/写0xFFP1 输出锁存
0x04极性反转 0读/写0x00输入极性反转
0x05极性反转 1读/写0x00输入极性反转
0x06配置 0读/写0xFF0=输出,1=输入
0x07配置 1读/写0xFF0=输出,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,扫描当然全无应答。

A2A1A07 位地址写字节读字节
0000x200x400x41
0010x210x420x43
0100x220x440x45
0110x230x460x47
1000x240x480x49
1010x250x4A0x4B
1100x260x4C0x4D
1110x270x4E0x4F

读 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_US5半周期延时,约 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,读 0x020x55检查写时序和地址
第二端口回读写 0x03=0xAA,读 0x030xAA检查自动递增
输入快照读 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 的坑大多不在函数本身,而在上拉、地址和配置顺序,先把这三样锁死,后面改引脚、换模块都只是改宏的事。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进