简介: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_INPUT | 0x00 | 只读 | 读取 8 路输入引脚当前电平 |
PCA9698_REG_OUTPUT | 0x01 | 读/写 | 输出锁存值,写 1 拉高,写 0 拉低 |
PCA9698_REG_POLARITY | 0x02 | 读/写 | 极性反转,置 1 时对应输入位取反 |
PCA9698_REG_CONFIG | 0x03 | 读/写 | 方向配置,位为 1 表示输入,0 表示输出 |
PCA9698_GPIO_NUM | 8 | 常量 | 声明芯片提供 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_info | 2.6.x | .type = "pca9698" | 板级文件board-xxx.c |
平台数据 +i2c_new_device | 2.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/set:GPIO 操作背后的位运算与锁
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+ 手动kfree | devm_kzalloc自动释放 |
| 注册 gpiochip | gpiochip_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 # 方向寄存器写 0,8 路全部设为输出 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: pca9698@20 { 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命令验证输出,就能把硬件、设备和驱动三层的问题一次隔离开。
本文还有配套的精品资源,点击获取