资讯详情

STM32F103模拟IIC驱动PCA9555实现16路IO扩展

📅 2026/10/26 23:45:38 | 华诺云谱 👁 阅读
STM32F103模拟IIC驱动PCA9555实现16路IO扩展
简介面向嵌入式开发者与高校学生的 STM32F103 软件模拟 IIC 访问 PCA9555 实例工程重点解决在不依赖硬件 I2C 外设时扩展 16 位 IO 端口的通信问题。工程围绕 SCL/SDA 的 GPIO 时序模拟、起始与停止条件、7 位从机地址及读写位、PCA9555 输入/输出与中断相关寄存器的读写展开适合作为 IIC 协议入门、IO 扩展和嵌入式外设调试的参考。压缩包共 127 个文件约 2MB以 33 个 h 头文件、32 个 c 源文件为核心配合 Keil 工程配置、编译中间文件及 hex/axf 烧录产物便于直接打开、编译与对照学习。已有 4838 人学习读者可从中获得完整可运行的工程骨架、软件模拟 IIC 的时序实现思路、PCA9555 寄存器操作范例以及编译烧录与排错参考。1. 从两根线到十六个 IOSTM32F103 模拟 IIC 驱动 PCA9555 的真实价值手里拿着一块 STM32F103 最小系统板需要外扩 16 个按键或继电器GPIO 已经快用完了但硬件 IIC 的引脚又刚好被其他外设占用——这种场景我遇到过不止一次。PCA9555 就是为这类情况准备的它是 16 位 IIC 转并行 IO 扩展芯片通过两根线就能管住 16 个 IO还能配置成输入或输出支持中断上报。项目标题里那句「亲测有效」并不夸张模拟 IIC软件 IIC在 STM32 上比硬件 IIC 更可控时序能按 PCA9555 的数据手册调整出问题能逐位抓取波形不像硬件外设那样只能对着寄存器配置发愁。这篇笔记面向已经能点亮 LED、但还没把 IO 扩展芯片跑通的嵌入式开发者尤其是使用 STM32F103 这类经典型号的读者。我会把模拟 IIC 的时序怎么实现、PCA9555 的寄存器怎么读写、上电初始化顺序、读写单个位和整个端口的方法讲清楚再落到实际能编译运行的代码。硬件 IIC 的玄学问题不少模拟 IIC 至少能让每条时序线都看得见。2. 模拟 IIC 与 PCA9555 的匹配逻辑为什么不用硬件外设2.1 先算清楚引脚账为什么模拟方案更合适STM32F103 的硬件 IIC 一般挂在 PB6/PB7I2C1或 PB10/PB11I2C2上。如果你在同一个项目里已经用到了这些引脚再想加 PCA9555就得考虑重映射或者切到软件模拟。模拟 IIC 的本质是用两个普通 GPIO 分别充当 SCL 和 SDA靠代码控制高低电平来生成 START、STOP、ACK 这些时序单元。它不占用硬件 IIC 外设引脚可以随便挑比如 PB8/PB9、PA5/PA6 都能用。我通常选开漏输出模式并外接 4.7kΩ 上拉电阻。原因很直接IIC 总线是开漏结构多个设备共享时谁拉低谁就占线如果配成推挽一旦从机也在拉低就变成两个推挽输出对顶容易烧引脚。这一点和硬件 IIC 配置一致不能省。选 PCA9555 而不是 74HC595 的理由也值得说清。74HC595 只能输出无法读取输入状态做按键扫描就不行PCA9555 的 16 个 IO 可以按端口配置方向端口 0 和端口 1 各自独立配置输入输出混用很方便。它还有中断输出引脚任一输入引脚状态变化都能触发不用 MCU 轮询。代价是 IIC 协议本身比 SPI 慢不过 IO 扩展这种场景对速度不敏感几十 kHz 就够用。2.2 PCA9555 的寄存器地图四个命令字节就够了PCA9555 内部只有四个可寻址的寄存器每个寄存器 8 位对应一个端口的 8 个引脚命令字节寄存器读写作用0x00输入端口 0读读 P0 口 8 个引脚的实时电平0x01输入端口 1读读 P1 口 8 个引脚的实时电平0x02输出端口 0读写设置 P0 口输出锁存值0x03输出端口 1读写设置 P1 口输出锁存值0x04极性反转 0读写翻转 P0 口输入极性0x05极性反转 1读写翻转 P1 口输入极性0x06配置端口 0读写P0 方向1 为输入0 为输出0x07配置端口 1读写P1 方向1 为输入0 为输出这八个命令字节是理解 PCA9555 的顺序。配置寄存器的位定义为 1 表示输入为 0 表示输出上电默认全部是输入因此如果不先写配置寄存器就直接写输出寄存器引脚仍会保持高阻灯不会亮。极性反转寄存器一般保持 0x00除非你希望把按键的低有效直接读成高有效可以用它省掉软件取反。这里有个容易忽略的点命令字节在写操作后会自动递增也就是你写 0x02输出端口 0之后再连续写一个字节它会自动落到 0x03输出端口 1。所以初始化时可以用一次两字节写把两个端口的值一起设好效率比两次单字节写高。2.3 从机地址怎么定7 位地址左移一位PCA9555 的 7 位从机地址由 A0、A1、A2 三个引脚决定公式是0100 A2 A1 A0。三个引脚都接地时7 位地址是 0x20写操作字节是 0x40读操作字节是 0x41。A0 接高电平时地址变成 0x21写字节 0x42以此类推。这个换算是新手最容易翻车的地方数据手册里给的是 7 位地址而代码里需要在末尾补一位读写位直接拿 0x20 当写地址发出去从机不会应答。我一般会在头文件里把地址定义成一个宏再用移位生成读写字/* PCA9555 7 位从机地址: A2 A1 A0 全接地 0x20 */ #define PCA9555_ADDR 0x20 /* 写字节 7 位地址左移 1 位, 最低位 0 */ #define PCA9555_WRITE_ADDR (PCA9555_ADDR 1) /* 读字节 7 位地址左移 1 位, 最低位 1 */ #define PCA9555_READ_ADDR ((PCA9555_ADDR 1) | 0x01)这段宏看起来简单但把PCA9555_ADDR 1写成PCA9555_ADDR是在我排查过的故障里出现频率最高的。另一个细节是读操作采用两段式先写一次地址字节带写标志再发一次重复起始条件带读标志然后连续读两个字节并且只在最后一个字节回 NACK。如果两个字节都回 ACK从机会继续保持 SDA 输出下一轮通信容易出错。3. 五步搭起模拟 IIC 驱动层从引脚定义到字节收发3.1 GPIO 初始化和时序宏把微秒级延时落到实处模拟 IIC 的第一层是引脚电平操作和延时。F103 主频 72MHz 时一个简单 for 循环空转的耗时很难精确估算而且编译器优化等级一变循环就被优化掉。我倾向用 SysTick 做微秒延时或者至少把延时函数放到单独文件里并加__attribute__((noinline))防止内联。PCA9555 数据手册对 SCL 高电平和低电平的最小时间有要求标准模式下总线频率 100kHz半周期约 5 微秒留一点余量按 5 到 6 微秒设置比较稳。#include stm32f10x.h #define IIC_SCL_HIGH() GPIOB-BSRR GPIO_BSRR_BS8 #define IIC_SCL_LOW() GPIOB-BRR GPIO_BRR_BR8 #define IIC_SDA_HIGH() GPIOB-BSRR GPIO_BSRR_BS9 #define IIC_SDA_LOW() GPIOB-BRR GPIO_BRR_BR9 #define IIC_SDA_READ() ((GPIOB-IDR GPIO_IDR_IDR9) ! 0) /* 5 微秒量级的软件延时, 实际工程建议换成 SysTick 计时 */ static void iic_delay(void) { volatile uint32_t n 60; while (n--) { } } void iic_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); /* SCL 和 SDA 都配置为开漏输出, 外接 4.7k 上拉 */ gpio.GPIO_Pin GPIO_Pin_8 | GPIO_Pin_9; gpio.GPIO_Mode GPIO_Mode_Out_OD; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, gpio); /* 释放总线, 空闲时两根线都是高电平 */ IIC_SCL_HIGH(); IIC_SDA_HIGH(); }这里用 BSRR 和 BRR 寄存器直接置位、清零比GPIO_SetBits、GPIO_ResetBits函数少一层调用开销时序更平整。SDA 读取用 IDR 直接取位。开漏模式下即使写 1引脚实际电平也由上拉电阻和总线上的从机决定所以读 IDR 能得到真实总线状态这是模拟 IIC 能回读 ACK 的前提。如果你用推挽模式SDA 读出来永远是自己写的值从机是否应答就完全测不出来故障排查会变成黑匣子。3.2 起始、停止、应答三个时序单元的实现模拟 IIC 的所有操作都由三个基本单元拼出来起始条件、停止条件、应答位。起始条件是在 SCL 为高时SDA 从高变低停止条件是 SCL 为高时SDA 从低变高。应答位是在发送完 8 位数据后释放 SDA在第 9 个 SCL 高电平期间读取 SDA 是否为低。这三个函数写对了整个协议就成立写错了后面无论怎么调都读不到数据。void iic_start(void) { IIC_SDA_HIGH(); IIC_SCL_HIGH(); iic_delay(); IIC_SDA_LOW(); /* SCL 高电平期间 SDA 下降沿 起始 */ iic_delay(); IIC_SCL_LOW(); } void iic_stop(void) { IIC_SDA_LOW(); IIC_SCL_HIGH(); iic_delay(); IIC_SDA_HIGH(); /* SCL 高电平期间 SDA 上升沿 停止 */ iic_delay(); } /* 主机发送应答: ack1 回 ACK, ack0 回 NACK */ void iic_send_ack(uint8_t ack) { IIC_SCL_LOW(); if (ack) { IIC_SDA_LOW(); } else { IIC_SDA_HIGH(); } iic_delay(); IIC_SCL_HIGH(); iic_delay(); IIC_SCL_LOW(); IIC_SDA_HIGH(); /* 释放 SDA, 准备下一字节 */ } /* 主机等待从机应答, 返回 0 表示收到 ACK, 1 表示 NACK */ uint8_t iic_wait_ack(void) { uint8_t ret; IIC_SDA_HIGH(); /* 释放 SDA, 让从机拉低 */ iic_delay(); IIC_SCL_HIGH(); iic_delay(); ret IIC_SDA_READ() ? 1 : 0; IIC_SCL_LOW(); return ret; }时序里几个顺序不能乱。iic_start中先把 SDA 拉高、SCL 拉高再让 SDA 下降如果顺序颠倒从机会把这次变化当成普通数据位。iic_send_ack里在 SCL 低电平期间改变 SDA然后拉高 SCL 让从机采样这个「低电平改数据、高电平采样」的规则贯穿整个 IIC 协议。iic_wait_ack中先释放 SDA 再拉高 SCL顺序颠倒的话你自己拉低 SDA 而 SCL 同时变高就会被从机误判为起始或停止条件。3.3 字节收发与寄存器读写封装有了时序单元字节收发就是 8 次循环每次在 SCL 低时把数据位放到 SDA 上然后拉高 SCL 让从机采样。读字节则相反每次拉高 SCL 后从 SDA 取位再拉低。字节收完之后要处理 ACK发送方向由主机在收完从机的 ACK 后决定是否继续。/* 主机向从机发送一个字节, 返回从机的应答: 0ACK, 1NACK */ uint8_t iic_write_byte(uint8_t data) { uint8_t i, ack; for (i 0; i 8; i) { IIC_SCL_LOW(); if (data 0x80) { IIC_SDA_HIGH(); } else { IIC_SDA_LOW(); } data 1; iic_delay(); IIC_SCL_HIGH(); iic_delay(); } IIC_SCL_LOW(); ack iic_wait_ack(); return ack; } /* 主机从从机读一个字节, send_ack1 表示读完后回 ACK, 0 表示回 NACK */ uint8_t iic_read_byte(uint8_t send_ack) { uint8_t i, data 0; IIC_SDA_HIGH(); /* 释放 SDA, 由从机驱动 */ for (i 0; i 8; i) { data 1; IIC_SCL_HIGH(); iic_delay(); if (IIC_SDA_READ()) { data | 0x01; } IIC_SCL_LOW(); iic_delay(); } iic_send_ack(send_ack); return data; }注意iic_read_byte循环结束后调用iic_send_ack把主机的应答发出去。读单个寄存器时给 NACK读多个寄存器时前几个字节给 ACK、最后一个给 NACK。把这个细节搞混会出现读到的第一个字节正确后续字节全错或者总线被从机卡住的情况。在字节层之上我封了三个函数写单个寄存器、读单个寄存器、连续读多个寄存器。这些函数内部处理地址字节、命令字节和重复起始对上层完全隐藏协议细节/* 写 PCA9555 单个寄存器: reg 为命令字节, val 为写入值 */ uint8_t pca9555_write_reg(uint8_t reg, uint8_t val) { iic_start(); if (iic_write_byte(PCA9555_WRITE_ADDR)) { iic_stop(); return 1; } if (iic_write_byte(reg)) { iic_stop(); return 2; } if (iic_write_byte(val)) { iic_stop(); return 3; } iic_stop(); return 0; } /* 读 PCA9555 单个寄存器 */ uint8_t pca9555_read_reg(uint8_t reg, uint8_t *val) { iic_start(); if (iic_write_byte(PCA9555_WRITE_ADDR)) { iic_stop(); return 1; } if (iic_write_byte(reg)) { iic_stop(); return 2; } iic_start(); /* 重复起始条件 */ if (iic_write_byte(PCA9555_READ_ADDR)) { iic_stop(); return 3; } *val iic_read_byte(0); /* 只读一个字节, 回 NACK */ iic_stop(); return 0; }返回值携带错误码很重要。三个返回值分别对应从机地址没应答、命令字节没应答、数据字节没应答可以根据返回值判断是哪一步出了问题。如果全部返回 1从机地址就没被应答先查接线和地址引脚如果返回 3说明地址和命令字节都被应答了但写数据时出问题可能是从机忙或总线被拉低。3.4 上电初始化顺序配置寄存器必须最先写PCA9555 上电后所有引脚默认为输入输出寄存器是 0xFF。想要某些引脚输出必须先写配置寄存器把对应位清 0再写输出寄存器设定初始电平。顺序反了会出现引脚短暂输出高电平、继电器误动的现象这一点在做执行器控制时很容易遇到。/* 端口 0 全部输出, 端口 1 全部输入; 输出端口初始为低电平 */ void pca9555_init(void) { iic_gpio_init(); /* 先定方向: bit1 输入, bit0 输出 命令 0x06 是配置端口 0, 命令 0x07 是配置端口 1 */ pca9555_write_reg(0x06, 0x00); pca9555_write_reg(0x07, 0xFF); /* 再定初始电平, 正点原子风格的板子常把 P0 接 LED 阳极, 这里写 0x00 让 16 位输出全部为低 */ pca9555_write_reg(0x02, 0x00); pca9555_write_reg(0x03, 0x00); /* 极性反转寄存器保持默认 0x00, 不翻转输入 */ pca9555_write_reg(0x04, 0x00); pca9555_write_reg(0x05, 0x00); }初始化里每个寄存器都是单写。如果嫌 6 次事务太多可以用连续写先写命令 0x02然后连续发两个字节分别落到输出端口 0 和输出端口 1因为命令字节会自动递增。配置寄存器同理从 0x06 开始连续写两个字节就能同时设好两个端口的方向。这种写法省时间但可读性差一些新项目里我通常先按单写跑通再优化合并。4. 读写操作踩坑记录五条排查清单4.1 读回来的端口值全 0xFF 或全 0x00现象初始化写配置寄存器看起来正常读输入端口时无论怎么按按键返回的都是 0xFF 或 0x00。原因通常是 SDA 没有配成开漏输出或者上拉电阻没接。推挽输出模式下主机读 IDR 只能读到自己写出去的电平从机拉低总线的动作对 MCU 不可见。解决方法是检查 GPIO 模式配置确认是GPIO_Mode_Out_OD并用万用表量 SDA 空闲时是否为 3.3V。空闲不为高说明上拉缺失或总线被短路。4.2 从机地址写错导致完全无应答现象pca9555_write_reg每次都返回 1起始条件之后第一个字节就收到 NACK。原因是地址字节用错了把 7 位地址 0x20 直接当作写字节发出去从机比较的是完整字节的 7 位最低位的读写位被当成了地址的一部分地址变成 0x10 对应的高位自然无人应答。解决方法是确认宏定义PCA9555_WRITE_ADDR是0x20 1并用逻辑分析仪抓一下第一个字节看看实际发出的 8 位数据是不是0100 0000。4.3 连续读多个寄存器时只有第一个字节正确现象读端口 0 和端口 1 的连续读函数返回的第一个字节数据正确第二个字节始终为 0xFF 或没有变化。原因是在读第一个字节后回了 ACK但从机的数据更新需要时间或者主机在发出 ACK 后没有及时切回接收态就发了下一个 SCL 脉冲。另一种常见原因是漏掉了重复起始条件在写完命令字节之后直接进入读而没有重新发 START。解决方法是补上iic_start()重复起始并且确认非最后一字节回 ACK、最后一字节回 NACK。4.4 写输出寄存器后引脚电压不变化现象配置寄存器写了 0x00 把端口 0 设为输出再写输出寄存器 0x00 或 0xFF用万用表量引脚却始终是高电平时或高阻态。原因可能是初始化顺序写反了先写输出寄存器再写配置寄存器写入输出寄存器的瞬间引脚还是输入等配置寄存器改成输出时才生效看起来好像「写不进」。另一个原因是 PCA9555 的供电和上拉电压不一致输出高电平被钳位在较低电压驱动 LED 时看不到亮。解决方法是调整初始化顺序配置寄存器必须在输出寄存器之前写并确认 VDD 与 IO 上拉电压匹配。4.5 总线被从机拉低后无法恢复现象程序运行中复位 MCU 后PCA9555 仍保持着上一次通信中途的状态SDA 一直被它拉低新程序发起始条件时 SCL 为高但 SDA 本来就是低的起始条件无法生成整个总线挂死。原因是从机在读取过程中被复位打断它还在等待第 9 个时钟持续驱动 SDA 为低。解决方法是上电初始化时先发 9 个 SCL 脉冲把从机的移位寄存器清空再发 STOP 条件让总线回到空闲。这个恢复序列在 IIC 从设备较多的系统里应该成为固定动作。/* 上电时先恢复总线: 9 个 SCL 脉冲 STOP */ void iic_bus_recovery(void) { uint8_t i; IIC_SDA_HIGH(); for (i 0; i 9; i) { IIC_SCL_LOW(); iic_delay(); IIC_SCL_HIGH(); iic_delay(); } iic_stop(); }这段恢复代码建议放在iic_gpio_init之后、任何 PCA9555 通信之前。我遇到过板子热插拔 IO 扩展模块后总线锁死的情况加了这段之后省了不少重新上电的麻烦。5. 把驱动变成可靠工具中断输入与批量读写技巧5.1 用中断引脚代替轮询减少总线占用的做法PCA9555 的 INT 引脚在任一输入引脚状态变化时拉低配置成下降沿触发接 MCU 的外部中断即可。这样主循环不用反复读输入端口按键按下时才进中断在中断里读一次输入端口并清中断。需要注意的是PCA9555 的中断是「变化触发」读一次输入端口之后锁存器才会释放 INT 引脚如果只读一个端口另一个端口有变化时 INT 不会释放所以中断里最好把两个端口都读一遍。/* 外部中断里读两个输入端口, 读操作本身会清除中断锁存 */ void EXTI9_5_IRQHandler(void) { uint8_t p0, p1; if (EXTI_GetITStatus(EXTI_Line5) ! RESET) { pca9555_read_reg(0x00, p0); pca9555_read_reg(0x01, p1); key_event_handler(p0, p1); EXTI_ClearITPendingBit(EXTI_Line5); } }中断服务函数里调用 IIC 读函数会引入几毫秒的阻塞如果有更高优先级的中断对时序敏感可以把读操作放到标志位里退出中断后主循环再读。这一点和按键消抖要一起考虑机械按键的抖动会产生多次中断可以在读回值之后做 10 到 20 毫秒的软件确认。5.2 端口值的按位操作避免读改写覆盖其他位直接对一个端口写 0xFF 或 0x00 会覆盖该端口所有引脚如果同一个端口上既有需要保持状态的输出又有需要切换的输出就得先读回输出寄存器再按位修改。PCA9555 的输出寄存器是可读的读出来的就是当前锁存值用这种方式做按位操作最安全。/* 修改端口 0 的单个输出位, 不影响同端口其他引脚 */ uint8_t pca9555_set_output_bit(uint8_t port, uint8_t bit, uint8_t level) { uint8_t reg (port 0) ? 0x02 : 0x03; uint8_t val; if (pca9555_read_reg(reg, val)) { return 1; } if (level) { val | (uint8_t)(1u bit); } else { val (uint8_t)~(1u bit); } return pca9555_write_reg(reg, val); }读改写中间存在一个窗口期如果 INT 中断抢占并修改同一个端口可能丢失更新。简单系统里可以关中断保护或者干脆把输出端口按用途分区不同任务操作不同端口。我在一个小型控制器项目里就把端口 0 固定给继电器、端口 1 固定给按键彻底避免共享写入。5.3 速率与上拉的边界两个容易忽略的硬约束模拟 IIC 的速率不是越快越好。SCL 频率提高后上拉电阻和总线电容构成的 RC 延时会让上升沿变缓PCA9555 在高电平期间采到的电平可能还没爬到位表现为偶发 NACK 或数据错位。常规 4.7kΩ 上拉配 100kHz 是稳妥组合如果非要跑到 400kHz上拉要降到 2.2kΩ 左右并缩短走线而且模拟 IIC 的延时函数要按实测调整。另一个约束是 PCA9555 的驱动能力。每个输出引脚灌电流和拉电流能力有限直接驱动继电器线圈会烧 IO必须加三极管或驱动芯片。它的中断引脚是开漏输出也需要上拉。这些硬件层面的边界往往比软件时序更容易被新手忽略。5.4 上板验证的顺序从回读到输出的闭环验证驱动是否正常我一般按四步走第一步只读设备地址确认收到 ACK第二步读配置寄存器默认值 0xFF确认能正确读出第三步写配置寄存器把端口 0 设为输出再读回来确认写入生效第四步在端口 0 交替写 0x55 和 0xAA用万用表或 LED 观察引脚变化。四步都通过说明地址、时序、读写方向都没有问题剩下的就是业务逻辑。这套顺序的好处是每步只验证一个变量出问题时能立刻定位是地址、总线时序还是寄存器写入。如果第一步就失败先查接线和上拉第二步失败查读时序第三步失败查命令字节递增或重复起始条件第四步失败查输出寄存器与配置寄存器的顺序。5.5 从单点验证到反复可用的习惯模拟 IIC 驱动写完一次不代表以后都可靠。我在每次换板子、换上拉电阻、换主频之后都会重新跑一遍上面那个四步验证因为时序延时和 GPIO 翻转速度都可能变化。把iic_bus_recovery放在初始化最前面把每个读写函数的返回值都做判断把寄存器操作封装成带错误码的函数——这些看起来繁琐的动作能避免后面调试时在硬件和软件之间反复怀疑。希望这篇笔记能帮到正在用 STM32F103 扩展 IO 的你少走一点弯路把时间留给真正需要调试的业务逻辑。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑