ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux驱动开发实战:字符设备、设备树与I2C驱动解析

Linux驱动开发实战:字符设备、设备树与I2C驱动解析 我做了Linux驱动开发好几年踩过的坑比写过的代码还多。这东西入门门槛确实不低内核态和用户态是完全两套思维模式很多从应用层转过来的朋友上来就被内存管理、并发控制、中断上下文这些东西劝退了。但这又是一个绕不过去的硬技能不管是做嵌入式、物联网还是边缘计算只要涉及操作系统和硬件的联动驱动就是必经之路。这篇就围绕字符设备驱动框架、设备树配置、I2C总线驱动、动态加载与调试调优这几个核心维度把我实际项目中积累的经验和能直接落地的代码逻辑一次说透。1. 整体设计思路驱动开发的核心是让硬件能被管理1.1 驱动在操作系统里的真实位置先理清一个基本认知驱动程序不是应用程序它是内核的一部分承担着硬件能力抽象化的职责。用户空间的程序没办法直接操作物理寄存器、没办法配置DMA、也没办法响应硬件中断所有这些底层操作都必须通过驱动来完成。我在做一块ARM平台主板适配的时候接了一个压力传感器通过I2C接口通信。应用层想做的是每隔100毫秒读一次压力值看起来很简单对吧但如果直接把设备地址、寄存器地址、读时序都写进应用代码里系统一重启、内核一升级、设备一更换整条链路就全废了。驱动做的就是把怎么和这个传感器说话这件事固化下来向上提供一个统一的read接口应用层只需要open(/dev/pressure)然后read剩下的全部由驱动代劳。所以驱动开发的第一条原则接口是给内核和用户空间看的逻辑是给硬件用的。想清楚这两层边界整个框架才不会设计歪。1.2 字符设备驱动为什么是入门首选Linux设备驱动分为字符设备、块设备和网络设备三大类。字符设备驱动在这三类中最简单、也最容易理解它不需要处理复杂的块缓冲机制也没有网络协议栈的纠缠核心就是两件事注册设备号、实现file_operations结构体里的方法。我见过很多初学者一上来就去啃platform驱动、PCIe驱动绕了一大圈发现连最基本的设备节点在哪里生成都搞不清楚。正确路径应该是先把字符设备驱动吃透理解了用户态的open/read/write/close如何一步步走到内核态的对应回调理解了设备号、设备节点、file_operations三者之间的关系再去看总线驱动和平台驱动就会顺畅很多。字符设备驱动的架构可以拆成四层设备号的分配与注销file_operations回调函数的实现设备节点的生成与权限控制私有数据的组织与访问我实际敲代码的时候往往会把这四层拆开来写每一层单独测试全部通过了再合并调试效率非常高。2. 核心细节解析设备号、文件操作接口与数据通路2.1 设备号管理主设备号与次设备号的分工逻辑设备号是驱动在内核中的身份证。它由主设备号major和次设备号minor组成。主设备号用于标识设备对应的驱动次设备号用于标识同一个驱动管理的不同设备实例。有次做项目时我同一块板子上挂了六片同样的ADC芯片它们共用同一个主设备号次设备号0到5分别对应每片芯片。这样一来驱动只需要一份逻辑用户空间通过不同的设备节点就能访问不同的物理设备。设备号的分配有两种方式静态申请和动态分配。我在实际项目中更推荐动态分配因为静态指定主设备号容易和内核里已有的设备冲突。动态分配用alloc_chrdev_region函数内核自动挑一个空闲的主设备号给你配合makedev宏把主次设备号打包成一个 dev_t 类型。dev_t dev_num; int major, minor; alloc_chrdev_region(dev_num, 0, 6, pressure_sensor); major MAJOR(dev_num); minor MINOR(dev_num);注意这里的pressure_sensor不是设备节点的路径而是驱动在内核里的名称后面创建设备节点时会用到。2.2 file_operations驱动和用户态之间的唯一桥梁file_operations结构体是字符设备驱动里最核心的数据结构它定义了用户空间的系统调用如何映射到内核态的具体函数。每次用户在应用层调用read内核就会通过这个结构体找到对应的.read回调。我整理过一个最小驱动需要的字段字段对应系统调用作用owner-指向模块自身防止驱动被卸载时还有进程在用openopen首次访问设备时执行初始化releaseclose关闭设备时执行清理readread从设备读取数据writewrite向设备写入数据unlocked_ioctlioctl传输控制命令特别要提醒的是owner字段我给一个团队review代码时发现有人把这个字段留空了结果模块卸载时系统直接卡死。这个字段必须设为THIS_MODULE它关系到模块的引用计数。实现的代码路径长这样static int sensor_open(struct inode *inode, struct file *filp) { struct sensor_dev *sdev container_of(inode-i_cdev, struct sensor_dev, cdev); filp-private_data sdev; return 0; } static ssize_t sensor_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct sensor_dev *sdev filp-private_data; char data[8]; int ret; // 从硬件读取数据的实际操作走I2C或者寄存器映射 ret read_pressure_data(sdev, data, count 8 ? 8 : count); if (ret 0) return ret; if (copy_to_user(buf, data, ret)) { pr_err(copy_to_user failed\n); return -EFAULT; } return ret; } static const struct file_operations sensor_fops { .owner THIS_MODULE, .open sensor_open, .read sensor_read, .release sensor_release, };这里的核心细节是copy_to_user内核态不能直接访问用户空间的缓冲区指针必须通过这个函数把数据拷贝过去否则会引发内核页错误。这也是新手最容易踩的坑之一。2.3 设备节点自动生成device_create简化一切设备节点就是 /dev 下面的那个文件。早期驱动需要手动mknod创建设备节点现在大家都用device_create函数自动创建。它会生成设备节点、设置好权限还能在 /sys 里创建关联属性。推荐的流程是class_create定义设备类别device_create在类别下创建设备节点device_destroy和class_destroy做卸载清理static struct class *sensor_class; static int __init sensor_init(void) { // 动态申请设备号 alloc_chrdev_region(dev_num, 0, 1, pressure_sensor); cdev_init(sensor_cdev, sensor_fops); sensor_cdev.owner THIS_MODULE; cdev_add(sensor_cdev, dev_num, 1); // 自动创建设备节点 /dev/pressure_sensor sensor_class class_create(pressure_sensor_class); device_create(sensor_class, NULL, dev_num, NULL, pressure_sensor); return 0; }我用udevadm monitor验证过在device_create调用的一瞬间用户态就能看到新设备节点出现整个过程完全是自动化的。设备节点路径就是 /dev/pressure_sensor应用层直接对这个路径操作就行。3. 实操过程完整的字符设备驱动框架搭建3.1 从零开始搭建一个可编译的驱动模块这一节直接上完整代码我以我们项目里的一个温度传感器驱动为模板它支持打开、读取、关闭三个最基本操作。这个框架可以复制到任何字符设备驱动里改改就能用。#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define DRIVER_NAME temp_sensor #define TEMP_DEV_CNT 1 static int temp_major 0; static dev_t temp_dev_num; static struct cdev temp_cdev; static struct class *temp_class; static struct device *temp_device; static int temp_value 25; static int temp_open(struct inode *inode, struct file *filp) { // 所以open里只做初始化不涉及实际硬件操作 pr_info(temp_sensor: device opened\n); return 0; } static ssize_t temp_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char kbuf[32]; int len snprintf(kbuf, sizeof(kbuf), %d\n, temp_value); if (count len) return -EINVAL; if (copy_to_user(buf, kbuf, len)) return -EFAULT; return len; } static int temp_release(struct inode *inode, struct file *filp) { pr_info(temp_sensor: device closed\n); return 0; } static const struct file_operations temp_fops { .owner THIS_MODULE, .open temp_open, .read temp_read, .release temp_release, }; static int __init temp_init(void) { int ret; // 动态分配设备号 ret alloc_chrdev_region(temp_dev_num, 0, TEMP_DEV_CNT, DRIVER_NAME); if (ret 0) { pr_err(temp_sensor: failed to alloc region\n); return ret; } temp_major MAJOR(temp_dev_num); // 注册字符设备 cdev_init(temp_cdev, temp_fops); temp_cdev.owner THIS_MODULE; ret cdev_add(temp_cdev, temp_dev_num, TEMP_DEV_CNT); if (ret 0) { pr_err(temp_sensor: failed to add cdev\n); goto err_unregister; } // 设备类与设备节点 temp_class class_create(DRIVER_NAME _class); if (IS_ERR(temp_class)) { ret PTR_ERR(temp_class); goto err_cdev_del; } temp_device device_create(temp_class, NULL, temp_dev_num, NULL, DRIVER_NAME); if (IS_ERR(temp_device)) { ret PTR_ERR(temp_device); goto err_class_destroy; } pr_info(temp_sensor: init success, major%d\n, temp_major); return 0; err_class_destroy: class_destroy(temp_class); err_cdev_del: cdev_del(temp_cdev); err_unregister: unregister_chrdev_region(temp_dev_num, TEMP_DEV_CNT); return ret; } static void __exit temp_exit(void) { device_destroy(temp_class, temp_dev_num); class_destroy(temp_class); cdev_del(temp_cdev); unregister_chrdev_region(temp_dev_num, TEMP_DEV_CNT); pr_info(temp_sensor: removed\n); } module_init(temp_init); module_exit(temp_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple temperature sensor driver);这段代码的几个关键细节每一条路径都要有对应的错误处理内核模块初始化一旦出错必须把之前申请成功的资源逐一回滚否则系统里会留下僵尸资源。read 里用snprintf构造输出字符串再通过copy_to_user拷贝到用户空间这是最安全的数据传递方式。pr_info是内核态的打印函数用户态看到的就是 dmesg 里的日志。3.2 编译、加载与调试三板斧驱动编译不依赖IDE直接用内核的构建系统。我建议在你的开发机上搭一个交叉编译环境目标板用ARM架构的话需要指定交叉编译器前缀。下面是最常用的编译方式# 内核源码树外编译模块 make -C /lib/modules/$(uname -r)/build M$(pwd) modules # 在目标板上加载 insmod temp_sensor.ko # 查看模块和主设备号 lsmod | grep temp_sensor cat /proc/devices | grep temp_sensor # 确认设备节点 ls -l /dev/temp_sensor # 查看内核日志 dmesg | tail -20加载模块后如果一切正常你会看到/proc/devices 里出现 temp_sensor 和对应的主设备号/dev/temp_sensor 节点被自动创建dmesg 里打印出 temp_sensor: init success测试应用代码用一句话概括就是打开、读取、关闭三个动作int fd open(/dev/temp_sensor, O_RDONLY); char buf[32] {0}; read(fd, buf, sizeof(buf)); printf(temp read: %s, buf); close(fd);如果这个流程能跑通恭喜你你的字符设备驱动框架就已经完全打通了。4. 从字符设备到设备树总线地址与硬件拓扑管理4.1 设备树到底解决了什么问题做嵌入式Linux的时候设备树Device Tree是绕不开的配置环节。设备树是一种描述硬件拓扑的数据结构它以树状形式告诉内核这块板子上有什么硬件、挂在哪个总线、占用什么地址、中断号是多少。我最早接触设备树时也觉得很抽象后来形象地理解成一块板子的户口本。内核启动后通过解析这个文件才知道去哪里找设备、初始化什么样的驱动。没有设备树之前这些信息全部写死在板级文件里每换一块板子都要改内核代码重新编译。有了设备树硬件描述和驱动代码分离开换板卡只需改设备树源文件驱动代码完全不用动。设备树源文件后缀是 .dts编译后生成 .dtb 文件由bootloader加载后传给内核。4.2 设备树里如何描述一个I2C温度传感器我举一个实际项目中用过的例子。感知层有一个TMP102温度传感器挂在I2C1总线上设备地址是0x48。设备树描述长这样i2c1 { status okay; clock-frequency 100000; tmp102: tmp10248 { compatible ti,tmp102; reg 0x48; interrupt-parent gpio1; interrupts 15 IRQ_TYPE_EDGE_FALLING; }; };这里几个关键属性compatible驱动匹配的核心字段格式为厂商,型号驱动里要用module_platform_driver或者i2c_driver里的id_table去匹配它reg设备地址0x48不是内存地址是I2C设备的从机地址interrupts中断配置告诉内核这个设备通过GPIO1_15上报中断驱动侧要用i2c_get_match_data和of_device_id表来匹配static const struct of_device_id tmp102_of_match[] { { .compatible ti,tmp102 }, { } }; MODULE_DEVICE_TABLE(of, tmp102_of_match); static struct i2c_driver tmp102_driver { .probe tmp102_probe, .id_table tmp102_idtable, .driver { .name tmp102, .of_match_table tmp102_of_match, }, }; module_i2c_driver(tmp102_driver);设备树改完后一定要用make dtbs或对应的目标平台命令重新编译把生成的 .dtb 放进启动分区。我在项目里调设备树时踩过最多的坑就是忘记重新编译改了dts文件却没生效排查半天才发现是启动加载的还是旧的dtb。4.3 设备树、系统裁剪优化与启动速度有次做量产设备性能优化客户要求冷启动时间控制在3秒以内。内核裁剪和设备树的精简在这里给了很大的帮助。系统裁剪核心是减小内核体积和缩短启动时间具体方法包括去掉不需要的驱动和内核配置项比如不用的网卡驱动、不用的文件系统支持压缩内核镜像用LZ4或者XZ压缩算法精简设备树里不必要的节点减少内核初始化时扫描的设备数量关闭内核日志输出到串口改为内存日志让系统只初始化必要的设备其他设备在业务需要时再动态加载驱动模块我当时的做法是把所有不需要的驱动从内核内置改成模块化开机只加载根文件系统和基础通信驱动。启动时间直接从5.8秒压缩到2.9秒效果非常直接。5. I2C设备驱动实战从注册函数到数据通信5.1 i2c_driver 注册的结构拆解I2C设备驱动在整个嵌入式Linux里出现频率极高温度传感器、光照传感器、EEPROM、触摸屏控器全部走I2C。理解I2C设备驱动的注册机制基本上就能覆盖一大半外设驱动需求。I2C设备驱动的注册流程分四条线并行总线层负责管理I2C控制器在设备树里叫i2c节点设备层描述挂在总线上的从设备驱动层通过id_table或of_match_table匹配设备probe函数在匹配成功后触发执行硬件初始化最核心的注册结构体是i2c_driver重点看static const struct i2c_device_id tmp102_idtable[] { { tmp102, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, tmp102_idtable); static struct i2c_driver tmp102_driver { .probe tmp102_probe, .remove tmp102_remove, .id_table tmp102_idtable, .driver { .name tmp102, .of_match_table tmp102_of_match, }, }; module_i2c_driver(tmp102_driver);module_i2c_driver这个宏做了两件事在模块加载时调用i2c_add_driver在卸载时调用i2c_del_driver省去了写init和exit函数的麻烦。5.2 probe函数里做的事情probe 是驱动匹配成功后的第一个回调相当于驱动的构造函数。在此要完成私有数据的分配、硬件寄存器初始化、中断申请、以及把字符设备或输入设备注册到内核。我写过的tmp102 probe函数static int tmp102_probe(struct i2c_client *client) { struct tmp102_data *data; int ret; data kzalloc(sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; // 保存i2c_client私链备份 >static int tmp102_read_temp(struct tmp102_data *data, int *temp) { struct i2c_msg msgs[2]; u8 reg_addr TMP102_REG_TEMP; u8 reg_data[2]; int ret; msgs[0].addr >static ssize_t restricted_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct pid *pid get_task_pid(current-group_leader, PIDTYPE_PID); kuid_t uid current_uid(); // 白名单校验只有特定UID才能读取 if (!uid_eq(uid, GLOBAL_ROOT_UID) uid.val ! 1000) { pr_warn(restricted device: uid%d pid%d denied\n, uid.val, pid_nr(pid)); put_pid(pid); return -EACCES; } put_pid(pid); // 正常读取逻辑 return real_read(filp, buf, count, ppos); }current宏指向当前进程的task_structcurrent_uid()拿到的是进程的真实UID配合uid_eq做比较。这层拦截在驱动层实现用户态无法绕过因为系统调用最终都会走到这个回调。我又在这个基础上扩展了基于时间段的访问控制比如某个设备只允许工作日上午9点到下午6点操作过了时间返回-EPERM。这种需求放到应用层不好做内核态一个时间比较就搞定了。6.3 动态拦截与内核安全加固的边界内核态做拦截虽然有效但要注意一个边界内核态的代码权限很大一有问题就是整个系统崩溃。我在做这类功能时会同步配置kernel/yama/ptrace_scope等安全参数做到内核态、用户态双重防护。生产环境强烈建议开启内核的CONFIG_SECURITY和CONFIG_LSM相关配置配合SELinux或者AppArmor做强制访问控制。驱动层的校验是第一道闸门但绝对不能当成唯一防线。7. 常见问题与排查技巧实录7.1 模块加载失败的几种典型情况我在带新人时发现驱动开发90%的问题都集中在加载阶段。下面这个表格是实际调试中总结的高频问题现象可能原因排查方法insmod提示 Unknown symbol依赖的模块未加载或符号未导出先加载依赖模块检查 /proc/kallsyms 确认符号加载后 /dev 下没有节点device_create没执行或udev规则异常查dmesg确认class_create和device_create都成功open设备时提示 No such device设备节点存在但cdev_add没注册成功检查cdev_add返回值确认设备号没冲突内核日志出现任 panicked内存越界或空指针解引用用KASAN重新编译内核开启内存检测copy_to_user返回非零用户态缓冲区非法用access_ok提前校验用户指针遇到前三个问题90%的原因是设备号冲突或没创建设备节点。后面两个问题则需要借助内核自带的调试工具来定位。7.2 调试工具链推荐这些命令我每天都在用dmesg内核日志输出源绝对排第一的调试工具/proc/devices、/proc/iomem、/proc/interrupts查看系统内核资源分配情况cat /sys/kernel/debug/内核调试文件系统挂载后可以看设备树解析结果和GPIO状态ftrace追踪内核函数调用排查驱动热点问题perf性能排查利器遇到驱动占用CPU过高时用针对I2C调试我的三板斧是先看 i2cdetect -l确认I2C总线号i2cdetect -y bus号扫描总线上挂载的设备地址i2cget / i2cset 直接读写寄存器确认设备本身是否正常这三板斧能帮你快速定位问题是在硬件链路还是驱动逻辑上。有一次设备data引脚虚焊用i2cdetect压根扫不出设备地址直接就锁定硬件问题不用在软件层浪费时间。7.3 性能调优从驱动层缩短数据通路的经验有次给一个边缘计算盒子做算法部署模型推理本身没问题但数据采集环节始终跑不满性能预期。用perf分析后发现问题不在算法在驱动每次read都触发一次中断中断处理里还要做寄存器读写、拷贝数据整个数据通路的DMA配置也不合理。优化后的方案启用DMA传输减少CPU参与搬运使用内核kfifo做环形缓冲区中断里只拷贝数据不在中断上下文做复杂处理批量读取模式一次read请求把多次采样结果打包传出减少系统调用次数用mmap映射共享内存减少copy_to_user开销这里特别说一下kfifo无锁环形缓冲区非常适合一边是中断一边是应用层读取的场景。重点是它不需要加锁利用CPU的读改写原子性就能保证数据一致性性能比mutex方案高很多。算法嵌入式部署时还要注意驱动提供的采样精度和速率是否匹配模型输入要求。比如模型需要100Hz采样率驱动却按轮询方式每次read都取最新值中间采样点丢失会造成数据质量问题。8. 实战心得从驱动开发到系统级的几点体会回看这几年做的项目从最早写一个简单的hello驱动到后来完整的产品级驱动框架搭建、设备树配置、系统裁剪优化、算法嵌入式部署这条路上最深的体会是驱动开发不只是写代码让硬件工作而是在系统层面找到效率和稳定的平衡点。新手时期我特别迷信一步到位的写法总觉得代码越精简越高级结果被各种并发问题、中断上下文坑得怀疑人生。后来学乖了所有共享资源一律加并发保护所有用户态数据一律安全拷贝所有错误路径一律资源回滚。代码多几行换来的是稳定性和可维护性。关于学习路径Linux设备驱动开发前期看起来是个编程问题其实更多是理解操作系统原理的过程。你得先搞清楚进程调度、内存管理、中断机制才能理解驱动为什么这样写。一台设备的驱动逻辑其实比应用代码简单得多核心就在于它运行在更底层的环境一个错误就能带走整个系统。在后续的项目里我觉得可以继续深挖的方向包括更复杂的并发模型、DMA性能极限压榨以及驱动与用户态的数据交互优化。这些都是建立在扎实基础之上的进阶方向前期把底子打牢固后面学什么都快。
返回列表