如果你的电脑跑过 Linux,却感觉内核和驱动的大门始终没有真正敞开;如果你已经能熟练操作ls、cd、grep,但面对/dev目录下的设备文件、面对insmod加载的.ko模块时依然觉得朦胧;如果你在嵌入式 Linux 项目里反复被“内核态、字符设备、中断、并发”这些词卡住,那么设备驱动开发,就是你从“会用 Linux”跨向“懂 Linux”的那道分水岭。
最近《手把手教你学Linux设备驱动开发》正式出版,很多读者把它称为“硬核宝典”。书籍的价值在于把零散的内核知识体系化,但真正能落地的,还是你自己动手写出来的第一个驱动。这篇文章不打算只谈书的内容,而是围绕 Linux 设备驱动开发整理一套从环境搭建、原理拆解到完整字符设备实战、常见报错排查的闭环路径。文中会给出可复制的代码和 Makefile,也会讲清楚每一步为什么这样做。无论你是嵌入式方向的学生、正在转行 Linux 开发的工程师,还是工作中需要接触内核模块的服务端开发者,都可以跟着走一遍。
1. 设备驱动先搞懂这 4 个背景概念
1.1 什么是设备驱动
用一句通俗的话说:设备驱动是操作系统与硬件设备之间的“翻译官”。应用层程序不会直接去读写寄存器、操作中断控制器,它只需要调用open、read、write、ioctl这些标准接口;驱动则在内核态把这些调用翻译成硬件能够理解的寄存器操作、时序控制、数据收发动作。
从 Linux 内核源码树的视角看,drivers/目录是整个内核中体量最大的子系统之一,里面包含了gpio、i2c、spi、usb、net、input、char等大量细分目录。这也说明设备驱动不是一种单一的编写技巧,而是围绕“内核如何管理硬件”的一整套工程方法。
1.2 用户态与内核态的边界
驱动代码运行在内核态,这带来两个关键差别:
- 访问权限不同:内核态可以直接访问物理地址、中断寄存器、DMA 控制器;用户态只能通过系统调用进入内核。
- 出错后果不同:用户态程序段错误通常只影响自身进程;内核态代码一旦崩溃,大概率直接 panic,整个系统重启。
因此在学习驱动开发时,你写的每一行代码都要比普通应用代码更谨慎。最常见的心态变化是:从“只要能跑就行”变成“先确认有没有锁、有没有内存泄漏、有没有非法指针”。
1.3 设备文件与主次设备号
驱动加载成功后,系统需要在/dev下创建设备文件,应用程序通过读写这个文件来访问硬件。设备文件本身不存储数据,它只是一个访问入口。设备文件通过主设备号区分驱动类型,通过次设备号区分同一类驱动下的不同设备实例。
例如常见的:
ls -l /dev/ttyS0输出中会有类似c 4 64的字样,c表示字符设备,4是主设备号,64是次设备号。理解主次设备号之后,再看mknod、udev规则、/proc/devices这些内容,就会自然串联起来。
1.4 设备驱动的三种基本类型
Linux 设备模型把设备驱动分为三种基本类型:
| 类型 | 数据访问特征 | 典型设备 | 常见接口 |
|---|---|---|---|
| 字符设备 | 按字节流顺序读写 | 串口、GPIO、传感器 | open/read/write/ioctl |
| 块设备 | 按数据块随机读写 | 硬盘、SD 卡、eMMC | submit_bio/request |
| 网络设备 | 以数据包为单位收发 | 网卡、WiFi 模块 | net_device_ops |
对于初学者,字符设备是性价比最高的入门方向。它逻辑清晰、调试方便、可以直接用echo和cat验证效果。本文的实战部分也以字符设备驱动为例。
2. 环境准备与版本说明
2.1 开发机与目标机的选择
设备驱动开发通常采用“交叉开发”模式:
- 开发主机:安装 Linux 的 PC 或虚拟机,负责编写代码、编译内核模块。
- 目标机:运行 Linux 的开发板,也可以是同一台 PC 上的 Linux 系统,用于加载和测试模块。
如果暂时没有开发板,完全可以在自己的 Linux 主机上完成本文示例。字符设备驱动不依赖特定硬件,可以在通用 x86_64 Linux 上编译加载。若你的目标是嵌入式场景,再无缝切换到交叉编译工具链。
需要注意,驱动模块必须与当前运行内核的版本、配置完全匹配,否则insmod会报version magic错误。
2.2 内核头文件与编译工具
编译内核模块不需要完整内核源码,但必须安装与当前内核版本完全对应的头文件包。以 Ubuntu/Debian 系为例:
uname -r sudo apt-get update sudo apt-get install linux-headers-$(uname -r) sudo apt-get install build-essential如果桌面版内核头文件包不完整,可以额外安装:
sudo apt-get install linux-headers-genericCentOS/RHEL/Fedora 系使用:
uname -r sudo yum install kernel-devel kernel-headers这里的核心是保证/lib/modules/$(uname -r)/build这个软链接指向有效的内核头文件目录。编译模块时,Kbuild 系统会通过它找到内核源码树。
2.3 内核源码是否必须完整下载
如果你的目的只是编译一个独立模块,那么只安装linux-headers-*就足够了。但是,如果你需要修改内核配置、编译整个内核、调试内核本身,那么需要下载完整内核源码:
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.tar.xz tar -xf linux-6.6.tar.xz cd linux-6.6 make menuconfig make -j$(nproc)如果暂时无法下载或编译整棵树,不要卡在这一步。本文后面的实战只需要内核头文件就能运行。
2.4 本文环境说明
为了演示代码,后续所有命令默认在以下环境执行:
- 操作系统:Ubuntu 22.04 LTS(x86_64)
- 内核版本:5.15 系列(以实际
uname -r输出为准) - 编译器:gcc 11
- 不需要开发板,不需要完整内核源码
版本不必和我完全一致,重点在于方法。只要你的内核头文件安装正确,示例代码都可以复现。
3. Linux 设备驱动开发核心原理解拆解
3.1 内核模块的加载与卸载
Linux 支持在运行时动态加载模块,模块文件通常以.ko(kernel object)结尾。最简单的内核模块包含两个入口:
module_init:模块加载时执行的初始化函数。module_exit:模块卸载时执行的清理函数。
一个“空跑”的模块如下:
// 文件路径:hello_drv/hello.c #include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init hello_init(void) { printk(KERN_INFO "hello_init: module loaded\n"); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO "hello_exit: module removed\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple hello world kernel module");这里有三个细节值得新手注意:
__init和__exit宏:用于告诉内核,这些函数在初始化/卸载完成后可以释放内存,降低内存占用。printk不是printf:它输出到内核日志,而不是终端。查看方式通常是dmesg或journalctl -k。MODULE_LICENSE("GPL")不是可选项:如果不声明,模块加载时会提示module license 'unspecified' taints kernel。它影响内核符号导出、以及部分 GPL 导出符号的使用。
对应的 Makefile:
# 文件路径:hello_drv/Makefile obj-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 dmesg | tail -5 sudo rmmod hello dmesg | tail -5预期输出中可以看到hello_init: module loaded和hello_exit: module removed。这就是驱动的“最小可运行闭环”。
3.2 字符设备驱动的核心数据结构
实际设备驱动不会只打印日志,它需要让应用程序能够通过文件接口访问。这就要用到两个核心结构体:
struct file_operations:描述驱动支持的open、read、write、release、unlocked_ioctl等操作。struct cdev:字符设备在内核中的抽象对象。
梳理它们的关系:
应用层 open("/dev/mydrv") → 内核虚拟文件系统 → 找到字符设备 cdev → 调用 cdev 中记录的 file_operations 回调 → 最终执行驱动中实现的 xxx_open()所以在驱动初始化时,需要完成三件事:
- 分配设备号:
alloc_chrdev_region或register_chrdev_region。 - 初始化 cdev:
cdev_init。 - 添加 cdev 到内核:
cdev_add。
卸载时顺序相反:cdev_del后释放设备号。
3.3 设备号的分配方式
设备号分为动态分配和静态指定两种方式:
- 动态分配:调用
alloc_chrdev_region,由内核自动分配一个可用的主设备号。优点是不会冲突,缺点是设备号不固定,需要读取/proc/devices获取。 - 静态指定:调用
register_chrdev_region,自己指定主设备号。优点是可以固定/dev节点,缺点是如果设备号被占用,注册会失败。
对于学习和产品原型,优先使用动态分配。这样可以在多台环境上无冲突加载,逻辑也更干净。
3.4 file_operations 中的 read 和 write 语义
当用户在应用层执行read(fd, buf, count)时,内核最终会调用驱动里的.read方法。这个方法有几个约束:
- 数据从内核态拷贝到用户态,必须使用
copy_to_user,不能直接memcpy。 - 返回值表示实际读到的字节数,如果为 0,应用层会认为读到文件末尾。
- 如果暂时没有数据可读,驱动可以选择阻塞睡眠或返回错误码。
同理,write方法需要调用copy_from_user把用户数据拷贝到内核空间。原因是内核态不能直接访问用户态指针,必须经过安全检查,防止用户传入非法地址导致内核崩溃。
3.5 从字符设备到 platform 驱动的进阶路径
字符设备只是“接口层”。真实硬件驱动通常还要和设备总线、设备树打交道,这就引入platform_driver。它把一个驱动与设备树中的compatible字符串匹配,匹配成功后调用probe函数完成硬件初始化。
学习顺序建议:
- 先写纯字符设备驱动,不涉及具体硬件,把 file_operations 机制吃透。
- 再写 platform 驱动,让驱动和设备树产生关联。
- 然后接触中断、内核定时器、tasklet、工作队列。
- 最后进阶到具体子系统:输入子系统、IIO 子系统、网络驱动等。
这条路很长,但第一步永远是“写一个能在开发板上加载的字符设备”。
4. 完整实战:手写一个可读写的字符设备驱动
下面进入本文的主菜:实现一个字符设备驱动,它内部维护一个 4KB 的缓冲区,支持read、write、open、release,并且支持通过ioctl清空缓冲区。这个例子不依赖任何具体硬件,因此在 PC 和开发板上都可以运行。
4.1 数据结构设计
驱动的核心是一个缓冲区,以及用于保护并发访问的互斥锁:
#define BUF_LEN 4096 static char device_buf[BUF_LEN]; static int buf_len = 0; static int major = 0; static struct cdev my_cdev; static struct class *my_class = NULL; static DEFINE_MUTEX(my_mutex);DEFINE_MUTEX是内核提供的静态互斥锁定义宏。之所以要加锁,是因为read和write可能在多进程下并发调用,如果两个进程同时写缓冲区,如果没有锁保护,可能会互相覆盖产生竞态。
4.2 完整驱动代码
// 文件路径:mychr_drv/mychr.c #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> #include <linux/mutex.h> #include <linux/slab.h> #define BUF_LEN 4096 #define MY_IOCTL_CLEAR _IO(0xAA, 0x01) static char *device_buf; static int buf_len = 0; static int major = 0; static struct cdev my_cdev; static struct class *my_class = NULL; static DEFINE_MUTEX(my_mutex); static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "mychr: open\n"); return 0; } static int my_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "mychr: release\n"); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { ssize_t ret; if (mutex_lock_interruptible(&my_mutex)) return -ERESTARTSYS; if (*ppos >= buf_len) { mutex_unlock(&my_mutex); return 0; } if (count > buf_len - *ppos) count = buf_len - *ppos; if (copy_to_user(buf, device_buf + *ppos, count)) { mutex_unlock(&my_mutex); return -EFAULT; } *ppos += count; ret = count; mutex_unlock(&my_mutex); return ret; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { if (mutex_lock_interruptible(&my_mutex)) return -ERESTARTSYS; if (count > BUF_LEN - *ppos) count = BUF_LEN - *ppos; if (copy_from_user(device_buf + *ppos, buf, count)) { mutex_unlock(&my_mutex); return -EFAULT; } *ppos += count; if (*ppos > buf_len) buf_len = *ppos; mutex_unlock(&my_mutex); return count; } static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case MY_IOCTL_CLEAR: mutex_lock(&my_mutex); memset(device_buf, 0, BUF_LEN); buf_len = 0; mutex_unlock(&my_mutex); printk(KERN_INFO "mychr: buffer cleared\n"); break; default: return -EINVAL; } return 0; } static struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .release = my_release, .read = my_read, .write = my_write, .unlocked_ioctl = my_ioctl, }; static int __init mychr_init(void) { dev_t dev; if (alloc_chrdev_region(&dev, 0, 1, "mychr") < 0) { printk(KERN_ERR "mychr: alloc_chrdev_region failed\n"); return -1; } major = MAJOR(dev); cdev_init(&my_cdev, &my_fops); my_cdev.owner = THIS_MODULE; if (cdev_add(&my_cdev, dev, 1) < 0) { printk(KERN_ERR "mychr: cdev_add failed\n"); unregister_chrdev_region(dev, 1); return -1; } my_class = class_create(THIS_MODULE, "mychr_class"); if (IS_ERR(my_class)) { printk(KERN_ERR "mychr: class_create failed\n"); cdev_del(&my_cdev); unregister_chrdev_region(dev, 1); return PTR_ERR(my_class); } device_create(my_class, NULL, dev, NULL, "mychr"); device_buf = kzalloc(BUF_LEN, GFP_KERNEL); if (!device_buf) { printk(KERN_ERR "mychr: kzalloc failed\n"); device_destroy(my_class, dev); class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev, 1); return -ENOMEM; } printk(KERN_INFO "mychr: init success, major=%d\n", major); return 0; } static void __exit mychr_exit(void) { dev_t dev = MKDEV(major, 0); kfree(device_buf); device_destroy(my_class, dev); class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(dev, 1); printk(KERN_INFO "mychr: exit\n"); } module_init(mychr_init); module_exit(mychr_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Linux Drv Learner"); MODULE_DESCRIPTION("A simple char device driver with ioctl");4.3 Makefile 与编译
# 文件路径:mychr_drv/Makefile obj-m := mychr.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如果编译成功,当前目录下会出现mychr.ko文件。
4.4 加载驱动并创建设备节点
由于驱动代码中使用了class_create和device_create,在桌面 Linux 环境下,udev会自动在/dev/mychr创建设备节点。但如果你在最小系统或开发板上没有 udev,也可以手动创建:
sudo insmod mychr.ko cat /proc/devices | grep mychr假设输出得到主设备号为240(实际数字以你的系统为准),然后手动创建设备节点:
sudo mknod /dev/mychr c 240 0 sudo chmod 666 /dev/mychr如果 udev 正常工作,可以直接检查:
ls -l /dev/mychr4.5 编写用户态测试程序
驱动加载成功后,使用一个简单的 C 程序验证读写和 ioctl:
// 文件路径:mychr_drv/test_mychr.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #define MY_IOCTL_CLEAR _IO(0xAA, 0x01) int main(void) { int fd; char wbuf[] = "hello, linux device driver!"; char rbuf[128] = {0}; ssize_t n; fd = open("/dev/mychr", O_RDWR); if (fd < 0) { perror("open"); return 1; } n = write(fd, wbuf, strlen(wbuf)); printf("write %zd bytes\n", n); lseek(fd, 0, SEEK_SET); n = read(fd, rbuf, sizeof(rbuf) - 1); printf("read %zd bytes: %s\n", n, rbuf); ioctl(fd, MY_IOCTL_CLEAR, 0); lseek(fd, 0, SEEK_SET); n = read(fd, rbuf, sizeof(rbuf) - 1); printf("after clear, read %zd bytes\n", n); close(fd); return 0; }编译并运行:
gcc -o test_mychr test_mychr.c ./test_mychr预期输出类似:
write 28 bytes read 28 bytes: hello, linux device driver! after clear, read 0 bytes到这里,你已经基本掌握了字符设备驱动的完整套路:从模块框架到设备号分配、cdev 注册、文件操作实现、用户态交互、ioctl 清空操作。
4.6 运行验证与日志查看
如果读写结果不符合预期,优先查看内核日志:
dmesg | tail -20你会看到驱动中printk输出的各类提示。这里的KERN_INFO、KERN_ERR只是日志级别,它们结合系统日志服务最终显示/存储会根据配置而定,不要依赖printf的方式去理解内核日志。
5. Linux 驱动开发常见问题与排查思路
驱动开发中最大的障碍往往不是语法,而是“编译不过”和“加载失败”这类环境问题。以下整理了新手最常见的几类问题。
5.1 编译阶段报错
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
linux/xxx.h: No such file or directory | 内核头文件未安装 | 执行apt-get install linux-headers-$(uname -r) |
version magic '5.15.0-...' should be '...' | 模块头文件版本与当前内核不一致 | 重新检查linux-headers包版本 |
| 编译时函数不存在 | 内核 API 版本差异 | 使用grep在内核头文件中确认 API 是否存在 |
make: *** No rule to make target 'modules' | KDIR路径无效 | 检查/lib/modules/$(uname -r)/build软链接 |
5.2 加载阶段报错
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
insmod: ERROR: could not insert module mychr.ko: Operation not permitted | 缺少 root 权限或 Secure Boot 限制 | 使用sudo;关闭 Secure Boot 或签名模块 |
insmod: ERROR: could not insert module: Unknown symbol | 依赖的其他模块未加载 | 先加载依赖模块,或检查Module.symvers |
insmod: ERROR: could not insert module: Invalid module format | 模块架构与内核架构不匹配 | 确认是否误用了交叉编译出来的模块 |
加载后没有/dev/mychr | 没有 udev 或设备创建失败 | 查看dmesg;手动mknod |
5.3 运行阶段异常
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
copy_to_user返回非 0 | 用户态指针无效 | 检查应用层传入的 buf 是否有效、count 是否过大 |
read 返回-EFAULT | 内存拷贝失败 | 确认 buf 有足够空间,并检查指针生命周期 |
| write 后 read 读到空 | 文件偏移没有复位 | 用户程序在 read 前执行lseek(fd, 0, SEEK_SET) |
| 多个进程同时读写数据错乱 | 未加锁或锁使用不当 | 使用mutex、spinlock或atomic保护共享数据 |
5.4 系统日志的使用建议
驱动开发过程中,printk是你最直接的调试手段。但注意生产环境不要留下过多无意义日志。调试阶段可以临时使用printk(KERN_DEBUG "xxx\n"),发布前建议删除或降级。
查看日志的常用命令:
dmesg dmesg | grep mychr journalctl -k -f如果日志太多干扰,可以只查看当前模块日志:
dmesg -w | grep -E "mychr"6. 设备驱动开发最佳实践与工程建议
6.1 内核编程规范
内核代码遵循 Linux kernel coding style。以下几点是初学者最容易忽略的:
- 缩进用 Tab,而不是空格。
- 函数名使用小写加下划线。
- 结构体通常小写加下划线,例如
my_drv_data。 - 注释风格使用
/* ... */而不是//。
虽然内核从 C99 开始也放宽了一些约定,但整体上保持旧风格能让代码更容易被内核社区接受。更重要的是,内核社区对代码格式的审查很严格,如果你未来想向上游提交补丁,格式是第一道关卡。
6.2 错误处理与资源释放
驱动的初始化函数中一旦某个步骤失败,要确保已经申请的资源全部释放。典型错误是:cdev_add成功了,但后续device_create失败时忘记cdev_del,导致模块卸载后/dev或设备号残留。
一个标准做法是使用goto式错误处理:
static int __init mychr_init(void) { dev_t dev; int ret; ret = alloc_chrdev_region(&dev, 0, 1, "mychr"); if (ret < 0) return ret; cdev_init(&my_cdev, &my_fops); ret = cdev_add(&my_cdev, dev, 1); if (ret < 0) goto err_unregister_region; my_class = class_create(THIS_MODULE, "mychr_class"); if (IS_ERR(my_class)) { ret = PTR_ERR(my_class); goto err_cdev_del; } device_buf = kzalloc(BUF_LEN, GFP_KERNEL); if (!device_buf) { ret = -ENOMEM; goto err_class_destroy; } device_create(my_class, dev, NULL, "mychr"); major = MAJOR(dev); return 0; err_class_destroy: class_destroy(my_class); err_cdev_del: cdev_del(&my_cdev); err_unregister_region: unregister_chrdev_region(dev, 1); return ret; }这种写法的好处是:错误处理和正常流程分离,后续增加新的初始化步骤时不容易遗漏释放。
6.3 并发与锁的选择
驱动可能运行在多个上下文中:进程上下文、中断上下文、软中断等。不要想当然地认为只有一个进程在调用驱动。
- 保护普通读写的临界区,使用
mutex。 - 如果临界区可能在中断上下文中执行,使用
spinlock。 - 对简单计数操作,优先
atomic_t。
原则是:互斥锁(mutex)可以睡眠,适合进程上下文;自旋锁(spinlock)不能睡眠,适合短临界区和中断上下文。选错锁会导致死锁或崩溃。
6.4 内存管理
内核内存分配和释放必须匹配:
kmalloc对应kfree。kzalloc对应kfree。vmalloc对应vfree。- 分配 DMA 缓冲区时使用
dma_alloc_coherent,对应dma_free_coherent。
不要在驱动中混用这些接口。此外,内核态分配内存时建议明确GFP_KERNEL还是GFP_ATOMIC:进程上下文可以睡眠,使用GFP_KERNEL;中断上下文不能睡眠,使用GFP_ATOMIC。
6.5 设备树与可移植性
从长远角度看,现代 Linux 设备驱动越来越依赖设备树(Device Tree)。设备树描述硬件资源的位置和属性,驱动在probe函数中读取设备树节点来获取寄存器地址、中断号等信息,而不是写死资源。这样的驱动更容易移植到不同主板上。
设备树中的compatible属性承担驱动与设备匹配的“身份证”作用:
mychr { compatible = "vendor,mychr"; reg = <0x0 0x1000>; };驱动侧对应声明:
static const struct of_device_id mychr_of_match[] = { { .compatible = "vendor,mychr", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mychr_of_match);当你在开发板上运行驱动时,这比手动加载模块更符合工程习惯。
6.6 调试与日志分级
调试驱动的常用手段包括:
- 使用
printk分级打印。 - 使用
ftrace跟踪内核函数调用。 - 使用
perf分析性能。 - 使用
/proc或debugfs导出运行时状态。 - 使用
kgdb在内核中打断点。
对于初学者,printk和dmesg已经足够解决绝大多数问题。正确做法是一开始就规划好转打印:驱动加载/卸载打一次,open/read/write/ioctl关键路径打一次,错误路径必须打。之后通过日志级别把调试信息区分开。
7. 学习路线与资源建议
《手把手教你学Linux设备驱动开发》这本书的价值在于它把入门需要的内核知识压缩成一条相对清晰的学习路径。但如果只是买书而不动手,效果会大打折扣。结合本文的实战,建议你按以下路线推进:
- 第 1 周:理解内核模块机制,跑通 hello 模块并尝试修改模块参数。
- 第 2 周:掌握字符设备驱动框架,写出带
read/write/ioctl的完整驱动。 - 第 3-4 周:在开发板上运行驱动,学习设备树基础,理解
probe流程。 - 第 5-8 周:学习中断、内核定时器、工作队列,尝试编写 GPIO/按键驱动。
- 第 9 周以后:进入具体子系统,例如输入子系统、IIO 子系统、LCD、触摸屏、网络驱动等。
这期间需要反复查阅的资料:
- Linux 内核源码(尤其是
drivers/内同类型驱动的实现)。 Documentation/driver-api目录下的内核文档。- Kernel Newbies 网站上的 API 变更信息。
驱动开发最忌“只读不写”。每学一个机制,就在内核源码里找一个相似驱动,把它的结构抄下来改成自己的功能。等你完整写过 3 到 5 个不重样的字符驱动、platform 驱动后,再回头看书中的中断、并发、内存管理章节,会发现概念开始“落地”了。
这也正是“硬核宝典”这类系统化书籍的意义:它们能帮你省去在博客和文档碎片之间来回跳转的时间,但你仍然要亲手敲完每一行代码。
设备驱动开发的路上没有太多捷径,但有一条明确的主线:先跑通最小模块,再深入机制,最后研究框架。本文从环境、原理到实战、排错给出了一条可执行的起步路径,如果你的手边已经有一台 Linux 机器,现在就可以从第 2 节开始安装内核头文件,然后编译第一个 hello 模块。真正写过一个驱动之后,你会发现自己对 Linux 的理解已经向前迈了一大步。