Linux驱动开发入门:30分钟从零编写可加载内核模块
这类主题最怕一上来就讲内核源码、宏内核微内核,把新手直接劝退。我做了十多年嵌入式,带过不少新人,发现驱动开发真正卡住人的往往不是内核多复杂,而是第一步怎么把环境搭起来、第一个模块怎么编译加载、出了问题怎么查。这篇文章不绕远,直接带你从零写一个能实际加载、能看到打印、能卸载的驱动模块,把编译、加载、卸载、调试这几个关键环节的坑先踩一遍。
适合谁看?如果你已经会用 Linux 基本命令,写过 C 程序,但一提到“内核编程”、“驱动开发”就觉得神秘,不知道从哪下手,那这篇就是为你写的。我会假设你手头有一台能跑 Linux 的机器(实体机或虚拟机都行),有 root 权限,并且愿意动手敲命令。
最核心的价值就一点:让你在 30 分钟内,看到自己写的代码以内核模块的形式跑起来,并理解背后“为什么”要这么写、这么编译、这么加载。有了这个基础,你再去看那些复杂的驱动框架、设备树、子系统,才不会发懵。
1. 先别急着写代码,把环境和编译链条搞清楚
很多人一上来就复制一段驱动代码,然后make报一堆错,接着就开始查各种依赖,折腾半天还没看到输出。我的建议是:先确保你的环境能编译出内核模块,再谈怎么写驱动。这个顺序不能乱。
1.1 确认你的 Linux 系统版本和内核头文件
驱动模块是内核的一部分,编译它需要用到当前运行内核对应的头文件和构建脚本。第一步,先看系统信息:
uname -r这个命令会输出类似5.15.0-91-generic的结果,这就是你当前运行的内核版本。接下来,你需要安装对应版本的linux-headers包。不同发行版命令不同:
- Ubuntu/Debian:
sudo apt update sudo apt install linux-headers-$(uname -r) - CentOS/RHEL/Fedora:
sudo yum install kernel-devel-$(uname -r) # 或 sudo dnf install kernel-devel-$(uname -r) - Arch Linux:
sudo pacman -S linux-headers
安装完成后,检查头文件是否存在:
ls /lib/modules/$(uname -r)/build这个build目录通常是一个符号链接,指向/usr/src/linux-headers-$(uname -r)。如果这个目录存在,并且里面有Makefile、Kconfig、scripts等子目录,说明头文件安装正确。
注意:有些云服务器或定制系统可能没有对应的 headers 包,或者
uname -r输出的版本和仓库里的版本对不上。这时候你可能需要自己下载内核源码并编译,但那属于进阶操作。对于第一次接触驱动开发,我更建议在本地虚拟机或实体机上操作,避免环境问题消耗太多时间。
1.2 准备一个独立的目录和最简单的 Makefile
驱动模块的编译和普通 C 程序不同,它需要内核的构建系统(kbuild)来参与。你不需要自己写复杂的编译命令,而是写一个简单的Makefile,告诉 kbuild 你要编译什么。
新建一个目录,比如~/myfirstdriver,在里面创建两个文件:hello.c和Makefile。
先看hello.c,这是你的第一个内核模块:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple hello world kernel module"); MODULE_VERSION("0.1"); static int __init hello_init(void) { printk(KERN_INFO "Hello, world! Driver loaded.\n"); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO "Goodbye, world! Driver unloaded.\n"); } module_init(hello_init); module_exit(hello_exit);这段代码做了几件事:
- 包含必要的内核头文件:
init.h、module.h、kernel.h。 - 用
MODULE_*宏声明模块的许可证、作者、描述和版本。许可证必须声明,否则加载时会有警告。最常见的是GPL。 - 定义两个函数:
hello_init和hello_exit,分别对应模块加载和卸载时要执行的代码。 printk是内核里的“printf”,用于输出日志。KERN_INFO是日志级别,表示普通信息。module_init和module_exit是宏,它们告诉内核:hello_init是入口函数,hello_exit是退出函数。
再看Makefile:
obj-m += hello.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean这个Makefile的关键在于-C参数。它告诉make:“先切换到内核的构建目录(/lib/modules/$(uname -r)/build),然后读取那里的顶层 Makefile,并在当前目录(M=$(PWD))执行modules目标。”这样,编译工作就交给了内核的构建系统,它会自动处理架构、编译器标志、依赖关系等复杂问题。
obj-m += hello.o表示要编译一个名为hello.ko的内核模块,源文件是hello.c。如果你有多个源文件,比如hello.c和helper.c,可以写成obj-m += hello.o和hello-objs := hello.o helper.o。
1.3 第一次编译:看输出,别只看最后一行
在~/myfirstdriver目录下,直接运行make:
make如果一切正常,你会看到类似这样的输出:
make -C /lib/modules/5.15.0-91-generic/build M=/home/yourname/myfirstdriver modules make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic' CC [M] /home/yourname/myfirstdriver/hello.o MODPOST /home/yourname/myfirstdriver/Module.symvers CC [M] /home/yourname/myfirstdriver/hello.mod.o LD [M] /home/yourname/myfirstdriver/hello.ko make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'重点看几个文件是否生成:
hello.ko:最终的内核模块文件。hello.mod.c、hello.mod.o:模块信息文件。Module.symvers:符号版本文件。.hello.ko.cmd、.hello.o.cmd等隐藏文件:编译命令记录。
如果编译报错,最常见的原因有:
- 头文件路径不对:确认
/lib/modules/$(uname -r)/build存在且是有效的内核源码目录。 - 权限问题:普通用户可能无法读取某些内核头文件。确保你是用
sudo安装的 headers,并且当前用户有读取权限。 - 编译器版本不匹配:内核模块需要用系统默认的
gcc编译,如果你自己安装了其他版本的 gcc,可能需要切换。 - 架构不匹配:在 x86 主机上编译 ARM 的模块,需要交叉编译工具链,这里先不展开。
第一次编译成功,hello.ko文件生成,你就完成了环境验证。接下来才是加载和卸载。
2. 加载、卸载、看日志:理解模块的生命周期
编译出.ko文件只是第一步,让它跑起来才是关键。加载和卸载模块需要 root 权限,因为这会直接操作内核。
2.1 加载模块:insmod 和 modprobe 的区别
最直接的加载命令是insmod(insert module):
sudo insmod hello.ko运行后,似乎什么也没发生。这是因为printk的输出默认不会直接打印到终端,而是送到了内核日志缓冲区。你需要用dmesg命令查看:
dmesg | tail -5你应该能看到类似这样的输出:
[ 1234.567890] Hello, world! Driver loaded.这说明你的模块已经成功加载,并且hello_init函数被执行了。
除了insmod,还有一个更常用的命令modprobe。它和insmod的主要区别是:
insmod:只加载指定的.ko文件,不处理依赖。如果模块 A 依赖模块 B,你需要先手动insmod B.ko,再insmod A.ko。modprobe:会自动处理依赖关系。它会从/lib/modules/$(uname -r)目录下查找模块,并递归加载所有依赖。同时,它还会读取模块的别名、参数等信息。
对于我们自己写的简单模块,用insmod就够了。但在生产环境或使用标准内核模块时,modprobe更安全、更方便。
加载后,可以用lsmod查看当前已加载的模块:
lsmod | grep hello输出会显示模块名、占用内存大小、被引用次数等信息。
2.2 卸载模块:rmmod 和模块状态
卸载模块用rmmod:
sudo rmmod hello注意,这里用的是模块名hello,而不是文件名hello.ko。模块名是源码中通过MODULE_LICENSE等宏隐式定义的,默认就是源文件的基础名(去掉.c)。
卸载后,再次查看内核日志:
dmesg | tail -5你会看到新增了一行:
[ 1234.678901] Goodbye, world! Driver unloaded.这说明hello_exit函数也被成功执行了。
这里有个关键点:卸载模块时,内核会检查模块的“引用计数”。如果其他模块或内核正在使用这个模块提供的功能(比如调用了它导出的函数),引用计数会大于 0,rmmod会失败,并提示Module hello is in use。我们的第一个模块没有导出任何符号,也没有被其他模块依赖,所以可以正常卸载。
2.3 理解 printk 和内核日志级别
你可能注意到,printk的输出没有直接出现在终端上。这是因为内核日志有优先级机制。printk的第一个参数就是优先级,比如KERN_INFO、KERN_ERR、KERN_DEBUG等。
优先级从高到低:
KERN_EMERG:系统不可用KERN_ALERT:需要立即行动KERN_CRIT:紧急情况KERN_ERR:错误条件KERN_WARNING:警告条件KERN_NOTICE:正常但重要的情况KERN_INFO:信息性消息(我们用的这个)KERN_DEBUG:调试级消息
默认情况下,只有优先级高于KERN_INFO(即KERN_WARNING及以上)的消息才会直接打印到控制台。我们的KERN_INFO消息只会进入日志缓冲区,需要用dmesg查看。
你可以通过修改/proc/sys/kernel/printk来调整控制台日志级别,但作为驱动开发者,更常见的做法是:
- 开发时用
dmesg -w实时跟踪日志:dmesg -w会持续显示新的内核日志,方便调试。 - 使用
KERN_ERR或KERN_WARNING打印关键错误:这样错误信息能直接显示在终端,更容易被发现。 - 通过
syslog或journalctl查看系统日志:在生产环境中,内核日志会被转发到系统日志服务。
2.4 模块参数:让模块在加载时接收配置
很多时候,我们希望模块在加载时能接受一些参数,比如调试开关、设备地址、缓冲区大小等。这可以通过模块参数实现。
修改hello.c,增加参数支持:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple hello world kernel module with parameters"); MODULE_VERSION("0.2"); static char *whom = "world"; static int howmany = 1; module_param(whom, charp, S_IRUGO); module_param(howmany, int, S_IRUGO); MODULE_PARM_DESC(whom, "The name to greet"); MODULE_PARM_DESC(howmany, "Number of times to greet"); static int __init hello_init(void) { int i; for (i = 0; i < howmany; i++) printk(KERN_INFO "Hello, %s! Driver loaded.\n", whom); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO "Goodbye, %s! Driver unloaded.\n", whom); } module_init(hello_init); module_exit(hello_exit);新增的部分:
whom和howmany是两个静态变量,作为模块参数。module_param宏声明参数:第一个是变量名,第二个是类型(charp表示字符指针,int表示整数),第三个是权限(S_IRUGO表示所有人可读)。MODULE_PARM_DESC为参数提供描述,加载模块时可以通过modinfo查看。
重新编译并加载:
make sudo rmmod hello # 如果之前已加载,先卸载 sudo insmod hello.ko whom="Linux" howmany=3 dmesg | tail -5输出应该类似:
[ 1234.789012] Hello, Linux! Driver loaded. [ 1234.789013] Hello, Linux! Driver loaded. [ 1234.789014] Hello, Linux! Driver loaded.如果不指定参数,则使用默认值(whom="world",howmany=1)。
查看模块信息:
modinfo hello.ko你会看到参数描述出现在输出中。
模块参数是驱动调试和配置的重要手段。比如,你可以通过一个debug参数控制是否打印调试信息,而不需要重新编译模块。
3. 从模块到驱动:理解字符设备驱动的基本框架
上面的hello.ko只是一个内核模块,还不是真正的“驱动”。驱动的主要任务是管理硬件设备,为用户空间提供访问接口。在 Linux 中,最常见的驱动类型是字符设备驱动,它提供字节流式的访问,比如键盘、鼠标、串口等。
3.1 字符设备驱动的核心要素
一个最简单的字符设备驱动需要实现以下几个部分:
- 设备号(dev_t):内核用主设备号(major)和次设备号(minor)来唯一标识一个设备。主设备号对应一类驱动,次设备号对应该类驱动下的具体设备。
- 文件操作结构体(struct file_operations):定义设备支持的操作,如
open、read、write、release(close)、ioctl等。驱动开发者需要实现这些函数指针。 - 注册与注销函数:在模块初始化时向内核注册设备,在模块退出时注销设备。
- 设备节点(device node):在
/dev目录下的一个文件,用户空间通过这个文件与驱动交互。驱动注册后,需要手动或自动创建节点。
3.2 实现一个简单的“零设备”驱动
我们来实现一个最简单的字符设备驱动:它不对应任何真实硬件,只是在内核中分配一段内存,用户可以通过读写/dev/zero_dev来操作这段内存。这有点像/dev/zero,但我们会加入自己的逻辑。
新建文件zero_dev.c:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> // 文件操作结构体 #include <linux/cdev.h> // 字符设备结构 #include <linux/slab.h> // kmalloc, kfree #include <linux/uaccess.h> // copy_to_user, copy_from_user MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple zero character device driver"); MODULE_VERSION("0.1"); #define DEVICE_NAME "zero_dev" #define BUFFER_SIZE 1024 static int major_num = 0; // 主设备号,0 表示动态分配 static struct cdev zero_cdev; // 字符设备结构 static char *device_buffer = NULL; // 设备内存缓冲区 // 文件操作函数 static int zero_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "zero_dev: device opened\n"); return 0; } static int zero_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "zero_dev: device closed\n"); return 0; } static ssize_t zero_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t bytes_to_read; int ret; // 计算实际可读取的字节数 if (*offset >= BUFFER_SIZE) return 0; // 已经读到末尾 bytes_to_read = min(count, (size_t)(BUFFER_SIZE - *offset)); // 将内核缓冲区数据复制到用户空间 if (copy_to_user(buf, device_buffer + *offset, bytes_to_read)) { printk(KERN_ERR "zero_dev: failed to copy data to user\n"); return -EFAULT; } *offset += bytes_to_read; printk(KERN_INFO "zero_dev: read %zu bytes from offset %lld\n", bytes_to_read, *offset); return bytes_to_read; } static ssize_t zero_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { size_t bytes_to_write; int ret; // 计算实际可写入的字节数 if (*offset >= BUFFER_SIZE) return -ENOSPC; // 缓冲区已满 bytes_to_write = min(count, (size_t)(BUFFER_SIZE - *offset)); // 将用户空间数据复制到内核缓冲区 if (copy_from_user(device_buffer + *offset, buf, bytes_to_write)) { printk(KERN_ERR "zero_dev: failed to copy data from user\n"); return -EFAULT; } *offset += bytes_to_write; printk(KERN_INFO "zero_dev: wrote %zu bytes to offset %lld\n", bytes_to_write, *offset); return bytes_to_write; } // 文件操作结构体,定义设备支持的操作 static struct file_operations zero_fops = { .owner = THIS_MODULE, .open = zero_open, .release = zero_release, .read = zero_read, .write = zero_write, }; static int __init zero_init(void) { dev_t dev_num; int ret; // 1. 动态分配设备号 ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { printk(KERN_ERR "zero_dev: failed to allocate device number\n"); return ret; } major_num = MAJOR(dev_num); printk(KERN_INFO "zero_dev: allocated major number %d\n", major_num); // 2. 分配内核缓冲区 device_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL); if (!device_buffer) { printk(KERN_ERR "zero_dev: failed to allocate buffer\n"); ret = -ENOMEM; goto fail_buffer; } memset(device_buffer, 0, BUFFER_SIZE); // 初始化为零 // 3. 初始化并添加字符设备 cdev_init(&zero_cdev, &zero_fops); zero_cdev.owner = THIS_MODULE; ret = cdev_add(&zero_cdev, dev_num, 1); if (ret < 0) { printk(KERN_ERR "zero_dev: failed to add cdev\n"); goto fail_cdev; } printk(KERN_INFO "zero_dev: driver loaded successfully\n"); return 0; fail_cdev: kfree(device_buffer); fail_buffer: unregister_chrdev_region(dev_num, 1); return ret; } static void __exit zero_exit(void) { dev_t dev_num = MKDEV(major_num, 0); // 1. 删除字符设备 cdev_del(&zero_cdev); // 2. 释放缓冲区 kfree(device_buffer); // 3. 释放设备号 unregister_chrdev_region(dev_num, 1); printk(KERN_INFO "zero_dev: driver unloaded\n"); } module_init(zero_init); module_exit(zero_exit);这个驱动做了以下几件事:
- 在
zero_init中:- 调用
alloc_chrdev_region动态申请一个设备号(主设备号由内核分配,次设备号从 0 开始)。 - 用
kmalloc分配一块内核内存作为缓冲区,并初始化为 0。 - 用
cdev_init初始化字符设备结构体,绑定file_operations。 - 用
cdev_add将设备添加到内核。
- 调用
- 实现了
open、release、read、write四个基本操作。read:将内核缓冲区的数据复制到用户空间。write:将用户空间的数据复制到内核缓冲区。- 使用了
copy_to_user和copy_from_user在内核和用户空间之间安全地复制数据。
- 在
zero_exit中,按相反顺序释放资源:删除设备、释放缓冲区、释放设备号。
3.3 编译、加载、创建设备节点
修改Makefile,支持编译多个模块:
obj-m += hello.o obj-m += zero_dev.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean编译:
make加载模块:
sudo insmod zero_dev.ko查看内核日志,确认加载成功:
dmesg | tail -5输出应包含:
[ 1234.901234] zero_dev: allocated major number 248 [ 1234.901235] zero_dev: driver loaded successfully注意major number可能不同,内核会动态分配一个未被使用的主设备号。
现在驱动已经加载,但用户空间还无法访问,因为/dev下没有对应的设备节点。我们需要手动创建:
# 先查看分配的主设备号 cat /proc/devices | grep zero_dev # 输出类似:248 zero_dev # 创建设备节点,主设备号替换为实际的,次设备号为 0 sudo mknod /dev/zero_dev c 248 0 sudo chmod 666 /dev/zero_dev # 允许所有用户读写3.4 测试驱动:从用户空间读写
现在可以用普通用户命令测试驱动了。
先写数据到设备:
echo "Hello from userspace" > /dev/zero_dev查看内核日志:
dmesg | tail -5会看到类似:
[ 1234.912345] zero_dev: device opened [ 1234.912346] zero_dev: wrote 21 bytes to offset 0 [ 1234.912347] zero_dev: device closed再读数据:
dd if=/dev/zero_dev bs=1 count=50 2>/dev/null | od -c输出会显示缓冲区的前 50 个字节(ASCII 字符形式)。因为我们的缓冲区初始化为 0,且write写入了数据,所以你会看到Hello from userspace后面跟着一堆空字符(\0)。
你也可以用cat读取全部内容:
cat /dev/zero_dev | od -c | head -203.5 自动创建设备节点
手动mknod不方便,而且主设备号是动态分配的。更好的做法是让驱动加载时自动创建设备节点。这需要用到udev机制(现代 Linux 发行版都使用systemd-udev)。
简单来说,当驱动调用cdev_add注册设备后,内核会向用户空间发送一个uevent。udev守护进程收到事件后,会根据规则在/dev下创建设备节点。
要让udev正确创建节点,我们需要在驱动中创建一个class和device。修改zero_init函数:
#include <linux/device.h> // 新增头文件 static struct class *zero_class = NULL; static struct device *zero_device = NULL; static int __init zero_init(void) { dev_t dev_num; int ret; // 1. 动态分配设备号 ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { printk(KERN_ERR "zero_dev: failed to allocate device number\n"); return ret; } major_num = MAJOR(dev_num); printk(KERN_INFO "zero_dev: allocated major number %d\n", major_num); // 2. 分配内核缓冲区 device_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL); if (!device_buffer) { printk(KERN_ERR "zero_dev: failed to allocate buffer\n"); ret = -ENOMEM; goto fail_buffer; } memset(device_buffer, 0, BUFFER_SIZE); // 3. 初始化并添加字符设备 cdev_init(&zero_cdev, &zero_fops); zero_cdev.owner = THIS_MODULE; ret = cdev_add(&zero_cdev, dev_num, 1); if (ret < 0) { printk(KERN_ERR "zero_dev: failed to add cdev\n"); goto fail_cdev; } // 4. 创建设备类 zero_class = class_create(THIS_MODULE, "zero_class"); if (IS_ERR(zero_class)) { printk(KERN_ERR "zero_dev: failed to create class\n"); ret = PTR_ERR(zero_class); goto fail_class; } // 5. 创建设备节点 zero_device = device_create(zero_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(zero_device)) { printk(KERN_ERR "zero_dev: failed to create device\n"); ret = PTR_ERR(zero_device); goto fail_device; } printk(KERN_INFO "zero_dev: driver loaded successfully\n"); return 0; fail_device: class_destroy(zero_class); fail_class: cdev_del(&zero_cdev); fail_cdev: kfree(device_buffer); fail_buffer: unregister_chrdev_region(dev_num, 1); return ret; } static void __exit zero_exit(void) { dev_t dev_num = MKDEV(major_num, 0); // 删除设备节点和类 if (zero_device) device_destroy(zero_class, dev_num); if (zero_class) class_destroy(zero_class); // 删除字符设备 cdev_del(&zero_cdev); // 释放缓冲区 kfree(device_buffer); // 释放设备号 unregister_chrdev_region(dev_num, 1); printk(KERN_INFO "zero_dev: driver unloaded\n"); }重新编译加载:
make sudo rmmod zero_dev # 如果之前已加载 sudo insmod zero_dev.ko现在检查/dev目录:
ls -l /dev/zero_dev你应该能看到设备节点已经自动创建,并且权限正确(通常是crw-rw-rw-)。这样就不需要手动mknod了。
4. 调试与排查:驱动开发中最常遇到的几个坑
驱动开发比普通应用开发更容易出问题,因为你的代码运行在内核空间,一个错误可能导致系统崩溃(内核 panic)。下面是我在实际项目中总结的几个常见坑点和排查方法。
4.1 编译错误:头文件找不到或版本不匹配
现象:make时报错,提示linux/module.h: No such file or directory或类似信息。
排查顺序:
- 确认内核头文件已安装:
ls /lib/modules/$(uname -r)/build,确保目录存在且不是空目录。 - 确认编译器版本:
gcc --version,内核模块需要用系统默认的 gcc 编译。如果你有多个 gcc 版本,可能需要update-alternatives --config gcc切换。 - 确认架构匹配:在 x86 主机上编译 x86 模块,在 ARM 开发板上编译 ARM 模块。交叉编译需要设置
ARCH和CROSS_COMPILE环境变量。 - 清理并重新编译:有时候旧的编译产物会导致问题,
make clean后再make。
4.2 加载失败:insmod 报错
现象:sudo insmod xxx.ko失败,提示Invalid module format、Unknown symbol或Operation not permitted。
排查顺序:
- 模块与内核版本不匹配:最常见的原因。确保你编译模块用的内核头文件版本和当前运行的内核版本完全一致。用
uname -r和cat /lib/modules/$(uname -r)/build/include/config/kernel.release对比。 - 缺少依赖符号:如果提示
Unknown symbol,说明模块引用了其他模块或内核导出的符号,但该符号不存在。用modprobe --show-depends查看依赖,或手动加载依赖模块。对于自己写的模块,检查是否用EXPORT_SYMBOL正确导出了符号。 - 权限问题:
insmod需要 root 权限。确认用了sudo。某些安全策略(如 SELinux、AppArmor)可能限制模块加载,可以暂时禁用或调整策略。 - 模块已加载:
lsmod | grep xxx查看是否已加载。已加载的模块需要先rmmod。
4.3 运行时报错:内核崩溃或打印异常
现象:模块加载成功,但执行某个操作(如read、write)时系统崩溃,或dmesg中出现Oops、general protection fault等错误。
排查顺序:
- 检查指针和内存访问:内核空间不能直接解引用用户空间指针,必须用
copy_from_user、copy_to_user。确保缓冲区大小正确,没有越界。 - 检查资源分配与释放:
kmalloc后是否检查返回值?kfree的参数是否有效?cdev_add和cdev_del是否配对?class_create和class_destroy是否配对? - 检查锁和并发:如果多个进程同时访问驱动,是否需要加锁?简单场景可以用
mutex,但要注意死锁。 - 检查打印信息:在关键路径加
printk,用KERN_ERR级别确保能及时看到。但注意不要在高频路径打印太多,会影响性能。 - 使用内核调试工具:
kdb、kgdb、systemtap等,但这些工具学习成本较高。对于初学者,printk是最直接的调试手段。
4.4 用户空间访问失败:权限或节点问题
现象:驱动加载成功,/dev/xxx也存在,但用户程序无法打开设备。
排查顺序:
- 检查设备节点权限:
ls -l /dev/xxx,确认权限是crw-rw-rw-或crw-rw----。如果是后者,普通用户需要属于该设备所属组。 - 检查设备节点类型:字符设备是
c,块设备是b。确认mknod或device_create时类型正确。 - 检查主次设备号:
cat /proc/devices查看驱动注册的主设备号,ls -l /dev/xxx查看设备节点的主次设备号,两者必须一致。 - 检查驱动中的
open函数:是否返回了错误码?比如权限检查失败返回-EACCES。 - 用
strace跟踪用户程序:strace -e open,openat your_program,看open系统调用返回什么错误码。
4.5 内存泄漏:模块卸载后资源未释放
现象:多次加载卸载模块后,系统内存逐渐减少,或cat /proc/slabinfo看到某些对象数量只增不减。
排查顺序:
- 确保每个
alloc都有对应的free:kmalloc/kfree,alloc_chrdev_region/unregister_chrdev_region,cdev_add/cdev_del,class_create/class_destroy,device_create/device_destroy。 - 检查错误处理路径:在
init函数中,如果中间步骤失败,需要逆向释放之前申请的资源。上面的示例代码用了goto标签实现回滚。 - 使用内核内存检测工具:
kmemleak、kasan等,可以在编译内核时启用,帮助检测内存泄漏。
4.6 性能问题:copy_to/from_user 开销大
现象:驱动能工作,但读写速度慢,CPU 占用高。
排查顺序:
- 减少用户空间与内核空间的数据拷贝次数:如果可能,一次传输更大块的数据,而不是多次小数据。
- 检查是否在临界路径中加了锁:不必要的锁会降低并发性能。
- 检查
printk频率:printk会消耗 CPU 时间,尤其是在高速数据路径中。生产版本可以考虑用pr_debug或动态调试(dynamic debug)。 - 考虑使用更高效的接口:对于大量数据,可以考虑
mmap让用户空间直接映射内核缓冲区,避免拷贝。但mmap实现更复杂,需要处理虚拟内存映射。
4.7 系统升级后驱动失效
现象:系统内核升级后,原来编译的模块无法加载。
原因:内核模块与内核版本紧密绑定。内核升级后,模块需要重新编译。
解决方案:
- 每次内核升级后重新编译驱动:这是最直接的方法。可以写一个
dkms(Dynamic Kernel Module Support)包,让系统在升级内核时自动重新编译模块。 - 保持内核头文件版本与运行内核一致:如果手动编译,确保安装的是新内核对应的
linux-headers。 - 考虑将驱动提交到上游内核:如果你的驱动是通用硬件驱动,可以尝试提交到内核主线,这样以后就不需要单独编译了。
驱动开发是一个需要耐心和细心的工作。我的建议是:每次只增加一个小功能,编译测试通过后再继续。不要一次性写几百行代码然后一起调试。多用printk打印关键变量和流程,先确保逻辑正确,再考虑性能和优化。
最后,驱动开发离不开内核源码。当你遇到不熟悉的函数或数据结构时,最好的老师是内核源码本身。你可以通过apt-get source linux-image-$(uname -r)下载当前内核的源码,或者在线浏览 kernel.org 的源码。多看、多模仿、多实践,慢慢就能掌握这门手艺。
