简介:这是一份基于STM32平台的PCA9555芯片I2C总线GPIO扩展驱动源码,面向需要快速扩充数字输入输出通道的嵌入式开发者。该芯片支持16路双向IO、中断输出和2.3至5.5伏宽电压供电,可灵活配置每一路输入输出方向,常用于按键扫描、LED控制、传感器信号读取等场景。资源包仅2个文件,包含一个C源文件与一个头文件,整体大小6KB,结构精简,便于直接加入使用标准外设库或HAL库的工程。源码覆盖I2C初始化、从机地址配置、端口读写以及上层GPIO操作接口,注释清晰,可按实际硬件调整引脚复用与总线参数,快速完成移植;同时还提供错误检测与超时处理思路,能帮助开发者避开常见通信故障。目前已有1947人学习下载,适合中高级嵌入式开发者作为参考,对GPIO扩展和I2C驱动程序的设计思路有借鉴意义。 做单片机开发的人迟早会遇到一个问题:GPIO不够用。尤其是STM32这种引脚功能虽然丰富、但总共就那么几十个脚的MCU,一旦接了显示屏、按键矩阵、LED指示灯、传感器,引脚基本就见底了。我之前做一款控制器时,把PA、PB、PC全部规划完还差十来根线,后来果断上了PCA9555,16路IO瞬间解围,这才算真正体会到什么叫“硬件不够,扩展来凑”。
这次要聊的这份源码,名字很直白:PCA9555驱动源码,适用于STM32平台。压缩包里除了标准的pca9555.c和pca9555.h之外,还带了一份基于固件库的示例工程。整体代码风格偏寄存器操作,没有引入复杂的RTOS依赖,很适合直接搬进自己的工程里改。下面我会把这包源码的底层原理、关键函数、移植步骤和踩坑记录都拆开讲清楚,内容尽量偏实操,希望能帮你少走弯路。
1. 先理清楚:这包源码到底解决什么问题
1.1 为什么STM32要外接IO扩展芯片
很多人初期会觉得“STM32引脚已经够多了”,但真正做项目时才发现,需求永远比引脚多。一个简单的温控面板可能就要消耗掉十几个GPIO:数码管段选位选、继电器控制、蜂鸣器、按键扫描、状态LED。如果用直接IO去怼,资源非常紧张;如果用动态扫描或者串行移位寄存器(比如74HC595)去省引脚,逻辑设计又要多花不少功夫。
IO扩展芯片的好处就在于:把并行的16路IO“折叠”成两根线的I2C总线。MCU只需要两根引脚(SCL、SDA),就能得到16个可配置为输入或输出的IO口,这就是PCA9555这类芯片的核心价值。它不像74HC595那样只能单向输出,而是双向、可读状态、可配置极性,相当于MCU引脚外置的一个小副本。
1.2 PCA9555选型思路:为什么不选MCP23017或PCF8574
市面上I2C IO扩展芯片不少,PCF8574只有8路且不带方向配置,适合简单场景;MCP23017和PCA9555功能类似,都支持16路IO,但寄存器组织和中断输出方式有差异。这份源码选择PCA9555,我个人觉得很合理:它是NXP的经典产品,价格便宜、库存量大、STM32生态里资料多,中文手册也好找。
另外PCA9555支持三个地址引脚A0/A1/A2,可以在同一条I2C总线上挂最多8片,也就是理论上128路IO扩展。这一点对做大型点阵、多路采集的项目来说很重要。地址选择比MCP23017的跳线设计更常规,硬件上直接接VCC或GND就行,不用额外搞逻辑电平判断。
1.3 源码包里都有什么
解压之后,核心文件就是这几种:
- pca9555.c / pca9555.h:驱动主体,封装了初始化、读引脚、写引脚、方向配置等接口
- I2C相关文件(如bsp_i2c.c / bsp_i2c.h):基于STM32标准外设库的I2C读写函数,包含超时处理
- Example工程:一个基于STM32F103的演示工程,展示点亮LED和读取按键状态的基本流程
需要注意的是,这份驱动是基于旧版的STM32标准外设库(SPL)写的,不是HAL库。好在驱动并不复杂,移植到HAL库时只需要重写底层I2C读写的几个函数就行,上层逻辑不用动。
2. PCA9555驱动源码核心点拆解
2.1 寄存器与I2C协议基础
PCA9555内部有8个寄存器,分为4对,分别对应两组端口(P0和P1)。第一次接触这个芯片的人最容易被寄存器配对搞晕,我整理了一张速查表:
| 寄存器地址 | 名称 | 功能说明 |
|---|---|---|
| 0x00 / 0x01 | Input Port 0/1 | 读取引脚电平,输入模式时使用 |
| 0x02 / 0x03 | Output Port 0/1 | 写入引脚电平,输出模式时使用 |
| 0x04 / 0x05 | Polarity Inversion 0/1 | 极性反转设置,置1则输入电平取反 |
| 0x06 / 0x07 | Configuration 0/1 | 方向配置,置1为输入,置0为输出 |
芯片上电后,Configuration寄存器默认是0xFF,换句话说所有引脚默认都是高阻输入状态。这个细节必须记住,很多新手写程序时忘了初始化方向,结果写输出没有任何反应,还以为是芯片坏了。
I2C设备地址也有讲究。PCA9555的7位设备地址固定为0x20(二进制0100000),加上A0/A1/A2三个引脚的电平后组成完整地址。换算成8位写地址的公式是:0x20 << 1 | A2<<2 | A1<<1 | A0,然后再左移一位变成8位地址(0x40开始)。源码里常见的宏定义是这样的:
#define PCA9555_DEV_ADDR 0x40 // A0=A1=A2=0时的8位写地址如果A0接VCC,地址就要变成0x41,A1接VCC是0x42,依此类推。实际操作中建议把三个地址引脚固定接地,地址就锁死在0x40/0x41那一档,省得计算麻烦。
2.2 核心函数逐行解读
源码里的函数封装得比较精简,用起来基本就是“初始化-配置方向-读写数据”三步。我挑几个关键函数说一下底层逻辑。
初始化函数:PCA9555_Init
void PCA9555_Init(void) { I2C_GPIO_Config(); // 配置STM32的I2C引脚,开漏输出+上拉 I2C_Speed_Config(); // 设置I2C时钟,一般是100kHz或400kHz PCA9555_WriteReg(PCA9555_CONFIG_PORT0, 0xF0); // 设置P0高4位输入,低4位输出 PCA9555_WriteReg(PCA9555_CONFIG_PORT1, 0xFF); // 设置P1全部输入 PCA9555_WriteReg(PCA9555_POLARITY_PORT0, 0x00); // 取消极性反转 PCA9555_WriteReg(PCA9555_POLARITY_PORT1, 0x00); }值得注意这行:PCA9555_WriteReg(PCA9555_CONFIG_PORT0, 0xF0);。它把P0口高4位设成输入、低4位设成输出,这种半输入半输出的配置很实用。比如P00~P03接LED,P04~P07接按键,一片芯片既管输出又管输入,典型的应用设计。
写引脚函数:PCA9555_WritePin
写引脚不是一次性写单个bit,而是“读-改-写”的思路:先读取当前的输出寄存器值,然后在内存中修改对应位,最后整个字节写回去。这样做的好处是不会影响其他已经设置好的引脚状态。源码实现类似于:
void PCA9555_WritePin(uint8_t port, uint8_t pin, uint8_t level) { uint8_t val = PCA9555_ReadReg(port == 0 ? PCA9555_OUTPUT_PORT0 : PCA9555_OUTPUT_PORT1); if (level) val |= (1 << pin); else val &= ~(1 << pin); PCA9555_WriteReg(port == 0 ? PCA9555_OUTPUT_PORT0 : PCA9555_OUTPUT_PORT1, val); }这个函数设计很典型,利用读回输出寄存器当前值做位操作,避免多个任务并发改引脚时互相覆盖。不过要注意:如果多个任务同时调用这个函数,上一轮读值和下一轮写值之间可能插入其他任务的操作,导致状态丢失。工程上用的话,建议在函数外面加一层互斥锁或关中断保护。
读引脚函数:PCA9555_ReadPin
uint8_t PCA9555_ReadPin(uint8_t port, uint8_t pin) { uint8_t val = PCA9555_ReadReg(port == 0 ? PCA9555_INPUT_PORT0 : PCA9555_INPUT_PORT1); return (val >> pin) & 0x01; }读取输入寄存器前,确认对应引脚已经被配置为输入模式,否则读到的是输出状态,毫无意义。另外PCA9555输入引脚内部没有上拉,是真正的开漏/高阻输入。如果接的是按键,外部必须加上拉电阻到VCC;如果接的是开漏输出的传感器(比如一些温度传感器),也要外部上拉,否则信号浮空,读出来的值就随机跳变。
2.3 代码里容易被忽略的几个细节
我见过很多人直接抄这份源码,结果用着用着出问题,多数问题都出在下面几个地方。
寄存器写后要不要延时?
PCA9555的I2C操作频率上限是400kHz(有的型号到1MHz),在100kHz模式下,两个连续寄存器访问之间不需要额外的延时。但如果是模拟I2C(软件GPIO模拟时序),芯片虽然速度快,MCU的翻转速度更慢,完全来得及。真正需要留意的是STM32硬件I2C在通信结束后有没有产生BUSY标志,如果不清除,下一次读操作会一直卡死在等待标志位的地方。源码中的超时计数就是干这个的,不要图省事删掉。
极性反转寄存器默认是0
寄存器上电默认值:方向寄存器=0xFF(全部输入),极性反转寄存器=0x00(不反转),输出寄存器=0xFF(输出高电平)。如果想让某个LED上电默认是灭的,方向设置为输出之后,输出寄存器要保持1;如果设置为输出低电平,LED会在一上电就亮。这一点在初始化顺序上要设计好,最好先写输出寄存器再配置方向,避免中间状态闪烁一下。
地址选择引脚的接法
A0、A1、A2引脚内部没有下拉,悬空时电平不确定,这会导致I2C地址随机变化。硬件上必须明确接VCC或GND,不能留空。之前帮朋友排查一个问题,他的板子I2C上挂了8片PCA9555,其中一片地址偶尔扫不到,最后发现就是A0引脚虚焊,偶尔悬空,地址就在0x40和0x41之间跳,折腾了好久。
3. STM32平台上的移植实操
3.1 移植前的准备
移植之前,先把I2C总线的硬件连接理清楚。PCA9555的SCL和SDA都是开漏结构,必须接上拉电阻到电源,阻值一般选4.7kΩ到10kΩ之间。如果总线上设备多或者线长,阻值适当降低到2.2kΩ。不接上拉是I2C最常见的翻车原因之一,信号完全不工作,用示波器看就是一条平线。
软件方面,准备一份能跑通的I2C底层驱动,不论是标准库的硬I2C、HAL库的阻塞式I2C,还是软件模拟I2C都行。PCA9555驱动底层只依赖两个函数:读寄存器和写寄存器。把这层抽象好,移植就成功了一大半。
3.2 底层I2C读写函数对接
源码中的I2C读写函数大概长这样:
uint8_t I2C_ReadByte(uint8_t devAddr, uint8_t regAddr) { uint8_t val = 0; I2C_Start(); I2C_SendByte(devAddr); // 发送设备地址+写位 I2C_WaitAck(); I2C_SendByte(regAddr); // 发送寄存器地址 I2C_WaitAck(); I2C_Start(); I2C_SendByte(devAddr | 0x01); // 重新发送设备地址+读位 I2C_WaitAck(); val = I2C_RecvByte(); I2C_SendNack(); I2C_Stop(); return val; }这段逻辑是I2C读寄存器的标准流程:先写设备地址和寄存器地址,然后重启总线,再发读命令,最后读取一个字节。用HAL库的读者可以直接用HAL_I2C_Mem_Read和HAL_I2C_Mem_Write替代,已经封装好了这套流程。
需要重点去理解的是HAL库中的Timeout参数,这个参数不是越大越好。之前我设置成100ms,结果I2C总线被拉死时,程序在这个函数里卡了100ms才报错,整个系统像死机了一样。后来把超时时间压到5ms,配合错误回调函数做重试,总线上暂时性故障时,系统最多抖一下就能恢复。
3.3 一个完整的使用例子:LED加按键
这里给一个可以直接抄的例程场景:PCA9555的P00~P03接四个LED,P04接一个按键,按键外部接10kΩ上拉到VCC,按下后引脚接地。目标是让四个LED循环流水灯,按键按下时切换流水方向。
// main函数中初始化 int main(void) { // STM32系统初始化... PCA9555_Init(); // 单独配置P0口:低4位输出,高4位输入 PCA9555_WriteReg(PCA9555_CONFIG_PORT0, 0xF0); PCA9555_WriteReg(PCA9555_OUTPUT_PORT0, 0x00); // 初始全灭 uint8_t dir = 1; uint8_t current_led = 0; while (1) { // 读取按键(P04),低电平有效 if (PCA9555_ReadPin(0, 4) == 0) { dir = -dir; // 切换流水方向 delay_ms(200); // 简单防抖 } // 点亮当前LED PCA9555_WritePin(0, current_led % 4, 1); // 熄灭其他LED for (int i = 0; i < 4; i++) { if (i != current_led % 4) PCA9555_WritePin(0, i, 0); } delay_ms(300); current_led += dir + 4; current_led %= 4; } }这个代码我简化了一些边界处理,但功能逻辑是完整的,拿去改就能用。如果你用的是HAL库,只需要把PCA9555_WriteReg和PCA9555_ReadReg内部的I2C读写换成HAL_I2C_Mem_Write和HAL_I2C_Mem_Read即可,注意PCA9555地址要左移一位后传入HAL函数。
3.4 提高效率的批量写入方案
上面例子里,每次操作单个引脚都要读-改-写一次I2C寄存器,效率不高。如果你要控制多个LED,更好的做法是操作前先一次性读出当前输出值,在内存里做完全部位修改,最后只写一次寄存器,这样I2C通信次数从N次降为1次。驱动源码里如果没提供这种批量操作函数,改起来也非常容易:直接对输出寄存器的缓存变量做位操作,然后统一WriteReg即可。
这个优化在高频刷新LED、动态扫描按键矩阵时很有效。I2C本身速率有限,在400kHz下,一次写寄存器操作大概要几十微秒,如果上百个引脚轮流改状态,耗时就能明显感知到。数据量大时建议把I2C速率调到400kHz(受限于PCA9555规格),并在PCB布局上缩短SCL/SDA走线长度。
4. 排坑笔记:我用下来遇到的那些问题
4.1 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| I2C扫描不到任何设备 | SCL/SDA缺少上拉电阻 | 加4.7kΩ~10kΩ上拉到VCC |
| 地址偶尔扫描不到 | A0/A1/A2引脚悬空或虚焊 | 确保三根引脚明确接VCC或GND |
| 写入输出无反应 | 未配置方向寄存器(默认全输入) | 先写Configuration寄存器再写Output寄存器 |
| 读取按键值乱跳 | 未设外部上拉电阻 | PCA9555内部无上拉,需要外接10kΩ上拉 |
| 读取电平与预期相反 | 极性反转寄存器被改 | 保持Polarity寄存器为0x00 |
| 系统偶尔卡死在I2C | 硬件I2C的BUSY标志未清 | 加超时和错误重试机制 |
| 多个PCA9555地址冲突 | 多片地址引脚配置相同 | 使用A0/A1/A2组合区分,保证唯一地址 |
| 上电瞬间LED闪一下 | 输出寄存器先于方向寄存器配置 | 初始化时先写Output为0,再配置方向为输出 |
这张表里的问题,基本都是我实际调试过程中碰到的,有的甚至反复踩了好几次。典型案例就是上电LED闪一下的问题,当时我以为是复位电路有问题,折腾了很久,后来才反应过来是初始化顺序的锅。PCA9555上电默认所有引脚是高阻输入,如果外部电路有下拉,引脚电压为0;这时先配置方向为输出,输出寄存器默认全是1,引脚立即被拉到高电平,LED自然亮一下,直到后续代码把输出寄存器清零才熄灭。解决办法就是初始化时先向输出寄存器写0,再配置方向为输出。
4.2 几个压箱底的排查技巧
技巧一:用单次读寄存器代替连续读
排查PCA9555通信问题时,不要一上来就调应用代码。写一个最简单的函数,对着0x00寄存器(输入端口0)连续读10次,把返回数据用串口打印出来。如果读到的值一直变化且不规律,基本能断定是硬件连接问题;如果读到的值是固定但不对,多半是地址错误;如果读到的值一直为0xFF,检查是不是方向配置没生效。这个“小步快跑”的排查方法,能帮你快速缩小问题范围,避免在应用层大海捞针。
技巧二:测量SCL/SDA电平
把I2C总线的静态电平测一下,正常情况应该是高电平(VCC)。如果测量结果是低电平,说明有设备把总线拉死了。把PCA9555拆掉测,电平恢复高,那就是PCA9555的问题,补焊或更换;如果拆掉还是低,问题出在STM32或其他I2C设备上。这个方法在排查“总线锁死”时特别高效,比反复看代码和示波器抓波形快得多。
技巧三:I2C总线加串阻
长走线或高电平电压摆率过大时,I2C通信可能出现数据错位。我后来习惯在SCL和SDA上各串一个33Ω的电阻,能有效抑制振铃。如果你手头有示波器,看波形边沿如果过冲明显,串电阻是成本最低的解决办法。对于走线超过10cm的板子,这个操作能减少很多莫名其妙的偶发通信异常。
技巧四:善用逻辑分析仪
不要只依赖示波器,I2C协议分析逻辑分析仪比示波器直观得多。用几十块钱的8通道逻辑分析仪,配合Sigrok PulseView软件,可以直接解码出I2C通信的每一个字节、每一个ACK/NACK,一目了然。排查地址错误、寄存器写错这类问题,效率比示波器高一个量级。
4.3 中断引脚的扩展用法
PCA9555还有一个常常被忽略的功能:INT输出引脚。PCA9555在检测到输入引脚状态变化时,会将INT引脚拉低,直到MCU读取输入寄存器为止。源码包里没有用到这个引脚,但实际上很实用,尤其是需要做按键唤醒低功耗设备时。
接法很简单:PCA9555的INT引脚连接到STM32的一个外部中断输入引脚(比如PA0),配置为下降沿触发。当按键按下时,PCA9555检测到电平变化,拉低INT引脚,触发STM32中断,MCU再从PCA9555读取具体是哪个引脚发生了变化。这样MCU不需要轮询按键,既能省电又能提高响应速度。
要注意的是,PCA9555的INT输出是开漏结构,同样需要接上拉电阻到VCC,否则信号无法输出高电平。而且中断触发后会一直保持低电平直到I2C主设备读取输入寄存器,所以在中断服务函数里一定要安排一次读操作,否则会反复触发中断,把系统拖死。
5. 扩展思路:从IO扩展走向整板复用
PCA9555的价值不只在省几个引脚,它还能帮项目做模块化设计。我现在的做法是:把显示、按键、指示灯全部都挂在PCA9555上,MCU只负责核心控制逻辑,板子上的硬件模块之间用标准I2C总线互联,这样后续升级MCU平台(比如从STM32F103换成STM32F407),底层驱动只需要改I2C硬件初始化部分,上层的PCA9555控制逻辑可以直接复用,大大减少了移植工作量。
另外一个思路是用多片PCA9555做类似“串行总线背板”的结构。工业控制板卡上,主控板通过I2C总线连接多个功能板,每块功能板上放一片PCA9555,地址由板卡上的拨码开关决定。这样系统扩展性非常强,增加一块板卡不需要改动任何硬件,只要软件里按地址扫描一遍,发现新设备就初始化对应功能模块。这也是I2C总线协议天然支持多设备寻址所带来的设计自由度。
在具体操作上,建议把PCA9555的驱动看成一个独立模块,不要和具体应用逻辑耦合。接口设计成“读某个端口、写某个端口、配置某个端口方向”这三级,上层模块不需要关心PCA9555的寄存器细节。这样代码维护起来很舒服,以后换成PCF8575或者MCP23017,只需要改驱动内部实现,应用代码一行都不用动。
如果你现在手头正好有个项目因为引脚不够用而纠结,我的建议是直接上PCA9555,别犹豫。先把这份驱动的I2C底层打通,然后用最简单的读写测试做验证,确认通信正常之后,再把按键、LED、继电器这些外设逐步接上去。整个过程半天就能完成。等这块通了之后,你会发现自己打开了嵌入式设计的一扇门:原来IO资源是可以这样灵活调度的。
本文还有配套的精品资源,点击获取