十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux设备驱动开发实战:字符设备框架、设备树与调试指南

Linux设备驱动开发实战:字符设备框架、设备树与调试指南 1. 从硬件到驱动的第一公里核心思路与框架选型1.1 先搞明白驱动的本质——它不是“软件”是“翻译官”上个月调一块带屏幕的板子外设死活没反应寄存器写进去读出来全是0xFF示波器一量片选都没拉。折腾了半天才发现不是硬件问题是内核里那个驱动压根没被加载。这种事在Linux设备驱动开发里太常见了——你写的不只是几行代码而是让硬件和内核在同一个时间线里运行起来。很多刚入门的朋友会有一个误区驱动不就是配置寄存器嘛其实不完全对。配寄存器只是最底层的那步驱动真正要做的是把硬件能力“翻译”成Linux内核能理解的操作接口。比如你写一个I2C温度传感器驱动最终用户态程序根本不关心你用的什么寄存器、什么时序它只要open一个设备节点然后read一次就能拿到温度值。这一整套流程——从内核抽象、设备模型、文件操作、中断处理到数据传输——才是Linux设备驱动开发的完整版图。这篇文章我从头到尾梳理一遍字符设备驱动框架怎么搭、设备树怎么配、probe函数怎么触发、并发和中断怎么处理、oops怎么排查以及一些进阶玩法像file_operations拦截这类内核机制。内容尽量贴近实际项目所以会穿插大量实操经验和踩坑记录。适合两类人一是刚接触驱动开发、想找一个完整切入点的新手二是已经写过一些简单模块但遇到设备树、I2C、调试问题想系统补一遍的进阶开发者。1.2 三种驱动框架怎么选字符设备、块设备、网络设备Linux驱动按数据交互方式可以粗分成三大类这是选型时第一个要考虑的。字符设备char device按字节流读写像串口、GPIO、I2C、SPI、温度传感器绝大多数控制器外设都走这条路。数据量不大但实时性、灵活性要求高。框架核心就是file_operations结构体用户态open/read/write驱动里对应实现。块设备block device按块读写主要面向硬盘、SD卡、eMMC这类存储介质内核会介入页缓存、I/O调度普通驱动开发很少直接碰它。网络设备net device走sk_buff收发数据包面向网卡、无线、虚拟网络设备像驱动一个以太网控制器就得接net_device框架。实际项目里字符设备框架用得最频繁很多I2C、SPI外设驱动本质上也是字符设备只不过子系统帮你封装好了上层的文件操作你只要实现底层的读写、探测函数。所以下面我会花大篇幅把字符设备框架讲透。从Linux内核源码的目录分布也能看出这种分类思路drivers/下面按子系统划分i2c/、spi/、gpio/、usb/、pci/、net/……每个子系统都有自己的核心框架和驱动接入点。你写驱动前先判断外设挂在哪个总线上比一上来就翻寄存器手册要靠谱得多。1.3 一个容易被忽略的学习路径先读内核文档再翻驱动代码现在很多教程上来就让你写hello world模块其实方向有点偏。驱动开发的学习路径最好是反过来的找一份和你目标外设同类的现成驱动比如你要写I2C传感器驱动先看内核里drivers/iio/或drivers/hwmon/下面的代码。读内核文档重点看Documentation/driver-api/和Documentation/devicetree/bindings/里面告诉你这个子系统的接口长什么样。再去看芯片手册的寄存器描述这时候你才知道每个寄存器该在哪个回调函数里配。内核源码是最好的“活文档”它不仅有标准用法还有各种边界情况的处理方式。我在项目里通常的做法是先在内核里搜compatible xxx找到同类设备的驱动照着改效率比从零读手册高出很多。2. 字符设备驱动怎么搭骨架三个核心数据结构2.1 file_operations、cdev、设备号是怎么配合的字符设备驱动的骨架本质上就是三个东西设备号、cdev结构体、file_operations操作集。设备号是驱动的“身份证”由主设备号和次设备号组成。主设备号标识驱动类型次设备号标识同一驱动下的不同设备。早期写法是手动指定一个主设备号比如register_chrdev(240, demo)但这样容易冲突也限制了这个驱动能管理的设备数量。现在推荐用动态申请dev_t devno; alloc_chrdev_region(devno, 0, 1, demo_drv); major MAJOR(devno);alloc_chrdev_region会从空闲段里给你分配一个主设备号第二个参数是起始次设备号第三个是连续设备数量最后是设备名。这样无论设备多少都不用手动去找空闲号。cdev结构体描述一个字符设备它把设备号和file_operations绑定在一起。cdev_add之后内核就知道“这个设备号对应的操作是什么”。而file_operations就是整个驱动的灵魂它定义了用户态能对设备做什么——read、write、ioctl、mmap、poll……热词里那个“linux 内核 动态加载 file_operations 拦截 read write”问的就是这个东西的原理。file_operations本身就是一组函数指针内核所有对设备文件的读写操作最终都会走到这组指针上来。如果你在驱动里改掉read/write的指向或者在一个已注册设备的fops上做替换就能实现对文件操作的拦截。透明加密、安全审计、监控工具底层逻辑都离不开这套机制。至于怎么改、有什么坑第7节专门讲。2.2 从注册到释放驱动的完整生命周期一个字符设备驱动的生命周期就对应模块的加载和卸载。加载通常做四件事申请设备号初始化cdev并注册创建device class创建device节点卸载则反着来。下面是按这个思路写的一套最小驱动模板我实际项目里就是在这个基础上扩展的#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEV_NAME demo_drv #define CLASS_NAME demo_class static int major; static struct cdev demo_cdev; static struct class *demo_class; static char kernel_buf[1024]; static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { size_t len; // 内核态不能直接操作用户态指针必须用 copy_to_user len min(count, strlen(kernel_buf)); if (copy_to_user(buf, kernel_buf, len)) return -EFAULT; return len; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { size_t len min(count, sizeof(kernel_buf) - 1); if (copy_from_user(kernel_buf, buf, len)) return -EFAULT; kernel_buf[len] \0; return len; } static int demo_open(struct inode *inode, struct file *filp) { return 0; } static int demo_release(struct inode *inode, struct file *filp) { return 0; } static struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .release demo_release, .read demo_read, .write demo_write, }; static int __init demo_init(void) { dev_t devno; // 1. 动态申请设备号 alloc_chrdev_region(devno, 0, 1, DEV_NAME); major MAJOR(devno); // 2. 注册字符设备 cdev_init(demo_cdev, demo_fops); cdev_add(demo_cdev, devno, 1); // 3. 创建设备类 demo_class class_create(DEV_NAME); // 4. 创建设备节点 /dev/demo_drv device_create(demo_class, NULL, devno, NULL, DEV_NAME); pr_info(demo_drv: init, major%d\n, major); return 0; } static void __exit demo_exit(void) { dev_t devno MKDEV(major, 0); device_destroy(demo_class, devno); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(devno, 1); pr_info(demo_drv: exit\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);这里有几个细节想强调一下都是我实际踩过的坑用户态传下来的指针内核态绝对不能直接访问。copy_from_user和copy_to_user不只是拷贝还会做地址合法性检查和缺页处理。直接解引用用户态指针轻则oops重则被利用提权。class_create返回值在新内核里需要检查IS_ERR只写一个demo还好产品代码里不加检查设备类创建失败后会留下一堆残留cdev。cdev_del之后设备节点还在必须用device_destroy清理否则下次加载同名设备时udev可能识别出错。2.3 为什么说设备节点是由“事件”驱动的很多新手会问我在驱动里device_create之后为什么/dev/demo_drv就自动出现了其实不是内核直接创建的文件而是内核通过kobject uevent向用户空间发了一个“设备加入”的事件。udev或嵌入式里的mdev收到事件后根据/sys/class/demo_class/demo_drv/dev里读到的设备号再去调用mknod帮你创建设备节点。所以在开发板上如果发现/dev/下面没有设备节点先查两件事有没有挂devtmpfs或运行mdev/udev守护进程/sys/class/demo_class/demo_drv/dev里的设备号是否存在。手动补救的方法就是自己mknodmknod /dev/demo_drv c $(cat /sys/class/demo_class/demo_drv/dev | cut -d: -f1) \ $(cat /sys/class/demo_class/demo_drv/dev | cut -d: -f2)这种方式排障时很管用至少能区分是驱动注册失败还是用户空间的事件处理链路出了问题。3. 实战从零编译最小驱动模块3.1 环境准备和编译Makefile在开始编译模块之前先确认环境有没有内核头文件。以Ubuntu为例uname -r sudo apt install linux-headers-$(uname -r)如果是在开发板上交叉编译则提前准备好对应版本的内核源码并完成编译这里假设大家用的是PC虚拟机做实验。模块的Makefile其实很简单obj-m : demo_drv.o KERN_DIR : /lib/modules/$(shell uname -r)/build all: make -C $(KERN_DIR) M$(PWD) modules clean: make -C $(KERN_DIR) M$(PWD) cleanobj-m告诉Kbuild把这个文件编译成可加载模块。-C的意思是切换到内核源码目录读取那里的Kbuild配置M$(PWD)表示你要编译的外部模块所在目录。这是Linux模块构建的标准方式不管源码放哪都适用。如果内核头文件路径被修改过KERN_DIR改成实际路径即可。用echo obj-m : demo_drv.o这种方式也可以但建议用小写的Makefile因为很多交叉编译工具链对大写Makefile处理有历史兼容问题。3.2 编译、加载和验证的完整流程执行make你会看到类似这样的输出make -C /lib/modules/5.15.0-91-generic/build M/home/user/demo modules CC [M] /home/user/demo/demo_drv.o MODPOST /home/user/demo/Module.symvers LD [M] /home/user/demo/demo_drv.ko生成demo_drv.ko之后按顺序执行sudo insmod demo_drv.ko dmesg | tail lsmod | grep demo_drv ls -l /dev/demo_drv echo hello /dev/demo_drv cat /dev/demo_drv sudo rmmod demo_drvinsmod这块有个很典型的坑如果提示Operation not permitted大部分情况下不是权限问题而是内核开启了模块签名校验Secure Boot或特定内核配置。最快的验证方式是dmesg看有没有module verification failed的报错。如果开启了要么关掉Secure Boot要么用当前内核构建时生成的signing_key.pem给模块签名。我在开发板上遇到过好几次模块编译成功但insmod后dmesg完全没有输出后来发现是pr_info的级别跟/proc/sys/kernel/printk的当前控制台级别不匹配信息被过滤掉了。排查时可以临时调高控制台级别echo 8 /proc/sys/kernel/printk这个方法解决了很多“驱动看起来没跑起来”的假象。3.3 用户态测试程序和注意事项驱动给用户态提供了文件接口那验证驱动时也应该用标准的文件接口去测。我习惯写一个几十行的测试工具而不是依赖shell的echo/cat因为echo和cat对读写长度、非阻塞等标志控制不了。#include stdio.h #include fcntl.h #include string.h #include unistd.h int main(void) { char buf_write[] driver test; char buf_read[64] {0}; int fd open(/dev/demo_drv, O_RDWR); if (fd 0) { perror(open); return -1; } write(fd, buf_write, strlen(buf_write)); read(fd, buf_read, sizeof(buf_read)); printf(read: %s\n, buf_read); close(fd); return 0; }编译运行gcc test.c -o test ./test如果read返回的数据不对优先检查驱动里的copy_to_user长度和用户态buf大小是否匹配。很多诡异问题其实只是长度算错了比如字符串结尾的\0没带过去导致用户态printf一直打印出多余字符。4. 有了设备树之后驱动注册才真正“活”起来4.1 设备树到底解决了什么问题在设备树普及之前板级文件里写满了设备的注册信息每换一块板子就要改一次内核源码。设备树Device Tree, DTS把“硬件长什么样”从内核代码里剥离出来用一棵树来描述CPU、内存、总线、外设的拓扑。驱动只需要通过compatible、reg、interrupts等属性来匹配设备不需要关心具体板卡怎么连线。这个转变对嵌入式Linux开发影响很大。现在跑ARM64的开发板几乎都在用设备树。你拿到一块新板子第一步往往是看它的dts文件看它默认打开了哪些节点、关闭了哪些节点这决定了你的驱动能不能在probe阶段被调起来。4.2 一个I2C设备节点的配置套路以I2C总线上挂一个温度传感器为例设备树里通常这样写i2c0 { status okay; clock-frequency 400000; temp_sensor: temp48 { compatible example,tmp108; reg 0x48; interrupt-parent gpio1; interrupts 5 IRQ_TYPE_EDGE_FALLING; }; };每个属性都有明确作用reg表示设备在I2C总线上的地址0x48是常见温度芯片的7位地址写错的话驱动probe阶段根本找不到设备。interrupt-parent和interrupts表明中断控制器和中断号。IRQ_TYPE_EDGE_FALLING表示下降沿触发。clock-frequency是总线频率很多传感器支持快速模式400kHz但有些老芯片只能跑100kHz这个值配错会导致总线通信不稳定。驱动侧要做的就是告诉内核compatible匹配规则和probe入口。I2C设备驱动注册函数的标准写法是static const struct of_device_id tmp108_of_match[] { { .compatible example,tmp108 }, { } }; static struct i2c_driver tmp108_driver { .driver { .name tmp108, .of_match_table tmp108_of_match, }, .probe tmp108_probe, .remove tmp108_remove, .id_table tmp108_id, }; module_i2c_driver(tmp108_driver);module_i2c_driver是i2c子系统的快捷宏它会把driver注册到I2C核心然后I2C核心根据设备树里的compatible来匹配。匹配成功后内核调用probe函数你在这里分配设备结构、初始化资源、注册字符设备或工业IO子系统设备。4.3 probe函数不被调用的排查顺序probe函数不被调用是驱动开发中最高频的问题。我的排查顺序基本固定/sys/bus/i2c/devices/下有没有生成对应地址的目录。没有说明设备树节点没生效检查status和reg。compatible是否完全一致注意比较字符串连大小写和下划线都不能差。设备树是否被正确编译进内核或加载了DTB。在开发板上跑dtc -I fs /proc/device-tree | grep tmp108能快速确认固件里实际用的设备树。驱动有没有加载成功。ls /sys/bus/i2c/drivers/tmp108/如果驱动已加载却没绑定设备多半是id_table或of_match_table漏配。这四步走完能解决绝大多数probe不进来的问题。5. 并发、中断与性能驱动从“能跑”到“抗造”的鸿沟5.1 驱动里的并发竞争为什么崩得比应用快应用层写多线程死锁了顶多卡住驱动里如果对共享数据不加保护系统直接oops甚至内核崩溃。原因在于驱动运行在内核态同一个驱动可能被多个进程同时open、read、write也可能被中断上下文打断。中断上下文无法休眠所以不能随便拿互斥锁。常用的锁有这么几个自旋锁spinlock适合临界区很短的情况。持有自旋锁时CPU忙等所以临界区里不能有休眠操作。互斥锁mutex会睡眠适合临界区有较长操作的场景。但不能用在原子上下文比如中断处理函数。原子操作针对计数、标志位这类简单变量比如atomic_t开销最小。一个经典失误是在持有自旋锁的临界区里调用copy_to_user用户态页不在内存时会触发缺页进程睡眠但锁还握着另一个CPU上的自旋锁等待者就会一直空转系统表现就是卡死。这类问题很难复现一旦复现就不是小问题。我平时写驱动的原则数据量小用原子变量临界区短用自旋锁临界区有IO、有等待用mutex读写分离的场景用读写锁但要留意写者优先问题。5.2 中断的底半部机制怎么选硬件中断到来处理函数里你要尽快完成应答把耗时工作推到底半部。早期Linux用软中断和tasklet现在更多用工作队列和线程化中断。tasklet软中断上下文不能休眠适合处理时间非常短的工作比如清除中断标志、启动下一次DMA。工作队列workqueue进程上下文可以休眠适合处理耗时操作比如读取传感器数据、上报事件。线程化中断threaded irq把中断处理函数整体放到内核线程里执行代码写起来最直观适合需要频繁读写寄存器的外设比如触摸屏、按键。选型时我基本是中断里只做标记实际数据读取放workqueue如果驱动模型本身支持request_threaded_irq优先用中断线程这种方式。5.3 ioctl是驱动的“控制面板”设备文件读写负责数据流控制功能一般走ioctl。驱动里定义ioctl命令有一套规范用_IO、_IOR、_IOW、_IOWR这几个宏来生成命令码里面包含魔数、序号、方向和数据大小。#define DEMO_MAGIC d #define DEMO_GET_STATUS _IOR(DEMO_MAGIC, 1, int) #define DEMO_SET_VALUE _IOW(DEMO_MAGIC, 2, int)魔数用来避免不同驱动之间的命令冲突序号区分功能。用户态用ioctl(fd, DEMO_SET_VALUE, val)调用。我见过一些项目图省事直接在驱动里用if判断一个自定义整数结果哪天两个模块魔数撞了调用方传错命令驱动就把数据写进不对的寄存器查起来特别抽象。建议从一开始就用标准宏。6. 调试与排查驱动开发九成时间都在和内核“吵架”6.1 printk、dynamic debug和ftrace怎么配合printk是最直接的调试方式但用多了就会发现两个痛点一是生产环境不想老是看到调试信息二是在中断上下文里printk打太多会导致实时性劣化。Linux内核提供了dynamic debug机制可以动态控制特定文件的打印级别。假设你的驱动是drivers/demo/demo.c加载后通过debugfs开关echo file drivers/demo/demo.c p /sys/kernel/debug/dynamic_debug/control这时驱动里的pr_debug才会输出。想关掉时把p换成-p就行。这套机制比改代码加printk再编译要高效得多尤其是产品现场排障不用重新编译内核就可以打开日志。如果问题涉及函数调用流程比如probe为什么没进、中断为什么没触发可以用ftrace跟踪内核函数echo function /sys/kernel/debug/tracing/current_tracer echo tmp108_probe /sys/kernel/debug/tracing/set_ftrace_filter echo 1 /sys/kernel/debug/tracing/tracing_on然后触发一次设备探测再cat /sys/kernel/debug/tracing/trace查看调用情况。这种方式比printk更精确能看到函数级调用链对于理解驱动和内核子系统的绑定关系特别有用。6.2 遇到oops怎么读关键信息oops是内核崩溃时的现场快照信息一大堆但真正要抓的就几处BUG: unable to handle kernel NULL pointer dereference at 0000000000000018 RIP: 0010:demo_read0x2e/0x50 [demo_drv] Call Trace: ? demo_probe0x1a/0x40 [demo_drv]第一行告诉你发生了什么空指针解引用。RIP指向具体函数和偏移demo_read0x2e/0x50表示demo_read函数偏移0x2e处出错函数总大小为0x50。使用addr2line或objdump -d demo_drv.ko可以反汇编到具体指令看是哪个变量是空的。Call Trace是函数调用链能帮你判断错误是从哪个路径进来的比如是从read系统调用进来还是从probe阶段进来的。很多新手看到oops就慌实际上只要定位到RIP对应的源码行八成就是忘了判空、错用了指针类型或者锁没初始化。6.3 常见问题速查表现象常见原因排查手段insmod报Operation not permitted模块签名校验、权限不足、内核不许可dmesg查具体报错关Secure Boot或签名insmod无输出printk级别低或日志被缓冲echo 8 /proc/sys/kernel/printk/dev节点不存在udev/mdev没跑起来或device_create失败检查class和device_create返回值手动mknodopen设备返回No such devicecdev_add失败或设备号不对检查dmesg确认设备号probe不执行compatible不匹配、status disabled、驱动没加载按4.3排查顺序走一遍读写数据异常copy_to_user/from_user长度计算错误打印实际传入的count和返回len中断不触发中断号配错、触发方式不对、中断被屏蔽cat /proc/interrupts检查设备树interrupts并发场景系统崩溃锁没保护共享数据或持锁进入休眠检查临界区是否有睡眠调用这张表是我平时给自己团队做培训时的浓缩版覆盖了80%的新手问题。7. 进阶玩法file_operations 拦截与透明加密思路7.1 file_operations 为什么能成为“钩子”热词里提到的“linux 内核 动态加载 file_operations 拦截 read write”和“linux 内核 透明加密”本质上是用到了Linux VFS层的钩子机制。用户态对任何文件做读写最终都会走struct file对应的f_op也就是file_operations里的函数指针。被打开文件后file结构体里的f_op指针指向具体文件系统或设备驱动实现的那组操作。如果我们能替换这个指针或者把链路上某个环节的函数指针换掉就能在用户态读写数据时插入自己的处理逻辑。应用场景有很多安全审计记录某个进程对某个文件的读写行为。透明加密用户态读到的是解密后的明文磁盘上存的是密文加解密逻辑在读写回调里完成。动态调试临时给某个设备文件增加数据统计。这套机制驱动开发者并不陌生文件系统、设备驱动、安全模块都在用。但要注意任何内核层的钩子修改都涉及全局状态操作不当会导致系统不稳定甚至崩溃所以做实验时务必在虚拟机或独立开发板上验证。7.2 一个最小拦截实现思路实现思路大致分三步准备一个模块模块里定义自己的file_operations比如my_read、my_write在调用真实函数前后加入自己的处理。拿到目标文件打开后的struct file替换f_op。但要保存原f_op调用时用原函数指针。在模块退出时恢复原f_op否则文件操作会悬空。这段逻辑写起来不难难在正确性文件被多个进程打开时每个进程有自己的struct file实例你需要逐个替换或者更聪明的做法是在打开路径上做拦截。有些文件系统会对f_op做强校验直接替换可能导致其他子系统行为异常。如果原f_op里的函数指针被内核其他模块引用替换后必须确保引用计数和生命周期正确。如果只是想做透明加密更稳妥的路线是使用内核提供的用户态文件系统框架FUSE或者eBPF的file monitor能力而不是直接改f_op。当然以学习Linux内核机制为目的手写一次这样的模块对VFS和文件系统的理解会有质的提升。只是要在合规合法的前提下进行不要将这类技术用于绕过权限或破坏防护机制。7.3 动态加载拦截功能时的安全提醒内核层的一切操作都是最高权限。写这类功能时我的建议是在专用测试环境里做不要在生产环境直接上。加内核模块签名避免被恶意模块替换。如果用于加密类产品提前评估性能损耗read/write路径上增加一次加解密运算吞吐量通常会明显下降。保存好原始f_op的备份模块卸载时务必恢复。我最初接触这个方向是做一个数据审计需求需要统计某个串口设备的数据流量一开始想改驱动源代码后来直接用一个拦截模块避免反复编译目标驱动代码实测效果不错。前提是清楚设备驱动原来的open/read实现不能盲目替换。最后一个小建议驱动开发先学会“少写代码”回顾这些年的开发经验我最深的一个体会是Linux设备驱动开发的复杂度很大程度不在C语言本身而在于你对内核机制的理解程度。框架选错了后面全是补丁锁用错了问题要到现场才爆发设备树配错了probe默默失败日志都不给你一条。所以给新手的建议是写代码之前先花时间读内核里同类驱动的实现。比如你要做I2C传感器驱动就把drivers/hwmon/下面的芯片驱动读一遍你会发现很多经验都是模式化的——probe里初始化、注册中断里只标记工作队列再处理数据。用这种“模式化”的思路去做驱动开发更像是在内核框架上做填空题而不是从零搭积木。另一点是要尽早熟悉动态调试和trace工具它们能让你在问题复现时即时抓现场而不是反复改代码加printk、重新编译加载模块。我现在的习惯是驱动里默认打pr_debug发布时不开dynamic debug现场出问题了临时打开定位完再关。这样既能保证日志可用又不影响正常业务。这套方法让我在多个项目里少熬了很长的夜希望你也能用上。
返回列表