ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32 IO口不够用?PCA9555驱动源码与GPIO扩展实战指南

STM32 IO口不够用?PCA9555驱动源码与GPIO扩展实战指南 简介面向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数量是否支持输入中断输出地址扩展典型特点PCA9555I2C16支持支持8个应用最广资料多驱动好写PCF8574I2C8支持支持8个准双向IO读取前要写1MCP23017I2C/SPI16支持支持8个双电源供电IO驱动能力略强74HC595移位/SPI8不支持输入无级联扩展便宜只能输出适合纯LED驱动如果你是第一次接触扩展IO我很推荐从PCA9555入手。原因有两个一是寄存器结构比PCF8574清晰每个端口有独立的输入、输出、方向控制寄存器逻辑直观二是在原厂和各大开源社区能找到大量参考源码出问题也好排查。74HC595虽然便宜但只能做输出一旦方案中途要加输入功能就得推翻重来灵活性差很多。2. 驱动源码背后的芯片知识寄存器与地址2.1 8个寄存器各自干什么很多人写驱动上来就抄代码结果改了地址、改了引脚就是不工作。说句实话PCA9555一共就那么8个寄存器花十分钟把寄存器搞明白比盲目改代码管用一百倍。PCA9555内部有16个IO口分为Port 0和Port 1两组每组8个。对应的寄存器也按端口分组寄存器地址寄存器名称位宽功能说明0x00Input Port 08读取Port 0引脚电平0x01Input Port 18读取Port 1引脚电平0x02Output Port 08写入Port 0输出锁存值0x03Output Port 18写入Port 1输出锁存值0x04Polarity Inversion 08配置Port 0输入极性是否反转0x05Polarity Inversion 18配置Port 1输入极性是否反转0x06Configuration Port 08配置Port 0每个IO的方向0x07Configuration Port 18配置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或1pin参数是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调不通我的临时方案是切到软件模拟I2CGPIO直接翻转电平。软件模拟的时序完全自己控制只要把延时调好几乎不会失败。但它的缺点是占用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引脚的接口预留出来千万别做成跳线需外部手动修改的形式。我见过不少板子因为地址默认配置写死后期加功能时只能飞线改地址那叫一个痛苦。本文还有配套的精品资源点击获取
返回列表