ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32F103软件模拟IIC驱动PCA9555扩展16路IO

STM32F103软件模拟IIC驱动PCA9555扩展16路IO

简介:面向STM32F103嵌入式开发者及需要扩展GPIO接口的学习者,这套模拟IIC访问PCA9555十六位IO扩展芯片的工程代码可用于理解起始与停止条件、7位从机地址、读写位、应答时序,以及输入、输出、中断使能和中断状态等寄存器的配置方法。压缩包共127个文件、约2MB,含33个.h头文件和32个.c源文件,并配有Keil工程、HEX/AXF下载产物及.o、.d、.crf等编译中间文件,目录完整,便于直接编译、对照和移植。已有4838人学习下载。读者可参考其中的GPIO模拟SCL/SDA实现、寄存器读写流程与通信排错思路,将代码用于课程设计、IO口扩展实验或现有项目模块替换,快速建立从总线时序到芯片控制的完整认识。

1. 一块板子挂 16 个按键还不够用:模拟 IIC 驱动 PCA9555 的落地场景

去年帮朋友改一块 STM32F103 的控制板,原本只留了 8 个按键位,结果结构那边把面板改成了 24 个按键加 16 路指示灯,GPIO 直接不够用。重新画板来不及,只能飞线挂一片 PCA9555 上去救火。麻烦的是这板子的 IIC 引脚早就被占用,硬件 IIC 外设只剩下一组还被 SPI 抢了,最后是用两个普通 GPIO 软件模拟时序跑通的。这篇笔记拆的就是这套「stm32f103 IIC PCA9555」方案:在 72MHz 的 F103 上用软件翻转引脚,稳定读写 16 位 IO 扩展芯片,从时序、代码到联调踩坑全走一遍。适合手头 GPIO 紧张、又想快速扩出 16 路输入输出的开发者照着抄。

2. 技术选型:为什么在 STM32F103 上宁愿用 GPIO 模拟也不碰硬件 IIC

2.1 PCA9555 到底是什么:16 位准双向 IO 扩展

PCA9555 是一颗 IIC 接口的 16 位 IO 扩展芯片,本质上是把两个 8 位端口(Port0、Port1)映射到 8 个寄存器上,主机通过两根线就能读写这 16 个引脚的电平和方向。它的寄存器布局非常规整,一共 8 个地址,两两成对:

寄存器地址名称复位值作用
0x00INPUT0随引脚读 Port0 引脚实际电平(只读)
0x01INPUT1随引脚读 Port1 引脚实际电平(只读)
0x02OUTPUT00xFF写 Port0 输出锁存值
0x03OUTPUT10xFF写 Port1 输出锁存值
0x04POLARITY00x00Port0 输入极性反转
0x05POLARITY10x00Port1 输入极性反转
0x06CONFIG00xFFPort0 方向:1=输入,0=输出
0x07CONFIG10xFFPort1 方向:1=输入,0=输出

这里有个特别容易翻车的点:CONFIG 寄存器的语义是「1 表示输入」,和很多人脑子里「1 表示使能输出」的直觉正好相反。芯片上电复位后 CONFIG 全是 0xFF,也就是 16 个引脚默认全是高阻输入,输出寄存器全是 0xFF。也就是说,什么都不配置的情况下,你往 OUTPUT 寄存器写值是不会反映到引脚上的,必须先动 CONFIG。这条我在第一次调试时卡了将近两个小时,后面会单独展开。

7 位从机地址是固定的高 4 位0100加上 A2、A1、A0 三个引脚决定的最低 3 位,所以同一根总线上最多能挂 8 片,地址范围 0x20 到 0x27。板子上如果只挂一片,通常三个地址脚全部接地,地址就是 0x20;这一点在做多片扩展时必须先量一下地址脚的电平,别想当然。

2.2 硬件 IIC 的坑与模拟 IIC 的收益

在 F103 上用硬件 IIC 外设,老一点的开发者都听过各种传闻:总线锁死、事件标志判断顺序敏感、DMA 配合起来更绕。客观说这些坑大多能通过正确的中断优先级和超时处理规避,但在这种「救火式改板」的场景里,收益和成本不划算。原因有这么几条:

一是引脚冲突。F103 的 IIC1 默认在 PB6/PB7,IIC2 在 PB10/PB11,这块板子上 PB6/PB7 已经被别的功能占了,重映射又牵扯到 AFIO 时钟和复用配置,改一处牵动一片。二是代码体积和可移植性。软件模拟的代码就一两百行,换颗 MCU 只要改几个宏定义,硬件外设那套初始化加事件状态机换个平台就得重写。三是调试可见性。模拟 IIC 的每一个边沿都是自己写出来的,出问题用逻辑分析仪一抓,波形和代码一一对应,比对着外设状态寄存器猜事件要快得多。

代价也很明确:软件模拟吃 CPU 时间,100kHz 速率下每传一个字节大概占用几十微秒,而且时序精度依赖延时函数的稳定性。所以我一般会把速率压到 100kHz 这一档,做 IO 扩展这种低频操作完全够用,同时把模拟 IIC 的读写放在主循环的低优先级任务里,不放到中断里跑。

提示:如果总线上挂了多片器件、且其中某片对时序要求较高,模拟 IIC 的速率可以再降一档到 50kHz,先保证通信成功率,再谈速度。

2.3 时序预算与引脚资源核算

F103 跑到 72MHz,一个指令周期约 13.9ns。IIC 标准模式 100kHz 意味着一个完整位周期 10μs,半个周期 5μs,换算下来大约 360 个时钟周期。所以延时函数不需要精确到微秒级,用几个 NOP 加一个短循环兜住就行,只要保证高电平时间和低电平时间大致均衡。常见做法是让iic_delay()里的循环次数做成宏,方便在 50k/100k/400k 三档之间切换:

/* 依据主频和目标速率调整循环次数: * 72MHz 下 8 次循环 ≈ 4~5us,对应约 100kHz * 若改到 50kHz,把这个值调到 16~20 */ #define IIC_DELAY_LOOP 8 static void iic_delay(void) { uint32_t i = IIC_DELAY_LOOP; while (i--) { __NOP(); /* 防止编译器把空循环优化掉 */ } }

参数说明:IIC_DELAY_LOOP是小一号的调节旋钮,改它比改代码结构安全;__NOP()的作用是让这个循环在 -O2 优化下不被整体删除。这里的数值不是精确算出来的,而是用逻辑分析仪测一遍实际 SCL 频率再微调,我一般会先写 8,测出来 110kHz 左右就接受,超了就往上加到 10。

引脚资源这边要算一笔账:模拟 IIC 只占用 2 个 GPIO,换回来 16 个可用 IO,而且这 2 个引脚只要是普通 GPIO 就行,不挑复用功能,布线时非常自由。如果拿 16 个真实 GPIO 去换,F103 的 48 脚封装基本就排不下了。这也是为什么在 GPIO 吃紧的中小型项目里,PCA9555 这类 IO 扩展芯片一直有市场。

3. 工程结构:从 bsp_iic 到 pca9555 的三层文件划分

3.1 目录与文件职责表

这套代码我一般按「底层时序 → 器件驱动 → 应用逻辑」切成三层,不把 IIC 时序和 PCA9555 寄存器操作揉在一个文件里。揉在一起的后果是,下次换一颗 IIC 传感器,底层时序代码得复制一遍,改一个延时两处同步,早晚出岔子。目录结构大致如下:

project/ ├── bsp/ │ ├── bsp_iic.c /* 模拟 IIC 时序:起停、收发字节、ACK */ │ ├── bsp_iic.h /* 引脚宏、延时宏、对外接口声明 */ │ ├── pca9555.c /* PCA9555 寄存器读写、16 位封装、消抖 */ │ └── pca9555.h /* 器件地址、寄存器地址、错误码 */ ├── app/ │ ├── key_task.c /* 按键扫描与事件分发 */ │ └── led_task.c /* 指示灯输出控制 */ └── main.c /* 初始化顺序与主循环调度 */

bsp_iic.c只管电平怎么翻转,完全不知道总线上挂的是什么;pca9555.c只调用iic_start / iic_write_byte / iic_read_byte / iic_stop这几个接口,不碰 GPIO 寄存器;app层再往上只用pca9555_write_port16和pca9555_read_port16这类语义化函数。分层之后,想换器件只动中间层,想换 MCU 只动底层。

3.2 三层调用关系与接口约定

接口约定上,我把底层所有函数的返回值统一成uint8_t:0 表示成功,非 0 表示失败,失败码在pca9555.h里用宏定义清楚。这样做的好处是排查时能直接看出「卡在第几个字节没收到 ACK」,而不是只有一个笼统的通信失败。

/* pca9555.h —— 错误码集中定义,方便串口打印定位 */ #define PCA_OK 0 #define PCA_ERR_ADDR 1 /* 从机地址无应答 */ #define PCA_ERR_REG 2 /* 寄存器地址无应答 */ #define PCA_ERR_DATA 3 /* 数据字节无应答 */ #define PCA_ERR_READ 4 /* 读模式地址无应答 */ /* bsp_iic.h —— 底层对外只暴露这 5 个函数 */ void iic_init(void); void iic_start(void); void iic_stop(void); uint8_t iic_write_byte(uint8_t dat); /* 返回 0 表示收到 ACK */ uint8_t iic_read_byte(uint8_t send_ack); /* send_ack=1 发 ACK,0 发 NACK */

把 ACK 语义反过来记(返回 1 表示成功)也常见,但一定要在两个文件里保持一致。我见过一个工程底层返回 1 表示收到 ACK,上层判断写成if (iic_write_byte(x))当作成功,结果所有通信都被判定成功,从机根本没接也能一路跑下去,直到输出引脚不动才发现。这种类型错误编译器不报,只能靠约定加代码走查。

3.3 引脚映射与宏定义集中管理

引脚定义全部塞进bsp_iic.h的宏里,不在 .c 文件里出现任何GPIOB、GPIO_Pin_6这种硬编码。这块看起来是小节,实操中改板换引脚的概率极高,尤其是这种飞线救火的场景,引脚换一次要翻好几个文件就很难受。

/* bsp_iic.h —— 换板只需要改这一段 */ #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 IIC_GPIO_CLK RCC_APB2Periph_GPIOB #define SCL_H() GPIO_SetBits(IIC_SCL_PORT, IIC_SCL_PIN) #define SCL_L() GPIO_ResetBits(IIC_SCL_PORT, IIC_SCL_PIN) #define SDA_H() GPIO_SetBits(IIC_SDA_PORT, IIC_SDA_PIN) #define SDA_L() GPIO_ResetBits(IIC_SDA_PORT, IIC_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_PORT, IIC_SDA_PIN)

这里的关键在SDA_READ()。SDA 引脚配置成开漏输出(GPIO_Mode_Out_OD)时,GPIO_ReadInputDataBit读的是输入数据寄存器 IDR,反映的是引脚上的真实电平,而不是输出锁存器 ODR 的值。这一点非常重要:开漏模式下主机写 1 相当于释放总线,此时如果从机把 SDA 拉低,IDR 读到的就是 0,ACK 采样才成立。如果误配成推挽输出,主机写 1 就是硬拉高,从机想拉低也拉不动,读回来的永远是 1,表现就是「永远 NACK」。

上拉电阻这块,PCA9555 的 SDA/SCL 是开漏结构,必须外部上拉。常见做法是 4.7kΩ 到 3.3V,总线长或者挂片多的时候降到 2.2kΩ。依靠 F103 内部那点弱上拉(30~50kΩ)在 100kHz 下边沿会非常缓,示波器上看是明显的 RC 充电曲线,勉强能通但抗干扰极差。

4. 核心代码逐段拆解:一次寄存器写入涉及的 4 个原子动作

4.1 起始与停止:两个边沿决定一切

IIC 的起始条件定义是「SCL 为高电平期间,SDA 出现下降沿」,停止条件是「SCL 为高电平期间,SDA 出现上升沿」。注意这里的顺序:先把两根线都拉高,稳定一段时间,再动 SDA。如果先拉低 SDA 再拉高 SCL,那从机看到的是数据位变化,不是起始信号,通信直接从第一步就跑偏。

void iic_start(void) { SDA_H(); /* 先确保两根线都是高电平 */ SCL_H(); iic_delay(); SDA_L(); /* SCL 高电平期间 SDA 下降:起始条件 */ iic_delay(); SCL_L(); /* 拉低 SCL,准备发送第一位 */ iic_delay(); } void iic_stop(void) { SCL_L(); /* 先从低电平开始,保证 SDA 变化安全 */ SDA_L(); iic_delay(); SCL_H(); /* 拉高 SCL */ iic_delay(); SDA_H(); /* SCL 高电平期间 SDA 上升:停止条件 */ iic_delay(); }

iic_start里第一次iic_delay()是为了让 SCL 高电平保持足够时间,从机才能识别这个边沿;第二次延时是为了让 SDA 的低电平保持住,避免紧接着拉低 SCL 时被误判。iic_stop里先SCL_L()再动 SDA,是因为停止条件要求 SDA 的上升沿发生在 SCL 高电平期间,如果此时 SCL 恰好是高,直接拉高 SDA 也能触发,但顺序上不够保险——从机可能还停在某个等待状态。写成先拉低再拉高,时序上更干净。

还有一个容易被忽略的细节:iic_stop结束后,总线处于空闲状态,SCL 和 SDA 都应该被释放为高。如果代码里某条错误分支提前return而没有执行iic_stop,SDA 可能被主机拉在低电平,下一次iic_start时从机看到的是「总线忙」,直接不响应。这就是「第一次能通、第二次死锁」的典型成因。

4.2 字节收发与 ACK 采样

发送一个字节时,数据在 SCL 低电平期间改变,在 SCL 高电平期间被从机采样,这是 IIC 的基本规则。8 位发完之后第 9 个时钟是应答位,主机必须释放 SDA(写 1),让从机有机会把它拉低。

uint8_t iic_write_byte(uint8_t dat) { uint8_t i, ack; for (i = 0; i < 8; i++) { if (dat & 0x80) { SDA_H(); } else { SDA_L(); } dat <<= 1; /* 左移,下一位到最高位 */ iic_delay(); SCL_H(); /* 从机在 SCL 高电平期间采样 */ iic_delay(); SCL_L(); iic_delay(); } SDA_H(); /* 释放 SDA,交给从机驱动应答位 */ iic_delay(); SCL_H(); /* 第 9 个时钟 */ iic_delay(); ack = SDA_READ(); /* 0 = 从机应答,1 = 无应答 */ SCL_L(); iic_delay(); return ack; }

参数说明:入参dat是要发送的 8 位数据,高位先发;返回值ack为 0 表示收到应答,非 0 表示从机没理你。第 9 个时钟之前那句SDA_H()是关键,如果不释放,主机仍在驱动 SDA,从机想拉低也拉不动,采样到的永远是 1。

接收字节的过程反过来,主机释放 SDA,由从机驱动,主机在 SCL 高电平期间采样。收完 8 位后主机要发一个应答位告诉从机「还要不要继续读」,最后一个字节必须发 NACK,否则从机会一直占着 SDA 等你继续读。

uint8_t iic_read_byte(uint8_t send_ack) { uint8_t i, dat = 0; SDA_H(); /* 主机释放 SDA,全程由从机驱动 */ for (i = 0; i < 8; i++) { dat <<= 1; iic_delay(); SCL_H(); /* 从机在此时把数据放到 SDA 上 */ iic_delay(); if (SDA_READ()) { dat |= 0x01; } SCL_L(); iic_delay(); } if (send_ack) { SDA_L(); /* 发送 ACK:拉低 SDA */ } else { SDA_H(); /* 发送 NACK:保持高电平 */ } iic_delay(); SCL_H(); iic_delay(); SCL_L(); iic_delay(); SDA_H(); /* 释放总线 */ return dat; }

send_ack的取值要特别注意:读单个寄存器时传 0(NACK),读连续多个寄存器时前面几个传 1、最后一个传 0。写反了不会立刻报错,但会表现为「读到的第二个字节不对」或者「读完之后总线被占住」。这个坑我在早期项目里踩过一次,症状是读 16 位端口数据时高字节总是 0xFF,查了半天以为是 PORT1 寄存器没使能。

4.3 PCA9555 寄存器读写与地址自增

有了底层的起停和字节收发,上一层就是把「起始 + 地址 + 寄存器 + 数据 + 停止」串起来。写单个寄存器最直接:

uint8_t pca9555_write_reg(uint8_t reg, uint8_t val) { uint8_t ret = PCA_OK; iic_start(); if (iic_write_byte(PCA9555_ADDR << 1) != 0) { ret = PCA_ERR_ADDR; /* 地址字节没应答:片子没接或地址错 */ } else if (iic_write_byte(reg) != 0) { ret = PCA_ERR_REG; /* 寄存器地址没应答 */ } else if (iic_write_byte(val) != 0) { ret = PCA_ERR_DATA; /* 数据没应答 */ } iic_stop(); return ret; }

PCA9555_ADDR是 7 位地址,左移一位之后最低位补 0 表示写方向。返回值分级的意义在于:拿到PCA_ERR_ADDR说明问题在硬件连接或地址跳线,拿到PCA_ERR_DATA说明前两个字节是对的、只是最后一步出问题,排查方向完全不同。

读寄存器要发两次起始条件:第一次是「写方向地址 + 寄存器地址」,第二次是「读方向地址」,中间不插入停止,这叫重复起始。重复起始的作用是切换方向的同时不释放总线,防止被别的设备抢占。

uint8_t pca9555_read_reg(uint8_t reg, uint8_t *val) { uint8_t ret = PCA_OK; iic_start(); if (iic_write_byte(PCA9555_ADDR << 1) != 0) { ret = PCA_ERR_ADDR; } else if (iic_write_byte(reg) != 0) { ret = PCA_ERR_REG; } else { iic_start(); /* 重复起始,不插停止 */ if (iic_write_byte((PCA9555_ADDR << 1) | 0x01) != 0) { ret = PCA_ERR_READ; } else { *val = iic_read_byte(0); /* 单字节读,末字节 NACK */ } } iic_stop(); return ret; }

参数说明:reg是 0x00 到 0x07 的寄存器地址,val是输出参数,调用前不必初始化,函数成功时写入。(PCA9555_ADDR << 1) | 0x01就是 7 位地址加读方向位。注意这里的iic_start()是重复起始,不能替换成iic_stop(); iic_start();,虽然多数情况下也能通,但会释放总线,多主场景下有风险。

PCA9555 支持寄存器地址自增:写完一个字节之后,内部的地址指针会自动加 1。利用这个特性,读 16 位输入只需要一次读操作,而且能保证高低字节是同一时刻的快照,避免「读完低字节后引脚变化,高字节已经不是同一状态」的撕裂问题。

uint8_t pca9555_read_seq(uint8_t reg, uint8_t *buf, uint8_t len) { uint8_t i, ret = PCA_OK; iic_start(); if (iic_write_byte(PCA9555_ADDR << 1) != 0) { ret = PCA_ERR_ADDR; } else if (iic_write_byte(reg) != 0) { ret = PCA_ERR_REG; } else { iic_start(); if (iic_write_byte((PCA9555_ADDR << 1) | 0x01) != 0) { ret = PCA_ERR_READ; } else { for (i = 0; i < len; i++) { /* 最后一个字节发 NACK,其余发 ACK 让从机继续给数据 */ buf[i] = iic_read_byte((i == (len - 1)) ? 0 : 1); } } } iic_stop(); return ret; }

这个函数是后面所有 16 位操作的基础。buf由调用者提供,长度至少len;len一般取 2,读的是 INPUT0 + INPUT1。有了它,16 位读就变成一行调用。

4.4 上层封装:16 位输出与按键扫描

底下的东西齐了,上层就可以写得非常接近业务语义。初始化的时候先把两个端口的 CONFIG 写清楚,再设一个安全的初始输出。

void pca9555_init(void) { /* 全部配置为输出:CONFIG 写 0 */ pca9555_write_reg(PCA9555_REG_CONFIG0, 0x00); pca9555_write_reg(PCA9555_REG_CONFIG1, 0x00); /* 输出默认全高,避免继电器之类的负载上电误动作 */ pca9555_write_reg(PCA9555_REG_OUTPUT0, 0xFF); pca9555_write_reg(PCA9555_REG_OUTPUT1, 0xFF); }

如果这 16 个引脚里既有输入又有输出,就把0x00改成对应的位图,比如 Port0 低 4 位做输出、高 4 位做输入,那 CONFIG0 就写0xF0。这一步千万不能省,也不能指望「写 OUTPUT 就能改电平」,上电默认是输入态。

16 位输出的封装用地址自增一次写完两个字节,比调两次write_reg快一半:

uint8_t pca9555_write_port16(uint16_t val) { uint8_t ret = PCA_OK; iic_start(); if (iic_write_byte(PCA9555_ADDR << 1) != 0) { ret = PCA_ERR_ADDR; } else if (iic_write_byte(PCA9555_REG_OUTPUT0) != 0) { ret = PCA_ERR_REG; } else if (iic_write_byte((uint8_t)(val & 0xFF)) != 0) { ret = PCA_ERR_DATA; /* 写 OUTPUT0,指针自动指向 OUTPUT1 */ } else if (iic_write_byte((uint8_t)(val >> 8)) != 0) { ret = PCA_ERR_DATA; /* 写 OUTPUT1 */ } iic_stop(); return ret; }

val的低字节对应 Port0 的 P0.0~P0.7,高字节对应 Port1 的 P1.0~P1.7。这个映射关系最好在头文件里写一行注释,不然过两个月自己都要重新推一遍位序。

按键扫描这块,难点不在读,而在消抖和边沿检测。IO 扩展芯片没有中断引脚引回来(其实 PCA9555 有 INT 引脚,但飞线救火的场景一般不会再加一根),只能靠定时轮询。

#define KEY_SCAN_MS 10 #define KEY_DEBOUNCE_CNT 3 /* 连续 3 次读到相同状态才算稳定 */ void key_scan_task(void) { static uint16_t last_raw = 0xFFFF; static uint16_t stable = 0xFFFF; static uint8_t cnt = 0; uint16_t now; if (pca9555_read_seq(PCA9555_REG_INPUT0, (uint8_t *)&now, 2) != PCA_OK) { return; /* 读失败直接跳过,不要动稳定状态 */ } if (now == last_raw) { if (cnt < KEY_DEBOUNCE_CNT) { cnt++; } if (cnt == KEY_DEBOUNCE_CNT) { stable = now; /* 状态确认,可在此做上升/下降沿比较 */ } } else { cnt = 0; /* 电平变了,重新计数 */ } last_raw = now; }

KEY_DEBOUNCE_CNT取 3、配合 10ms 的扫描周期,等效消抖时间约 30ms,机械按键足够。注意read_seq失败时直接return,不要让失败的读结果进入消抖状态机,否则总线抖动会被当成按键抖动,反而更乱。这个模块我建议每 10ms 由定时器标志触发一次,不要放在while(1)里裸跑,否则扫描周期完全取决于主循环其他任务的耗时。

5. 联调排查:从读回 0xFF 到连续 8 小时不掉线

5.1 上电自检的三步验证法

硬件焊完第一次上电,别急着跑业务代码,按三步走能省很多时间。第一步,不接 PCA9555,只跑底层,用逻辑分析仪看iic_start之后第一个字节是不是0x40(0x20 左移一位),SCL 频率是不是在预期范围。第二步,接上片子,调pca9555_write_reg(CONFIG0, 0x00),看有没有 ACK,这一步过了说明地址和电气连接都没问题。第三步,写OUTPUT0 = 0x00再写0xFF,拿万用表量对应引脚,能跟着变就说明整条链路通了。

我一般会封装一个自检函数,上电时跑一遍,结果通过串口打出来:

void pca9555_selftest(void) { uint8_t val = 0; /* 1. 配置 Port0 为输出 */ if (pca9555_write_reg(PCA9555_REG_CONFIG0, 0x00) != PCA_OK) { printf("selftest: config0 fail\r\n"); return; } /* 2. 写输出并回读 OUTPUT 寄存器(回读的是锁存值,不是引脚) */ pca9555_write_reg(PCA9555_REG_OUTPUT0, 0x55); pca9555_read_reg(PCA9555_REG_OUTPUT0, &val); if (val != 0x55) { printf("selftest: output0 mismatch, got 0x%02X\r\n", val); return; } /* 3. 把 Port1 配成输入,读一次 INPUT1 看是否稳定 */ pca9555_write_reg(PCA9555_REG_CONFIG1, 0xFF); pca9555_read_reg(PCA9555_REG_INPUT1, &val); printf("selftest: pass, input1=0x%02X\r\n", val); }

这里有个细节需要提醒:回读 OUTPUT 寄存器读到的是输出锁存器的值,不是引脚实际电平。它能验证 IIC 读写双向通路的正确性,但验证不了引脚有没有被外部电路拉死。真要验证引脚,得靠万用表或者把输出接到输入上做回环。

5.2 五条踩坑记录(现象、原因、解决)

现象一:上电后读任何寄存器都返回 0xFF,或者干脆读不到数据,iic_write_byte一直返回 NACK。原因通常有两个:一是从机地址算错了。PCA9555 的地址是0100+ A2A1A0,如果板子上 A0 通过电阻上拉而不是接地,地址就从 0x20 变成 0x21 甚至 0x27,代码里写死 0x20 自然没人应答。二是 SDA/SCL 没有外部上拉,或者上拉接到了 5V 而 MCU 是 3.3V 供电,电平不匹配。 解决办法:先用万用表量 A0/A1/A2 三个脚的实际电平,按0x20 | (A2<<2) | (A1<<1) | A0算出地址;再量 SDA、SCL 在空闲时是不是 3.3V,不到就补上拉电阻。这一步别偷懒,我见过因为一个 0Ω 电阻没焊导致查了一下午的。

现象二:能写不能读,写寄存器返回成功,读寄存器拿到的全是 0xFF。原因是 SDA 引脚配置成了推挽输出,主机在接收数据位时仍然在驱动总线,从机拉不低,读回来自然全是 1。 解决办法:把 SDA 改成开漏输出模式GPIO_Mode_Out_OD,并在读之前调用SDA_H()释放总线。开漏模式下写 1 等效于高阻,IDR 能读到引脚真实电平;推挽模式没有这个特性。

现象三:单独测试一切正常,接上实际负载之后偶发通信失败,重启又能好。原因是时序被中断打断。模拟 IIC 完全靠 GPIO 翻转,如果在这期间进了中断,某个高电平或低电平被拉长,超过从机的超时容忍范围就会丢帧。另外继电器、电机这类负载动作时电源波动大,也会干扰总线。 解决办法:把一次完整的 IIC 事务包在临界区里,进之前关总中断、出之后开;同时把速率降到 50kHz 增加容错余量。电源这边加 100nF 去耦,必要时给 SDA/SCL 串 22Ω 电阻抑制振铃。

现象四:跑一段时间后总线死锁,SCL 和 SDA 都被拉低,重新初始化也没用。原因是从机在一次传输中途被复位(比如电源瞬断),它还在等剩下的时钟,死死拉着 SDA 不放,主机这边发起始条件它也不认。 解决办法:加一个总线恢复函数,在初始化之前先发 9 个时钟脉冲,把从机的移位寄存器走完,再补一个停止条件。

void iic_bus_recover(void) { uint8_t i; SDA_H(); /* 主机释放 SDA */ for (i = 0; i < 9; i++) { /* 最多 9 个时钟,把从机状态机推完 */ SCL_H(); iic_delay(); SCL_L(); iic_delay(); } iic_stop(); /* 补一个停止条件复位从机 */ }

这个函数在iic_init()一开始调用一次,能把上电前总线被拉低的情况清干净,属于典型的「后悔药」。

现象五:输入引脚读到的值一直在跳,按键明明没按却有事件。原因是 CONFIG 寄存器没配对,引脚还是上电默认的输入高阻态,悬空引脚电平随机。或者引脚配成了输入但没有外部上拉/下拉,浮空输入在数字电路里就是玄学。 解决办法:先确认 CONFIG 对应位是 1,再确认输入引脚外部有确定的上下拉。PCA9555 内部没有上拉电阻,这一点和某些带内部上拉的 MCU 引脚不一样,必须靠外部电路。按键接法一般是引脚到地加 10kΩ 上拉到 3.3V,按下读 0、松开读 1。

5.3 用逻辑分析仪定位时序问题

软件模拟 IIC 最大的优势是可观测。花几十块买个 8 通道的逻辑分析仪,接上 SCL、SDA、GND 三根线,用上位机软件按 IIC 协议解码,能直接看到每一次起始、地址、数据、ACK 的原始波形。常见的问题在波形上一目了然:ACK 位是高的说明从机没应答;数据位在 SCL 高电平期间跳变说明主机没有遵守「低电平改数据」的规则;SCL 高电平时间远长于低电平说明某处被中断打断。

我一般会抓三段波形做对比:正常读写的、ACK 失败的、总线死锁的。三张图放在一起,出错时的特征非常明显。总线死锁的典型波形是 SDA 一直保持低电平而 SCL 还在正常翻转,这时就能确认是从机在等时钟,直接调用iic_bus_recover()解决。不用逻辑分析仪,光靠打印日志猜时序,效率差一个数量级。

6. 二次开发:把模拟 IIC 改造成可复用总线并做回归验证

这套代码跑通之后,下一步通常是想复用——毕竟板子上不止一颗 IIC 器件。我建议做两件事:把bsp_iic.c里的引脚宏和延时参数抽成一个结构体,在初始化时传入,这样同一份代码可以实例化出多条软件 IIC 总线;再在pca9555.c上把器件地址做成参数,多片 PCA9555 级联时只需要改一个入参。

typedef struct { GPIO_TypeDef *scl_port; uint16_t scl_pin; GPIO_TypeDef *sda_port; uint16_t sda_pin; uint8_t delay_loop; /* 速率调节:8≈100k,16≈50k */ } soft_iic_t; /* 多总线实例:一条挂 PCA9555,一条挂其他器件,互不干扰 */ static soft_iic_t g_iic_ext = { GPIOB, GPIO_Pin_6, GPIOB, GPIO_Pin_7, 8 };

改造成本不高,但要留意几个点。一是宏函数要换成带参数的普通函数或者函数指针,性能会掉一点,100kHz 这个速率下完全无感。二是时序函数不要放进结构体,iic_delay()保持全局一份,走结构体反而增加调用开销。三是把 ACK 返回值统一成「0 成功」的语义之后,多个器件驱动可以共用同一套错误处理。

改完之后必须做回归验证,我一般跑三项:连续读写 10000 次看有没有失败,这是稳定性;断电重启 50 次看初始化成功率,这是上电时序余量;把总线短路到地再释放,看恢复函数能不能救回来,这是鲁棒性。三项都过,这套代码才敢往量产板上放。

有次我图省事,改完直接烧板子,结果第一天好好的,第二天现场反馈按键全乱。回头查才发现改结构体的时候把delay_loop的默认值填成了 0,iic_delay()里的while(i--)直接跳过,SCL 频率飙到几百 kHz,短距离飞线勉强能通,线一长就全靠运气。从那以后我每次动时序参数,都强制用逻辑分析仪量一遍实际频率再烧板子,不靠「看起来能跑」判断。希望这套东西能帮到你少走点弯路。

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

RELATED READING

延伸阅读

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