简介:TCA6416A是一款通过I2C接口扩展16位GPIO的芯片,常用于嵌入式系统中按键扫描、LED控制等场景。该资源面向单片机驱动开发或Linux底层驱动调试人员,提供可直接移植的C语言驱动源码,包含寄存器配置、输出控制、极性设置及初始化等核心接口。资源共2个文件,1个h头文件负责寄存器地址与函数声明,1个c源文件实现具体读写逻辑,压缩包仅2KB,结构精简,适合快速集成到现有工程。已有387人学习下载,可作为I2C从设备驱动编写的入门参考。通过阅读源码,开发者可以掌握TCA6416A寄存器配置流程、I2C读写时序的组织方式,并在此基础上扩展适配不同主控平台,减少重复造轮子的时间。
1. TCA6416A 驱动这份 rar:能直接跑吗?坑在哪
TCA6416A 驱动这份 rar,很多人是奔着“解压就能用”去下载的,但真正的坑不在驱动代码里,而在芯片的默认状态上。这颗 16 位 I2C GPIO 扩展芯片解决的是主控引脚不够用的问题,适合嵌入式驱动开发、硬件调试、产品原型验证这三类场景。我拆过好几套同类资源,结论一致:寄存器表背熟、时序写对、避坑清单随身带,一两个小时就能跑起来;反过来,光看代码不动手连调,最容易在 I2C 地址和方向配置上翻车。这篇从拆包讲到移植,把能省的弯路一次省掉。
2. 拆开资源包:寄存器地图与驱动骨架,先对清 8 个寄存器
拿到 TCA6416A.rar 之后,我习惯先把文件清单和芯片手册对照一遍。这份资源里的驱动源码、平台配置、测试程序基本都齐,但真正决定能不能跑起来的,是你对这颗芯片寄存器的理解程度。
2.1 先背熟这张寄存器表:8 个寄存器决定一切
TCA6416A 这类 I2C GPIO 扩展芯片,本质就是一个挂在 I2C 总线上的寄存器组。CPU 通过 I2C 写寄存器让它输出电平,读寄存器拿到外部输入状态。整颗芯片只有 8 个寄存器,地址从 0x00 排到 0x07,踩坑基本都在这 8 个地址上。
| 寄存器地址 | 名称 | 读写属性 | 作用 |
|---|---|---|---|
| 0x00 | Input Port 0 | 只读 | 读回 P0~P7 当前输入电平 |
| 0x01 | Input Port 1 | 只读 | 读回 P10~P17 当前输入电平 |
| 0x02 | Output Port 0 | 读写 | 设置 P0~P7 输出电平,写 1 拉高 |
| 0x03 | Output Port 1 | 读写 | 设置 P10~P17 输出电平 |
| 0x04 | Polarity Inversion 0 | 读写 | 输入极性翻转,对应位写 1 则读回值取反 |
| 0x05 | Polarity Inversion 1 | 读写 | P10~P17 极性翻转开关 |
| 0x06 | Configuration 0 | 读写 | 方向控制,某位写 0=输出,写 1=输入 |
| 0x07 | Configuration 1 | 读写 | P10~P17 方向控制 |
第一要记住的是 0x06/0x07 和 0x02/0x03 的关系。很多人以为写了 Output Port 就能让引脚输出,但芯片上电后 Configuration 默认是 0xFF,也就是所有引脚都是高阻输入态,Output 寄存器里写什么都不会真正送到引脚上。所以驱动初始化第一步永远是先配方向,这一步漏了,后面全是白调。
第二要记住 Input Port 和 Output Port 虽然都叫 Port,语义完全不同。读 0x02 拿到的只是输出锁存器里的值,不是引脚上实际的电平;想知道外部把某个引脚拉高还是拉低,必须读 0x00/0x01。如果同一颗芯片的引脚同时被外部信号和输出寄存器驱动,只有读 0x00 才能看到真实电平,这是排查“写了没反应”和“读了不对”的分界线。
2.2 解压后先找这三个文件:驱动核心、平台头文件、测试程序
这份 rar 解开后是一套内核态 I2C client 驱动加一个用户态测试程序。我看包的习惯是先把文件清单过一遍,重点找三个文件:tca6416a_core.c、tca6416a_platform.h、test_tca6416a.c。
| 文件 | 作用 | 必读原因 |
|---|---|---|
| tca6416a_core.c | 驱动主文件,包含 probe、I2C 读写、GPIO 操作函数 | 读写时序和寄存器操作都在这 |
| tca6416a_platform.h | 平台配置头文件 | 地址、总线号、方向掩码、中断引脚 |
| test_tca6416a.c | 用户态测试程序 | 移植前先在命令行验证寄存器操作 |
| Makefile 或编译脚本 | 编译配置 | 确认要用的内核版本和头文件路径 |
如果包里没有 test 文件,建议自己补一个,后面第 5 章的 i2cget/i2cset 也能干同样的事。拿到代码我先看 tca6416a_core.c 里的 probe 函数,一个驱动写得好不好,probe 里三件事一眼就能看出来:有没有先读 Configuration、有没有把中断请求线程化、有没有把寄存器读写的错误码透传出来。
2.3 设备树里挂接:reg 写 0x20 不是 0x40
在 I2C 总线上,设备树里的 reg 属性填的是 7 位从地址,不是 8 位写地址。TCA6416A 的地址引脚 A0/A1/A2 全接地时,7 位从地址是 0x20,内核在总线上发送时自动左移一位变成 0x40。这是标准做法,但也是移植时最容易写错的地方。
&i2c1 { tca6416a: tca6416a@20 { compatible = "tca6416a-gpio"; reg = <0x20>; gpio-controller; #gpio-cells = <2>; interrupt-parent = <&gpio_ext>; interrupts = <7 IRQ_TYPE_EDGE_FALLING>; }; };compatible 字符串必须和驱动里 i2c_driver.id_table 写的完全一致,否则 probe 不会被执行。interrupts 里的 7 是接到主控的外部中断号,IRQ_TYPE_EDGE_FALLING 对应 INT# 引脚下降沿触发,因为芯片中断输出是低电平有效,事件发生时从高拉低。如果这里写了 IRQ_TYPE_LEVEL_LOW,也可以工作,但需要注意中断处理函数里必须清掉中断条件,否则会一直被拉低,这个细节在第 4 章展开。
3. 读写时序逐个过:probe 初始化、读输入、写输出的参数细节
很多移植失败不是看不懂代码,而是没理解 I2C 读写时序本身。TCA6416A 的寄存器操作看着简单,但读和写的事务结构完全不同,写输出还有一个“一次传三字节”的习惯做法。
3.1 probe 里第一件事:把 Configuration 写成你要的方向
上电默认全输入,这是芯片的设计,不是 Bug。所以 probe 里第一步就是根据平台配置写 0x06/0x07,注意不要写死成 0x00,因为板子上可能部分引脚要做输入,用掩码最稳。
#include <linux/i2c.h> #include "tca6416a_platform.h" static int tca6416a_write_reg(struct i2c_client *client, u8 reg, u8 val) { u8 buf[2] = { reg, val }; struct i2c_msg msg = { .addr = client->addr, .flags = 0, /* 0 表示写事务 */ .len = 2, /* 第 1 字节是寄存器地址,第 2 字节是数据 */ .buf = buf, }; int ret = i2c_transfer(client->adapter, &msg, 1); return (ret == 1) ? 0 : -EIO; } static int tca6416a_probe(struct i2c_client *client) { int ret; /* 按平台头文件里的掩码初始化方向:0 输出,1 输入 */ ret = tca6416a_write_reg(client, 0x06, TCA6416A_CFG_P0); if (ret) return ret; ret = tca6416a_write_reg(client, 0x07, TCA6416A_CFG_P1); if (ret) return ret; return 0; }逻辑说明:这个函数把“寄存器地址 + 一个数据字节”打包成一个 2 字节的写事务。i2c_transfer 返回的是成功传输的消息数,所以返回值要跟 1 比较,而不是跟 0 比较。很多新手写成 if (ret < 0),把“传了 0 条消息”也当成功,结果寄存器根本没写进去。
参数说明:TCA6416A_CFG_P0 是 0x00~0xFF 的掩码。比如要 P0~P3 输出、P4~P7 输入,就写成 0xF0;要全部输出就是 0x00;全部输入就是 0xFF。高字节同理。这个掩码不要放在驱动核心文件里写死,应该留在平台头文件里,换板子只改头文件。
3.2 读输入端口:先写寄存器地址,再重复起始位读数据
GPIO 扩展芯片不像 EEPROM 那样能用“只读地址”直接读,必须先告诉芯片要读哪个寄存器,然后才能把数据读回来。这两步必须放在同一个事务里完成。拆成两次 I2C 事务会引入 STOP 和 START,虽然大多数情况下也能读到,但总线中间插了别的设备操作就可能错位。
static int tca6416a_read_port(struct i2c_client *client, u8 reg, u8 *val) { struct i2c_msg msgs[2]; int ret; msgs[0].addr = client->addr; msgs[0].flags = 0; /* 第一段:写寄存器地址 */ msgs[0].len = 1; msgs[0].buf = ® msgs[1].addr = client->addr; msgs[1].flags = I2C_M_RD; /* 第二段:读数据,自动插入重复起始位 */ msgs[1].len = 1; msgs[1].buf = val; ret = i2c_transfer(client->adapter, msgs, 2); return (ret == 2) ? 0 : -EIO; }说明:内核 I2C 子系统看到第一个 message 是写、第二个是读,并且地址相同,就会在中间插入 REPEATED START,而不是 STOP。这一点不需要你手动控制,但必须理解,否则用 i2cget 一条条敲命令调试没问题,一写成驱动就怀疑人生。
如果要一次读回两个输入端口,把 msgs[1].buffer 换成长度 2 的数组,设置 len = 2 即可。芯片支持自动地址递增,读 0x00 时连续读 2 个字节,第二个字节就是 0x01 的内容。
3.3 写输出端口:一次写三字节,别拆成两次
TCA6416A 的寄存器地址会自动递增,连续写时地址指针自动跳到下一个寄存器。写 16 路输出时,从 0x02 开始连续写三个字节是最稳的做法。
static int tca6416a_write_outputs(struct i2c_client *client, u16 val) { u8 buf[3] = { 0x02, val & 0xFF, (val >> 8) & 0xFF }; struct i2c_msg msg = { .addr = client->addr, .flags = 0, .len = 3, .buf = buf, }; return (i2c_transfer(client->adapter, &msg, 1) == 1) ? 0 : -EIO; }逻辑说明:buf[0] 是起始寄存器地址,buf[1] 写给 Output Port 0,buf[2] 因为地址自动递增落到 Output Port 1。一次事务写完两个字节,避免中间被其他线程或中断打断造成高低字节不一致。
如果业务只需要操作某一位,比如只翻 P3,也建议读回当前输出值、改位、再整口写回。TCA6416A 同一字节的 8 个引脚共享一个寄存器,任何一位的更新都会把整个字节覆盖式写进去,读-改-写必须基于 Output Port 寄存器而不是 Input Port。多线程场景下,这个读-改-写序列要用互斥锁保护,否则两个线程同时读回同一个旧值再写回,后写的会把先写的位变化覆盖掉。
4. 避坑手册:五个翻车现场,每个都有原因和后悔药
这份资源我前后给三个板子移植过,遇到的问题高度集中。下面五条是出现频率最高的,每一条都有现场、有原因、有解法。
4.1 i2cdetect 扫不到 0x20:7 位地址和 8 位地址搞混
现象:i2cdetect -y 1 扫总线,TCA6416A 的 0x20 位置是空的,0x40 也没有,其他设备正常。
原因:最常见是设备树 reg 里写了 0x40。内核把 reg 当作 7 位地址直接左移一位放总线,0x40 左移变成 0x80,从机当然不应答。另一个常见原因是地址引脚虚焊,从地址变成某个悬空值,或者干脆不定。
解决:先量 A0/A1/A2 的静态电平,按公式 0x20 | (A2 << 2) | (A1 << 1) | A0 算一次,然后单独扫这个地址:i2cdetect -y -r 1 0x20 0x20。如果还是空,重点检查 SCL/SDA 上拉电阻,4.7k 到 10k 都行,但不能没有。这块板子当初就是漏了上拉,总线一直低电平,所有 I2C 设备都探不到。
4.2 写 0x55 读回全是 0:方向寄存器还是输入
现象:i2cset 写 Output Port 0 为 0x55,用万用表量引脚全是低电平或高阻,读 Input Port 0 全是 0x00。
原因:Configuration 上电是 0xFF,引脚全输入。Output Port 寄存器是写了,但它只是锁存器,引脚处于输入态时不会驱动外部。
解决:先把 0x06 写成 0x00,0x07 写成 0x00,再写 0x02/0x03。验证顺序是 i2cset -y 1 0x20 0x06 0x00,再 i2cset -y 1 0x20 0x02 0x55,最后用万用表量 P0 引脚。注意有些初始化代码会故意保留部分输入,不要想当然认为 Configuration 一定是 0x00,要读回来确认。
4.3 中断只触发一次:读输入寄存器清中断被漏掉
现象:按键按下 INT# 拉低,第一次 gpio 中断正常,之后再按没反应,INT# 一直停留在低电平。
原因:芯片的中断逻辑是输入电平变化时拉低 INT#,读取 Input Port 寄存器后,如果引脚电平已经恢复原状态,中断条件才被清除。只处理中断但不读输入寄存器,中断锁存一直不释放,后续变化进不来。
解决:在中断处理函数里强制读一次 0x00 和 0x01,两个端口都读一遍。哪怕应用只需要低字节,高字节不读也可能残留旧状态。读的动作就是清中断的标准操作,不是可选项。
4.4 I2C 总线 hang 死:读写切换缺 restart 或 ACK 没查
现象:驱动跑一会儿,整条总线上其他设备也不应答,必须复位主控才能恢复。
原因:用两个独立的 write 和 read 去模拟“写寄存器地址再读数据”,中间发了 STOP,设备处于半就绪状态;或者第二个 message 的 flags 忘了加 I2C_M_RD,把读事务发成了写,从机收到错误地址后把总线拉死。
解决:内核驱动里坚持用双 message 的 i2c_transfer,让控制器自动处理重复起始。用户态调试时读操作要用 ioctl 的 I2C_RDWR 组合读写,不要先 write 再 read 两次调用。另外每个 i2c_transfer 的返回值都要检查,出现 -EREMOTEIO 就说明对方回了 NACK,越早暴露越好。
4.5 掉电重启后配置丢光:寄存器不保存状态
现象:板子断电重启,之前配好的输出方向和电平全部回到默认,产品上电瞬间引脚乱跳。
原因:TCA6416A 的寄存器是 RAM 型,不是 EEPROM,上电即复位成默认值。驱动如果只在开机初始化一次,掉电再上电等于回到起点。
解决:把配置流程放 probe 是最低要求,关键是把“复位后恢复”做成对外接口,每次系统 resume 时重写 Configuration 和 Output。硬件上如果引脚上电瞬间必须保持特定电平,就在外部加下拉或上拉电阻,芯片初始化前软件本来也来不及兜底。
5. 移植到自己板子:地址映射、三个参数、命令行验证
资源里的驱动写得再规整,拿到自己板子上也要改配置。这一章是移植清单,照着过一遍就能跑。
5.1 地址引脚 A2/A1/A0 决定从地址:先算清楚再接
TCA6416A 的三个地址引脚直接决定 I2C 从地址。接高接低不要靠猜,按下面这张表对照。
| A2 | A1 | A0 | 7 位从地址 | 8 位写地址 |
|---|---|---|---|---|
| 0 | 0 | 0 | 0x20 | 0x40 |
| 0 | 0 | 1 | 0x21 | 0x42 |
| 0 | 1 | 0 | 0x22 | 0x44 |
| 0 | 1 | 1 | 0x23 | 0x46 |
| 1 | 0 | 0 | 0x24 | 0x48 |
| 1 | 1 | 1 | 0x27 | 0x4E |
要说明的是,8 位写地址是 7 位地址左移一位,0x20 左移一位就是 0x40。如果你用逻辑分析仪抓波形,看到从机地址是 0x40,那就是 7 位 0x20 正常,不是设备地址写错。
5.2 移植必改的三个参数:总线号、地址、方向掩码
tca6416a_platform.h 里集中了所有移植参数,换板子只改这个文件。我最常动的三处如下:
#define TCA6416A_I2C_BUS 1 /* 挂在 I2C1 上,改成实际总线号 */ #define TCA6416A_I2C_ADDR 0x20 /* 7 位从地址,来自地址引脚 */ #define TCA6416A_I2C_RETRIES 3 /* 传输失败重试次数 */ #define TCA6416A_I2C_TIMEOUT_MS 100 /* 单次传输超时 */ #define TCA6416A_INT_GPIO 87 /* 接到主控的哪个 GPIO */ #define TCA6416A_CFG_P0 0xF0 /* P0~P3 输出,P4~P7 输入 */ #define TCA6416A_CFG_P1 0x00 /* P10~P17 全部输出 */TCA6416A_I2C_BUS 要和设备树里挂载的 i2c 节点对应,也决定了 i2cdetect -y 后面跟的数字。TCA6416A_CFG_P0 这种掩码是方向配置,0 表示输出,1 表示输入。TCA6416A_INT_GPIO 是主控侧的中断引脚号,不能和总线上其他设备的中断共用,否则中断风暴会很难看。如果板子对时序敏感,可以把总线速率从默认 100k 调到 400k,但要先确认线缆长度和上拉电容,400k 下长线容易出毛刺。
5.3 命令行快速验证:i2cget、i2cset 三步测试法
驱动还没编译好之前,先用命令行验证硬件链路,能省下大量调试时间。
# 步骤 1:扫描总线,确认 TCA6416A 出现在 0x20 i2cdetect -y 1 # 步骤 2:把 Configuration 0 写成 0x00,P0~P7 全部设为输出 i2cset -y 1 0x20 0x06 0x00 # 步骤 3:往 Output Port 0 写 0x55,量引脚电压 i2cset -y 1 0x20 0x02 0x55 # 步骤 4:把 P0~P7 配成输入,读回引脚状态 i2cset -y 1 0x20 0x06 0xFF i2cget -y 1 0x20 0x00命令里 -y 1 的 1 是总线号,0x20 是 7 位从地址,0x06 是寄存器地址,0x55 是写入的数据。步骤 3 写完用万用表量 P0、P2、P4、P6 应该是高电平,P1、P3、P5、P7 是低电平。步骤 4 里 i2cget 读 0x00 返回的才是真实输入电平,不是输出锁存值。这套命令能跑通,再编译驱动,基本一次过。
6. 进阶:用中断把 TCA6416A 变成异步事件源,别再用轮询
资源包里的基础驱动一般是轮询读输入,但产品上真正好用的是中断触发。TCA6416A 的 INT# 引脚本来就是为这个设计的。
6.1 用线程化中断处理读输入和清中断
把中断请求线程化,可以在中断上下文里少干活,把读寄存器和清中断放到线程里执行。
ret = request_threaded_irq(chip->irq, NULL, tca6416a_irq_handler, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, "tca6416a", chip);IRQF_ONESHOT 保证中断线程执行期间该中断被屏蔽,读 Input Port 清完中断条件后自动重新使能。处理函数里读 0x00 和 0x01 两个字节,然后上报给上层。
6.2 软件去抖:这种芯片没有内部滤波
TCA6416A 没有内部输入滤波,按键、继电器触点直接接上去,中断会抖得不像样。常见做法是读到变化后先等 20ms 再确认电平,确认变了才上报。
static irqreturn_t tca6416a_irq_handler(int irq, void *dev_id) { u16 val; tca6416a_read_port(client, 0x00, &val); msleep(20); /* 简单去抖,实际项目可用定时器 */ tca6416a_read_port(client, 0x00, &val2); if (val != val2) return IRQ_HANDLED; /* 稳定后上报给输入子系统或业务层 */ return IRQ_HANDLED; }读两次的目的是确认电平稳定,第一次读本身也是清中断的动作。如果你的应用对响应时间敏感,20ms 可以改成 5ms,但要有思想准备,抖动会在快速连续触发时漏报。
6.3 验收时用逻辑分析仪抓三段波形
驱动写完别急着合入,抓波形确认三件事。第一段是初始化写 Configuration 时的 SCL 频率和 ACK 位,确认地址对、时序对。第二段是读输入时的重复起始位,应该看到地址字节后有 RESTART,而不是中间出现 STOP。第三段是按键按下时 INT# 拉低、读寄存器后恢复高电平的完整过程。这三段波形没问题,基本可以放心量产。
从那以后,我每次移植 GPIO 扩展芯片都强制走一遍流程:量地址引脚、扫 i2cdetect、命令行验证寄存器、逻辑分析仪过时序,四步缺一不可。希望帮到你。
本文还有配套的精品资源,点击获取