Linux驱动开发入门:从Hello World模块到虚拟字符设备驱动实践
🚀 30+款热门AI模型一站整合,DeepSeek/GLM/Qwen 随心用,限时 5 折。 👉 点击领海量免费额度
这类主题最怕一上来就讲内核架构、源码目录、编译系统,新手看完还是不知道从哪里动手。我建议换个顺序:先别管那些复杂概念,直接动手写一个能加载、能卸载、能打印日志的最小驱动模块,跑起来再说。
跑通之后,你自然就理解了模块是什么、怎么编译、怎么加载、怎么和内核交互。这时候再去看驱动框架、设备模型、并发控制,就知道每个部分到底在解决什么问题了。
下面我就按这个“先跑通,再理解”的顺序,带你走一遍 Linux 驱动开发最核心的落地流程。整个过程我会尽量避开那些一次用不上的理论,把重点放在环境、步骤、参数和实际会遇到的坑上。
1. 动手之前,先搞清楚“驱动”到底要做什么
很多人被“驱动”这个词吓住了,以为要操作硬件寄存器、要懂芯片手册。其实对于入门来说,你可以先把驱动理解成一个“内核态的程序”,它负责三件事:
- 向内核注册自己:告诉内核“我来了,我能管理某种设备”。
- 提供一组标准操作函数:比如打开(
open)、读取(read)、写入(write)、关闭(close)、控制(ioctl)等。用户程序通过系统调用最终会走到这些函数里。 - 在合适的时机被加载和卸载:通常是系统启动时加载,或者手动用命令加载。
你第一次写的驱动,完全可以不碰真实硬件。我们就写一个“虚拟字符设备驱动”,它不对应任何物理设备,只是在内存里划一块空间,让用户程序能像读写文件一样读写这块内存。这样做的好处是,你能集中精力理解驱动框架本身,不用分心去调试硬件。
1.1 你需要准备什么环境
别在物理机上直接折腾,万一模块写崩了可能导致内核恐慌(Kernel Panic),系统就起不来了。最稳妥的方式是用虚拟机。
虚拟机软件:VMware Workstation 或 VirtualBox 都行。
Linux 发行版:推荐 Ubuntu 20.04 LTS 或 22.04 LTS。它们内核版本较新,社区支持好,包管理器方便。别用太老的发行版,内核和工具链可能不匹配。
系统配置:给虚拟机分配至少 2 核 CPU、4GB 内存、30GB 硬盘空间。安装时记得勾选“安装 OpenSSH server”和“安装开发工具”,这样后面装编译环境省事。
内核头文件:这是编译驱动必须的。驱动是内核的一部分,编译时需要知道当前内核的数据结构、函数声明在哪里。在 Ubuntu 里,安装命令是:
sudo apt update sudo apt install linux-headers-$(uname -r)命令里的
$(uname -r)会自动获取你当前运行的内核版本号,然后安装对应版本的头文件包。装完后,头文件通常在/lib/modules/$(uname -r)/build这个链接指向的目录里。编译工具链:主要是
gcc和make。如果安装系统时没选开发工具,就手动装:sudo apt install gcc make
环境准备好后,先别急着写代码。打开终端,运行uname -r确认内核版本,再运行ls /lib/modules/$(uname -r)/build确认内核头文件目录存在。这两步没问题,后面编译才不会报找不到文件的错。
2. 从最小的“Hello World”模块开始
驱动开发的第一步不是写驱动,而是写一个能加载到内核的“模块”。模块就是一个可以动态加载和卸载的内核代码单元。我们先写一个除了打印日志什么也不干的模块,目标是掌握编译、加载、卸载、查看日志的完整流程。
2.1 编写模块源码
创建一个工作目录,比如~/driver_study,然后新建文件hello.c:
// hello.c - 最简单的内核模块 #include <linux/init.h> // 包含模块初始化和清理函数的宏 #include <linux/module.h> // 包含模块相关的基本宏和函数 #include <linux/kernel.h> // 包含内核打印函数 printk 等 // 模块加载时自动调用的函数 static int __init hello_init(void) { // printk 是内核空间的打印函数,类似于用户空间的 printf // KERN_INFO 是日志级别,表示普通信息。消息会输出到内核日志缓冲区。 printk(KERN_INFO "Hello, world! Driver module loaded.\n"); return 0; // 返回 0 表示初始化成功 } // 模块卸载时自动调用的函数 static void __exit hello_exit(void) { printk(KERN_INFO "Goodbye, world! Driver module unloaded.\n"); } // 以下宏用于告诉内核模块的入口和出口函数 module_init(hello_init); // 指定加载时调用的函数是 hello_init module_exit(hello_exit); // 指定卸载时调用的函数是 hello_exit // 模块的元信息 MODULE_LICENSE("GPL"); // 声明模块采用 GPL 许可证,必须要有,否则加载可能警告 MODULE_AUTHOR("Your Name"); // 作者信息 MODULE_DESCRIPTION("A simple hello world kernel module"); // 模块描述 MODULE_VERSION("0.1"); // 模块版本关键点解释:
__init和__exit是给函数打的标签,告诉内核这些函数只在加载/卸载时用一次,用完后可以释放它们占用的内存。printk的输出默认不会显示在终端上,而是写到了内核的环形日志缓冲区里。需要用dmesg命令查看。MODULE_LICENSE(“GPL”)必须写,而且最好是”GPL”。很多内核符号(函数、变量)只对 GPL 协议的模块导出,如果你的模块协议不对,加载时可能会报错“模块污染内核”,甚至无法使用某些内核功能。
2.2 编写 Makefile
内核模块不能用普通的gcc命令编译,必须用内核的构建系统(kbuild)。我们需要一个Makefile来告诉make工具如何调用kbuild。
在同一目录下创建Makefile(注意 M 大写):
# 指定内核源码目录,$(shell uname -r) 获取当前内核版本 KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build # 指定当前模块源码目录 PWD := $(shell pwd) # 默认目标:编译模块 obj-m := hello.o # 编译规则 all: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules # 清理规则 clean: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean关键点解释:
obj-m := hello.o:告诉内核构建系统,我们要把一个名为hello.o的目标文件构建成模块(m)。注意,这里的hello.o会自动由同名的hello.c源文件编译而来。$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules:这是核心命令。-C $(KERNEL_DIR):先切换到内核源码目录。M=$(PWD):告诉内核构建系统,模块的源码在$(PWD)(即当前目录)。modules:执行内核源码目录里Makefile中定义的modules目标,也就是编译模块。
- 这个
Makefile非常简单,但它隐藏了内核模块编译的所有复杂细节,比如处理内核依赖、符号表等。
2.3 编译、加载、卸载、查看日志
现在可以开始实操了。
编译模块: 在
~/driver_study目录下打开终端,直接运行make。make如果一切正常,你会看到类似下面的输出,并生成几个新文件,其中最重要的是
hello.ko(ko就是 kernel object,内核模块文件)。make -C /lib/modules/5.15.0-91-generic/build M=/home/yourname/driver_study modules make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic' CC [M] /home/yourname/driver_study/hello.o MODPOST /home/yourname/driver_study/Module.symvers CC [M] /home/yourname/driver_study/hello.mod.o LD [M] /home/yourname/driver_study/hello.ko make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'如果报错:最常见的是
Makefile:xxx: *** “No rule to make target ‘modules’. Stop.”。这几乎肯定是KERNEL_DIR路径不对。请再次用ls -l /lib/modules/$(uname -r)/build确认该目录存在且是一个有效的链接。加载模块: 加载模块需要 root 权限,因为这是向内核插入代码。
sudo insmod hello.ko命令执行后看起来什么都没发生(没有输出),这是正常的,因为
printk的消息在日志里。查看加载日志: 使用
dmesg命令查看内核日志。为了只看我们模块的消息,可以用grep过滤,或者看最后几行。sudo dmesg | tail -5 # 或者 sudo dmesg | grep “Hello”你应该能看到我们写的
”Hello, world! Driver module loaded.”这条信息。查看已加载模块:
lsmod | grep hello这个命令会列出所有已加载的模块,并用
grep过滤出包含 “hello” 的行。你应该能看到hello模块,以及它的大小和被谁使用(目前是0)。卸载模块:
sudo rmmod hello注意,这里用的是模块名
hello,而不是文件名hello.ko。查看卸载日志:
sudo dmesg | tail -5 # 或者 sudo dmesg | grep “Goodbye”你应该能看到
”Goodbye, world! Driver module unloaded.”。
恭喜!到这里,你已经完成了一个完整的内核模块“开发-编译-加载-卸载”循环。这个流程是所有驱动开发的基石。接下来,我们在这个模块里加入“设备”的概念。
3. 进阶:创建一个简单的字符设备驱动
字符设备(Character Device)是指以字节流形式被顺序访问的设备,比如键盘、鼠标、串口。我们创建一个虚拟的字符设备,用户程序可以像读写普通文件一样读写它。
3.1 驱动需要实现的核心结构
Linux 内核用struct file_operations这个结构体来抽象设备能做的操作。我们的驱动就是要实现这个结构体里的函数指针,然后把它注册给内核。
修改(或新建)mydev.c文件:
// mydev.c - 一个简单的字符设备驱动 #include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> // 包含 file_operations 结构体 #include <linux/cdev.h> // 包含字符设备结构体 cdev #include <linux/device.h> // 用于自动创建设备节点(可选但推荐) #include <linux/uaccess.h> // 包含 copy_to_user/copy_from_user #define DEVICE_NAME “mydev” // 设备名称 #define CLASS_NAME “myclass” // 设备类名称 static int major_num = 0; // 主设备号,0 表示动态分配 static struct class* mydev_class = NULL; static struct cdev my_cdev; // 我们用一个简单的全局数组模拟设备内存 static char device_buffer[1024]; static int buffer_index = 0; // 当用户程序执行 open() 系统调用打开设备文件时,这个函数被调用 static int mydev_open(struct inode *inodep, struct file *filep) { printk(KERN_INFO “mydev: Device has been opened.\n”); return 0; } // 当用户程序执行 close() 系统调用关闭设备文件时,这个函数被调用 static int mydev_release(struct inode *inodep, struct file *filep) { printk(KERN_INFO “mydev: Device has been closed.\n”); return 0; } // 当用户程序执行 read() 系统调用从设备文件读取时,这个函数被调用 static ssize_t mydev_read(struct file *filep, char __user *buffer, size_t len, loff_t *offset) { int bytes_to_copy; int ret; // 计算还能从设备缓冲区读取多少字节 bytes_to_copy = min((size_t)(buffer_index – *offset), len); if (bytes_to_copy <= 0) { return 0; // 表示 EOF (End Of File) } // 将内核空间的数据拷贝到用户空间 buffer ret = copy_to_user(buffer, device_buffer + *offset, bytes_to_copy); if (ret) { printk(KERN_ERR “mydev: Failed to copy %d bytes to user.\n”, ret); return -EFAULT; // 返回错误码 } printk(KERN_INFO “mydev: Sent %d bytes to user.\n”, bytes_to_copy); *offset += bytes_to_copy; // 更新文件偏移量 return bytes_to_copy; // 返回实际读取的字节数 } // 当用户程序执行 write() 系统调用向设备文件写入时,这个函数被调用 static ssize_t mydev_write(struct file *filep, const char __user *buffer, size_t len, loff_t *offset) { int bytes_to_copy; int ret; // 计算设备缓冲区还能写入多少字节 bytes_to_copy = min((size_t)(sizeof(device_buffer) – buffer_index), len); if (bytes_to_copy <= 0) { return -ENOMEM; // 设备缓冲区已满 } // 将用户空间 buffer 的数据拷贝到内核空间 ret = copy_from_user(device_buffer + buffer_index, buffer, bytes_to_copy); if (ret) { printk(KERN_ERR “mydev: Failed to copy %d bytes from user.\n”, ret); return -EFAULT; } printk(KERN_INFO “mydev: Received %d bytes from user.\n”, bytes_to_copy); buffer_index += bytes_to_copy; *offset += bytes_to_copy; return bytes_to_copy; // 返回实际写入的字节数 } // 定义设备支持的操作集合 static struct file_operations fops = { .owner = THIS_MODULE, .open = mydev_open, .release = mydev_release, .read = mydev_read, .write = mydev_write, }; // 模块初始化函数 static int __init mydev_init(void) { int ret; dev_t dev_num; printk(KERN_INFO “mydev: Initializing the device driver.\n”); // 1. 动态申请一个主设备号(和此设备号) ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { printk(KERN_ERR “mydev: Failed to allocate device number.\n”); return ret; } major_num = MAJOR(dev_num); // 提取主设备号 printk(KERN_INFO “mydev: Allocated major number %d.\n”, major_num); // 2. 初始化 cdev 结构体,并将其与 file_operations 关联 cdev_init(&my_cdev, &fops); my_cdev.owner = THIS_MODULE; // 3. 将 cdev 添加到内核系统 ret = cdev_add(&my_cdev, dev_num, 1); if (ret < 0) { printk(KERN_ERR “mydev: Failed to add cdev to system.\n”); unregister_chrdev_region(dev_num, 1); return ret; } // 4. (可选但推荐) 使用 udev/class 接口自动创建设备节点 mydev_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(mydev_class)) { printk(KERN_ERR “mydev: Failed to create device class.\n”); cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(mydev_class); } // 在 /dev 目录下创建设备文件节点 device_create(mydev_class, NULL, dev_num, NULL, DEVICE_NAME); printk(KERN_INFO “mydev: Device node created at /dev/%s.\n”, DEVICE_NAME); // 初始化设备缓冲区 memset(device_buffer, 0, sizeof(device_buffer)); buffer_index = 0; printk(KERN_INFO “mydev: Driver initialization successful.\n”); return 0; } // 模块清理函数 static void __exit mydev_exit(void) { dev_t dev_num = MKDEV(major_num, 0); // 根据主设备号生成设备号 printk(KERN_INFO “mydev: Cleaning up the device driver.\n”); // 销毁设备节点和类(与创建顺序相反) device_destroy(mydev_class, dev_num); class_destroy(mydev_class); // 从系统中删除 cdev cdev_del(&my_cdev); // 释放设备号 unregister_chrdev_region(dev_num, 1); printk(KERN_INFO “mydev: Driver cleanup successful.\n”); } module_init(mydev_init); module_exit(mydev_exit); MODULE_LICENSE(“GPL”); MODULE_AUTHOR(“Your Name”); MODULE_DESCRIPTION(“A simple character device driver”);3.2 关键代码解析与避坑点
file_operations结构体:这是驱动和内核的“契约”。我们实现了open,release,read,write四个最基本的操作。owner字段通常设为THIS_MODULE。- 用户空间与内核空间的数据拷贝:这是驱动开发中最容易出错的地方之一。内核不能直接访问用户空间指针,用户空间也不能直接访问内核空间指针。必须使用
copy_from_user和copy_to_user这两个函数在两者之间安全地拷贝数据。忘记使用它们或使用错误是导致系统崩溃的常见原因。 - 设备号管理:设备号由主设备号(标识设备类型)和次设备号(标识具体设备)组成。
alloc_chrdev_region用于动态申请一个未被使用的主设备号。cat /proc/devices可以查看系统中已注册的设备号。 cdev结构体:内核用struct cdev来管理一个字符设备。需要先cdev_init初始化它,再cdev_add将其添加到系统。- 自动创建设备节点:老式方法需要手动
mknod命令创建设备文件。现代驱动使用class_create和device_create,驱动加载时,udev 规则会自动在/dev下创建对应的设备节点(如/dev/mydev),极大方便了测试。 - 错误处理:内核编程必须严谨处理错误。在
init函数中,任何一步失败,都必须逆向清理之前已成功的步骤(比如申请了设备号后初始化 cdev 失败,就要先释放设备号再返回错误)。这是内核代码健壮性的基本要求。
3.3 编译和测试这个驱动
修改 Makefile: 将
obj-m := hello.o改为obj-m := mydev.o,或者直接新增一行obj-m += mydev.o来同时编译多个模块。编译:
make生成
mydev.ko。加载驱动:
sudo insmod mydev.ko用
dmesg | tail -10查看日志,应该能看到驱动初始化成功,并打印出动态分配的主设备号(比如247),以及设备节点创建信息。检查设备节点:
ls -l /dev/mydev你应该能看到类似
crw——- 1 root root 247, 0 …的文件。c表示字符设备,247, 0就是主设备号和次设备号。编写用户态测试程序: 新建
test_mydev.c:// test_mydev.c #include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main() { int fd; char write_buf[] = “Hello from userspace!”; char read_buf[1024] = {0}; // 1. 打开设备文件 fd = open(“/dev/mydev”, O_RDWR); if (fd < 0) { perror(“Failed to open the device.”); return -1; } printf(“Device opened successfully.\n”); // 2. 向设备写入数据 int bytes_written = write(fd, write_buf, strlen(write_buf)); printf(“Wrote %d bytes to device: %s\n”, bytes_written, write_buf); // 3. 为了从头读,我们先关闭再打开(或者用lseek,这里简单演示) close(fd); fd = open(“/dev/mydev”, O_RDWR); // 4. 从设备读取数据 int bytes_read = read(fd, read_buf, sizeof(read_buf) – 1); printf(“Read %d bytes from device: %s\n”, bytes_read, read_buf); // 5. 关闭设备 close(fd); return 0; }编译并运行测试程序:
gcc -o test_mydev test_mydev.c sudo ./test_mydev因为设备文件默认属于 root,所以测试程序也需要
sudo运行。你应该能看到程序成功打开设备、写入字符串、再读出相同字符串。查看驱动日志:
sudo dmesg | grep “mydev:”你会看到类似这样的输出,记录了驱动内部函数的调用:
mydev: Device has been opened. mydev: Received 22 bytes from user. mydev: Device has been closed. mydev: Device has been opened. mydev: Sent 22 bytes to user. mydev: Device has been closed.卸载驱动:
sudo rmmod mydev再次检查
/dev/mydev文件应该消失了。
至此,你已经完成了一个具备基本读写功能的字符设备驱动。用户程序通过标准的open、read、write、close系统调用,就能与你的驱动交互。这就是驱动最核心的价值:为硬件(或虚拟设备)提供统一的文件操作接口。
4. 从“能跑”到“能用”:生产级驱动要考虑什么
上面的例子为了简洁,省略了很多生产环境中必须考虑的问题。一个真正的驱动,至少要处理好以下几点:
4.1 并发与同步
我们的mydev驱动有个严重问题:buffer_index是全局变量,如果两个进程同时调用write,它们会互相覆盖数据,导致混乱。内核是多任务环境,驱动必须假设自己的函数会被多个执行上下文(进程、中断)同时调用。
解决方案:使用内核提供的同步机制。
- 信号量 (
semaphore)或互斥锁 (mutex):用于保护较长时间、可睡眠的临界区。在open或write开始时加锁,结束时解锁。 - 自旋锁 (
spinlock):用于保护非常短促、不可睡眠的临界区(比如中断处理函数)。 - 原子变量 (
atomic_t):用于简单的计数器操作。
例如,在驱动中引入互斥锁:
#include <linux/mutex.h> static DEFINE_MUTEX(mydev_mutex); // 定义并初始化一个互斥锁 static ssize_t mydev_write(...) { mutex_lock(&mydev_mutex); // 加锁 // … 临界区代码 … mutex_unlock(&mydev_mutex); // 解锁 return bytes_to_copy; }4.2 阻塞与非阻塞 I/O
用户程序打开设备文件时,可以指定O_NONBLOCK标志要求非阻塞操作。我们的驱动目前没处理这个。在read函数中,如果设备没有数据可读,应该:
- 如果文件打开模式是阻塞的,让进程睡眠等待,直到有数据。
- 如果文件打开模式是非阻塞的,立即返回
-EAGAIN错误。
这通常通过wait_queue(等待队列)和检查filep->f_flags & O_NONBLOCK来实现。
4.3 完善的文件操作
我们只实现了最基本的四个操作。一个完整的驱动可能还需要:
llseek:调整文件读写位置。poll/select/epoll:支持 I/O 多路复用,让用户程序可以监控多个设备是否可读/可写。ioctl:用于实现设备特定的控制命令,比如设置波特率、读取状态等。这是驱动实现复杂功能的主要接口。mmap:将设备内存映射到用户进程地址空间,实现零拷贝的高性能访问。
4.4 电源管理与热插拔
对于真实硬件,驱动可能需要处理系统休眠/唤醒事件,或者设备的热插拔(USB设备等)。这需要实现struct dev_pm_ops中的回调函数。
4.5 使用设备树(Device Tree)
在嵌入式 Linux 中,硬件信息(如寄存器地址、中断号)不再硬编码在驱动里,而是写在设备树(.dts)文件中。驱动需要通过of_*系列函数(Open Firmware)从设备树中获取这些资源。这是现代 Linux 驱动,尤其是平台设备(Platform Device)驱动的标准做法。
5. 调试与排查:驱动出问题了怎么看
驱动运行在内核态,崩溃会导致整个系统不稳定。调试比用户程序困难,主要靠日志和分析。
5.1 打印日志的艺术 (printk)
printk是驱动开发者的好朋友。它有多个日志级别:
KERN_EMERG:紧急,系统可能不可用。KERN_ALERT:需要立即行动。KERN_CRIT:临界状态。KERN_ERR:错误条件。KERN_WARNING:警告条件。KERN_NOTICE:正常但重要的情况。KERN_INFO:提示信息(我们例子中用的)。KERN_DEBUG:调试信息。
建议:
- 错误路径(
if (ret < 0))用KERN_ERR。 - 关键状态变化(初始化成功、打开关闭)用
KERN_INFO。 - 详细的流程跟踪用
KERN_DEBUG,并通过内核参数控制是否输出。 - 使用
%s,%d,%p等格式符时务必小心,确保类型匹配。 - 日志不是越多越好,关键点打日志即可,避免刷屏。
5.2 使用dmesg和journalctl
dmesg:直接查看内核环形缓冲区日志。常用dmesg | tail -n 50看最新,dmesg | grep “你的驱动名”过滤。journalctl -k:在使用了 systemd 和 journal 的系统上,这个命令可以查看内核日志,并且支持时间过滤、优先级过滤等,更强大。journalctl -f:实时跟踪内核日志输出,类似于tail -f。
5.3 常见错误与排查顺序
insmod失败,提示Invalid module format:- 最常见原因:编译模块用的内核头文件版本(
/lib/modules/xxx/build) 和当前运行的内核版本(uname -r) 不一致。确保虚拟机没有自动更新内核后重启,而你还在用旧的头文件编译。解决方法是安装匹配的头文件并重新编译。 - 检查命令:
uname -r和ls /lib/modules/$(uname -r)/build
- 最常见原因:编译模块用的内核头文件版本(
insmod失败,提示Unknown symbol in module:- 你的模块使用了某个内核函数或变量,但内核没有导出(
EXPORT_SYMBOL)这个符号。可能是函数名拼写错误,或者你用的函数是某个内核配置选项下的,当前内核没开启。用sudo cat /proc/kallsyms | grep function_name查看该符号是否存在。
- 你的模块使用了某个内核函数或变量,但内核没有导出(
模块加载后,系统不稳定或死机:
- 可能原因:驱动代码有严重 bug,如空指针解引用、死锁、栈溢出等。
- 排查:加载后立即用
dmesg看有无Oops或kernel panic信息。Oops会打印出错的调用栈和寄存器值,是宝贵的调试信息。 - 预防:先在虚拟机中测试;编写时注意指针判空、资源释放;使用
BUG_ON()或WARN_ON()在特定条件触发时主动抛出错误,便于定位。
用户程序读写设备返回错误(如 -1):
- 检查
errno(perror会打印)。常见错误:ENODEV:设备不存在。检查/dev/下设备节点是否存在,驱动是否加载。EACCES:权限不足。检查设备节点权限(ls -l /dev/your_dev),用户是否有读写权限。测试时可以用sudo。EFAULT:非法地址。通常是copy_to/from_user失败了,检查用户空间缓冲区指针是否有效。EINVAL:无效参数。检查ioctl的命令号或参数是否正确。
- 检查
资源泄漏:
- 每次
insmod后,用lsmod查看模块大小。如果反复加载卸载,模块大小不断增长,可能发生了内存泄漏(kmalloc没有kfree)。 - 确保
exit函数释放了init函数申请的所有资源(设备号、cdev、class、内存、中断、定时器等),顺序与申请时相反。
- 每次
5.4 更高级的调试手段
printk时间戳:dmesg -T可以显示人类可读的时间,方便判断事件顺序。- 动态调试 (
Dynamic Debug):可以运行时开启/关闭特定文件、函数的pr_debug打印,非常灵活。需要内核开启CONFIG_DYNAMIC_DEBUG。 - 使用
strace跟踪用户程序:strace ./test_program可以看到用户程序发出的所有系统调用及其参数、返回值,对于判断是驱动问题还是应用问题很有帮助。 - 内核调试器 (
KGDB):配合另一台机器进行源码级单步调试,功能强大但配置复杂,适合调试极其棘手的问题。 - 仿真器 (
QEMU):在 QEMU 中运行内核和驱动,可以方便地使用 GDB 进行调试,是嵌入式驱动开发的常用方法。
驱动开发真正的门槛不是语法,而是对内核机制的理解和调试排错的能力。最好的学习方式就是像我们今天这样,从一个最简单的模块开始,让它跑起来,然后一点点增加功能,每加一个功能就测试,遇到问题就按上面的链路去查日志、分析代码。这个过程里积累的经验,远比只看书要深刻得多。
我个人更建议你把第一个能跑通的驱动代码保存好,把它当作一个“脚手架”。以后学新的内核机制(比如锁、等待队列、中断、DMA),就在这个框架上添加、修改、测试。有了这个可运行、可修改的起点,再去看那些经典的驱动开发书籍,比如《Linux设备驱动程序》,你会发现自己能看懂、能关联上的东西越来越多。
🚀 30+款热门AI模型一站整合,DeepSeek/GLM/Qwen 随心用,限时 5 折。 👉 点击领海量免费额度
