资讯动态

PCA9698 I2C GPIO扩展驱动剖析:从寄存器到Linux内核移植

发布时间:2026/9/12 3:32:12 来源:尧图企业网站定制
简介PCA9698是NXP推出的一款I2C总线GPIO扩展芯片支持8路独立方向配置、上拉/下拉电阻、中断输出及宽范围逻辑电平可适应不同电源与工作环境广泛适用于工业自动化、智能家居和物联网设备。这套资源提供面向Linux 2.6.28的驱动源码包源码中实现了设备注册、I2C读写、GPIO方向配置、中断处理、ioctl控制接口及内存映射等核心功能可帮助嵌入式开发者快速在主控板上识别并控制PCA9698。压缩包共2个文件分别为pca9698.c驱动源文件与pca9698.h头文件整体仅3KB代码精简便于直接阅读、移植或二次开发。已有172人浏览学习说明其在同类I2C外设驱动资源中具备一定参考价值。开发者可结合头文件中的寄存器定义、错误码与API声明系统理解芯片初始化、GPIO输入输出切换、中断事件上报和用户态交互流程同时也能借鉴驱动框架适配其他Linux内核版本或相似GPIO扩展芯片是一份实用的底层驱动基础样例尤其适合具有一定Linux驱动基础的中级嵌入式开发者参考。1. 从 pca9698_gpio.rar 里翻出的 pca9698.c是一个被验证过的 I2C GPIO 扩展模板做嵌入式网关或者数据采集板的时候总会遇到一个尴尬CPU 自带的 GPIO 不够用。串口占掉两个状态 LED 占掉三个剩下的还要接继电器和拨码开关引脚瞬间告急。手边刚好有一片 PCA9698于是翻出这个pca9698_gpio.rar压缩包里面是pca9698.h和一份基于 Linux 2.6.28 内核编写的pca9698.c驱动。几百行 C 语言代码把 I2C GPIO 扩展这件事讲得干干净净。这份驱动有两类读者值得细看一类是急着在 Linux 上把 PCA9698 跑起来的嵌入式开发直接拿它当模板改一改就能用另一类是想搞清楚 I2C 子系统和 gpio_chip 框架如何衔接的驱动学习者。老内核代码没有设备树、没有 devm_ 系列 API反而更容易看清本质注册设备、读写寄存器、把引脚操作封装成 gpiolib 标准接口。下面按寄存器、驱动框架、核心操作、移植验证的顺序把它拆开。2. PCA9698 寄存器偏移约定与 I2C 读写时序2.1 从地址引脚与 pca9698.h 中的寄存器宏PCA9698 的 I2C 从地址不是唯一的。芯片的高 4 位地址固定低 3 位由硬件引脚 A0、A1、A2 的电平决定因此同一条 I2C 总线上最多可以挂 8 片 PCA9698地址范围从 0x20 到 0x27。实际项目中A0-A2 全部拉低是最常见的做法这样默认地址就是 0x20。pca9698.h头文件里把这些寄存器和地址定义成了宏驱动和上层应用共用这一份定义。宏定义寄存器偏移读写属性作用PCA9698_REG_INPUT0x00只读读取 8 路输入引脚当前电平PCA9698_REG_OUTPUT0x01读/写输出锁存值写 1 拉高写 0 拉低PCA9698_REG_POLARITY0x02读/写极性反转置 1 时对应输入位取反PCA9698_REG_CONFIG0x03读/写方向配置位为 1 表示输入0 表示输出PCA9698_GPIO_NUM8常量声明芯片提供 8 路 GPIO从这份偏移可以看出PCA9698 的操作模型非常直接方向集中在一个配置寄存器里输入和输出分别对应独立的数据口。与 PCF8574 那种「读即输入、写即输出」的复用模式相比PCA9698 把方向控制单独拆出来反而让驱动代码更清晰。下面这一段是从pca9698.h中整理出的核心部分#define PCA9698_I2C_BASE_ADDR 0x20 #define PCA9698_REG_INPUT 0x00 #define PCA9698_REG_OUTPUT 0x01 #define PCA9698_REG_POLARITY 0x02 #define PCA9698_REG_CONFIG 0x03 #define PCA9698_GPIO_NUM 8这里的PCA9698_I2C_BASE_ADDR是 A0-A2 全部拉低时的地址。如果你的板子上 A0 接了高电平地址就要换成 0x21。很多第一次接触 I2C GPIO 扩展的人驱动加载后i2cdetect扫描不到设备第一反应是芯片坏了实际上只是地址算错了。PCA9698_GPIO_NUM被定义为 8对应芯片提供的 8 路通道gpio_chip 注册时直接引用这个值。2.2 i2c_smbus 读写封装一次函数调用完成完整时序Linux 内核的 I2C 子系统为 SMBus 兼容设备提供了一组便捷函数i2c_smbus_read_byte_data和i2c_smbus_write_byte_data是其中最常用的两个。它们在内部完成了「发起起始位、发送从地址、发送寄存器偏移、重新发起起始位、读取或写入数据」的完整时序驱动开发者不需要关心 I2C 控制器底层细节。对于 PCA9698 这种单字节寄存器的芯片用这两个函数就够了。static int pca9698_read_reg(struct i2c_client *client, u8 reg) { int val; val i2c_smbus_read_byte_data(client, reg); if (val 0) dev_err(client-dev, read reg 0x%02x failed: %d\n, reg, val); return val; /* 0~255 为正常数据负数统一当作错误码 */ }第一个参数client是i2c_board_info或设备树节点绑定后生成的客户端结构体里面保存了从地址、总线号、设备指针等信息调用者不需要手动指定地址第二个参数reg是上一节表格里的寄存器偏移。函数返回值值得注意正常情况返回 0 到 255 的字节值负数代表传输失败例如总线被其他设备拉死、从设备无 ACK 应答等。所以每次读完寄存器第一件事就是判断返回值是否小于 0再决定是继续使用还是上报错误这个习惯可以避免后续拿到一个负值去做位运算而得到莫名其妙的结果。2.3 不是所有的 GPIO 都有 8 种工作模式接触过 STM32 或者 MTK 平台的开发者对「GPIO 的 8 种工作模式」应该不陌生比如推挽输出、开漏输出、上拉输入、下拉输入、模拟输入等每种模式还对应不同的寄存器配置组合。PCA9698 这类 I2C GPIO 扩展芯片没有这么复杂它只有输入和输出两种方向输出也不区分推挽和开漏输入不支持内部上拉下拉电阻的软件配置。如果需要上拉必须在硬件设计阶段在引脚上外接电阻。这个区别在驱动实现上的体现就是CONFIG寄存器每一位只有 0 和 1 两个状态0 表示输出1 表示输入。看清楚这一点就不会在移植驱动时试图去找「复用功能选择寄存器」或者「驱动能力配置位」也不用把 MCU 库函数里那套 GPIO_InitStructure 的思路搬过来。3. 深入 i2c_driver 框架pca9698.c 的注册与绑定过程3.1 模块入口与 i2c_driver 结构体定义Linux 2.6.28 时代I2C 设备驱动需要通过i2c_add_driver接口向 I2C 子系统注册一个i2c_driver结构体。这个结构体里有driver.name、id_table、probe和remove四个关键成员。其中id_table是一个以空结构体结尾的数组用于告诉 I2C 核心这个驱动支持哪些设备名probe 函数在设备与驱动匹配成功后被调用remove 在设备移除时执行清理。pca9698.c中对应的代码结构如下static const struct i2c_device_id pca9698_id[] { { pca9698, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, pca9698_id); static struct i2c_driver pca9698_driver { .driver { .name pca9698, .owner THIS_MODULE, }, .id_table pca9698_id, .probe pca9698_probe, .remove pca9698_remove, }; module_i2c_driver(pca9698_driver);MODULE_DEVICE_TABLE的作用是把设备匹配表导出到模块的别名信息中用户空间的 udev 工具通过它自动加载对应驱动模块。module_i2c_driver是内核提供的一个便捷宏它会在预处理阶段展开成module_init和module_exit两个入口分别调用i2c_add_driver和i2c_del_driver。展开后的效果等价于在模块加载时注册驱动、卸载时注销驱动省去了手写两个入口函数的样板代码。这个宏在 2.6.28 中已经存在一直沿用到现在新内核版本依然兼容。3.2 probe 函数中的资源分配与 gpio_chip 注册probe函数是整个驱动的核心。它要做的事情分为四步分配私有数据结构、初始化gpio_chip回调函数、注册 gpiochip、把私有数据挂到i2c_client上。在 2.6.28 内核中还没有devm_kzalloc这类资源管理 API内存分配使用kzalloc如果在后续步骤失败必须手动kfree否则就会内存泄漏。这一点对于习惯了新内核写法的开发者来说是最需要留意的差异。static int pca9698_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct pca9698_chip *chip; struct gpio_chip *gc; int ret; /* 分配私有结构体存放 client、锁和 gpio_chip */ chip kzalloc(sizeof(*chip), GFP_KERNEL); if (!chip) return -ENOMEM; chip-client client; spin_lock_init(chip-lock); gc chip-gc; gc-base -1; /* 让内核动态分配 gpio 编号 */ gc-ngpio PCA9698_GPIO_NUM; gc-owner THIS_MODULE; gc-label client-name; gc-dev client-dev; gc-direction_input pca9698_direction_input; gc-direction_output pca9698_direction_output; gc-get pca9698_get; gc-set pca9698_set; ret gpiochip_add(gc); if (ret) { kfree(chip); /* 注册失败必须释放刚才分配的内存 */ return ret; } i2c_set_clientdata(client, chip); return 0; }gc-base -1的含义是请求内核动态分配一组 GPIO 编号而不是使用固定的起始值。注意 2.6.28 时代并不是所有架构都支持动态分配部分平台需要手动指定一个未被占用的base例如 200具体以目标平台的ARCH_NR_GPIOS和现有占用情况为准否则gpiochip_add会返回-EBUSY。ngpio设为 8意味着这个 gpio_chip 管理 8 个引脚上层代码通过gpio_get_value(base n)或者 libgpiod 来访问第 n 路。label使用client-name在/sys/kernel/debug/gpio中会显示为 pca9698方便排查。3.3 设备绑定从 i2c_board_info 到设备树老内核没有设备树I2C 设备需要在板级文件或者 arch 目录下用i2c_board_info静态声明。这种方式把硬件拓扑写死在 C 代码里换一块板子就要重新编译内核。后来的内核版本引入了设备树compatible字符串与驱动中的of_match_table匹配硬件描述从代码中彻底分离。三种方式的适用场景差异如下表绑定方式常见内核版本匹配依据典型代码位置i2c_board_info2.6.x.type pca9698板级文件board-xxx.c平台数据 i2c_new_device2.6.x 至 3.x运行时动态注册驱动内部或板级初始化代码设备树节点3.x 之后compatible nxp,pca9698.dts源文件如果你的项目是在老内核上运行可以参考i2c_board_info方式在板级文件里加上I2C_BOARD_INFO(pca9698, 0x20)并把irq字段填上芯片 INT 引脚对应的中断号。设备树方式则更简单第 5 章会给出一个可直接使用的设备树节点示例。4. direction_input 到 get/setGPIO 操作背后的位运算与锁4.1 方向控制的读-改-写陷阱PCA9698 的方向配置集中在CONFIG寄存器每一位对应一个引脚。这意味着修改任意一个引脚的方向都必须先把整个寄存器读出来改其中一位再整体写回去。这个操作在并发环境下必须用自旋锁保护否则两个进程同时调用direction_input和direction_output后写的一方会覆盖先写一方的结果导致对方的引脚方向被意外改动。static int pca9698_direction_input(struct gpio_chip *gc, unsigned offset) { struct pca9698_chip *chip gpiochip_get_data(gc); unsigned long flags; int reg, ret; spin_lock_irqsave(chip-lock, flags); reg pca9698_read_reg(chip-client, PCA9698_REG_CONFIG); if (reg 0) { spin_unlock_irqrestore(chip-lock, flags); return reg; /* 读失败不要继续写直接返回错误码 */ } ret pca9698_write_reg(chip-client, PCA9698_REG_CONFIG, reg | BIT(offset)); spin_unlock_irqrestore(chip-lock, flags); return ret; }BIT(offset)是内核里常用的位操作宏等价于1U offset。reg | BIT(offset)将对应位置 1其余位保持不变即把第 offset 路引脚设置为输入。如果想把某一路设为输出对应的direction_output回调中应该使用reg ~BIT(offset)。这里的offset是 gpio_chip 内部的引脚序号从 0 开始与硬件引脚的对应关系在ngpio定义时就确定了。spin_lock_irqsave除了获取自旋锁还会保存并关闭本地中断防止中断处理函数中访问同一组寄存器时发生死锁。4.2 读写引脚电平为什么 get 和 set 的实现不对称读取输入电平和设置输出电平是两个逻辑上对称、实现上不对称的操作。get回调直接读取INPUT寄存器并取出对应位set回调则必须对OUTPUT寄存器做读-改-写。原因在于 PCA9698 的输出寄存器只有 8 位一次写操作会同时更新所有输出引脚的电平如果直接写入一个新值其他引脚的输出状态就会丢失。所以每次set前先要把旧值读出来。下面这段代码是两个回调的完整实现static int pca9698_get(struct gpio_chip *gc, unsigned offset) { struct pca9698_chip *chip gpiochip_get_data(gc); int val; val pca9698_read_reg(chip-client, PCA9698_REG_INPUT); if (val 0) return val; return (val offset) 0x01; /* 取第 offset 位的电平 */ } static void pca9698_set(struct gpio_chip *gc, unsigned offset, int value) { struct pca9698_chip *chip gpiochip_get_data(gc); int reg; reg pca9698_read_reg(chip-client, PCA9698_REG_OUTPUT); if (reg 0) return; if (value) reg | BIT(offset); else reg ~BIT(offset); pca9698_write_reg(chip-client, PCA9698_REG_OUTPUT, reg); }这里有一点容易踩坑对于配置为输入的引脚set回调不应该被调用反过来对配置为输出的引脚调用get读取的是INPUT寄存器在某些芯片上读到的并不是输出锁存值而是引脚的实际电平。如果你的应用需要回读当前输出状态最好在私有数据里维护一个输出缓存每次set时同步更新缓存回读时直接查缓存而不是发 I2C 事务。这样做还能避免高频翻转引脚时的 I2C 总线带宽浪费因为一次读-改-写需要两次 I2C 通信在 400kHz 总线上实际操作时间已经接近微秒级。4.3 中断处理低电平有效与读取即清除PCA9698 提供 INT 引脚用于向主控制器上报输入状态变化默认低电平有效。当任意配置为输入的引脚电平发生变化时INT 引脚被拉低主控制器侧可以注册一个 GPIO 中断来响应。中断回调中第一件要做的事情是读取INPUT寄存器因为这个操作会同时清除芯片内部的中断状态释放 INT 引脚。如果不做这一步INT 会一直保持低电平中断触发一次之后就不再响应新的事件。static irqreturn_t pca9698_irq_handler(int irq, void *dev_id) { struct pca9698_chip *chip dev_id; int reg; reg pca9698_read_reg(chip-client, PCA9698_REG_INPUT); /* 读取即清中断 */ if (reg 0) return IRQ_NONE; /* 记录变化位或者通过内核线程/工作队列继续处理 */ return IRQ_HANDLED; }2.6.28 内核中gpio_chip结构体还没有后来的 irq domain 机制驱动通常需要自己维护一个irq_base实现gpio_chip-to_irq回调把引脚号映射到 Linux 中断号。如果你的板子上 INT 引脚接在主控的某个 GPIO 上也可以绕开 gpiolib 的中断框架直接调用request_irq注册这个中断处理函数在函数里通过schedule_work延后处理具体业务避免在中断上下文里做耗时的 I2C 操作。老驱动中这种「中断 工作队列」的组合非常普遍。5. 移植到新内核的 API 对照与板级验证5.1 2.6.28 与新内核之间的关键差异把这套驱动移植到 4.19 或 5.x 内核改动点主要集中在内存管理、gpiochip 注册方式和 I2C probe 原型上。下表列出最常遇到的几处差异照着改基本不会漏。改动点2.6.28 时代写法新内核推荐写法动态 GPIO 编号手动指定base或依赖平台base -1由 gpiolib 分配分配私有数据kzalloc 手动kfreedevm_kzalloc自动释放注册 gpiochipgpiochip_add(gc)devm_gpiochip_add_data(dev, gc, chip)probe 函数原型(client, const struct i2c_device_id *id)旧式兼容或probe_new设备匹配仅i2c_device_id同时支持of_match_table和acpi_match_table注意devm_gpiochip_add_data会在设备移除时自动注销 gpiochip不能再手动调用gpiochip_remove否则会 double-free。devm_kzalloc同理remove 函数里不再需要kfree。这属于 C 语言内存管理上最常见的误区资源释放责任从开发者转移到了设备模型上习惯手动释放的人反而容易多写一行导致内核崩溃。5.2 用 i2c-tools 验证 PCA9698 寄存器行为在没有写应用层代码之前先用 i2c-tools 验证硬件和驱动是否正确是最快的定位方法。假设 PCA9698 挂在 I2C 总线 0 上地址为 0x20依次执行下面的命令i2cdetect -y 0 # 扫描总线 0确认 0x20 处有设备响应 i2cget -y 0 0x20 0x03 # 读方向寄存器默认应是 0xff全部输入 i2cset -y 0 0x20 0x03 0x00 # 方向寄存器写 08 路全部设为输出 i2cset -y 0 0x20 0x01 0xAA # 输出 1010 1010观察引脚电平 i2cget -y 0 0x20 0x01 # 回读输出锁存值确认写入成功i2cdetect -y的-y参数跳过交互确认适合脚本化执行。如果扫描不到 0x20优先检查 A0-A2 的接线再确认 SDA 和 SCL 上有没有接上拉电阻很多自制板子在 I2C 上没有串联上拉导致信号无法被识别。0xAA是二进制的 1010 1010偶数位输出低、奇数位输出高用示波器或者万用表测量时特征非常明显比全写 0 或全写 1 更容易判断位序是否对应。5.3 设备树节点的最小写法新内核下设备树节点里只需要声明地址、指定 compatible 和 GPIO 控制器属性。gpio-controller属性向内核声明这是一个 GPIO 控制器#gpio-cells 2表示引用该控制器时需要两个参数分别是引脚号和默认标志位。下面是一个可用的设备树片段i2c2 { pca9698: pca969820 { compatible nxp,pca9698; reg 0x20; gpio-controller; #gpio-cells 2; interrupt-parent gpio1; interrupts 9 IRQ_TYPE_EDGE_FALLING; }; };interrupts中的IRQ_TYPE_EDGE_FALLING与 PCA9698 的 INT 引脚低电平有效特性对应。修改完设备树后内核会自动通过 compatible 匹配到驱动probe被调用时client-irq会被直接赋值为设备树中解析到的中断号驱动中可以直接使用。移植完成后用cat /sys/kernel/debug/gpio查看 gpiochip 是否注册成功再用 5.2 节的两条i2cset命令验证输出就能把硬件、设备和驱动三层的问题一次隔离开。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价