PCA9698 I2C GPIO扩展芯片Linux驱动开发与调试指南
简介PCA9698是恩智浦推出的I2C总线接口GPIO扩展芯片提供八路双向GPIO支持中断输出与独立逻辑电平非常适合需要灵活监控数字信号的嵌入式应用。这份Linux驱动源码包面向底层驱动开发人员针对内核2.6.28编写用于解决系统与PCA9698芯片之间通信及GPIO管理问题可应用于工业自动化、智能家居、物联网网关等场景。资源包共两个文件包含一个C源文件和一个头文件整体仅3KBC源文件实现了设备注册、I2C读写、中断处理和配置寄存器操作等驱动主体功能头文件则提供寄存器映射、错误码及API函数声明便于其他模块调用。目前已有172人学习借助该驱动源码可快速理解I2C类GPIO扩展芯片的驱动框架与交互时序也为后续移植到不同内核版本或定制外围控制逻辑提供了实用参考。1. PCA9698 到底解决什么问题40 路 GPIO 不够用的场景比想象中多工业主控板要同时读拨码开关、控继电器阵列、监测十几路 DISoC 原生的 GPIO 分完 I2C、SPI、SDIO 之后剩下的根本不够花。PCA9698 就是为这种板子准备的——它通过 I2C 总线扩展出 40 个双向 GPIO每一路都能独立配置输入/输出、推挽/开漏、输出驱动强度和上下拉还带 8 路 PWM 和一个中断输出脚。pca9698.c 这个驱动文件做的事就是把芯片的寄存器操作封装成标准 gpiochip让上层用 gpiod_ 系列 API 像操作普通 GPIO 一样操作扩展出来的 pin。这颗芯片适合两类人一类是硬件上 GPIO 资源吃紧想用一颗便宜芯片替代 CPLD 做简单逻辑控制的嵌入式工程师另一类是在 Linux 内核里写 I2C 外设驱动的开发者想找一个寄存器结构规整、便于封装的范例。它不适合高频率翻转的场景I2C 100kHz~400kHz 的带宽决定了单路 GPIO 翻转速率撑死几十 kHz超过这个预期就得换 SPI 扩展或直接上 CPLD。2. PCA9698 的寄存器地图与 GPIO 工作模式选择2.1 44 个寄存器怎么分布按端口分五组PCA9698 内部有 40 路 IO分成 Port0~Port4 五组每组 8 bit。它的寄存器布局非常有规律所有功能寄存器都按端口偏移排列同一功能的五个端口寄存器地址连续。寄存器功能Port0Port1Port2Port3Port4Input Port读输入电平0x000x010x020x030x04Output Port写输出电平0x080x090x0A0x0B0x0CPolarity Inversion极性反转0x100x110x120x130x14Configuration方向0为输出1为输入0x180x190x1A0x1B0x1COutput Drive Strength驱动强度0x200x210x220x230x24Input Latch输入锁存使能0x280x290x2A0x2B0x2CPull-up/pull-down Enable上下拉使能0x300x310x320x330x34Pull-up/pull-down Selection上下拉选择0x380x390x3A0x3B0x3CInterrupt Mask中断屏蔽0x400x410x420x430x44Interrupt Status中断状态0x480x490x4A0x4B0x4CPWM 相关的寄存器不按端口分组Prescaler0x50设置分频值PWM Period0x51和 Pulse Width0x52配合产生占空比输出使能寄存器 PWMPin Enable0x53控制哪几路输出 PWM 波形。初始化和配置的时候按这个表把五个端口都写一遍就能保证全芯片状态可控。驱动里最常见的初始化流程是把 0x18~0x1C 全部写成 0xFF全部设为输入避免上电瞬间输出错误电平或者按硬件设计写成 0x00~0xFF 混合对应引脚做输出接着配置 0x30~0x34 使能需要的上下拉最后把 0x40~0x44 的中断屏蔽寄存器写成 0xFF 关闭所有中断防止未使用引脚的电平跳变误触发。上电默认状态是全部为输出且输出高电平这一点在继电器等高有效负载上必须警惕因此 probe 阶段就应立即改写方向寄存器。2.2 I2C 地址与总线冲突排查PCA9698 的从机地址由硬件引脚 A0/A1/A2 决定地址高 4 位固定为 0b1100低 3 位来自这三个引脚的接法所以总线上最多级联 8 片地址范围是 0x20~0x27。硬件上如果 A0/A1/A2 全部接地默认地址就是 0x20。确认芯片是否在线先用 i2cdetect 探测i2cdetect -y -r 2参数-y跳过交互确认-r使用 SMBus read byte 方式探测PCA9698 支持这种协议数字2是 I2C 总线号。执行后如果看到20出现在地址表格中说明芯片正常响应。2.3 输入输出方向切换对 GPIO 8 种模式的映射Linux gpiolib 侧只区分 input/output 和开漏/推挽而具体到 PCA9698 芯片实际方向控制完全是靠 Configuration 寄存器一个 bit 完成的写 0 对应普通推挽输出写 1 对应输入所谓“开漏输出”则是把方向设为输出并用输出值 0 表示拉低、1 表示释放靠外部上拉电阻拉高。这跟 MTK SoC 上常见的 GPIO 8 种工作模式是同一思路——SoC 引脚有 mode0~mode7分别映射到不同的复用功能外部扩展芯片虽然没有模式复用但上下拉选择、驱动强度和开漏输出同样是要按位打开的组合开关。驱动里设置方向和输出值时必须分两次写寄存器而不是用“读-改-写”一步完成因为方向切换时如果输出寄存器里残留旧值引脚电平可能在过渡瞬间翻转。稳妥的做法是先写 0x08~0x0C 的输出电平寄存器再写 0x18~0x1C 的方向寄存器让引脚在时钟周期边界上无毛刺切到新状态。2.4 开漏模式的实际接线与上下拉数值PCA9698 的 Port0~Port4 在开漏模式下高电平完全依赖外部上拉电阻。我的经验值是3.3V 系统用 4.7kΩ 上拉到 VDDI2C 集电极开路场景用 10kΩ高速翻转场景PWM 输出超过 1kHz用 2.2kΩ 换取更陡的上升沿。启用输入锁存时还要注意锁存器在边沿到来后把电平保持住直到 CPU 读走 Input Port 寄存器才清除如果固件不及时读取后续边沿会被吞掉。3. 从 pca9698.c 驱动代码结构看 gpiochip 封装3.1 probe 函数的标准动作Linux 内核里写 PCA9698 驱动不需要直接操纵 i2c-dev 文件而是注册一个 i2c_driver。probe 阶段做四件事分配私有数据结构、初始化 regmap 或直接调用 i2c_smbus 系列函数、注册 gpio_chip、注册中断控制器。下面这段代码是典型的 5.x 内核写法static int pca9698_probe(struct i2c_client *client) { struct pca9698_chip *chip; struct gpio_chip *gc; int ret; chip devm_kzalloc(client-dev, sizeof(*chip), GFP_KERNEL); if (!chip) return -ENOMEM; chip-client client; i2c_set_clientdata(client, chip); gc chip-gc; gc-base -1; // 动态分配 GPIO 编号避免与 SoC 原生 GPIO 冲突 gc-ngpio 40; // 芯片支持 40 路 gc-parent client-dev; gc-owner THIS_MODULE; gc-label client-name; gc-get_direction pca9698_gpio_get_direction; gc-direction_input pca9698_gpio_direction_input; gc-direction_output pca9698_gpio_direction_output; gc-get pca9698_gpio_get_value; gc-set pca9698_gpio_set_value; gc-request pca9698_gpio_request; gc-free pca9698_gpio_free; ret devm_gpiochip_add_data(client-dev, gc, chip); if (ret) return ret; return pca9698_irqchip_setup(client, chip); }说明几点核心逻辑gc-base -1表示让 gpiolib 动态分配起始编号这对模块化加载很重要——如果你写死 base200和另一个扩展芯片驱动注册时就会打架devm_gpiochip_add_data是 managed 版本驱动卸载或 probe 失败时自动释放不需要在 remove 函数里调 gpiochip_remove。request/free回调不是必须的但对于控制继电器这类需要防误触发的引脚可以在 request 里先把输出寄存器设为安全电平。3.2 寄存器读写的三种方式内核里操作 PCA9698 寄存器按代码维护成本从低到高有三种直接用i2c_smbus_read_byte_data/i2c_smbus_write_byte_data用 regmap 封装或者自己写底层传输函数。小而美的驱动推荐第一种代码直观static int pca9698_read_reg(struct pca9698_chip *chip, u8 reg) { int val i2c_smbus_read_byte_data(chip-client, reg); if (val 0) dev_err(chip-client-dev, read reg 0x%02x failed: %d\n, reg, val); return val; } static int pca9698_write_reg(struct pca9698_chip *chip, u8 reg, u8 val) { int ret i2c_smbus_write_byte_data(chip-client, reg, val); if (ret) dev_err(chip-client-dev, write reg 0x%02x failed: %d\n, reg, ret); return ret; }这里的读函数返回值有两个含义负数表示 I2C 传输出错非负数表示寄存器值。凡是判断代码里if (value 0)的都是在做错误分流而if (value BIT(3))是正常的位测试。初始化时可以先读 0x18 拿到端口方向默认值再按设计改写避免把芯片恢复到上电状态之外的值。3.3 gpio 读值回调里必须处理输入锁存pca9698_gpio_get_value这个回调读的是 0x00~0x04 的 Input Port 寄存器。但如果芯片启用了 Input Latch0x28~0x2C 对应位写 1此时读 Input Port 会得到锁存值而不是实时电平必须返回两个值里更保守的一个。我的做法是驱动里维护一份last_read缓存gpio 读回调先读锁存寄存器、再读 Input Port、取两者叠加或按配置选择。static int pca9698_gpio_get_value(struct gpio_chip *gc, unsigned offset) { struct pca9698_chip *chip gpiochip_get_data(gc); u8 port offset / 8; u8 bit offset % 8; int val; val i2c_smbus_read_byte_data(chip-client, 0x00 port); if (val 0) return val; return !!(val BIT(bit)); }port offset / 8把 0~39 的线性 GPIO 编号映射到端口号bit offset % 8得到端口内位号。两个 I2C 操作之间如果引脚电平变化读到的是变化瞬间的状态这种窗口极小但在工业现场做急停信号采集时最好在应用层加软件滤波。3.4 中断子系统的接入方式PCA9698 的中断脚 INT 是低有效。驱动里要用gpiochip_irq_chip结构把芯片的 Interrupt Status 寄存器0x48~0x4C和 Linux 中断子系统对接。中断触发后ISR 读状态寄存器判断是哪一路变化然后调用generic_handle_irq把中断派发给对应 GPIO 的消费者。注意Interrupt Mask0x40~0x44默认值是 0xFF即所有中断都屏蔽必须把需要响应的位写 0 打开否则硬件触发信号到达 INT 脚但状态寄存器永远不会置位。4. 设备树配置和内核里的常见对接坑4.1 最简设备树节点驱动写完还差设备树把 PCA9698 的挂载位置告诉内核。标准的绑定写法是这样的i2c2 { status okay; clock-frequency 100000; pca9698: gpio-expander20 { compatible nxp,pca9698; reg 0x20; gpio-controller; #gpio-cells 2; ngpios 40; interrupt-controller; #interrupt-cells 2; interrupt-parent gpio1; interrupts 15 IRQ_TYPE_LEVEL_LOW; }; };#gpio-cells 2表示 gpiod_get 时第二个参数除了引脚编号还要给 flagsGPIO_ACTIVE_LOW 等。interrupts 15 IRQ_TYPE_LEVEL_LOW把 PCA9698 的 INT 脚接到 SoC 的 GPIO1_15 上类型是电平触发低有效对应 PCA9698 内部中断脚的开漏输出特性。4.2 GPIO 编号冲突和全局基址的隐患如果驱动里没设置gc-base而是默认从 0 开始多个 gpiochip 注册时内核会自动分配不冲突的编号。但如果内核命令行或板级文件里写了gpiochip_base常量就可能和 SoC 原生的控制器冲突。检查当前分配结果的命令grep -E gpiochip /sys/kernel/debug/gpio输出中每行显示一个控制器包括 base、ngpio、label。看到两个控制器 ngpio 加起来超过 512 且编号有重叠就需要改设备树或在驱动里显式指定 base。4.3 上下拉寄存器与 SoC 内部上拉的优先级PCA9698 内部上下拉使能后等效阻值约 100kΩ只能保证确定电平不适合做 I2C 上拉或强下拉。如果某路 GPIO 既要开漏输出又要内部上拉必须在 0x38~0x3C 把对应位写 0 选择下拉、0x30~0x34 写 1 使能然后外部再接一个 10kΩ 上拉。芯片内部下拉和外部上拉同时存在时电平取决于外部电阻阻值数十 kΩ 级别会分压导致高电平达不到 VDD——这是硬件设计最常见的误用之一。4.4 pinctrl 子系统对扩展 GPIO 的覆盖问题PCA9698 这类 I2C 扩展芯片没有被纳入 SoC 的 pinctrl 框架所以pinctrl_select_state对它不起作用。上层驱动想改 PCA9698 某个引脚的上下拉或驱动强度只能通过自定义 ioctl 或直接写 sysfs 分发到驱动回调。我的方案是在板级 dts 里给每个关键引脚定义成固定命名 gpio配合 GPIO 描述符里的 flags 传 GPIO_PULL_UP驱动里实现gpio_set_config时再映射到上下拉寄存器。注意内核的pinconfAPI 对扩展芯片支持依然很弱5.15 之后才有统一的gpio_set_config扩展。5. 调试和排错从 i2cdetect 到寄存器 dump 的完整链路5.1 探测链路不通时先查这几项i2cdetect能扫到 0x20 但读写寄存器超时的案例多半是clock-frequency配置过高。PCA9698 手册支援 400kHz但长走线和 10kΩ 上拉会让上升沿变缓把 I2C 总线频率降到 100kHz 后问题立刻消失。还有一个隐蔽的问题PCA9698 的 SDA/SCL 如果和 EEPROM 挂同一总线扫描地址可能会被另一颗器件误应答。用下面的命令单独读 PCA9698 的配置寄存器验证身份i2cget -y 2 0x20 0x18返回0x00说明芯片当前全部是输出模式Configuration 寄存器设为 0x00这是一个合法的默认状态。如果返回0xff则说明握手正常且寄存器值为 0xFF此时芯片处于全部输入模式。5.2 驱动加载失败时看 dmesg 与 sysfs 的关系驱动 probe 失败时dmesg 里能看到pca9698 2-0020: read reg 0x18 failed一类的错误。这类问题绝大多数集中在设备树reg 0x20与实际地址不匹配。确认方法ls /sys/bus/i2c/devices/ cat /sys/bus/i2c/devices/2-0020/name设备树节点地址是2-0020总线 2从机地址 0x20如果这里显示2-0021而物理芯片是 0x20就得改 dts 后重新编译。另有极少情况是compatible写错了内核只匹配驱动里的i2c_device_id表中的厂商型号字符串名称对不上直接忽略节点。5.3 寄存器级抓包与脚本验证调试寄存器状态最直接的方式是把整个芯片的寄存器组 dump 出来存入文件对比正常与异常板卡的差异for reg in 00 01 02 03 04 08 09 0a 0b 0c 10 11 12 13 14 18 19 1a 1b 1c 20 21 22 23 24 28 29 2a 2b 2c 30 31 32 33 34 38 39 3a 3b 3c 40 41 42 43 44; do printf reg 0x%s 0x%02x\n $reg $(i2cget -y 2 0x20 0x$reg) donei2cget的第三个参数是需要读的寄存器地址16 进制不带0x前缀。如果输出中某个寄存器和预期不一致基本可以锁定硬件短路或软件初始化时序问题。对 GPIO 读值的验证不能只看寄存器真实外部电路给的电平决定 Input Port 的值而不是寄存器里写什么。5.4 GPIO 子系统视角的验证命令驱动加载完成后用gpiodetect查看系统识别到了几个控制器gpiodetect gpioinfo pca9698如果 gpioinfo 里只能看到 32 行而不是 40 行多半是 dts 的ngpios属性没写或者写错。还有一种情况是驱动 probe 成功但gpiochip_add_data失败可能是因为 SoC 上已经注册了 512 个 GPIO分配编号越界。此时需要回看 dmesg 中的gpiochip_add_data错误在设备树里显式指定gpio-ranges。6. 进阶玩法PWM、输入锁存与多片级联的联合配置传统 GPIO 扩展只能做开关量PCA9698 还能输出 8 路 PWM。PWM 周期和脉宽是两个独立寄存器但注意它们共用一个预分频器因此不同路不能同时支持不同频率。设置 PWM 参数的寄存器操作顺序是先写 0x50 设定预分频再写 0x51 周期最后写 0x52 脉宽。使能某路输出时对 0x53 写对应 bit 置 1这样该引脚立即从 GPIO 模式切换到 PWM 输出模式驱动的set回调对该引脚失效。输入锁存配合中断可以实现极低 CPU 占用率的状态监测。将 0x28~0x2C 锁存使能寄存器全写 0xFF芯片会锁定所有输入引脚的变化边沿CPU 无需轮询 Input Port。当中断服务程序读走 0x48~0x4C 的状态寄存器后锁存位自动清除。实际验证里我用一个 1kHz 方波从外部灌入某输入引脚轮询方式下 CPU 占用率约 3%开启中断和锁存后下降到 0.2% 以内。多片 PCA9698 级联时除了 A0/A1/A2 接不同的地址还要注意一个细节中断脚 INT 可以接在一起用逻辑或形式连到 SoC 的一个 GPIO但因为中断脚是开漏输出必须在共用线上加一个 4.7kΩ 上拉电阻。这种情况下驱动读到中断后要遍历全部 8 个从机地址看哪一个的状态寄存器非零才能确定是哪个芯片触发的——这也是 pca9698.c 的 irq handler 里常见的一个 for 循环。如果只有一片芯片则直接把 INT 接到 SoC 的任意空闲中断 GPIO 即可省掉这一层软件遍历。本文还有配套的精品资源点击获取