
简介面向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 模拟也不碰硬件 IIC2.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/PB7IIC2 在 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_t0 表示成功非 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_ack1 发 ACK0 发 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 读到的就是 0ACK 采样才成立。如果误配成推挽输出主机写 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_stopSDA 可能被主机拉在低电平下一次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的取值要特别注意读单个寄存器时传 0NACK读连续多个寄存器时前面几个传 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由调用者提供长度至少lenlen一般取 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之后第一个字节是不是0x400x20 左移一位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, input10x%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 | (A22) | (A11) | 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≈100k16≈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的默认值填成了 0iic_delay()里的while(i--)直接跳过SCL 频率飙到几百 kHz短距离飞线勉强能通线一长就全靠运气。从那以后我每次动时序参数都强制用逻辑分析仪量一遍实际频率再烧板子不靠「看起来能跑」判断。希望这套东西能帮到你少走点弯路。本文还有配套的精品资源点击获取