☰
Linux驱动开发实战:从字符设备到设备树与并发保护
2026/10/20 14:04:00 网站建设 项目流程

从第一次在自己的电脑上成功加载一个自己写的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 构建过程和加载测试:看着简单,坑在细节里

把它编进内核模块,最简单的方式是写一个Makefile:

obj-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) clean

make之后会生成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,但这个信息太粗糙,需要结合日志确认。三步法我前面提到过,这里再展开一点:

  1. 先看dmesg | tail -n 50,找到mydrv相关的输出,很多时候 init 函数里早已打印了关键错误原因。
  2. 确认主设备号冲突,cat /proc/devices里是否有同名设备或者相同主设备号。
  3. 确认模块依赖,有些模块依赖其他模块导出的符号,用modprobe加载它通常会回溯依赖关系,比手动insmod要省心。

特别要提一句:如果init函数返回错误,模块加载会中止,但如果在init里已经做了一部分资源注册,就要在对应的错误分支里执行清理,否则会出现“模块加载失败,但部分资源已被占用”的诡异现象。这也是我在 review 代码时特别爱挑的地方。

5.2 遇到 oops 不要慌,按这个顺序读日志

驱动最让人头大的就是 oops。内核日志里有一段BUG: unable to handle kernel paging request at virtual address,后面跟着 PC 指针、调用栈、寄存器现场。读 oops 信息,顺序应该是:

  1. 先看 error code 和解码后的 fault address,判断是空指针、越界还是非法访问。
  2. 再看 PC 所在函数,如果符号表完整,addr2line可以直接定位到源码行。
  3. 最后结合调用栈,理清这条路从哪个系统调用进来的,再对照代码逻辑确定是哪个操作崩了。

我常用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/gdb+kgdb死锁、panic、内存损坏学习成本高,但处理疑难杂症必备

实际开发中,我建议先靠printk和/proc把逻辑打通,这个阶段效率是最高的;当遇到偶发性问题、时序问题、内存踩踏问题时,再引入 kprobe、ftrace 这类动态追踪手段。调试工具的切换,本质是从“猜”到“验证”的过程,越复杂的问题越不能靠猜。

写完这些,我觉得驱动开发和用户态编程最大的不同,就是它逼着你同时理解硬件、内核和上层应用三者的交互。很多同事问怎么学,我给的建议始终是先搭好开发环境、写好一个字符设备骨架、跑通设备树和 probe,然后再去挑战具体外设。遇到问题多看/var/log/kern.log,多用/proc和/sys验证状态,多给代码加注释,时刻想着“这段代码在 SMP 环境下会不会出问题”,这些习惯比掌握某个具体外设的函数接口要重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询