简介:面向STM32平台的PCA9555驱动源码,专为需要通过I2C总线扩展16路数字信号线的嵌入式项目设计,可有效解决主控GPIO引脚不足、外设接入受限的问题,常用于工业控制、智能硬件和传感器采集等场景,适合中高级嵌入式开发者快速移植与二次开发。资源包仅两个文件,分别为驱动源文件与配套头文件,压缩后约6KB,结构精简、依赖少,可直接加入现有STM32工程编译使用。目前已有1947人学习下载,说明该驱动在真实项目中具备较高参考价值。源码覆盖PCA9555的I2C初始化、器件地址配置、寄存器读写、输入输出方向控制及中断处理等核心API,头文件包含寄存器映射、功能宏与函数声明,源文件提供完整读写逻辑与基础示例,便于理解输入读取、输出控制及中断响应流程。开发者可基于现成接口快速完成板级通信验证,并根据实际电路调整地址、时钟和中断引脚等参数,从而稳定实现GPIO扩展、降低硬件设计复杂度。 跑STM32项目最烦的一件事就是做到一半发现IO口不够用。我前前后后经手过好几个板子,都栽在这上面。后来在项目里引入了PCA9555这颗16位I2C接口的GPIO扩展芯片,配合一份自己整理的STM32驱动源码,问题一下清爽了。这篇文章就把PCA9555的寄存器、初始化和读写接口完整拆开讲,顺便把我在实际调试中踩过的坑、用逻辑分析仪定位问题的过程、以及让这套驱动更稳定的几个细节一并写出来。适合手里有STM32开发板、想低成本扩展IO口,或者正准备给板子加按键、LED、继电器控制的朋友参考。
1. 为什么要用PCA9555而不是直接换芯片
1.1 最典型的几个应用场景
STM32的IO口看着不少,但真做产品时你会发现:几个按键要占IO,状态LED要占IO,继电器、蜂鸣器要占IO,要是再接个LCD或者编码器,GPIO瞬间见底。更麻烦的是有些封装只有那么多引脚,换大封装意味着重新画板、重新做BOM,成本和时间都不划算。
这时候外扩IO芯片是最务实的方案。PCA9555通过I2C总线通信,只需要占用MCU的SCL和SDA两根线,就能换回来16个独立的IO口。每个IO口都能单独配置成输入或者输出,输出模式下可以点LED、驱动小继电器(记得加三极管或MOS管),输入模式下可以接按键和开关。一颗芯片管16个口,两颗就是32个口,而MCU那边始终只占用两个引脚,这笔账怎么算都划算。
我在项目里最常用的场景是:用PCA9555管LED指示灯和按键矩阵,把STM32本体的GPIO留给需要高速或中断响应的外设。PCA9555的IO速度虽然不快,但处理这些“慢速事件”绰绰有余。
1.2 同类扩展方案横向对比
扩展IO的方案不止PCA9555一种,常见的还有74HC595串转并、MCP23017、PCF8574等。我简单列个对比表,方便你选型时心里有数。
| 芯片 | 接口 | IO数量 | 是否支持输入 | 中断输出 | 地址扩展 | 典型特点 |
|---|---|---|---|---|---|---|
| PCA9555 | I2C | 16 | 支持 | 支持 | 8个 | 应用最广,资料多,驱动好写 |
| PCF8574 | I2C | 8 | 支持 | 支持 | 8个 | 准双向IO,读取前要写1 |
| MCP23017 | I2C/SPI | 16 | 支持 | 支持 | 8个 | 双电源供电,IO驱动能力略强 |
| 74HC595 | 移位/SPI | 8 | 不支持输入 | 无 | 级联扩展 | 便宜,只能输出,适合纯LED驱动 |
如果你是第一次接触扩展IO,我很推荐从PCA9555入手。原因有两个:一是寄存器结构比PCF8574清晰,每个端口有独立的输入、输出、方向控制寄存器,逻辑直观;二是在原厂和各大开源社区能找到大量参考源码,出问题也好排查。74HC595虽然便宜,但只能做输出,一旦方案中途要加输入功能就得推翻重来,灵活性差很多。
2. 驱动源码背后的芯片知识:寄存器与地址
2.1 8个寄存器各自干什么
很多人写驱动上来就抄代码,结果改了地址、改了引脚就是不工作。说句实话,PCA9555一共就那么8个寄存器,花十分钟把寄存器搞明白,比盲目改代码管用一百倍。
PCA9555内部有16个IO口,分为Port 0和Port 1两组,每组8个。对应的寄存器也按端口分组:
| 寄存器地址 | 寄存器名称 | 位宽 | 功能说明 |
|---|---|---|---|
| 0x00 | Input Port 0 | 8 | 读取Port 0引脚电平 |
| 0x01 | Input Port 1 | 8 | 读取Port 1引脚电平 |
| 0x02 | Output Port 0 | 8 | 写入Port 0输出锁存值 |
| 0x03 | Output Port 1 | 8 | 写入Port 1输出锁存值 |
| 0x04 | Polarity Inversion 0 | 8 | 配置Port 0输入极性是否反转 |
| 0x05 | Polarity Inversion 1 | 8 | 配置Port 1输入极性是否反转 |
| 0x06 | Configuration Port 0 | 8 | 配置Port 0每个IO的方向 |
| 0x07 | Configuration Port 1 | 8 | 配置Port 1每个IO的方向 |
Configuration寄存器是方向控制的总开关:某一位写0,对应引脚就是输出模式;某一位写1,就是输入模式。这里有一个特别容易忽略的逻辑:如果你把某个引脚设为输出,但实际上没有往Output寄存器里写值,那这个引脚默认输出的是0,而不是高阻态。反过来,如果把引脚设为输入,Output寄存器的锁存值依然存在,一旦切回输出模式,引脚会立刻输出之前锁存的那个值。
Polarity Inversion寄存器默认是0x00,表示输入电平不做反转。只有当外部电路是“低电平表示有效”且你希望软件里读到1时,才把对应位置1。多数场景保持默认即可,别乱动。
2.2 设备地址怎么算
PCA9555的设备地址由硬件引脚A0、A1、A2的电平决定。芯片固定的基地址是0100,加上A2/A1/A0的组合,得到7位地址范围是0x20到0x27。也就是说,一条I2C总线上最多可以挂8片PCA9555,互不冲突。
实际编程时,HAL库函数里的DeviceAddr参数是8位地址形式,需要在7位地址基础上左移一位。比如A0/A1/A2全部接地时,7位地址是0x20,调用HAL_I2C_Mem_Read/Write时传入的是0x40。这里非常容易出错,特别是从网上找的例程有的直接传7位地址、有的传8位地址,混着看特别容易看晕。我的习惯是在头文件里统一写清楚:
#define PCA9555_BASE_ADDR 0x20 // HAL库接口需要左移一位 #define PCA9555_ADDR(a) ((uint16_t)((PCA9555_BASE_ADDR + (a)) << 1))硬件上A0/A1/A2引脚不能悬空,必须接VCC或GND。我见过有人为了省事把这三个脚悬空,结果I2C通信时好时坏,折腾了半天才发现是地址不稳定。
2.3 上电默认状态与输出锁存器的坑
PCA9555上电时,所有IO口默认是输入模式,Configuration寄存器默认是0xFF。这一点初看没问题,但它会引出两个经典坑。
第一个坑是:如果你初始化的时候忘了配置方向,直接调“写引脚”函数,电平根本不会出现在引脚上,因为芯片还处于输入模式。这时候用万用表量引脚,永远是高阻态,你会以为芯片坏了。
第二个坑和输出锁存器有关。PCA9555的输出值不是直接写到引脚上的,而是先写入一个锁存器,由锁存器决定引脚电平。当你把引脚从输入切回输出时,锁存器里之前存的值会立刻生效。这意味着:如果你在输入模式下往Output寄存器写过值,等切到输出模式后引脚会直接输出那个旧值,而不是复位后的默认值。很多“输出状态突然不对”的问题,根源都在这里。
所以我在写初始化函数时,一定会做三件事:设方向、清输出锁存、清极性反转。顺序也有讲究,先清Output再设方向更是稳妥,避免方向一改成输出就带着不确定的锁存值。
3. 驱动源码完整拆解:从结构体到读写接口
3.1 头文件与数据结构设计
驱动代码我习惯分成pca9555.c和pca9555.h两个文件。头文件里除了寄存器定义,还会定义一个结构体,把I2C句柄和当前设备地址封装起来。这样同一个驱动可以服务多片PCA9555,只要每个设备实例有自己的结构体变量即可。
/* pca9555.h */ #ifndef __PCA9555_H #define __PCA9555_H #include "stm32f1xx_hal.h" #define PCA9555_I2C_ADDR_BASE 0x20 /* 寄存器地址定义 */ #define PCA9555_REG_IN_PORT0 0x00 #define PCA9555_REG_IN_PORT1 0x01 #define PCA9555_REG_OUT_PORT0 0x02 #define PCA9555_REG_OUT_PORT1 0x03 #define PCA9555_REG_POL_PORT0 0x04 #define PCA9555_REG_POL_PORT1 0x05 #define PCA9555_REG_CFG_PORT0 0x06 #define PCA9555_REG_CFG_PORT1 0x07 #define PCA9555_PIN_MODE_IN 1 #define PCA9555_PIN_MODE_OUT 0 #define PCA9555_PIN_LEVEL_LOW 0 #define PCA9555_PIN_LEVEL_HIGH 1 typedef struct { I2C_HandleTypeDef *hi2c; uint8_t addr7; /* 7位设备地址 */ } PCA9555_HandleTypedef; uint8_t PCA9555_Init(PCA9555_HandleTypedef *dev, I2C_HandleTypeDef *hi2c, uint8_t addr7); HAL_StatusTypeDef PCA9555_SetPinMode(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t pin, uint8_t mode); HAL_StatusTypeDef PCA9555_WritePin(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t pin, uint8_t level); HAL_StatusTypeDef PCA9555_WritePort(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t value); uint8_t PCA9555_ReadPin(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t pin); uint8_t PCA9555_ReadPort(PCA9555_HandleTypedef *dev, uint8_t port); #endif这里把port参数设计成0或1,pin参数是0到7,用起来很直观。比如操作Port 0的第3脚,传参就是PCA9555_WritePin(&dev, 0, 3, 1)。结构体里保存7位地址,实际调用HAL库时再左移,内部细节不让上层关心。
3.2 初始化的三个关键动作
下面是pca9555.c里的初始化实现。我强调一下,这三个操作缺一不可:探测设备是否存在、全部配置为输入、清除输出锁存和极性反转。
/* pca9555.c */ #include "pca9555.h" static HAL_StatusTypeDef PCA9555_ReadReg(PCA9555_HandleTypedef *dev, uint8_t reg, uint8_t *data) { uint16_t addr = (uint16_t)((dev->addr7) << 1); return HAL_I2C_Mem_Read(dev->hi2c, addr, reg, I2C_MEMADD_SIZE_8BIT, data, 1, 100); } static HAL_StatusTypeDef PCA9555_WriteReg(PCA9555_HandleTypedef *dev, uint8_t reg, uint8_t data) { uint16_t addr = (uint16_t)((dev->addr7) << 1); return HAL_I2C_Mem_Write(dev->hi2c, addr, reg, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); } uint8_t PCA9555_Init(PCA9555_HandleTypedef *dev, I2C_HandleTypeDef *hi2c, uint8_t addr7) { dev->hi2c = hi2c; dev->addr7 = addr7; uint16_t addr = (uint16_t)(addr7 << 1); if (HAL_I2C_IsDeviceReady(hi2c, addr, 5, 100) != HAL_OK) { return 1; /* 设备不存在,返回错误 */ } /* 全部IO口先设为输入,避免上电瞬间输出不确定电平 */ PCA9555_WriteReg(dev, PCA9555_REG_CFG_PORT0, 0xFF); PCA9555_WriteReg(dev, PCA9555_REG_CFG_PORT1, 0xFF); /* 清除输出锁存,为后续切到输出模式做准备 */ PCA9555_WriteReg(dev, PCA9555_REG_OUT_PORT0, 0x00); PCA9555_WriteReg(dev, PCA9555_REG_OUT_PORT1, 0x00); /* 极性不反转 */ PCA9555_WriteReg(dev, PCA9555_REG_POL_PORT0, 0x00); PCA9555_WriteReg(dev, PCA9555_REG_POL_PORT1, 0x00); return 0; }初始化里先全部设成输入,主要是为了安全。设备上电时如果引脚是输出状态,而外部正好接着一个有源电路,就可能出现瞬间误动作,比如继电器吸合一下、LED闪一下。先把所有引脚置于高阻输入,等上层逐个调用SetPinMode配置成输出,再把锁存值清掉,这样整个上电过程可控。
3.3 读写函数怎么实现最稳
读写函数是驱动的主体。写引脚时,为了避免影响同端口其他引脚的状态,必须先读回当前输出锁存值,改对应位后再写回去。这种“读-改-写”的方式虽然多了一次I2C通信,但比直接覆盖整个端口要安全得多。
HAL_StatusTypeDef PCA9555_SetPinMode(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t pin, uint8_t mode) { uint8_t reg = (port == 0) ? PCA9555_REG_CFG_PORT0 : PCA9555_REG_CFG_PORT1; uint8_t val = 0; if (pin > 7) return HAL_ERROR; if (PCA9555_ReadReg(dev, reg, &val) != HAL_OK) return HAL_ERROR; if (mode == PCA9555_PIN_MODE_IN) { val |= (1 << pin); } else { val &= ~(1 << pin); } return PCA9555_WriteReg(dev, reg, val); } HAL_StatusTypeDef PCA9555_WritePin(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t pin, uint8_t level) { uint8_t reg = (port == 0) ? PCA9555_REG_OUT_PORT0 : PCA9555_REG_OUT_PORT1; uint8_t val = 0; if (pin > 7) return HAL_ERROR; if (PCA9555_ReadReg(dev, reg, &val) != HAL_OK) return HAL_ERROR; if (level == PCA9555_PIN_LEVEL_HIGH) { val |= (1 << pin); } else { val &= ~(1 << pin); } return PCA9555_WriteReg(dev, reg, val); } HAL_StatusTypeDef PCA9555_WritePort(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t value) { uint8_t reg = (port == 0) ? PCA9555_REG_OUT_PORT0 : PCA9555_REG_OUT_PORT1; return PCA9555_WriteReg(dev, reg, value); } uint8_t PCA9555_ReadPort(PCA9555_HandleTypedef *dev, uint8_t port) { uint8_t reg = (port == 0) ? PCA9555_REG_IN_PORT0 : PCA9555_REG_IN_PORT1; uint8_t val = 0; if (PCA9555_ReadReg(dev, reg, &val) != HAL_OK) return 0; return val; } uint8_t PCA9555_ReadPin(PCA9555_HandleTypedef *dev, uint8_t port, uint8_t pin) { uint8_t val = PCA9555_ReadPort(dev, port); return (val >> pin) & 0x01; }读取输入端口时,一定要注意:如果引脚被配置成输出,读Input寄存器返回的并不是引脚实时电平,而是输出锁存器的值。所以在读按键这类场景里,初始化时必须先用SetPinMode把对应引脚设为输入,同时外部要接上拉电阻,因为PCA9555内部没有可编程上拉电阻,悬空的输入引脚电平是不确定的。
4. 实测中踩过的坑与排查思路
4.1 常见问题速查表
调试PCA9555驱动踩坑太正常了。我把自己的翻车记录整理成一张速查表,基本覆盖了90%的异常情况。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| I2C通信超时,HAL_I2C_IsDeviceReady失败 | 设备地址错误 | 确认A0/A1/A2硬件电平,检查7位地址是否左移 |
| 写了引脚电平但输出无变化 | 没有配置方向寄存器 | 先用SetPinMode把对应引脚设为输出 |
| 输入引脚电平读数不对 | 引脚悬空或极性反转被配置 | 外部加上拉电阻,检查极性寄存器是否为0 |
| 重上电后输出状态乱跳 | 输出锁存器残留旧值 | 初始化时先清Output寄存器再配置方向 |
| 一片芯片正常,第二片通信偶尔失败 | 总线地址冲突或走线太长 | 检查A2/A1/A0地址是否重复,加长超时时间 |
| 同一端口只改一个引脚,其他引脚跟着乱 | 写操作没有读-改-写 | 改用WritePin函数逐个修改,而不是直接覆盖整个端口 |
其中地址问题是我遇到最多的。曾经有一次板子布线图里A0画的是接地,实际贴片时焊盘连到了VCC,结果程序里怎么改地址都不对,最后拿万用表量引脚电平才发现硬件和原理图不一致。排查这类问题时,先怀疑硬件,再怀疑软件,能省不少时间。
4.2 怎么用逻辑分析仪快速定位
PCA9555是I2C设备,I2C波形是排查问题的关键证据。我建议调试时在SCL、SDA两根线上各串一个几百欧的电阻,目的不是限流,而是方便用逻辑分析仪的夹子去测量,即使夹得不好也不至于影响总线。
抓波形时重点看三件事:启动信号、设备地址、写寄存器时的地址和数据。如果波形正常但没有ACK,大概率是设备地址不对。如果地址ACK了,但后续寄存器操作没反应,就要检查是不是寄存器地址定义写错了。我遇到过一例,把Configuration寄存器地址0x06误写成0x07,导致Port 0的方向配置写到了Port 1上,逻辑分析仪一看就明白了。
没有逻辑分析仪的话,也可以先用STM32读I2C状态寄存器,或者用软件模拟I2C配合串口打印中间变量。软件模拟I2C写起来不复杂,实测排查问题时反而比硬件I2C更直观,因为每一步的电平变化都可以打印出来。
4.3 软件I2C和硬件I2C怎么选
我自己的经验是:能上硬件I2C就上硬件I2C。STM32的硬件I2C在旧固件库时代确实有点坑,但HAL库版本已经稳定很多。只要把I2C时钟配置正确,SCL频率不要超过400kHz,硬件I2C的稳定性和速度都优于软件模拟。
不过偶尔也会遇到硬件I2C死活不工作的板子,尤其是引脚复用配置不对,或者I2C引脚没有配置成开漏输出且外部没有上拉电阻。STM32的I2C引脚必须配置为开漏模式,I2C协议要求外部上拉。有些人习惯把引脚配成推挽输出,看起来也能通信,但会出现电平毛刺,长时间运行容易丢数据。
如果遇到硬件I2C调不通,我的临时方案是切到软件模拟I2C,GPIO直接翻转电平。软件模拟的时序完全自己控制,只要把延时调好,几乎不会失败。但它的缺点是占用CPU资源,不适合频繁读写。等系统稳定后,再回过头来修硬件I2C配置,两条路都保留,是实战中比较稳妥的做法。
5. 进阶:把PCA9555用得更好的几个技巧
5.1 中断引脚的正确打开方式
PCA9555有一个INT输出引脚,默认高电平,当任意配置为输入的引脚电平发生变化且不是由本芯片写操作引起时,INT会被拉低。这个特性在做按键检测时特别有用,可以让STM32不用轮询I2C,而是在INT引脚上配置一个下降沿外部中断,按键按下时立即唤醒MCU。
使用中断有几个注意点。一是只有输入模式的引脚变化才会触发INT,输出模式下的引脚变化不会。二是读取输入寄存器或者写入本芯片任何寄存器后,INT会被清除。三是PCA9555是电平型中断,不是边沿型,所以中断服务函数里应该把相关输入端口的数据都读出来,再在循环里处理,避免漏掉同一时间段内多个引脚的变化。
我一般会把INT引脚接到STM32的EXTI外部中断引脚上,然后在中断回调里设置一个标志位,主循环检测到标志位后去读PCA9555的全部输入端口。这样既不会在中断里做耗时操作,也不会丢失按键事件。
5.2 总线上挂多片PCA9555的规划
一片PCA9555提供16个IO,如果还不够,可以在同一条I2C总线上挂多片。最多8片,提供128个IO,算下来对绝大多数项目都绰绰有余。挂多片时,规划地址要注意两点。
第一,A0/A1/A2地址要通过上下拉电阻区分开,不要让两片芯片的地址重复。第二,可以考虑给每片PCA9555的电源引脚加一个磁珠或者0欧电阻,物理上形成电源隔离,防止某一片短路拖垮整条总线的电源轨。地址规划建议用宏定义统一管理,不要散落在代码各处。
#define PCA9555_DEV_LED 0x20 #define PCA9555_DEV_BTN 0x21 #define PCA9555_DEV_RELAY 0x22分别初始化为不同的设备实例即可。驱动代码中所有函数都接收结构体指针参数,天然支持多实例并发使用,不需要为多片芯片编写重复逻辑。
5.3 从这套源码可以延伸出的功能
PCA9555其实不只是单纯的GPIO扩展。配合STM32的定时器和软件状态机,还能延伸出一些实用功能。比如利用PCA9555的输出引脚驱动LED进行呼吸灯效果,虽然刷新率受I2C速度限制,但只要在定时器中断里按步骤更新输出锁存值,也能做出比较顺滑的渐变效果。又比如用16个IO组成4x4或8x8的扫描矩阵,读按键或者驱动LED点阵屏,配合中断机制,延迟完全可以控制在可接受范围内。
还有一个很多人不熟悉的用法:PCA9555的输出引脚可以作为逻辑电平转换器的一部分,隔离开MCU和外部模块的电源域,特别是当外部模块电压和MCU不一致时,通过PCA9555做缓冲,能避免电平不匹配的问题。当然,PCA9555本身的工作电压范围是2.3V到5.5V,只要在芯片供电范围内,它就能输出对应的逻辑电平。
我个人的体会是,PCA9555驱动的核心价值不在于代码本身有多巧妙,而在于它帮你把“IO不够”这个硬件层面的问题,转化成了“多写几行I2C读写”这个软件层面的问题。只要初始化、方向配置、读改写这三件事做对,后面基本就是一马平川。最后再分享一个小建议:如果你的项目里用到了PCA9555,记得在硬件设计阶段就把A0/A1/A2的上下拉配置和INT引脚的接口预留出来,千万别做成跳线需外部手动修改的形式。我见过不少板子因为地址默认配置写死,后期加功能时只能飞线改地址,那叫一个痛苦。
本文还有配套的精品资源,点击获取