1. 从零开始为什么要在RK3399Pro上折腾TM1650键盘大家好我是老张一个在嵌入式圈子里摸爬滚打了十多年的老码农。今天想和大家聊聊一个非常具体、但又很实用的实战项目在瑞芯微的RK3399Pro这块高性能平台上驱动一颗小小的TM1650键盘扫描芯片。你可能要问现在都什么年代了一个键盘驱动有什么好讲的直接用现成的USB键盘不香吗这话对也不全对。在我做过的很多智能硬件项目里比如工业控制面板、自助终端机、智能家居中控甚至是某些特种设备你往往需要一个高度集成、稳定可靠、且成本可控的实体按键输入方案。一个外挂的USB键盘体积大、线缆乱、容易误触还占用了宝贵的USB主机口。而像TM1650这样的芯片它本身集成了键盘扫描和LED驱动通过最简洁的I2C两根线就能和主控通信可以轻松实现4x832个按键的矩阵扫描还能顺便驱动8段数码管或者LED指示灯简直是嵌入式面板的“瑞士军刀”。RK3399Pro作为一款性能强劲的六核ARM处理器双核A72四核A53常用于AI计算盒子、边缘服务器、高端平板。在这样的平台上做底层驱动开发听起来有点“杀鸡用牛刀”但实际意义很大。首先它能让你彻底掌握从硬件连接到内核驱动再到上层应用的全栈开发流程。其次在复杂系统中这种简洁、确定的GPIO/I2C控制方式比USB HID协议在某些实时性要求高的场景下更可靠。最后这个过程本身就是深入理解Linux内核驱动框架和设备树的绝佳练兵场。所以无论你是想给自己做的智能终端加个按键面板还是单纯想深入学习嵌入式Linux驱动开发跟着我把RK3399Pro和TM1650的I2C驱动从头到尾撸一遍保证收获满满。下面我就从硬件原理开始手把手带你走通整个流程。2. 硬件原理与通信基础读懂TM1650的“语言”在写代码之前我们必须先和硬件“对上暗号”。TM1650这颗芯片它只认I2C协议。所以第一步不是急着敲键盘而是先理解它们之间要怎么“说话”。2.1 I2C总线嵌入式世界的“电话线”你可以把I2C总线想象成一条简单的电话线。这条线上通常挂着很多设备比如各种传感器、EEPROM、像TM1650这样的外设每个设备都有一个唯一的“电话号码”7位设备地址。RK3399Pro作为“主机”打电话的人负责发起通话。TM1650作为“从机”接电话的人地址是固定的我们后面会看到。I2C通信就靠两根线SDA串行数据线用来传输实际的数据就像电话里说话的声音。SCL串行时钟线由主机产生的时钟信号用来同步数据就像打拍子确保双方一个字一个字地对齐听。通信的规则很简单但必须严格遵守起始信号Start当SCL是高电平时SDA从高变低。这就像你拿起电话听筒说“喂我要开始通话了”。数据传输在SCL为低电平时SDA可以变化准备好要发送的位0或1。当SCL变为高电平时SDA上的数据必须保持稳定这时对方会来读取这个位。如此重复8次传完一个字节。应答信号ACK/NACK主机每发送完一个字节8位就会在第9个时钟脉冲期间释放SDA线即把它置为高电平然后由从机来“回应”。如果从机成功收到了它就会在这个时钟周期内把SDA拉低这叫有效应答ACK。如果从机没收到或不想回应SDA保持高这就是非应答NACK。停止信号Stop当SCL是高电平时SDA从低变高。表示“我说完了挂电话”。对于读操作流程类似只是数据方向反了应答信号由主机发出。这些时序看起来繁琐但幸运的是Linux内核的I2C子系统已经帮我们封装好了i2c_transfer这样的函数我们只需要关心要发什么数据而不必手动去拉高拉低GPIO来模拟时序。不过理解这些原理对于调试时分析逻辑分析仪抓到的波形至关重要。2.2 TM1650的“身份证”与“指令集”现在我们知道怎么“打电话”了接下来要搞清楚TM1650的“电话号码”和它能听懂的“指令”。TM1650的7位I2C设备地址是0x24。这是由芯片硬件决定的。在通信时内核驱动会自动在后面加上读写位组成完整的8位地址字节所以我们通常在看代码或逻辑分析仪时会看到写地址0x480x24 1 | 0读地址0x490x24 1 | 1。光打通电话还不够你得告诉TM1650你要干什么。它主要接受两种命令通过命令字节的高几位来区分模式命令用来设置显示模式和亮度。格式是0100 1AB B。其中A是显示开关1开/0关B是亮度调节位000-111共8级。比如0x480100 1000就是打开显示亮度为默认级。在我们的键盘驱动场景下可以不用显示但发送一个基本的模式命令初始化一下是个好习惯。数据命令用来读取按键值。固定为0x490100 1001。当你向TM1650发送这个命令字节后紧接着发起一次读操作它就会把当前按下的键值通过SDA线送回来。键值怎么解读呢TM1650支持4x8矩阵它返回的一个字节数据8位就编码了按键信息。通常这个字节的位0到位3表示列0-7位4到位6表示行0-3具体映射需要根据你的实际键盘矩阵电路来定。芯片手册里会有一个表格比如返回0x44可能代表第4行第4列的按键被按下。这就是我们驱动最终要解析并上报给应用层的信息。3. 内核驱动实战让Linux认识TM1650理论准备就绪现在进入最核心的环节——编写Linux内核驱动。我们的目标是创建一个内核模块让RK3399Pro的Linux系统能够通过标准的文件操作open, read, close来读取TM1650的按键值。3.1 设备树DTS配置给硬件“上户口”设备树是Linux内核用于描述硬件配置的一种数据结构。你可以把它理解为一张贴在系统上的“硬件清单”。我们要在这张清单上添加TM1650告诉内核“嘿在I2C6总线上地址0x24的地方挂了一个兼容TM1650的设备”。找到你的RK3399Pro内核源码中的设备树文件通常是arch/arm64/boot/dts/rockchip/rk3399pro-xxx.dtsxxx是你的板子型号。在i2c6节点下添加如下内容i2c6 { status okay; /* 以下时序参数根据实际波形调整初次可先使用默认或注释掉 */ i2c-scl-rising-time-ns 140; i2c-scl-falling-time-ns 30; clock-frequency 400000; /* I2C总线速度400kHz */ tm1650: tm165024 { status okay; compatible tm1650; /* 用于驱动匹配的关键字 */ reg 0x24; /* 7位设备地址 */ }; };这里有几个关键点compatible tm1650这是驱动和设备匹配的“暗号”必须和驱动代码里写的一致。reg 0x24指定设备地址。clock-frequency设置I2C总线速率。TM1650支持到400KHz这里就设成最大。如果通信不稳定可以尝试降低到100KHz。i2c-scl-rising-time-ns这是针对RK平台可能需要的SCL上升/下降时间调整用于改善时序。如果驱动不通可以先注释掉这两行用内核默认值试试。修改完设备树后需要重新编译内核或设备树二进制文件dtb并更新到开发板上。3.2 驱动框架搭建匹配、探测与卸载驱动代码我们从一个最简单的I2C客户端驱动框架开始。创建一个文件比如tm1650_drv.c。首先定义驱动匹配表。当内核加载时它会遍历设备树寻找compatible属性与这里匹配的节点。#include linux/module.h #include linux/i2c.h #include linux/miscdevice.h #include linux/fs.h #include linux/uaccess.h #include linux/delay.h /* 1. 定义设备ID表用于非设备树匹配备用 */ static const struct i2c_device_id tm1650_id[] { { tm1650, 0 }, { } /* 结束标记 */ }; MODULE_DEVICE_TABLE(i2c, tm1650_id); /* 2. 定义设备树匹配表关键 */ static const struct of_device_id tm1650_of_match[] { { .compatible tm1650 }, { } /* 结束标记 */ }; MODULE_DEVICE_TABLE(of, tm1650_of_match); /* 声明一个全局的i2c_client指针方便其他函数使用 */ static struct i2c_client *tm1650_client;接下来定义驱动结构体把上面的表关联起来并指定探测probe和移除remove函数。/* 3. 定义i2c_driver结构体 */ static struct i2c_driver tm1650_driver { .driver { .name tm1650, .owner THIS_MODULE, .of_match_table of_match_ptr(tm1650_of_match), // 指向设备树匹配表 }, .probe tm1650_probe, .remove tm1650_remove, .id_table tm1650_id, // 指向设备ID表 };probe函数是驱动加载成功、找到匹配硬件后的入口点在这里我们要完成所有初始化工作。remove函数则在驱动卸载时负责清理。3.3 创建设备节点打通用户空间的桥梁内核驱动干完活得让用户空间的应用程序能访问到。这里我们使用杂项设备miscdevice这是最简单的一种字符设备注册方式内核会自动帮我们分配一个次设备号并在/dev目录下创建节点。首先在probe函数中我们初始化芯片并注册设备static int tm1650_probe(struct i2c_client *client, const struct i2c_device_id *id) { int ret; uint8_t init_cmd 0x48; // 示例打开显示默认亮度 printk(KERN_INFO tm1650: Device matched at address 0x%02x\n, client-addr); /* 保存client指针到全局变量 */ tm1650_client client; /* 可选发送初始化命令确保TM1650处于已知状态 */ ret i2c_master_send(client, init_cmd, 1); if (ret 0) { printk(KERN_ERR tm1650: Failed to send init command\n); // 不一定要因此失败继续尝试注册设备 } /* 注册杂项设备 */ ret misc_register(tm1650_misc_device); if (ret) { printk(KERN_ERR tm1650: Failed to register misc device\n); return ret; } printk(KERN_INFO tm1650: Driver probe successful, device node created.\n); return 0; }然后定义我们的杂项设备结构体和文件操作集合/* 定义文件操作函数集 */ static ssize_t tm1650_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos); static const struct file_operations tm1650_fops { .owner THIS_MODULE, .read tm1650_read, // 通常我们只需要读接口如果需要ioctl控制亮度可以在这里添加 }; /* 定义并初始化杂项设备 */ static struct miscdevice tm1650_misc_device { .minor MISC_DYNAMIC_MINOR, // 动态分配次设备号 .name tm1650, // 这将导致设备节点为 /dev/tm1650 .fops tm1650_fops, };这样当驱动加载后用户空间就能看到/dev/tm1650这个设备文件了。3.4 核心通信函数读取按键值现在来到驱动最关键的逻辑如何通过I2C读取TM1650的键值。我们需要实现tm1650_read函数以及底层真正的I2C读写函数。先写一个健壮的I2C读取辅助函数它使用i2c_transfer接口这是内核推荐的方式能处理完整的I2C协议时序static int tm1650_i2c_read_byte(struct i2c_client *client, u8 *val) { struct i2c_msg msgs[2]; u8 reg 0x49; // 读取按键数据的命令 int ret; /* 消息1发送命令写操作 */ msgs[0].addr client-addr; msgs[0].flags 0; // 写标志 msgs[0].len 1; msgs[0].buf reg; /* 消息2读取数据读操作 */ 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); if (ret 0) { printk(KERN_ERR tm1650: I2C transfer error %d\n, ret); return ret; } else if (ret ! 2) { printk(KERN_ERR tm1650: I2C transfer short read (%d)\n, ret); return -EIO; } return 0; }然后在read函数中调用它并将结果拷贝给用户空间static ssize_t tm1650_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { int ret; u8 key_value; ssize_t data_size sizeof(key_value); /* 检查用户缓冲区是否足够 */ if (count data_size) { return -EINVAL; } /* 从TM1650读取键值 */ ret tm1650_i2c_read_byte(tm1650_client, key_value); if (ret 0) { return ret; // 将I2C错误码返回给用户空间 } /* 将键值拷贝到用户空间 */ if (copy_to_user(buf, key_value, data_size)) { return -EFAULT; } /* 成功读取一个字节 */ return data_size; }最后别忘了模块的入口和出口以及remove清理函数static int tm1650_remove(struct i2c_client *client) { misc_deregister(tm1650_misc_device); printk(KERN_INFO tm1650: Driver removed\n); return 0; } module_i2c_driver(tm1650_driver); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(TM1650 Keypad Driver for RK3399Pro); MODULE_LICENSE(GPL);至此一个功能完整的TM1650 I2C键盘驱动就写好了。它通过设备树匹配硬件注册字符设备并在用户读取时通过I2C总线获取键值。4. 编译、测试与调试让按键“活”起来代码写完了但嵌入式开发“烧录-测试-调试”的循环才是主旋律。这一步我们会遇到各种各样的问题也是最涨经验的地方。4.1 编写Makefile与交叉编译首先为我们的驱动模块编写一个Makefile。假设你的RK3399Pro内核源码树路径是/path/to/rk3399pro-kernel。# 指定内核源码目录必须是你为RK3399Pro配置和编译过的内核 KERNEL_DIR ? /path/to/rk3399pro-kernel # 获取当前模块源码目录 PWD : $(shell pwd) # 模块名称必须和源文件基础名一致 obj-m tm1650_drv.o # 默认目标编译模块 all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules # 清理目标 clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean # 编译测试程序在x86主机上使用交叉编译工具链 CROSS_COMPILE ? aarch64-linux-gnu- CC $(CROSS_COMPILE)gcc test_app: tm1650_test.c $(CC) -o tm1650_test tm1650_test.c -static # 静态链接避免板子上缺库注意KERNEL_DIR一定要指向你为当前RK3399Pro开发板实际编译过的内核源码目录并且其配置.config必须与你运行的内核版本一致否则模块无法加载。在Ubuntu主机上打开终端进入驱动代码目录执行make。如果一切顺利你会得到tm1650_drv.ko文件这就是我们要加载的内核模块。4.2 编写用户空间测试程序光有驱动不行我们得写个小程序去调用它。创建一个tm1650_test.c文件#include stdio.h #include stdlib.h #include unistd.h #include fcntl.h #include string.h #include errno.h int main() { int fd; int ret; unsigned char key_val; char *dev_name /dev/tm1650; // 与驱动中 miscdevice.name 一致 // 1. 打开设备 fd open(dev_name, O_RDONLY); if (fd 0) { perror(Failed to open device); return -1; } printf(TM1650 device opened successfully.\n); // 2. 循环读取按键值 printf(Start reading keypad (press CtrlC to exit)...\n); while (1) { ret read(fd, key_val, sizeof(key_val)); if (ret 0) { perror(Read error); close(fd); return -1; } else if (ret sizeof(key_val)) { // 只打印有变化的按键值或者非零值根据TM1650特性无按键时可能返回0 if (key_val ! 0) { printf(Key pressed: 0x%02X\n, key_val); // 这里可以添加你的按键映射逻辑比如 // switch(key_val) { case 0x44: printf(Key 1\n); break; ... } } } // 添加一点延时避免CPU占用率过高 usleep(100000); // 100ms } // 3. 关闭设备 (实际上上面的循环不会退出这里只是规范写法) close(fd); return 0; }在主机上使用交叉编译工具链编译这个测试程序make test_app生成tm1650_test可执行文件。4.3 在RK3399Pro开发板上进行测试将编译好的tm1650_drv.ko和tm1650_test通过scp、U盘或者SD卡拷贝到RK3399Pro开发板的文件系统中例如/home/root。第一步加载驱动模块insmod tm1650_drv.ko使用dmesg命令查看内核日志你应该能看到类似这样的信息[ 123.456789] tm1650: Device matched at address 0x24 [ 123.456790] tm1650: Driver probe successful, device node created.这表示驱动匹配成功并且创建了/dev/tm1650节点。可以用ls /dev/tm1650确认。第二步运行测试程序chmod x tm1650_test ./tm1650_test现在按下连接在TM1650上的按键终端上应该会打印出对应的十六进制键值比如Key pressed: 0x44。4.4 常见问题与调试技巧如果测试不成功别慌这才是常态。我们可以按以下步骤排查检查硬件连接确保SDA、SCL、VCC、GND连接正确且牢固。用万用表量一下VCC电压是否正常通常是3.3V或5V看TM1650型号。检查I2C总线在开发板上先不加载我们的驱动用系统自带的i2cdetect工具扫描I2C6总线。i2cdetect -y 6 # 假设TM1650在I2C6上如果能看到0x24地址被显示不是--说明硬件连接和I2C控制器基本是好的。如果看不到检查设备树里i2c6的status是不是okay检查上拉电阻通常4.7KΩ是否接好。检查驱动加载日志dmesg | grep tm1650看是否有错误信息。常见的错误是probe失败可能原因是设备树匹配不上或者I2C通信失败。逻辑分析仪抓波形这是最强大的调试手段。用逻辑分析仪连接SDA和SCL线在触发read操作时抓取波形。你可以清晰地看到主机是否发出了正确的起始信号、设备地址0x48写/0x49读。TM1650是否给出了ACK应答。主机发送的读命令是否是0x49。TM1650返回的数据字节是什么。 对照I2C协议和TM1650时序图任何偏差都能被发现。调整I2C时序参数如果通信不稳定时好时坏可以回到设备树尝试降低clock-frequency比如从400000降到100000或者调整i2c-scl-rising-time-ns等参数这些参数需要参考RK3399Pro的芯片手册和实际PCB走线长度。键值映射不对如果按键有反应但打印的值和你预期的不符比如按第一个键显示0x44而不是0x01那问题不在驱动而在硬件矩阵布局与软件解码逻辑的对应关系上。你需要根据自己焊接的键盘矩阵去查阅TM1650数据手册中的键值表或者自己写一个简单的测试程序把所有按键按一遍记录下输出的十六进制值制作一个属于自己的键值映射表。5. 进阶优化与生产环境考量一个能跑通的驱动只是起点要把它用到实际产品中还需要考虑更多。5.1 实现按键中断与防抖我们上面的例子是“轮询”方式读取CPU需要不断去问TM1650有没有按键效率低且延迟高。TM1650本身不支持中断输出但我们可以通过软件定时器或工作队列来模拟中断轮询或者如果硬件设计允许可以利用TM1650的某个输出比如通过一个GPIO来触发外部中断但这需要额外的电路。更实际的是在驱动中加入软件防抖。机械按键在按下和释放时会产生一段时间的抖动可能导致一次按压被识别为多次。我们可以在驱动read函数中连续读取两次只有两次值相同且稳定一段时间比如5-10ms后才认为是一个有效的按键事件然后将这个稳定值上报。这能极大地提升用户体验。5.2 集成到输入子系统Input Subsystem我们现在的驱动提供的是原始字符设备接口上层应用需要自己解析键值。更专业、更符合Linux标准的方式是将驱动注册为输入设备。#include linux/input.h static struct input_dev *tm1650_input_dev; // 在probe函数中初始化 tm1650_input_dev input_allocate_device(); tm1650_input_dev-name TM1650 Keypad; tm1650_input_dev-id.bustype BUS_I2C; // 设置能产生哪些事件类型 set_bit(EV_KEY, tm1650_input_dev-evbit); // 设置支持哪些具体的键比如KEY_1, KEY_2, ... KEY_F10等 set_bit(KEY_1, tm1650_input_dev-keybit); // ... 设置所有可能的键位 input_register_device(tm1650_input_dev); // 在读取到键值后将其映射为标准Linux键码然后上报 input_report_key(tm1650_input_dev, KEY_1, 1); // 按下 input_sync(tm1650_input_dev); input_report_key(tm1650_input_dev, KEY_1, 0); // 释放 input_sync(tm1650_input_dev);这样做的好处是你的按键可以直接被系统图形界面如Qt、GTK、终端如evtest工具以及所有遵循输入标准的应用程序识别无需自己写应用层解析代码通用性极强。5.3 驱动稳定性与电源管理在产品中驱动必须稳定。这意味着要做好错误处理I2C通信失败的重试机制、内存申请失败的检查、模块卸载时资源的彻底释放。如果设备用在电池供电场景还需要考虑电源管理。在系统挂起suspend时驱动应该在对应的回调函数中将TM1650设置为低功耗模式如果支持或者至少停止轮询。在系统恢复resume时再重新初始化TM1650。这需要实现struct dev_pm_ops中的相关函数。5.4 代码维护与兼容性最后好的驱动代码应该有清晰的注释、遵循内核编码风格可以用checkpatch.pl脚本检查、以及良好的模块化。将I2C操作、键值解析、设备注册等逻辑分到不同的函数或文件中会让代码更容易维护和调试。另外要考虑内核版本的兼容性。不同版本的内核API可能会有细微变化比如of_match_ptr宏的使用、i2c_driver结构体的成员等。在编写和后续升级时需要查阅对应内核版本的内核文档。