
1. 先搞明白设备驱动到底在解决什么问题开始写驱动之前我建议你先想清楚一个事情Linux设备驱动开发本质上就是在做“内核和硬件之间的翻译官”。硬件厂商不会主动告诉你芯片内部怎么工作Linux内核也不关心你用的是哪家的传感器、哪颗LED、哪块显示屏。驱动要做的就是把硬件的能力“翻译”成内核能理解的统一接口让上层的应用程序不用关心底层硬件差异直接通过open、read、write、ioctl这些标准调用就能操作设备。很多初学者一上来就抱着《Linux设备驱动程序》第三版啃结果看了两周还在讲内存管理代码一行没写过最后放弃了。我的建议是换个思路先写一个最简单的字符设备驱动把整个开发、编译、加载、测试、调试的流程跑通再回头看那些理论你会发现书里的每个概念都能对上号。这篇博文我会从一个实际可运行的字符设备驱动入手把开发环境搭建、驱动框架、核心数据结构、并发控制、调试方法到实际项目中会遇到的问题全部过一遍。文章主要面向三类人一是正在学嵌入式Linux、准备做驱动方向的同学二是从单片机或应用开发转内核开发的工程师三是在工作中需要维护或移植设备驱动的朋友。看完这篇文章你应该能独立写出一个可以实际使用的字符设备驱动并且知道怎么排查驱动开发中最常见的那几类问题。我默认你已经具备一定的C语言基础懂一点Linux基本操作会用vim、gcc、make如果这些还不熟建议先把这两块补一补。内核编程对C语言的要求比应用编程高指针、内存、链表这些至少要达到熟练的程度。2. 内核态和用户态的边界是驱动开发的底层逻辑2.1 为什么驱动必须跑在内核态在Linux系统里CPU分了两个特权级别用户态和内核态。应用程序跑在用户态权限非常有限不能直接访问硬件寄存器、不能执行特权指令内核和驱动程序跑在内核态拥有完全的控制权。你可能会问为什么不能让应用程序直接操作硬件原因很简单——安全和稳定。如果任何程序都能直接写物理内存、直接控制硬件一个Bug就能让整个系统崩溃一个恶意程序就能拿到所有权限。所以操作系统把所有敏感操作收归内核统一管理应用程序需要操作硬件时通过系统调用open、read、write等陷入内核由驱动代码去完成真正的硬件交互。这里要注意驱动开发中大部分崩溃都是致命的。应用程序段错误顶多进程挂掉内核里一个空指针解引用就是整个系统Panic所有数据可能丢失。所以写驱动代码你的心态必须调整这是拿着手术刀在做手术不是拿着菜刀在切菜。2.2 设备文件是用户态和内核态的桥梁我们平时在Linux下操作硬件最常见的方式是通过设备文件。比如操作串口就是操作/dev/ttyS0操作磁盘就是操作/dev/sda。设备文件本身不包含数据它只是一个“入口”当你对它调用open、read、write时VFS虚拟文件系统会根据设备文件的类型和主设备号找到对应的驱动程序然后调用驱动里注册好的处理函数。设备文件分为两类字符设备和块设备。字符设备以字节流的方式读写数据比如串口、键盘、传感器块设备以块为单位读写数据比如硬盘、SSD、SD卡。驱动开发入门几乎都是从字符设备开始的因为字符设备的模型简单直接不需要处理复杂的页缓存、请求队列这些机制。3. 开发环境准备一套能跑的最小内核环境3.1 环境选型思路Linux设备驱动开发第一步准备环境。我不建议在实体机上直接搞万一驱动写崩了系统起不来你连救都救不回来。用虚拟机最稳妥VMware或者VirtualBox都行。操作系统嘛Ubuntu 20.04或者22.04 LTS版本用了这么多年稳定、资料多、遇到问题搜得到答案。内核版本方面我建议选一个4.x或5.x的长期支持版本比如5.4、5.10。为什么要强调内核版本因为驱动代码和内核版本强相关不同版本之间API可能微调你写驱动的时候用到的结构体、函数接口必须和你实际编译运行的内核版本完全一致。网上很多教程是老的代码在新内核上编译不过去就是版本差异导致的。我实际用的环境是VMware跑Ubuntu 20.04内核5.4.0这个组合文档最全、踩坑最少。3.2 内核源码和编译工具的安装驱动不是普通的应用程序不能直接gcc编译就完事它需要和内核源码树配合编译生成.ko模块文件然后由内核动态加载。所以必须先把内核源码准备好。驱动开发其实不需要完整编译整个内核但你需要安装内核头文件也就是/lib/modules/$(uname -r)/build这个目录。最简单的方式是直接安装系统自带的内核头文件包sudo apt update sudo apt install linux-headers-$(uname -r) sudo apt install build-essential装完之后检查一下ls /lib/modules/$(uname -r)/build如果里面能看到Makefile说明头文件就绪。对于初学者我建议就用这种方式不要在环境上花太多时间。如果之后要做内核源码级的调试再单独下载完整内核源码编译不迟。我自己调试驱动时通常还会额外装两个东西一个是minicom用来和串口设备通信测试一个是sysfsutils方便查看设备信息。但这些不是必须的后面用到再说。3.3 编译Hello World模块验证环境可用环境准备好之后先用一个最简单的模块验证工具链是否正常。创建hello.c#include linux/init.h #include linux/module.h static int __init hello_init(void) { printk(KERN_INFO Hello, kernel!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, kernel!\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello module);对应的Makefileobj-m : hello.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 sudo insmod hello.ko sudo dmesg | tail sudo rmmod hello sudo dmesg | tail如果dmesg里看到了Hello和Goodbye字样说明你的驱动开发环境已经通了。这个流程看似简单但它是后面所有开发的基础务必跑通再进行下一步。注意printk输出的日志不会直接打到终端而是写到内核日志缓冲区要用dmesg查看。生产环境中日志级别控制很重要调试阶段可以先把级别设低一点让日志直接输出到控制台。4. 一个完整的字符设备驱动从设计到实现4.1 需求定义我要做什么下面我用一个实际的例子带你把一个字符设备驱动完完整整写出来。需求很简单实现一个虚拟的字符设备应用层可以write数据进去可以read数据出来。这个设备内部用一块内存缓冲区保存数据最多存4KB。我们给它起个名字叫memdev。别看需求简单它涵盖了字符设备驱动的全部核心要素设备号管理、file_operations接口实现、内核内存分配、数据拷贝、并发控制、设备文件自动创建、模块卸载清理。把这一套吃透写真实的硬件驱动时你只需要把read和write里的逻辑换成实际操作硬件寄存器的代码就行了。4.2 设备号驱动的“身份证”每个字符设备在内核里都有一个唯一的设备号由主设备号和次设备号组成。主设备号标识驱动程序次设备号标识同一个驱动管理的不同设备。设备号的分配有两种方式静态分配和动态分配。静态分配就是你指定一个主设备号调用register_chrdev_region注册好处是设备号固定生成的设备文件名固定适合设备数量明确的产品动态分配是让内核帮你挑一个没用的主设备号用alloc_chrdev_region好处是不会冲突缺点是设备号不固定。我建议优先使用动态分配尤其是你自己学习调试的时候。原因很简单你不知道你的系统里哪些主设备号已经占用了硬编码一个可能冲突一冲突加载就失败。#define DEVICE_NAME memdev #define CLASS_NAME mem_class static int major_num; static struct class *mem_class NULL; static struct device *mem_device NULL; static int __init memdev_init(void) { dev_t dev_num; /* 动态分配设备号 */ alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); major_num MAJOR(dev_num); /* 注册设备驱动 */ cdev_init(mem_cdev, mem_fops); mem_cdev.owner THIS_MODULE; cdev_add(mem_cdev, dev_num, 1); /* 自动创建设备文件 */ mem_class class_create(THIS_MODULE, CLASS_NAME); mem_device device_create(mem_class, NULL, dev_num, NULL, DEVICE_NAME); printk(KERN_INFO memdev: major number %d\n, major_num); return 0; }注意上面的class_create和device_create这两个函数的作用是让系统在/dev目录下自动生成设备文件同时生成/sys/class/xxx相关的信息。不用它们也可以你可以手动mknod /dev/memdev c 主设备号 0来创建设备文件但自动创建省事得多强烈推荐。4.3 file_operations驱动对用户态暴露的操作入口file_operations结构体是字符设备驱动的核心它定义了应用层对设备文件执行open、read、write、release等操作时内核会调用驱动里的哪些函数。这是一个巨大的结构体我们只需要把用到的函数指针赋值剩下的保持NULL即可。static const struct file_operations mem_fops { .owner THIS_MODULE, .open mem_open, .read mem_read, .write mem_write, .release mem_release, };结构体里的每个回调函数内核都有统一的调用约定。比如read函数原型是ssize_t (*read) (struct file *filp, char __user *buf, size_t count, loff_t *f_pos);参数的含义filp是文件指针对应应用层open返回的文件描述符在内核中的表示buf是用户态缓冲区指针注意这个指针绝对不能在内核态直接解引用必须用copy_to_user拷贝数据过去count是应用层请求读取的字节数f_pos是文件读写位置。我见过很多初学者直接在mem_read里写*buf xxx然后模块一加载就重启虚拟机。原因就是直接访问了用户态指针内核态没有权限一访问就触发缺页异常驱动没做好异常处理整个内核Panic。4.4 缓冲区管理和数据拷贝设备内部需要一个缓冲区。这里我用内核的kzalloc分配一块4KB的内存static char *device_buffer; #define BUFFER_SIZE 4096 static int __init memdev_init(void) { device_buffer kzalloc(BUFFER_SIZE, GFP_KERNEL); if (!device_buffer) { printk(KERN_ERR memdev: failed to allocate buffer\n); return -ENOMEM; } return 0; }kzalloc和用户态的malloc类似但它是内核态的分配函数并且把分配的内存清零。GFP_KERNEL标志表示这是常规分配可能会睡眠所以不能用在中断上下文。read和write函数的实现是驱动开发中真正的难点因为涉及用户态和内核态的数据拷贝static ssize_t mem_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { size_t len; if (*f_pos BUFFER_SIZE) return 0; len min(count, (size_t)(BUFFER_SIZE - *f_pos)); if (copy_to_user(buf, device_buffer *f_pos, len)) { return -EFAULT; } *f_pos len; return len; } static ssize_t mem_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { size_t len; len min(count, (size_t)(BUFFER_SIZE - *f_pos)); if (len 0) return -ENOSPC; if (copy_from_user(device_buffer *f_pos, buf, len)) { return -EFAULT; } *f_pos len; return len; }copy_to_user和copy_from_user为什么不用memcpy因为它们内部会检查用户态缓冲区是否合法并且在拷贝过程中处理缺页整个过程是安全的。memcpy直接拷贝的话一旦用户态指针非法就是内核崩溃。4.5 并发控制驱动工程师最容易栽的坑刚才的代码看着能用但有个致命的问题没有并发保护。如果两个进程同时调用mem_write会出现什么情况两个进程同一时刻操作*f_pos和device_buffer数据就会错乱。驱动为什么要特别关注并发因为应用层的多线程、多进程是常态而内核可能在任意时刻被抢占硬件中断也可能随时打断执行。如果在这些情况下共享数据没有保护后果就是脏数据、内核崩溃而且崩溃的位置可能和问题代码的位置完全不同排查极其痛苦。最简单有效的保护方式就是互斥锁。我在这个驱动里加上一个mutex把read和write的临界区保护起来static struct mutex mem_mutex; static ssize_t mem_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { size_t len; mutex_lock(mem_mutex); len min(count, (size_t)(BUFFER_SIZE - *f_pos)); if (len 0) { mutex_unlock(mem_mutex); return -ENOSPC; } if (copy_from_user(device_buffer *f_pos, buf, len)) { mutex_unlock(mem_mutex); return -EFAULT; } *f_pos len; mutex_unlock(mem_mutex); return len; }mutex的使用规则很简单lock之后到unlock之间是临界区其他尝试lock的线程会在这里睡眠等待。这个驱动只有一个共享缓冲区一个锁就够了。真实驱动里设备寄存器、中断共享数据、环形缓冲区各有各的同步需求锁的粒度需要根据实际场景设计锁得太粗性能上不去锁得太细容易死锁。4.6 清理函数和模块卸载模块卸载时必须把加载时申请的资源全部释放。顺序和加载时相反static void __exit memdev_exit(void) { device_destroy(mem_class, MKDEV(major_num, 0)); class_destroy(mem_class); if (mem_cdev.owner) cdev_del(mem_cdev); unregister_chrdev_region(MKDEV(major_num, 0), 1); kfree(device_buffer); mutex_destroy(mem_mutex); printk(KERN_INFO memdev: module unloaded\n); }资源清理这块没什么技术含量但漏了任何一个就是内核内存泄漏。我见过有人忘了cdev_del结果模块卸载后再加载insmod报设备号冲突还有人忘了kfree加载卸载循环几百次之后系统内存被吃光。4.7 完整代码和测试方法整个驱动的完整代码就不分段贴了核心部分在上面已经全部覆盖。你把上面的代码按顺序拼起来加上头文件包含就是一份能用的驱动代码。编译加载之后测试方法如下sudo insmod memdev.ko ls -l /dev/memdev echo hello driver /dev/memdev cat /dev/memdevecho写入时open、write、release会被依次调用cat读取时open、read、release会被调用。如果一切都正常cat会输出你写入的内容。再用一个简单的C程序测试多进程并发读写可以检查锁是否正常工作// test_memdev.c #include stdio.h #include fcntl.h #include unistd.h #include string.h int main(void) { int fd open(/dev/memdev, O_RDWR); if (fd 0) { perror(open); return -1; } char buf[1024]; for (int i 0; i 100; i) { memset(buf, A (i % 26), sizeof(buf)); write(fd, buf, sizeof(buf)); lseek(fd, 0, SEEK_SET); read(fd, buf, sizeof(buf)); printf(iter %d: first byte %c\n, i, buf[0]); } close(fd); return 0; }编译运行这个程序如果锁没加对你会发现buf[0]经常不是预期的值。这个测试虽然简单但能非常直观地暴露并发问题。5. 中断、锁和延迟真实驱动绕不开的硬骨头5.1 中断处理程序为什么要分为上下半部字符设备驱动的框架熟悉之后下一个真正的拦路虎是中断处理。大部分真实硬件网卡、声卡、GPIO按键都是通过中断通知CPU“有事发生”的。中断处理程序有个铁律一定要快。因为中断处理程序执行期间当前进程被暂停其他中断也可能被屏蔽处理太慢会直接影响系统的实时性和吞吐量。但现实中很多设备的事件处理并不能短时间完成比如一个网卡收到一包数据要解析协议、投递到协议栈这些操作可能耗时较长。为了解决这个矛盾Linux把中断处理拆成了两部分上半部top half和下半部bottom half。上半部就是中断处理函数它在中断上下文执行只做最紧急的事情读取硬件状态、清中断标志、把需要稍后处理的数据保存到缓冲区然后立即返回整个过程要求微秒级完成。下半部负责真正的耗时处理比如数据解析、协议处理、唤醒等待队列它在更宽松的环境下执行可以被打断。下半部的实现方式有几种比如tasklet、工作队列、软中断。tasklet的特点是执行在软中断上下文不能睡眠工作队列执行在进程上下文可以睡眠。选择哪种取决于你要处理的任务是否能睡眠。初学者记住能在进程上下文做的事优先工作队列对实时性要求高、处理内容简单的用tasklet。5.2 自旋锁和互斥锁怎么选内核里还有一类锁叫自旋锁。它和mutex最大的区别是拿不到锁的时候不会睡眠而是在原地“自旋”也就是忙等待直到拿到锁。什么时候用自旋锁最重要的场景是中中断处理程序里。因为中断处理程序不能睡眠它没有进程上下文不能用mutex只能用自旋锁保护共享数据。另一个判断依据是临界区的大小如果临界区只是几个寄存器的读写、一个变量的累加几十个周期就能完成用自旋锁比mutex更高效因为mutex的睡眠唤醒开销在这种场景下反而是浪费。但是自旋锁有个大坑临界区绝对不能睡眠否则系统直接死锁。再一个自旋锁在单核CPU上其实是个空操作因为关了内核抢占就等于保护了但这只是优化细节编码上还是要按多核的标准来写。我实际开发中总结的经验是能用mutex就不用自旋锁尽量在进程上下文中完成复杂操作把中断里只留最小的处理逻辑。混用锁的时候要极端小心锁的顺序ABBA就是最常见的死锁模式。5.3 内核延迟忙等还是睡眠等待驱动开发中经常需要做延迟等待比如需要等待某个硬件操作完成或者在两个寄存器操作之间需要保证一定的时序。内核里延迟大致分两类忙等待和睡眠等待。忙等待用udelay和mdelay它们让CPU空转期间不释放CPU适合延迟时间很短微秒级的场景比如等待某个寄存器状态翻转睡眠等待用usleep_range、msleep、schedule_timeout等它们会让出CPU适合延迟时间较长毫秒级以上、对系统吞吐量有要求的场景。这里要特别提一个很多人踩的坑在中断上下文或者持有自旋锁的情况下绝对不能用sleep类函数。中断上下文不能睡眠持有自旋锁时睡眠会触发系统直接死锁或者崩溃。我调试过的很多崩溃最后定位都是这个问题。需要延迟先用代码注释标清楚当前上下文再选择对应的延迟函数这是非常必要的编码习惯。6. 调试设备驱动的实用技巧6.1 printk最朴素但最有效的调试手段内核调试手段有很多kprobe、ftrace、kgdb、QEMU调试但说句实在的我用得最多的还是printk。为什么简单直接不需要额外的硬件和调试工具随时随地打印日志。printk的使用和printf类似但它有日志级别控制。常用的是KERN_INFO、KERN_DEBUG、KERN_ERR这几个。日志级别的作用是只有级别高于当前控制台日志级别的消息才会打印到终端但不管是否打印到终端所有消息都会进入dmesg缓冲区。调试阶段我建议把printk的级别设为KERN_DEBUG然后通过调整/proc/sys/kernel/printk来让所有日志直接打到控制台echo 8 /proc/sys/kernel/printk这样你就能在系统日志里实时看到驱动的输出配合printk在关键路径上打点进入函数、关键分支、错误处理、离开函数。驱动的执行流程就一目了然了。但printk也有局限。你不能在中断处理程序里做复杂的打印因为printk本身可能睡眠还有printk输出会拖慢实时性生产环境一定要去掉或者用动态调试替代。6.2 使用QEMU调试内核模块当你遇到一个比较难缠的Bug反复看代码、加printk都定位不了的时候我推荐用QEMU GDB的方式调试内核。QEMU可以模拟一台完整的机器来运行Linux内核配合GDB可以设置断点、单步执行、查看内核变量。这个方法适合研究内核源码、定位复杂的驱动问题缺点是启动配置比较复杂学习和使用成本高。平时用printk就够了但建议花点时间搭一套QEMU调试环境关键时刻能救命。6.3 常见崩溃信息怎么看内核崩溃时的信息虽然看着吓人但其实是定位问题的最好线索。最常见的崩溃是NULL pointer dereference内核会打印出完整的调用栈、寄存器状态、出错地址。看崩溃信息重点看这几个部分第一Oops/Panic提示信息中给出的出错的函数名和行号第二调用栈Call Trace从栈里的函数调用序列可以判断出是谁调了谁第三寄存器信息中的指令指针RIP对应哪个函数一眼便知。配合objdump或者addr2line把RIP地址转换成源码行号问题基本就明确了。7. 驱动开发中的常见问题速查下面是我整理的一份常见问题表覆盖了驱动开发入门阶段能遇到的绝大多数问题。这些问题我全都实际踩过列出来帮你避坑。现象大概率原因解决办法insmod提示Operation not permitted模块签名校验失败或权限不足关闭Secure Boot获取root权限insmod提示Unknown symbol模块依赖的符号未导出检查内核配置和依赖模块rmmod提示Module is in use设备文件仍被占用检查应用的fd执行lsof /dev/xxx内核Panic提示NULL pointer解引用了空指针检查kmalloc/kzalloc的返回值copy_to_user返回非零用户态缓冲区非法不要传NULL检查用户态内存是否有效并发读写数据错乱缺少锁保护加mutex/spinlock保护共享数据中断处理函数中睡眠触发内核崩溃确认上下文改用到tasklet或工作队列cat设备文件卡死read函数没有正确返回0检查EOF条件写设备文件返回No space left缓冲区满了但没到EOF检查写偏移和缓冲区长度计算反复insmod/rmmod后内存耗尽模块退出未释放内存检查exit函数是否完整释放资源dmesg无任何输出printk级别高于控制台级别调整/proc/sys/kernel/printk这里重点说一下第一个问题。现在很多发行版默认开了Secure Boot加载未签名的内核模块会被拒绝。解决方式一般是在BIOS里关闭Secure Boot或者给模块签名。个人开发调试建议直接关掉生产环境才需要正规的签名流程。8. 学习驱动开发我的几条实际建议根据我这十几年的项目经验和带新人的心得最后分享几个方向性的建议。第一先做减法再做加法。不要一上来就想写一个网卡驱动或者GPU驱动那是很多年后的事情。老老实实把字符设备驱动吃透把read、write、ioctl搞明白再去碰中断、DMA、块设备、网络设备每一步都找一个小项目练手。字符设备驱动用熟了其他类型的驱动无非是换了一套框架和接口内核的底层逻辑是相通的。第二手头常备内核源码。在线看代码虽然方便但内核源码树里有大量的头文件、宏定义、示例代码离线查询速度快得多。而且你会发现很多驱动开发的疑问看代码就能解决比在网上搜答案更直接准确。第三一定要会看内核文档。内核自带Documentation目录下有大量驱动开发相关文档还有内核源码里的注释都是极好的参考资料。遇到一个函数不知道用法直接在内核源码里找它的实现比看二手博客靠谱得多。第四养成写驱动时随手记日志的习惯。很多驱动问题在开发阶段是随机出现的可能在加载第100次才暴露没有日志几乎没法定位。我在代码里喜欢加一个“进入/退出函数”级别的DEBUG日志上线前再统一清理或者用dynamic_debug控制开关这样既保证了开发效率也兼顾了生产环境的日志干净度。第五如果条件允许买一块真实的开发板树莓派、IMX6ULL、STM32MP1都行接上GPIO、传感器、显示屏这些外设写真正的硬件驱动。虚拟设备驱动只能让你学会框架真实硬件的体验完全不同——你要面对读寄存器时序不对、中断触发方式不对、数据线接反等各种现实问题这些才是驱动开发工作的常态也是这门手艺真正的价值所在。我这些年带过的不少人应用层写得很溜一到驱动就发怵但真正深入进去之后他们会发现驱动开发并不比应用开发更难只是规则更多、更底层一旦掌握了套路成就感是完全不一样的。希望这篇文章能帮你迈过这道坎享受控制硬件底层的那种独特乐趣。