简介:PCA9698是恩智浦推出的I2C总线接口GPIO扩展芯片,提供八路双向GPIO,支持中断输出与独立逻辑电平,非常适合需要灵活监控数字信号的嵌入式应用。这份Linux驱动源码包面向底层驱动开发人员,针对内核2.6.28编写,用于解决系统与PCA9698芯片之间通信及GPIO管理问题,可应用于工业自动化、智能家居、物联网网关等场景。资源包共两个文件,包含一个C源文件和一个头文件,整体仅3KB;C源文件实现了设备注册、I2C读写、中断处理和配置寄存器操作等驱动主体功能,头文件则提供寄存器映射、错误码及API函数声明,便于其他模块调用。目前已有172人学习,借助该驱动源码可快速理解I2C类GPIO扩展芯片的驱动框架与交互时序,也为后续移植到不同内核版本或定制外围控制逻辑提供了实用参考。
1. PCA9698 到底解决什么问题
40 路 GPIO 不够用的场景比想象中多:工业主控板要同时读拨码开关、控继电器阵列、监测十几路 DI,SoC 原生的 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。它的寄存器布局非常有规律:所有功能寄存器都按端口偏移排列,同一功能的五个端口寄存器地址连续。
| 寄存器功能 | Port0 | Port1 | Port2 | Port3 | Port4 |
|---|---|---|---|---|---|
| Input Port(读输入电平) | 0x00 | 0x01 | 0x02 | 0x03 | 0x04 |
| Output Port(写输出电平) | 0x08 | 0x09 | 0x0A | 0x0B | 0x0C |
| Polarity Inversion(极性反转) | 0x10 | 0x11 | 0x12 | 0x13 | 0x14 |
| Configuration(方向:0为输出,1为输入) | 0x18 | 0x19 | 0x1A | 0x1B | 0x1C |
| Output Drive Strength(驱动强度) | 0x20 | 0x21 | 0x22 | 0x23 | 0x24 |
| Input Latch(输入锁存使能) | 0x28 | 0x29 | 0x2A | 0x2B | 0x2C |
| Pull-up/pull-down Enable(上下拉使能) | 0x30 | 0x31 | 0x32 | 0x33 | 0x34 |
| Pull-up/pull-down Selection(上下拉选择) | 0x38 | 0x39 | 0x3A | 0x3B | 0x3C |
| Interrupt Mask(中断屏蔽) | 0x40 | 0x41 | 0x42 | 0x43 | 0x44 |
| Interrupt Status(中断状态) | 0x48 | 0x49 | 0x4A | 0x4B | 0x4C |
PWM 相关的寄存器不按端口分组:Prescaler(0x50)设置分频值,PWM Period(0x51)和 Pulse Width(0x52)配合产生占空比,输出使能寄存器 PWMPin Enable(0x53)控制哪几路输出 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Ω 上拉到 VDD,I2C 集电极开路场景用 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 动态分配起始编号,这对模块化加载很重要——如果你写死 base=200,和另一个扩展芯片驱动注册时就会打架;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 Latch(0x28~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 Mask(0x40~0x44)默认值是 0xFF,即所有中断都屏蔽,必须把需要响应的位写 0 打开,否则硬件触发信号到达 INT 脚,但状态寄存器永远不会置位。
4. 设备树配置和内核里的常见对接坑
4.1 最简设备树节点
驱动写完,还差设备树把 PCA9698 的挂载位置告诉内核。标准的绑定写法是这样的:
&i2c2 { status = "okay"; clock-frequency = <100000>; pca9698: gpio-expander@20 { 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 时第二个参数除了引脚编号还要给 flags(GPIO_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 即可,省掉这一层软件遍历。
本文还有配套的精品资源,点击获取