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

资讯详情

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

字符设备驱动开发实战:从代码骨架到设备树配置与性能调优

字符设备驱动开发实战:从代码骨架到设备树配置与性能调优 我见过太多从应用开发转过来的朋友C语言功底扎实Linux API也熟但一提到“设备驱动开发”就发怵。其实这不怪大家因为驱动开发和应用程序开发完全是两套思维模式——前者是你在调用操作系统后者是操作系统在调用你的代码。你写的应用程序永远是被动地等着被调用而驱动代码的每个函数都对应着用户空间某个系统调用的“最后一跳”。这篇文章我打算从零开始把一个字符设备驱动的完整生命周期讲透包括代码骨架、设备树匹配、调试手段、性能调优以及我这些年踩过的坑。内容偏实操适合想系统梳理驱动开发知识、准备嵌入式相关岗位面试或者正在啃开发板手册却发现“每个字都认识但串不起来”的同学。1. 先想清楚驱动开发与应用程序开发的分水岭在哪1.1 从“谁调用谁”看驱动的工作方式应用程序里你写一个int main() { open(/dev/xxx, ...); }看起来很普通。但这个调用背后发生的事情和你在用户态直接读写普通文件有天壤之别。当CPU处于用户态时它执行的是应用程序的指令一旦触发open系统调用CPU会切换到内核态通过VFS虚拟文件系统找到对应设备文件所关联的驱动再调用驱动中你实现的open函数。整个过程可以简化成下面这条链路用户程序 → 系统调用 → VFS → 设备驱动(你的代码) → 硬件关键区别在哪普通文件读写时VFS下面的落点是ext4、xfs这类文件系统驱动而设备文件读写时VFS下面的落点就是你写的那几个回调函数。所以说白了设备驱动开发就是实现一组特定的回调函数让内核能够在合适的时机调用它们去操作硬件。这里面还有一个重要的机制差异用户态和内核态的地址空间是隔离的。你在内核态不能直接使用用户态传进来的指针必须用copy_from_user/copy_to_user把数据搬过来或者用内核提供的其他安全访问接口。很多新手驱动写出死机十有八九是直接解引用了用户态指针。1.2 驱动三大分类为什么字符设备是学习主线Linux设备驱动传统上分三大类理解它们的区别能帮你快速定位自己正在做的事属于哪个方向分类数据模型典型代表对应系统调用字符设备字节流顺序读写串口、GPIO、I2C、帧缓冲open/read/write/ioctl块设备定长数据块可随机访问SSD、SD卡、NVMeread/write以块为单位网络设备数据包收发以太网卡、WiFisocket接口从学习路径来看字符设备驱动绝对是主线。原因有两个第一绝大多数简单硬件传感器、LED、按键、显示屏控制接口都可以抽象成字符设备第二字符设备驱动的代码量小、依赖少最快可以在一两百行内实现完整读写流程特别适合用来理解内核模块的加载、设备号管理、文件操作回调这套核心机制。1.3 环境准备别在第一步就翻车动手之前实验环境必须配好否则后面每一步都会莫名其妙地报错。我个人推荐的最小实验环境有两种一台Ubuntu/Debian虚拟机 内核头文件包。这种方式最适合只学驱动逻辑、不碰真实硬件的阶段。一块常见的ARM开发板树莓派、各类国产开发板都可以。适合需要操作GPIO、I2C等真实外设的场景。不管用哪种有一个坑必须避开驱动编译用的内核头文件必须和运行环境的内核版本严格一致。用uname -r查看当前内核版本然后sudo apt install linux-headers-$(uname -r)装完之后确认一下目录是否存在ls /lib/modules/$(uname -r)/build这个目录就是后面编译驱动时Makefile里KDIR指向的目标。如果这一步没配对后续编译时会出现一堆unknown symbol一类的报错定位起来特别浪费时间。2. 手写一个字符设备驱动从module_init到file_operations2.1 最小可用代码骨架先直接给一个可以运行的完整示例这个驱动做的事情很简单加载时分配设备号并注册字符设备卸载时释放用户程序可以read读到一段内核字符串也可以write写入数据但不做处理。麻雀虽小五脏俱全核心机制都在里面。#include linux/init.h #include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DRIVER_NAME demo_driver static int demo_major 0; static struct cdev demo_cdev; static dev_t demo_dev_num; static const char demo_data[] Hello from kernel!\n; static int demo_open(struct inode *inode, struct file *filp) { printk(KERN_INFO %s: open\n, DRIVER_NAME); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { size_t len strlen(demo_data); if (*f_pos len) return 0; if (count len - *f_pos) count len - *f_pos; if (copy_to_user(buf, demo_data *f_pos, count)) return -EFAULT; *f_pos count; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { char kbuf[64]; if (count sizeof(kbuf)) count sizeof(kbuf) - 1; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; printk(KERN_INFO %s: received: %s\n, DRIVER_NAME, kbuf); return count; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, }; static int __init demo_init(void) { int ret; ret alloc_chrdev_region(demo_dev_num, 0, 1, DRIVER_NAME); if (ret 0) { printk(KERN_ERR failed to alloc dev region\n); return ret; } demo_major MAJOR(demo_dev_num); cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; ret cdev_add(demo_cdev, demo_dev_num, 1); if (ret 0) { unregister_chrdev_region(demo_dev_num, 1); return ret; } printk(KERN_INFO %s: registered, major %d, minor 0\n, DRIVER_NAME, demo_major); return 0; } static void __exit demo_exit(void) { cdev_del(demo_cdev); unregister_chrdev_region(demo_dev_num, 1); printk(KERN_INFO %s: unregistered\n, DRIVER_NAME); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal character device driver);这一段代码必须逐行吃透它就是整个字符设备驱动的最小闭环。2.2 设备号分配老接口为什么被淘汰字符设备在内核里靠dev_t标识它同时包含主设备号和次设备号。主设备号用来区分驱动的类型次设备号用来区分同一驱动下的不同设备实例。分配设备号有两条路接口行为适用场景register_chrdev_region(dev, count, name)静态指定起始设备号需要固定主设备号时如某些老设备约定俗成的编号alloc_chrdev_region(dev, minor, count, name)内核动态分配主设备号大多数场景优先推荐现在的新代码基本都用alloc_chrdev_region因为它能避免设备号冲突。主设备号是有限的资源内核里同一主设备号只能对应一个驱动如果你硬编码了一个已经被占用的编号register_chrdev_region会直接失败。动态分配就没有这个烦恼虽然你会失去“固定设备节点路径”的便利但配合下面的自动创建设备节点机制这个问题很容易解决。顺带一提早年间还有个更老的register_chrdev函数它隐式地帮你在内核里创建了字符设备但没法支持多个次设备号。内核文档和大量现代驱动代码已经不太用它了学习时直接跳过即可避免新旧API混淆。2.3 cdev字符设备在内核里的“登记证”cdev是内核中表示字符设备的核心结构体它把设备号、file_operations、所属模块这三样东西绑定在一起。cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; cdev_add(demo_cdev, demo_dev_num, 1);cdev_init把操作函数表挂进cdev结构cdev_add才是真正把设备注册进内核的地方。cdev_add的第三个参数是设备数量如果你的设备占用多个连续的次设备号这里可以一次加上去。有一点要特别注意cdev_add是在alloc_chrdev_region之后调用的失败时要记得回滚释放设备号否则就会泄漏内核资源。卸载时对应的顺序是cdev_del先删设备再unregister_chrdev_region释放设备号顺序反了同样会有问题。2.4 file_operations驱动与用户态之间的“协议”file_operations是驱动里最核心的结构体没有之一。它定义了驱动对用户空间暴露的所有操作能力。我的示例中只实现了三个成员但实际项目中你还会经常遇到.open用户打开设备节点时调用常用于初始化硬件、递增模块引用计数.release用户关闭设备节点时调用用于释放资源.read/.write读写数据注意这里的buf是用户空间地址绝对不能在内核态直接解引用.unlocked_ioctl实现自定义命令比如设置波特率、读取设备状态、控制GPIO方向.llseek调整文件读写位置.poll实现非阻塞IO和select/epoll支持我特别想提醒的是read函数的返回值语义。返回正值表示读取了多少字节返回0表示读到文件末尾EOF返回负数是错误码。很多新手在这个返回值上栽跟头比如数据还没准备好时返回0用户态就会认为读到EOF从而退出循环实际上应该返回-EAGAIN让调用方重试。2.5 编译、加载和与用户态联动写一个最简单的Makefileobj-m : demo_driver.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean先解释一下这两行make命令的奥秘-C表示切换目录到内核源码树M$(PWD)告诉内核构建系统“喂帮我编译我当前目录下的模块”。内核源码树里有一套编译外部模块的标准机制obj-m : xxx.o的意思就是把这个目标文件编译成可加载模块.ko。编译加载流程如下make sudo insmod demo_driver.ko dmesg | tail正常情况下dmesg会打印出major xxx的信息。但此时/dev下还没有设备节点需要手动创建sudo mknod /dev/demo_driver c 主设备号 0 echo hi /dev/demo_driver cat /dev/demo_driver最后再删除模块sudo rmmod demo_driver这套流程你至少要走三遍每一遍都亲自动手才能真正理解设备号、设备节点、insmod/rmmod之间是怎么配合的。2.6 自动创建设备节点再也不想手敲mknod了手动mknod在生产环境不现实所以内核提供了一套设备模型机制来自动创建设备节点。原理是基于udev驱动在内核里注册一个设备类class然后创建一个设备实例用户态的udev守护进程收到事件后会自动在/dev下创建设备文件。这个过程在代码里需要增加两个步骤static struct class *demo_class; // 在cdev_add成功后 demo_class class_create(THIS_MODULE, DRIVER_NAME); if (IS_ERR(demo_class)) { ret PTR_ERR(demo_class); goto err_class; } device_create(demo_class, NULL, demo_dev_num, NULL, DRIVER_NAME); // 卸载时对应 device_destroy(demo_class, demo_dev_num); class_destroy(demo_class);加了这段之后只要驱动加载成功/dev/demo_driver就会自动出现。device_create的最后一个参数是设备节点名这里传的是DRIVER_NAME最终生成的节点就是/dev/demo_driver。3. 设备树来了现代内核下驱动怎么找到硬件3.1 一个“改代码才能换硬件”的旧时代在设备树普及之前内核代码里会直接硬编码硬件资源比如“UART2的寄存器基地址是0x10000000中断号是IRQ 42”。一旦换了同系列但外设地址不同的板子就得改代码重新编译整个内核。ARM架构下有大量五花八门的板卡这种模式维护起来是一场灾难。设备树Device TreeDT解决的就是这个描述问题。它用一种树状的文本格式把硬件平台的拓扑结构、寄存器地址、中断号、GPIO引脚、时钟频率等信息全部描述清楚。驱动代码里不再写死“基地址是多少”而是写“从设备树节点里获取base address”。板级细节交给.dts文件去描述驱动代码与具体板卡解耦。3.2 compatible驱动和设备节点之间的“暗号”设备树里每个节点都有一个compatible属性这是驱动匹配设备的核心。举个最常见的例子假设设备树里有这么一段/dts-v1/; / { compatible acme,coyote; demo-device { compatible acme,demo; reg 0x10000000 0x1000; interrupts 29; }; };驱动这边要做的就是声明自己支持的compatible字符串static const struct of_device_id demo_of_match[] { { .compatible acme,demo }, { } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_platform_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo_driver, .of_match_table demo_of_match, }, }; module_platform_driver(demo_platform_driver);当内核启动时遍历设备树中的节点发现节点demo-device的compatible是acme,demo就会与驱动的of_match_table中的表项匹配匹配成功后紧接着调用demo_probe。驱动真正的初始化逻辑几乎全在这个probe函数里。顺带一说设备树节点中任何跟你驱动有关的信息都在probe里通过device_node去解析。3.3 从“设备树配置”到“驱动读到资源”的完整链路有了匹配的基础下一步就是从设备树节点读取资源。这一步容易写错因为涉及一堆of_开头的函数。我用一个从设备树获取寄存器地址并请求GPIO的片段来演示static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int gpio_num; int ret; // 从设备树的reg属性获取寄存器物理地址 res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(pdev-dev, failed to get memory resource\n); return -ENXIO; } // 让内核把物理地址映射到虚拟地址空间 base devm_ioremap(pdev-dev, res-start, resource_size(res)); if (!base) { dev_err(pdev-dev, failed to ioremap\n); return -ENOMEM; } // 从设备树的gpios属性获取GPIO编号 gpio_num of_get_named_gpio(pdev-dev.of_node, led-gpios, 0); if (gpio_is_valid(gpio_num)) { ret devm_gpio_request(pdev-dev, gpio_num, demo_led); if (ret) dev_err(pdev-dev, failed to request gpio\n); } return 0; }如果你想在驱动里读设备树中自定义属性还有一组配套函数u32 val; const char *str; of_property_read_u32(dev_node, sample-rate-hz, val); of_property_read_string(dev_node, label, str);对应设备树里的写法demo-device { compatible acme,demo; sample-rate-hz 48000; label codec; };这个机制非常实用。比如传感器的采样频率、显示面板的分辨率、网络PHY的地址这些参数都可以通过设备树配置而不需要修改驱动代码。热搜词里的“设备树配置”也就是这个意思——驱动把行为参数化由设备树去决定具体数值。3.4 设备树编译DTS到DTB再到内核加载.dts文件不能直接被内核使用要先编译成二进制.dtbdtc -I dts -O dtb -o demo.dtb demo.dts如果是完整板级设备树也可以放进内核源码树统一编译在arch/arm/boot/dts/或其他架构目录下添加文件然后通过Kconfig/Makefile配置使能。在ARM嵌入式平台上部署时常见的方式是u-boot通过tftp或fatload加载内核镜像和.dtb或者把.dtb打包进内核镜像。开发板上常见的一种做法fatload mmc 0:1 0x81000000 kernel.img fatload mmc 0:1 0x82000000 board.dtb bootz 0x81000000 - 0x82000000有一点我要特别强调设备树修改后不重新编译内核只是把新的.dtb传给内核就能生效。这意味着你调设备树时不需要反复编内核省下的时间相当可观。很多开发板玩家在改dts时习惯性地全量编译内核其实性能很低的烧写流程就是这么来的。排查compatible匹配失败的通用思路是启动内核后查看/sys/firmware/devicetree/base目录找到对应节点是否存在然后确认MODULE_DEVICE_TABLE是否声明最后确认compatible字符串是否完全一致。字符串匹配是严格按字节比较的多一个空格或者大小写错误都会直接匹配失败这种问题最坑人。4. 排查与调优printk、debugfs与性能瓶颈4.1 printk的级别机制你未必真懂一提到内核调试大家都会说“printk大法好”。但printk的细节其实相当微妙它不像printf那样所有输出都无脑打印而是分优先级控制的。内核日志级别从高到低大致分为级别宏含义典型场景KERN_EMERG系统不可用内核崩溃前KERN_ALERT必须立即处理严重硬件故障KERN_CRIT严重错误驱动初始化失败KERN_ERR错误资源获取失败KERN_WARNING警告环回测试异常KERN_INFO信息模块加载信息KERN_DEBUG调试信息函数入口出口打印内核有一个默认的console_loglevel只有级别数字小于等于这个值的日志才会输出到控制台。在运行时你可以通过/proc/sys/kernel/printk调节cat /proc/sys/kernel/printk # 输出类似: 4 4 1 7这四个数字的含义分别是当前控制台日志级别、默认日志级别、最低控制台级别、默认启动日志级别。开发调试时如果想看到更多日志直接echo 8 /proc/sys/kernel/printk但我要提醒一句生产环境千万别把所有调试输出砸到控制台。printk在中断上下文、在锁持有状态下如果输出量巨大会让系统慢到不可用。我曾经在一个驱动里每个中断都打印一条信息结果系统卡到连ls都费劲原因就是串口输出强占了大量CPU时间。4.2 动态调试比printk更体面的工具如果你看内核模块源码会看到很多pr_debug()pr_info()这样的宏。pr_debug默认编译进模块但不会输出因为它在编译期被去掉了。要让这些调试信息活起来内核提供了dynamic debug机制# 打开某个模块所有debug打印 echo module demo_driver p /sys/kernel/debug/dynamic_debug/control前提是使能了内核的CONFIG_DYNAMIC_DEBUG选项。这套机制的价值在于生产环境不需要重新编译就能打开指定模块的详细日志定位完再关掉影响面完全可控。内核里还有个配套利器是debugfs。当驱动需要暴露内部状态给调试时可以在/sys/kernel/debug下创建只读文件#include linux/debugfs.h static struct dentry *demo_debug_dir; static struct dentry *demo_status_file; static int demo_status_show(struct seq_file *m, void *v) { seq_printf(m, driver loaded, dev major %d\n, demo_major); return 0; } static int demo_status_open(struct inode *inode, struct file *file) { return single_open(file, demo_status_show, NULL); } static const struct file_operations demo_status_ops { .open demo_status_open, .read seq_read, }; // 在probe/init中注册 demo_debug_dir debugfs_create_dir(demo_driver, NULL); demo_status_file debugfs_create_file(status, 0444, demo_debug_dir, NULL, demo_status_ops); // 在remove/exit中销毁 debugfs_remove(demo_status_file); debugfs_remove(demo_debug_dir);挂载debugfs后就能查看mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/demo_driver/status4.3 一次典型的模块加载失败排查链路说一个真实场景内核模块insmod时报错insmod: ERROR: could not insert module demo_driver.ko: Unknown symbol in module这种问题在论坛里天天出现。排查链路我捋一遍第一步看详细错误信息dmesg | tail -20常见输出是Unknown symbol xxx。这个xxx通常是你在驱动里用到的某个内核函数或变量。出现这种错误的根因通常是内核头文件版本不匹配符号不存在或签名不一致模块中引用的符号来自另一个尚未加载的模块模块未声明依赖关系第二步用modinfo查看模块依赖modinfo demo_driver.ko第三步如果是依赖问题先加载被依赖模块。如果不知道被依赖的模块是谁用nm来查看模块里未解析的符号nm -u demo_driver.ko再通过grep symbol /proc/kallsyms确认这个符号是否在内核中已存在。如果查不到说明你的内核没开对应配置如果查到了但格式带T文本符号说明模块和内核符号表之间的关联出了问题。这类问题还有一个更隐蔽的来源version magic。内核模块带了版本校验信息如果你用别的内核版本编出来的.ko去加载会报version magic x.x.x-generic should be x.x.x。这种报错解决思路很清晰——回到内核版本一致的环境下重新编译。4.4 性能调优别让驱动成为系统的“跛脚”驱动里的性能问题往往不是“算得慢”而是“调度不当”和“锁竞争”。第一个常见点是中断处理。如果一个中断处理函数里做了太多事——比如在中断上下文里读慢速设备的寄存器、打印日志、睡眠等待——系统响应会被拖死。Linux内核标准做法是中断处理分成两半**上半部top half**处理硬件相关的最小必要操作**下半部bottom half**即tasklet或workqueue处理耗时的数据搬移和协议处理。什么时候用tasklet什么时候用workqueue简单判断标准需要睡眠的操作如等待I/O完成、获取信号量只能用workqueue因为tasklet是原子上下文不能睡眠对延迟极度敏感的用tasklet因为它运行在软中断上下文优先级更高对应的API大致是// workqueue struct work_struct my_work; INIT_WORK(my_work, my_work_handler); schedule_work(my_work); // tasklet struct tasklet_struct my_tasklet; tasklet_init(my_tasklet, my_tasklet_handler, 0); tasklet_schedule(my_tasklet);第二个常见点是锁竞争。驱动里保护共享数据的自旋锁如果粒度过大比如整个读写流程都锁住多核场景下性能惨不忍睹。优化方向是缩小临界区只在真正操作共享数据时加锁或者用RCU/原子操作替代常规锁。第三个点更隐蔽printk调用。如果驱动在数据路径中打印日志即使等级被过滤不输出到控制台开销依然存在。实测下来每秒几千次的高频printk即使不显示也会拖慢驱动两三成的吞吐。数据路径上一律不要留printk改用tracepoint或者dynamic debug。5. 产业化前的必修课内核裁剪与模块完善5.1 系统裁剪优化嵌入式设备的第一课标题里的热搜词出现了“系统裁剪优化”这也是嵌入式驱动开发绕不开的环节。驱动的目标平台往往存储空间有限、对启动时间有要求意味着内核不能什么都编译进去。裁剪的基本原则是“最小可用”。实际操作路径是make menuconfig在配置界面里逐项关闭不需要的子系统。常见的可裁剪项包括不需要的文件系统把ext4、btrfs、XFS都关掉只留需要的、不需要的网络协议栈如果没有网络功能可以关掉IPV6、过滤器等、不需要的驱动声卡、蓝牙、各种不存在的板级外设。配置文件保存在/boot/config-$(uname -r)里可以先拷贝一个当前配置作为起点cp /boot/config-$(uname -r) .config make menuconfig裁剪后重编内核镜像体积往往能从几十MB降到几MB启动时间也可能从十几秒降到两三秒。但裁剪有风险关掉一个看似没用的配置可能刚好是某个驱动依赖的基础设施结果模块加载失败。所以裁剪要以实测为准每关一批就编译验证一次。另外还有一个容易被忽视的配置项CONFIG_DEVTMPFS_MOUNT它决定了系统启动时是否自动挂载/devtmpfs对设备节点自动创建影响巨大。很多板卡换了内核后发现设备节点不出现就是这里没开。5.2 编译选项对驱动的影响编译驱动时优化级别是有讲究的。默认情况下外部模块编译用的是-O2这通常没问题。但如果你在调试时发现变量被优化掉、断点位置漂移可以临时用-O0编译ccflags-y -O0 -g同样为了减小驱动模块体积可以加-Os优化尺寸。不过在一次真实项目中我遇到过一个奇怪现象一个驱动在-O2下正常换成-Os之后行为异常原因是编译器对结构体内存布局和相关优化出了问题。遇到这种玄学问题不要急着怀疑编译器优先检查代码里是否有未定义行为比如memcpy越界、未经初始化的变量、原子操作和普通读写混用。5.3 完善一个驱动应有的基本素养错误处理与生命周期驱动代码从“能跑”到“能交付”中间还有一段距离。最基本的一条做错误处理。看这个函数static int demo_probe(struct platform_device *pdev) { int ret; ret demo_hw_init(); if (ret) return ret; ret demo_register_misc(); if (ret) { demo_hw_exit(); return ret; } return 0; }这种“成功后失败要回滚”的模式在驱动里极其常见。每申请一份资源都要考虑后续某一步失败时如何释放已申请的资源。现代化一点的写法是用devm_系列接口。比如devm_ioremap()、devm_kzalloc()、devm_gpio_request()这些接口的特点是资源跟随设备生命周期走设备被注销时自动释放驱动代码里甚至不需要手动释放。这能极大地减少资源泄漏风险也是新驱动的主流风格。6. 我这些年踩过的坑驱动开发的十大实际问题6.1 并发与竞态面试必问工程必炸驱动开发的并发问题比应用开发严重得多因为内核里随时可能有多个进程、多个CPU核心、中断上下文在同时访问你的驱动。如果你在驱动里放了一个全局变量没有任何同步保护那么几乎可以确定在某个高负载场景下它会出错。常用的并发保护手段按场景选机制特点适用场景原子操作最简单硬件层面保证计数器、标志位自旋锁忙等不可睡眠开销小临界区极短、上下文不允许睡眠互斥体可睡眠开销较大临界区较长允许睡眠RCU读多写少读者无锁链表多读场景完成量线程间同步等待硬件操作完成选错锁的典型案例在中断处理函数里用了mutex_lock然后驱动在设备有数据时直接死锁或者触发BUG: scheduling while atomic。记住一条铁律中断上下文只能使用自旋锁、原子操作等不会睡眠的原语。6.2 内核内存分配malloc的换肤版但别乱用内核里kmalloc和vmalloc是两套不同的机制kmalloc分配物理连续内存适合DMA和外设访问但大块分配容易失败vmalloc分配虚拟地址连续、物理地址不一定连续的内存适合大块内存但访问效率略低分配标志也很有讲究。GFP_KERNEL允许睡眠适合进程上下文GFP_ATOMIC禁止睡眠适合中断上下文但失败率更高。还有一个高频错误kmalloc分配的内存没清零就使用导致读到随机值。用kzalloc替代kmalloc能避免一半这种问题。6.3 内核API版本差异今天写的代码明天编译不过Linux内核的API一直在演进很多老接口会被新接口替代。最常见的几个变化点create_proc_entry被proc_create替代后者需要传入struct proc_opsinit_timer被timer_setup替代驱动模型统一走向platform_driver老的ioctl无锁版本要改用unlocked_ioctl如果你编译一个老驱动在新内核上报错先别急着骂内核开发者。查一下内核源码树里的Documentation/process/deprecated.rst里面列了各种废弃接口和替代方案。实践中最快的定位方式是把报错信息复制到内核源码仓库里搜看其他驱动是怎么适配新接口的。我现在维护的驱动源码每升级一个内核版本光适配API改动就要花一到两天。6.4 模块签名与安全加固不被注意但必须做现代内核默认打开了模块签名验证CONFIG_MODULE_SIG加载未签名的模块时会直接拒绝。嵌入式开发板上经常遇到这种现象insmod: ERROR: could not insert module ...: Key was rejected by service。解决办法有两种思路。开发环境最简单的方式是在内核配置里关闭签名校验量产环境则要正式制作签名证书然后在内核配置里指定证书make menuconfig # Enable loadable module support → Module signature verification # Provide the file path to the certificate从结果上看手工制作证书流程并不复杂但涉及密钥管理企业里通常有专门的安全团队负责驱动开发者只需要把证书文件路径配置对就行。6.5 中断申请别让共享中断变成共享麻烦中断是驱动开发里最让人头疼的部分之一。如果你要申请一个中断ret request_irq(irq_num, demo_isr, IRQF_TRIGGER_RISING, demo_dev, dev_id);dev_id这个参数千万别传NULL。原因有两个第一中断释放时free_irq(irq, dev_id)需要这个参数来精确匹配如果传NULL在多设备共享同一中断线时会把别人的中断给释放掉第二共享中断号时内核通过这个dev_id来区分到底是哪个设备触发了中断。我见过一个真实案例某驱动所有设备都传NULL申请中断结果其中一个设备卸载时其他设备的中断全部失效排查了整整两天才定位到是dev_id的问题。6.6 一个经典死锁场景复盘有一次我调试一个串口驱动现象是发送数据时偶尔卡死控制台没有任何输出整个系统还能动但那个进程永远睡在D状态。排查过程先查看进程状态ps -el显示状态为D即不可中断睡眠说明进程在内核态卡住了。再看/proc/pid/stack发现进程阻塞在一个wait_event_interruptible上。继续追查是谁没有唤醒它结果发现是个经典的死锁局面——发送线程持有一个自旋锁然后等待硬件发送完成中断硬件完成中断处理函数需要获取同一个自旋锁才能更新发送完成标志。但在单核CPU上中断处理函数拿不到锁因为它被发送线程握着发送线程又永远等不到中断来释放自己。这就是教科书级的“自旋锁不可睡眠等待”的野生再现。解决办法发送线程在等待硬件完成时不应该持有自旋锁应该在等待前释放锁中断里获取锁更新状态后唤醒等待线程。这个案例给我最大的教训是驱动里的每一个锁都要画清楚获取顺序和持有时间否则并发场景下迟早要出事。6.7 驱动里不要“裸奔”安全合规视角内核驱动拥有整个系统最高的权限意味着驱动代码的漏洞可以直接导致内核崩溃甚至被攻击者利用。写入驱动时有几个红线问题从用户态拷贝数据一定要用copy_from_user且检查返回值不能直接memcpy用户指针对ioctl传入的命令号、参数大小必须校验防止恶意进程通过驱动越权读写内存对count等长度参数要检查边界防止缓冲区溢出使用锁时注意避免死锁尤其是多锁获取顺序不要在内核态直接执行用户提供的代码或命令业界的做法是驱动上板前做静态扫描如smatch、sparse和动态测试syzkaller级模糊测试但这些工具在小团队里常常被忽略。从个人经验看至少跑一遍sparse静态检查是底线能抓出很多__user标注不匹配的问题。7. 最后补一句驱动开发这条路本质上拼的是对内核机制的理解深度。字符设备驱动只是第一关后面还有中断子系统、内核同步机制、定时器与内核线程、DMA引擎、各类总线子系统I2C、SPI、PCIe每一块都有大量值得钻研的细节。如果后续有机会我可以再写写I2C设备驱动和DMA方向的实战笔记。你按照“先字符设备搭骨架再设备树感知硬件然后调通中断与并发最后深入具体总线”的顺序去学基本上能把大多数驱动的代码看懂、改得动、调得通。
返回列表