ARTICLE DETAIL

资讯详情

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

PCA9555 GPIO扩展芯片详解:从I2C原理到STM32实战驱动

PCA9555 GPIO扩展芯片详解:从I2C原理到STM32实战驱动 做单片机或者嵌入式开发总会有那么一天你的GPIO不够用了。板子上一堆传感器、按键、LED、拨码开关都要接MCU引脚就那么几十个还要留出一堆给SPI Flash、显示屏、电机驱动。我印象特别深的一次一个项目硬件已经打样了结果产品经理临时说要加8个按键和几个状态灯MCU引脚一个都不剩。当时唯一的出路就是挂一颗PCA9555搞定一切软件上根本没动太多硬件上就多了一颗芯片和两根线。这篇文章就把PCA9555这颗I2C GPIO扩展芯片讲透从内部原理、通信机制到裸机驱动、Linux驱动再结合STM32WBA65这颗新出的高性能BLE芯片做实战演示。如果你也遇到过引脚告急、或者想弄明白I2C通信到底怎么工作这篇文章可以直接当参考手册用。1. PCA9555到底是什么从管脚到寄存器逐个啃PCA9555是NXP原Philips出品的一款经典I2C接口GPIO扩展芯片24脚封装提供16路双向IO。市面上还有PCA9535、PCF8575等类似型号但PCA9555有个关键区别——它支持中断输出并且内部有极性反转寄存器用起来灵活很多。1.1 管脚功能与地址配置先看管脚布局。PCA9555的电源范围是2.3V到5.5V对3.3V和5V系统都很友好IO口输出电流能达到25mA灌电流直接驱动LED完全没问题不需要再加驱动三极管。关键管脚分几类电源与地VCC、GND旁边要放0.1uF去耦电容我习惯再加一个10uF电解电容电源更稳。I2C接口SCL、SDA标准I2C引脚开漏结构。中断输出INT低电平有效。当输入引脚状态发生变化时INT会拉低MCU可以通过这个引脚感知外部事件不用一直轮询I2C总线。这个功能特别适合按键矩阵不然你只能不停读I2CCPU占用高不说总线也容易被占满。地址选择A0、A1、A2这三个脚决定芯片的I2C从机地址悬空时内部下拉为低电平。IO口P0组P0.0~P0.7和P1组P1.0~P1.7共16路。地址计算规则是这样的PCA9555的固定地址头是0100即0x40的bit7~bit4加上A2、A1、A0组成的三个地址位最后一位是读写位。所以7位设备地址范围是0x20~0x27对应到8位读写地址就是0x40~0x4F。具体对应关系是A2A1A07位设备地址写地址(8位)读地址(8位)0000x200x400x410010x210x420x431110x270x4E0x4F这就是为什么一片I2C总线上最多能挂8颗PCA9555理论上可以扩展出128个IO当然实际还要考虑总线上拉电容和地址冲突。注意很多国产兼容芯片比如TCA9555地址完全一样但有些便宜芯片的地址映射可能不同用之前一定要看对应型号的数据手册别想当然。1.2 寄存器地图六个寄存器搞定所有操作PCA9555之所以用起来简单是因为它把所有功能都映射到了6个寄存器上通过I2C读写这些寄存器就能完成全部配置和数据操作。寄存器名称偏移地址(0/1代表P0/P1)功能说明上电默认值输入端口寄存器0x00 / 0x01读取IO引脚当前电平只读输出端口寄存器0x02 / 0x03设置IO输出电平0xFF极性反转寄存器0x04 / 0x05反转输入极性0x00配置寄存器0x06 / 0x070输出1输入0xFF这里有个很重要的细节上电默认值是所有引脚都是输入模式配置寄存器默认0xFF输出寄存器默认0xFF。这意味着如果你要配置某个引脚为输出并输出低电平顺序必须是先把配置寄存器对应位设成0输出模式再设输出寄存器为0。如果反过来可能会产生一个短暂的高电平毛刺在驱动MOS管、继电器的时候容易造成误动作。极性反转寄存器用得不多但有时候能救命。假如你接了一排按键按下时引脚变低松开时变高你希望读到的值是按下为1就可以把对应位设置成1这样输入寄存器读出来的结果就变成逻辑取反了。省去在软件里做异或操作的麻烦少几行代码多一分清晰。2. I2C通信机制搞懂时序才能写好驱动PCA9555挂在I2C总线上I2C通信的核心就是两根线SCL时钟线和SDA数据线都是开漏输出必须接上拉电阻到VCC。很多新手在这上面栽跟头我后面会详细说。2.1 地址帧和数据帧长什么样I2C通信以起始条件START开始SCL为高时SDA从高跳变到低。结束条件STOP相反SCL为高时SDA从低跳变到高。起始条件之后发送的是设备地址帧一共8位7位设备地址 1位读写位0表示写1表示读。以PCA9555地址0x20为例写操作时发送的字节是0100 00000x40读操作是0100 00010x41。发送完地址帧后从设备会回一个ACK位SDA拉低。如果等不到ACK说明设备不存在、地址不对或者硬件有问题。数据帧的格式取决于你要访问寄存器。写寄存器时先发寄存器偏移地址比如0x06对应配置寄存器再发数据。注意PCA9555支持自动地址自增连续写多个字节时不用重复发送寄存器地址。但读寄存器时有两种方式当前地址读直接发读地址读取上次操作位置的数据。随机地址读先用写操作发送寄存器偏移地址产生一个重复起始条件Repeated START再切到读模式。我强烈建议大家用随机地址读逻辑清晰也方便读连续寄存器。我曾经见过同行用当前地址读结果因为上一次操作位置不确定读回来的数据位置老是错排查了半天。I2C的时钟频率支持100kHz标准模式、400kHz快速模式PCA9555本身还支持1MHz高速模式。默认用400kHz就行总线长度短、负载轻的时候完全没问题。2.2 为什么I2C必须用开漏输出加上拉电阻这个问题是网上被问爆的也是面试嵌入式岗位的高频题。I2C协议从设计上就要求支持多设备共享总线同一时刻可能有多个设备都要往总线上发数据。如果用推挽输出Push-Pull两个设备同时一个输出高、一个输出低就会直接怼在一起。输出高的那个引脚要通过低阻抗路径灌入另一个设备轻则逻辑错误重则烧管子。开漏输出的结构就相当于一个MOS管的漏极只能主动拉低输出0拉高是完全靠外部上拉电阻完成的。这样有两个好处线与特性任何一个设备输出低总线就是低电平天然实现了多设备通信的仲裁。电平转换方便上拉电阻接3.3V各设备即使工作在不同电压域比如一个5V的设备和3.3V的MCU由于开漏结构不主动输出高总线电平由3.3V决定天然兼容。生活化理解就是I2C总线像一条公交专线所有设备都在同一个站台上车谁要发数据就把车按住拉低没人按的时候车自然停在高位上拉。2.3 上拉电阻怎么算要弄清两个边界上拉电阻阻值不能随便选我之前踩过坑用了1kΩ的电阻结果总线负载太重拉低时电流太大通信不稳定用过100kΩ的上升沿太缓信号变形高速模式下直接不通。选择上拉电阻要注意两个极端条件最小值限制设备输出低电平时VCC经过上拉电阻流到设备内部MOS管灌电流不能超过设备允许的最大值一般为3mA~5mA。按3.3V电源、3mA灌电流来算Rmin 3.3V / 3mA ≈ 1.1kΩ所以低于1kΩ就容易出问题。最大值限制总线上所有设备的引脚电容加上走线分布电容会形成一个RC充电回路。如果电阻太大上升沿时间常数过大在400kHz快速模式下信号拉不上去导致通信失败。I2C规范要求快速模式下上升时间不超过300ns假设总线电容约200pF那么 Rmax ≈ 300ns / 0.9 × 200pF ≈ 1.7kΩ。综合来看3.3V系统、400kHz单主设备、总线上不超过3~4个设备2.2kΩ到4.7kΩ是合理范围。总线越长、设备越多阻值适当小一些。我自己最常用的组合是4.7kΩ配100k模式2.2kΩ配400k模式基本没翻过车。3. 驱动开发实战从裸机到Linux用户态PCA9555的驱动写法分几个层次最底层是操作I2C控制器或者模拟I2C时序往上才是PCA9555寄存器读写逻辑。这里讲两种最常见的实战场景。3.1 裸机环境GPIO模拟I2C的实现思路很多国产单片机没有硬件I2C或者I2C外设用起来别扭状态机复杂、出错不好排查我就直接用GPIO模拟。模拟I2C只要注意时序细节可靠性和硬件I2C没差别。核心代码分成几层// 引脚宏定义按实际硬件修改 #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PIN GPIO_PIN_7 #define I2C_GPIO_PORT GPIOB #define SCL_HIGH() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define SCL_LOW() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define SDA_HIGH() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define SDA_LOW() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(I2C_GPIO_PORT, I2C_SDA_PIN) // 起始条件SCL高时SDA由高变低 static void i2c_start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(5); SDA_LOW(); delay_us(5); SCL_LOW(); } // 停止条件SCL高时SDA由低变高 static void i2c_stop(void) { SDA_LOW(); SCL_HIGH(); delay_us(5); SDA_HIGH(); delay_us(5); } // 发送一字节返回ACK状态 static uint8_t i2c_write_byte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) { SDA_HIGH(); } else { SDA_LOW(); } data 1; delay_us(3); SCL_HIGH(); delay_us(5); SCL_LOW(); delay_us(3); } // 释放SDA等待从设备ACK SDA_HIGH(); delay_us(3); SCL_HIGH(); delay_us(3); uint8_t ack SDA_READ(); // 低电平表示ACK SCL_LOW(); return ack 0 ? 0 : 1; }注意SDA方向的切换。发送数据时SDA要配置为输出模式等发送完释放总线时要把引脚切回输入模式或者写1让开漏输出释放否则你拉高了引脚从设备想拉低都拉不动。PCA9555的写操作封装如下void pca9555_write_reg(uint8_t dev_addr, uint8_t reg, uint8_t data) { i2c_start(); i2c_write_byte(dev_addr 1); // 写地址 i2c_write_byte(reg); // 寄存器偏移 i2c_write_byte(data); // 数据 i2c_stop(); }读操作要用到重复起始条件uint8_t pca9555_read_reg(uint8_t dev_addr, uint8_t reg) { uint8_t data; i2c_start(); i2c_write_byte(dev_addr 1); // 写地址先指定寄存器 i2c_write_byte(reg); i2c_start(); // 重复起始条件 i2c_write_byte((dev_addr 1) | 0x01); // 读地址 data i2c_read_byte(); // 读数据 i2c_stop(); return data; }读字节时要特别注意最后一个字节要回NACK给设备告诉它“不用继续传了”然后发停止条件。很多新手在这里漏掉NACK导致多读了一字节或者总线状态错乱。3.2 Linux环境用i2c-dev在用户态直接读写在嵌入式Linux里用PCA9555有两条路一是写内核驱动注册成gpio_chip这样PCA9555的引脚就能像原生GPIO那样被/sys/class/gpio或libgpiod使用二是直接留出I2C总线设备节点/dev/i2c-x应用层用ioctl读写寄存器这种方法快速验证硬件特别方便。对于快速验证我推荐第二种方案核心流程#include stdio.h #include fcntl.h #include linux/i2c-dev.h #include sys/ioctl.h int pca9555_write_reg(int fd, uint8_t reg, uint8_t data) { uint8_t buf[2] { reg, data }; if (write(fd, buf, 2) ! 2) { perror(write error); return -1; } return 0; } int pca9555_read_reg(int fd, uint8_t reg, uint8_t *data) { if (write(fd, reg, 1) ! 1) { perror(write reg error); return -1; } if (read(fd, data, 1) ! 1) { perror(read error); return -1; } return 0; } int main(int argc, char **argv) { int fd open(/dev/i2c-1, O_RDWR); if (fd 0) { perror(open i2c); return -1; } // 设置从机地址0x20为PCA9555基地址 if (ioctl(fd, I2C_SLAVE, 0x20) 0) { perror(i2c slave); return -1; } // 配置P0全部为输出 pca9555_write_reg(fd, 0x06, 0x00); // P0输出 1010 1010 pca9555_write_reg(fd, 0x02, 0xAA); uint8_t val; pca9555_read_reg(fd, 0x00, val); printf(Input P0 0x%02X\n, val); close(fd); return 0; }编译命令gcc -o pca_test pca_test.c。然后运行前确认I2C设备节点存在并且当前用户有权限操作可能需要加sudo。Linux内核驱动的方式也很值得研究。内核里其实已经有现成的pca953x驱动支持PCA9555在设备树里配置好节点内核会自动注册成GPIO控制器然后你在应用层就能直接操作了。设备树节点大致是这样i2c1 { pca9555: pca955520 { compatible nxp,pca9555; reg 0x20; gpio-controller; #gpio-cells 2; interrupt-parent gpio1; interrupts 3 IRQ_TYPE_LEVEL_LOW; }; };这样 Linux 里就能用gpiod_get或者命令行工具直接操作扩展IO。用libgpiod命令测试gpioset gpiochip0 01 # 把PCA9555的P0.0置高 gpioget gpiochip0 5 # 读P0.5电平4. 实战落地用STM32WBA65连接PCA9555STM32WBA65是ST新一代蓝牙低功耗芯片主打高性能BLE和丰富的外设资源但它的GPIO数量相比传统大容量系列还是偏紧的毕竟射频天线和射频前端也占了PIN。用PCA9555来做外设扩展算是处理MCU引脚不足的标准操作。4.1 硬件连接与初始化接线特别简单PCA9555的SCL、SDA分别接到STM32WBA65的对应I2C引脚我用的是I2C1的外设PB6/PB7然后SCL、SDA各接一个4.7kΩ上拉电阻到3.3V。A0~A2全部接地INT引脚接到MCU的一个外部中断输入引脚比如PA0用于按键事件唤醒。关键初始化代码void MX_I2C1_Init(void) { I2C_HandleTypeDef hi2c1; hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 400000; // 400kHz快速模式 hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 0; hi2c1.Init.AddressingMode I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 0; hi2c1.Init.GeneralCallMode I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(hi2c1); }顺便说一下GPIO配置I2C引脚要配置为复用开漏模式同时开启内部上拉或者外部上拉。如果你已经接了外部上拉电阻内部上拉开不开都行但如果没接外部电阻就一定要开内部上拉不然通信上电后就是死路一条。另外PCA9555的INT引脚要配置成输入上拉模式因为INT是开漏输出靠外部上拉拉高拉低表示有事件。4.2 核心读写示例配置PCA9555的P0口全部为输出用于控制8个LEDvoid pca9555_config_output(void) { uint8_t data 0x00; // 0输出模式 HAL_I2C_Mem_Write(hi2c1, 0x40, 0x06, I2C_MEMADD_SIZE_8BIT, data, 1, 100); }注意HAL库的HAL_I2C_Mem_Write内部已经包含了发寄存器地址写数据的完整流程第三个参数就是寄存器偏移地址传0x06就是写配置寄存器P0口。设备地址传0x40是8位写地址模式7位地址0x20左移一位HAL库的地址参数直接传8位形式别搞混。控制LED流水灯void led_flow(void) { uint8_t pattern 0x01; for (int i 0; i 8; i) { HAL_I2C_Mem_Write(hi2c1, 0x40, 0x02, I2C_MEMADD_SIZE_8BIT, pattern, 1, 100); pattern 1; HAL_Delay(200); } }按键检测用中断方式void EXTI0_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) ! RESET) { uint8_t input_val; // 读取P0输入寄存器假设按键接在P0.0-P0.3 HAL_I2C_Mem_Read(hi2c1, 0x41, 0x00, I2C_MEMADD_SIZE_8BIT, input_val, 1, 100); // 处理按键值把高四位对应的按键状态通过串口上报 char msg[16]; snprintf(msg, sizeof(msg), Key:0x%X, input_val 0x0F); HAL_UART_Transmit(huart1, (uint8_t*)msg, strlen(msg), 100); __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); } }这里有个细节读取输入寄存器时设备地址传0x41也就是读地址这和写地址是差一位的。HAL库的函数设计里DevAddress参数是完整的8位地址所以读操作你要自己或上0x01。5. 踩坑实录与排查技巧没有不翻车的嵌入式开发PCA9555的坑我也踩了不少。把最典型的几个问题和排查方法总结一下希望能帮你节省半天时间。5.1 常见问题速查表现象可能原因排查方法I2C读写无ACK地址错误、芯片没供电、SDA上拉缺失用逻辑分析仪抓起始条件和设备地址确认发送的从机地址是否符合A0~A2配置SDA线一直是低某设备拉死总线、从机没释放SDA逐个断开从设备查找凶手或者把SDA引脚砍掉后重新上电看是否恢复高电平高速模式下通信不稳定上拉电阻太大、总线电容过大、从设备不支持换2.2kΩ上拉降低频率到100k缩短总线走线长度输出寄存器写入但IO不变配置寄存器还是输入模式写0x06/0x07配置寄存器将对应位设为0输出中断一直触发输入引脚悬空、极性反转位没关把输入引脚配置成内部上拉或下拉确保输入电平确定读回的数据不对读地址写错、忘了重复起始条件参照3.1的随机地址读流程最好先用逻辑分析仪确认时序5.2 逻辑分析仪实测心得调试I2C设备逻辑分析仪是神器操作也很简单把SDA通道接到逻辑分析仪的CH0SCL接到CH1共地设置采样率10MHz以上然后解码设置里选I2C协议填入7位设备地址0x20。抓波形的时候重点看几个东西起始条件是否干净SDA在SCL高电平跳变到低这一段上不能有毛刺。地址字节的bit顺序是否正确地址是MSB先发第一个字节如果是0x40二进制是0100 0000。ACK位是否正常9个时钟周期中第9个周期SDA应该被从机拉低如果一直是高说明从机没响应。重复起始条件是否被正确识别读操作时如果没有重复起始直接拉低SDA/拉高SCL很多从机会不认。我这几年实际调试的经验是90%的I2C问题都出在硬件连接和时序不规范上软件逻辑反而是次要的。常见的总线卡死就是某个设备没释放SDA导致的。这时候最快的处理就是把SDA、SCL的GPIO先复位配置连续切换十几个时钟信号把总线上的设备状态机重置一遍很多问题就迎刃而解了。6. 扩展应用与几个实用技巧PCA9555不只是扩个LED、按键这么简单我用的比较多的是三块场景带中断的按键矩阵16路IO接16个按键每个按键按下都触发INT中断MCU通过一次I2C读取就拿到全部按键状态比起独立按键省引脚不说还不用轮询功耗能压得很低适合电池供电的可穿戴设备。STM32WBA65主打低功耗BLE配合带中断扩展IO是最佳搭档。产品配置拨码开关很多工控设备需要设备地址、波特率等参数拨码设置PCA9555读回来的值直接对应配置表省去出厂烧录环节。LED驱动与指示灯复用PCA9555每个IO可以输出25mA电流直接驱动LED绰绰有余。还可以利用极性反转寄存器做LED闪烁的逻辑反转省去在MCU里做状态翻转。再说一个技巧如果你用的是STM32的HAL库I2C的通信过程有超时参数务必设一个合理的超时值。我习惯设100ms这样当从设备异常时MCU不会永远卡死在等待ACK的死循环里系统能及时报错和恢复。个人经验里再补充一个PCA9555的IO上电瞬间是高电平输出寄存器默认0xFF如果你用这个IO控制继电器或者PMOS开关上电瞬间负载会被打开这可能带来安全问题。解决方法是让PCA9555先处于复位状态如果有复位引脚或者在配置阶段尽快把所有IO输出置低再切换成输出模式。如果硬件上做不到至少要在电路设计时考虑上电瞬间的行为。最后分享一个调试小技巧裸机环境下如果I2C怎么都读不到数据先用万用表量一下SCL和SDA的电平正常空闲时都是高电平。如果量到某个引脚是0.8V左右不稳定的中间电平那大概率是上拉电阻没焊好或者焊反了。别问我怎么知道这个细节的——我当年在PCA9555上花了整整一下午最后发现是飞线断裂导致SDA悬空电平漂在中间逻辑分析仪看起来全是乱码。先把电源、上拉、焊接这些基础项排查完再动软件你会少走很多弯路。
返回列表