资讯动态

Linux驱动开发实战:从字符设备到设备树与并发保护

发布时间:2026/10/20 14:04:10 来源:尧图企业网站定制
从第一次在自己的电脑上成功加载一个自己写的insmod模块到后来在嵌入式板子上调设备树、查中断、追内存山坳里踩过的坑大概能装满一箩筐。Linux 驱动开发这个名字乍一听好像门槛很高实际上剥开来看就是解决“内核怎么和硬件打交道、用户程序怎么和内核打交道”这两件事。如果你做过几年嵌入式或者系统开发迟早会撞上它如果你是刚入门想搞懂/dev下面的设备节点是怎么来的、应用层open/read/write怎么最终落到硬件寄存器上这篇文章就是为你准备的。我尽量不写成内核源码逐行注释那种风格而是以“我需要让一个设备跑起来该怎么一步步做”为主线把背后必须想清楚的机制、结构体和坑都讲透。全程会有一个可复现的字符设备示例骨架配合设备树、总线匹配、并发保护这些绕不开的知识点希望能帮你少走点弯路。1. 驱动开发的核心思路先搞懂内核态和用户态的边界1.1 为什么一切从“open/read/write”说起驱动是内核的一部分运行在内核态应用程序运行在用户态。二者之间有明确的分工用户态负责逻辑编排、业务处理内核态负责访问硬件、管理资源。驱动工程师最常打交道的字符设备核心就是实现一组文件操作接口也就是struct file_operations。struct file_operations { ssize_t (*read)(struct file *, char __user *, size_t, loff_t *); ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *); long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long); int (*open)(struct inode *, struct file *); int (*release)(struct inode *, struct file *); };只要注册好这组操作你在应用层写的open(/dev/mydrv, O_RDWR)就会触发驱动里的open函数read同理。这套抽象极其优雅因为对应用开发者来说设备就是一个文件对驱动开发者来说内核替你把文件系统、权限、缓冲区管理都做掉了你只需要专注在“数据从哪来、到哪去”这件事上。这也是为什么 Linux 驱动入门第一课永远是字符设备它把内核复杂的机制收缩成了一个清晰可懂的接口。1.2 内核态开发的语言和运行规则和用户态完全是两码事驱动跑在内核态意味着它没有你熟悉的 C 运行库可用也不能调用malloc、printf。内存申请用的是kmalloc、kzalloc打印日志用的是printk而且这些接口的语义和用户态差别非常大。printk输出到内核日志通过dmesg查看不同日志级别用数字或宏标记比如KERN_INFO、KERN_ERR。刚开始接触时会觉得很不适应但这就是内核开发的常态一切资源都得按内核的规则来拿。还有一点必须注意内核态代码里不能抛异常不能随便访问非法地址。用户态程序段错误之后只是进程崩掉内核态代码出错就是 oops严重时直接 panic 重启。所以写驱动时手要稳内存要越界检查指针要判空寄存器操作之前一定要确认硬件处于可访问状态。这个思路贯穿整个驱动开发生涯怎么强调都不过分。1.3 驱动开发的三个层次你处在哪一层按照我个人的理解驱动开发通常分三档第一档是纯写逻辑驱动不碰硬件寄存器比如各种虚拟设备、内存设备、加密算法封装重点是内核机制第二档是操作具体外设比如 I2C 传感器、SPI 屏、GPIO 按键、USB 设备核心是看懂芯片手册、配置时序、处理中断第三档是平台驱动 设备树 电源管理 DMA这一层要求对内核框架有全局理解知道一个外设从开机到 probe 再到 runtime PM 的完整生命周期。大多数入门者应该从第一档跳到第二档因为纯看内核机制容易枯燥纯调硬件又容易被时序搞到崩溃。建议先是写一个什么都不干、功能就是打印几行日志的字符驱动把它跑通然后加一个内存缓冲区实现简单的读写最后接真实硬件操作 GPIO 或者读取传感器数据。每一步都是上一层的自然延伸进阶曲线会比较平滑。2. 设备树、总线、probe为什么现代驱动都靠这套机制2.1 从“硬编码设备到内核”到“设备树描述硬件”早年 ARM Linux 的开发方式是把板级信息硬编码到内核源码里比如arch/arm/mach-xxx/board-xxx.c里面有一堆platform_device结构写明这个板子上有哪些外设、接在哪个地址、中断是多少。这种方法维护成本极高一个内核镜像只能支持一套硬件换了板子就要改内核重新编译。设备树Device Tree的引入本质上是把“硬件描述”从内核源码里拆出来变成一份独立的数据文件.dts编译成.dtb后交给内核解析。驱动这边只需要匹配 compatible 字符串比如vendor,my-device然后从设备节点里拿地址、中断、时钟、GPIO 编号等信息。这样一份内核镜像可以通吃多种板子换硬件只需要换 dtb这是现代驱动开发绕不过去的前提。2.2 platform 总线设备与驱动怎么对上号设备树节点是“物理设备信息”的载体platform_driver 是“驱动逻辑”的载体。内核通过 platform 总线把两者配对配对的核心依据是of_match_table里的 compatible 字符串和节点compatible属性是否一致。static const struct of_device_id mydrv_of_match[] { { .compatible vendor,my-device }, { /* sentinel */ } }; static struct platform_driver mydrv_pdriver { .probe mydrv_probe, .remove mydrv_remove, .driver { .name mydrv, .of_match_table mydrv_of_match, }, }; module_platform_driver(mydrv_pdriver);当两者匹配成功内核就会调用probe函数驱动在这里获得设备资源注册字符设备、混合设备、输入设备或其他子系统建立起“驱动逻辑”和“硬件信息”之间的连接。所以写现代驱动目光不能只盯着代码还得学会改设备树、查设备树甚至用ls /sys/firmware/devicetree/base/...这种运行时路径去验证解析结果。2.3 probe 失败时的常见原因从拿到“资源”说起probe里最常见的第一件事就是获取硬件资源寄存器地址、中断号、GPIO。设备树解析接口有一整套组合拳of_property_read_u32、of_irq_get、devm_platform_ioremap_resource这些。很多驱动写好后系统日志显示“driver registered”但没“probe”大概率是设备树节点 compatible 字符串写错、设备树没编译进内核、或者 pinctrl 配置把引脚占用了根本没分给你。我有个开发经验是不要一上来就钻到 probe 函数里先确认设备树节点是否存在——在设备树目录下能看到节点说明硬件描述已经被内核解析然后再确认驱动有没有注册/sys/bus/platform/drivers/mydrv是否存在最后看 probe 有没有执行。三步排查法能筛掉大半问题。3. 实操从零写一个完整字符驱动以及背后每一步的意义3.1 字符设备注册的最简完整骨架很多人学驱动卡在第一段代码不知道从哪里抄起。我给出一个我常用来验证内核环境的骨架它麻雀虽小五脏俱全注册了字符设备、生成了/dev节点、实现了简单的read/write逻辑。#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define DRV_NAME mydrv #define BUF_SIZE 1024 static int mydrv_major; static struct class *mydrv_class; static struct cdev mydrv_cdev; static char *mydrv_buf; static int mydrv_open(struct inode *inode, struct file *filp) { filp-private_data mydrv_buf; printk(KERN_INFO mydrv: open\n); return 0; } static ssize_t mydrv_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { size_t len strlen(mydrv_buf); size_t to_copy; if (*ppos len) return 0; to_copy min(count, len - *ppos); if (copy_to_user(buf, mydrv_buf *ppos, to_copy)) return -EFAULT; *ppos to_copy; return to_copy; } static ssize_t mydrv_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { size_t to_copy min(count, BUF_SIZE - 1); if (copy_from_user(mydrv_buf, buf, to_copy)) return -EFAULT; mydrv_buf[to_copy] \0; *ppos 0; return to_copy; } static const struct file_operations mydrv_fops { .owner THIS_MODULE, .open mydrv_open, .read mydrv_read, .write mydrv_write, }; static int __init mydrv_init(void) { int ret; mydrv_buf kzalloc(BUF_SIZE, GFP_KERNEL); if (!mydrv_buf) return -ENOMEM; ret alloc_chrdev_region(mydrv_major, 0, 1, DRV_NAME); if (ret 0) goto err_alloc; cdev_init(mydrv_cdev, mydrv_fops); mydrv_cdev.owner THIS_MODULE; ret cdev_add(mydrv_cdev, mydrv_major, 1); if (ret 0) goto err_cdev; mydrv_class class_create(DRV_NAME); if (IS_ERR(mydrv_class)) { ret PTR_ERR(mydrv_class); goto err_class; } device_create(mydrv_class, NULL, mydrv_major, NULL, DRV_NAME); printk(KERN_INFO mydrv: module loaded, major%d\n, MAJOR(mydrv_major)); return 0; err_class: cdev_del(mydrv_cdev); err_cdev: unregister_chrdev_region(mydrv_major, 1); err_alloc: kfree(mydrv_buf); return ret; } static void __exit mydrv_exit(void) { device_destroy(mydrv_class, mydrv_major); class_destroy(mydrv_class); cdev_del(mydrv_cdev); unregister_chrdev_region(mydrv_major, 1); kfree(mydrv_buf); printk(KERN_INFO mydrv: module unloaded\n); } module_init(mydrv_init); module_exit(mydrv_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Developer); MODULE_DESCRIPTION(A minimal character driver example);这里每一段都有讲究不是堆代码就完事。alloc_chrdev_region让内核动态分配主设备号而不是写死一个数避免冲突class_create和device_create配合是让应用层能看到/dev/mydrv的关键它依赖 devtmpfs 或者 udev 机制copy_to_user和copy_from_user是用来往返搬运用户态与内核态数据的专用接口绝不能用memcpy替代因为需要处理用户态指针的有效性以及缺页异常这是驱动开发最容易让人踩出重大事故的地方。3.2 构建过程和加载测试看着简单坑在细节里把它编进内核模块最简单的方式是写一个Makefileobj-m : mydrv.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) cleanmake之后会生成mydrv.ko然后执行sudo insmod mydrv.ko ls /dev/mydrv sudo cat /dev/mydrv echo hello driver | sudo tee /dev/mydrv sudo cat /dev/mydrv sudo rmmod mydrv实测下来最常见的问题有两个一是device_create之后/dev节点没出现。这通常是因为 devtmpfs 没有自动挂载或者用户空间没有 udev/mdev 之类的机制。嵌入式环境里如果 udev 没跑起来就需要手动mknod /dev/mydrv c major 0。二是设备号占用冲突如果社区里其他模块占用了同一个主设备号cdev_add就会返回 -EBUSY。使用动态分配能规避这个问题。3.3 从字符设备的“玩具逻辑”进阶到真实硬件操作上面的代码可以验证内核环境的健全性但你很快会发现它不解决任何实际问题。真实世界的驱动至少要处理和硬件相关的三件事内存访问、中断响应、时钟/延时。寄存器映射要用ioremap或devm_ioremap_resource拿到一个内核虚拟地址然后通过readl/writel操作寄存器中断则需要在设备树里配置gpio 某引脚 IRQ_TYPE_EDGE_RISING在驱动里用request_irq注册回调。这里的入门路径通常是GPIO LED输出、GPIO 按键输入 中断、I2C 传感器总线协议一路铺下来基本就掌握了一个典型嵌入式驱动开发的闭环。4. 并发、中断和一票经典的坑驱动稳定性的生死线4.1 为什么驱动里到处都是锁并发源不止一个用户态程序一般不用太担心并发进程之间的数据可以靠锁、队列、原子操作来保护但驱动的并发来源更多多个进程同时 open 同一个设备、同一个进程多线程同时 read/write还有内核本身的中断上下文、软中断、定时器等异步执行路径。这意味着你的驱动函数可能在任何时刻被多个执行流闯入数据竞争搞不好就是偶发性 oops极其难查。保护数据的手段无外乎原子变量、自旋锁、互斥锁、读写锁、RCU。选择哪种锁核心判断标准是“临界区有多长以及能不能睡眠”。我个人的经验贴士是临界区很短且不能睡眠时用自旋锁临界区可能很长、需要等待硬件完成操作时用互斥锁或 completion。自旋锁在临界区内绝对不能再获取同一个锁去 sleep否则就是死锁或内核告警。4.2 中断上下文里的限制决定了驱动代码的姿态中断处理函数hardirq handler里绝不能调用会睡眠的函数比如mutex_lock、kmalloc(..., GFP_KERNEL)、copy_to_user。中断上下文没有进程的概念调度器在那里没法正常工作一旦睡眠系统就基本卡死。这是驱动开发里非常硬性的规则新手很容易在中断里想当然地加锁、打印、分配内存。正确的姿势是中断里只做最快的数据接收和状态置位真正的数据处理交给底半部机制比如tasklet、workqueue或者内核线程。我在一次调试按键驱动时中断处理里顺手调了一个msleep(10)做消抖结果系统启动到一半直接死锁。后来改成在中断里只设置标志位把消抖和后续处理放到 workqueue 里执行问题立刻消失。这个案例后来反复出现在我跟同行交流时的“经典翻车”列表里。4.3 内核日志里常见的几个“恐怖”关键字你最好提前认识如果你开始看到BUG: scheduling while atomic、BUG: spinlock lockup、WARNING: CPU: N PID: X at ...这类输出说明你的驱动在并发和上下文管理上出问题了。这些日志不像应用层的Segmentation fault那么简单它往往携带着完整的调用栈。排查思路是先用/proc/locks、/proc/interrupts确认锁和中断状态然后打开内核的 lockdep 配置它能在死锁发牛之前准确地报出锁的依赖链对于驱动开发来说几乎必备。我强烈建议所有做驱动开发的人在开发内核里打开以下选项CONFIG_DEBUG_ATOMIC_SLEEP、CONFIG_PROVE_LOCKING、CONFIG_DEBUG_PAGEALLOC、CONFIG_KASAN如果架构支持。这就像是给驱动装上了刹车和防撞梁问题能大概率在测试阶段暴露出来而不是留给现场环境。5. 驱动调试与排查实践从 insmod 失败到 oops 反汇编5.1 三步法快速定位 load 失败的原因模块加载失败的时候insmod会打印一个错误号比如insmod: ERROR: could not insert module mydrv.ko: Device or resource busy但这个信息太粗糙需要结合日志确认。三步法我前面提到过这里再展开一点先看dmesg | tail -n 50找到mydrv相关的输出很多时候 init 函数里早已打印了关键错误原因。确认主设备号冲突cat /proc/devices里是否有同名设备或者相同主设备号。确认模块依赖有些模块依赖其他模块导出的符号用modprobe加载它通常会回溯依赖关系比手动insmod要省心。特别要提一句如果init函数返回错误模块加载会中止但如果在init里已经做了一部分资源注册就要在对应的错误分支里执行清理否则会出现“模块加载失败但部分资源已被占用”的诡异现象。这也是我在 review 代码时特别爱挑的地方。5.2 遇到 oops 不要慌按这个顺序读日志驱动最让人头大的就是 oops。内核日志里有一段BUG: unable to handle kernel paging request at virtual address后面跟着 PC 指针、调用栈、寄存器现场。读 oops 信息顺序应该是先看 error code 和解码后的 fault address判断是空指针、越界还是非法访问。再看 PC 所在函数如果符号表完整addr2line可以直接定位到源码行。最后结合调用栈理清这条路从哪个系统调用进来的再对照代码逻辑确定是哪个操作崩了。我常用objdump -d mydrv.ko mydrv.asm保留反汇编现场再用gdb加载 vmlinux 和 ko 文件来查看局部变量。有人会觉得驱动调试太难但熟练这套流程之后大多数 oops 都跑不出“指针用错了”“缓冲区算错了”“释放后还有人引用”这三个原因。5.3 数据流问题排查应用层现象反推驱动逻辑有时驱动本身不崩但应用层行为异常比如 read 永远返回 0、write 明明写了但再读却不对。这时不能闷头看代码要把数据流拉出来看。从应用open开始确认返回的 fd 是否为 -1然后用strace看系统调用的参数和返回值再把printk加在驱动的 open/read/write 入口和关键分支对比日志。我有个折腾了很久的案例设备在验证环境里读写全部正常一到现场就 read 超时。最后用strace发现应用层每次 read 都带了超大缓冲区而驱动里用的是copy_to_user一次全部拷贝不知道什么原因用户缓冲区有一部分不在可访问范围内于是返回-EFAULT应用层把它当成普通 IO 错误重试最终表现就是“卡死”。修复方法是把一次大拷贝改成循环分片拷贝问题立刻消失。这说明驱动开发不只是看内核还得懂系统调用层面的约束。5.4 常用调试手段的分类对比调试手段适用场景注意点printk/dmesg通用流程跟踪、错误状态确认日志级别要控制过多打印会影响实时性/proc、/sys节点检查设备注册状态、参数配置自建调试节点时要设计好读写权限strace用户态观察系统调用层的参数与返回码只观察得到用户态的表现内核内部要看日志ftrace函数调用跟踪、性能热点需要内核开启对应配置开销随范围增大而增加kprobe/kretprobe动态追踪某函数入口与返回对内核版本有一定依赖尽量在开发环境验证crash/gdbkgdb死锁、panic、内存损坏学习成本高但处理疑难杂症必备实际开发中我建议先靠printk和/proc把逻辑打通这个阶段效率是最高的当遇到偶发性问题、时序问题、内存踩踏问题时再引入 kprobe、ftrace 这类动态追踪手段。调试工具的切换本质是从“猜”到“验证”的过程越复杂的问题越不能靠猜。写完这些我觉得驱动开发和用户态编程最大的不同就是它逼着你同时理解硬件、内核和上层应用三者的交互。很多同事问怎么学我给的建议始终是先搭好开发环境、写好一个字符设备骨架、跑通设备树和 probe然后再去挑战具体外设。遇到问题多看/var/log/kern.log多用/proc和/sys验证状态多给代码加注释时刻想着“这段代码在 SMP 环境下会不会出问题”这些习惯比掌握某个具体外设的函数接口要重要得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑