☰
Ubuntu内核模块开发实战:从环境准备到字符设备驱动跑通
2026/10/21 3:36:56 网站建设 项目流程

我最早在Ubuntu上写驱动,是给一块外设板卡做字符设备接口。那阵子找资料发现一个尴尬的事:讲内核模块的文章不少,但要么是几年前的CentOS风格,要么直接跳到了复杂的PCIe驱动,很少有文章把“在Ubuntu当前内核版本下,从环境准备到驱动能跑通”这件事完整讲下来。所以这篇我打算用自己实际跑过的例子,把Ubuntu驱动开发的完整流程过一遍:这是给内核做外挂模块,不是普通的应用编程,你得理解模块加载机制、设备模型和调试套路。适合刚接触Linux驱动、需要在Ubuntu上实现自定义设备接口的开发者,也适合被模块加载报错折磨得头大的朋友。所有代码和命令都是我在x86_64环境上实测过的,你照着敲基本能一次跑通。

1. 先把思路理清楚:Ubuntu驱动开发到底在开发什么

1.1 内核模块与普通程序的本质区别

Linux驱动最常见的存在形式是内核模块,也就是编译出来的.ko文件。它是一个运行在内核空间的二进制,不是应用程序。两者最大的区别在于:应用程序跑在用户态,有自己的虚拟地址空间,出错顶多崩掉自己;内核模块跑在内核态,一个内存访问越界就可能让整个系统panic。

讲一个我自己的经历。之前做某个外设项目,客户现场反馈设备偶尔断连,一开始怀疑硬件,后来在内核日志里看到调用栈指向自定义驱动里一个全局缓冲区,原因是中断处理函数里直接访问了一个可能被释放的内存区域。这个问题在用户态程序里几乎不可能出现,但在内核驱动里非常典型。所以做驱动开发,心里要时刻有一根弦:你写的每一行代码都在最高权限下运行,任何一个未初始化的指针、未加保护的全局变量、错误的资源释放顺序,代价都是整个系统崩溃。

驱动的核心职责可以概括成三句话:识别设备、管理设备、向用户态提供访问接口。识别设备靠总线匹配,管理设备靠内核的设备模型,提供访问接口靠字符设备、块设备或网络设备接口。大多数自定义外设、采集卡、传感器、FPGA通信,走的都是字符设备这条路,这也是入门最合适的路径。

1.2 独立模块与内核内置两种形态怎么选

驱动可以编成.ko模块动态加载,也可以直接编进内核镜像。模块方式的优势是灵活,开发和调试阶段不用反复烧内核,insmod一个命令就能加载,出了问题rmmod卸载掉,系统依然健在。缺点是可能面临模块签名、内核版本绑定等问题。

内置方式的优势是启动早期就能用,不用等模块加载,对一些根文件系统依赖的设备很关键,比如存储控制器、系统总线驱动。但在开发阶段,除非你要改的东西和启动流程强相关,否则不建议直接编进内核。每次修改都要重新编译内核、重启,调试效率低到没法接受。

我的建议很简单:开发阶段一律用模块,功能稳定后再考虑是否内置。Ubuntu默认内核几乎都开启了模块自动加载,你把.ko放到/lib/modules/$(uname -r)/extra/下,然后用depmod和modprobe管理,体验和内置几乎没差别。

1.3 环境准备:内核头文件版本必须严格匹配

在Ubuntu上做驱动开发,第一步不是装某个IDE,而是把内核头文件装好。驱动模块编译时不需要完整的内核源码,但需要内核头文件里的构建接口和配置信息。Ubuntu把这一部分单独打包了。

sudo apt update sudo apt install build-essential linux-headers-$(uname -r)

装完以后务必确认这个目录存在:/lib/modules/$(uname -r)/build,它是一个指向/usr/src/linux-headers-$(uname -r)的符号链接。如果不存在,说明头文件没装对。

平时我们解压某个项目代码后写几个文件就能编译运行,但驱动不行。驱动必须链接到当前运行内核的接口上,所以内核版本、编译器版本、配置选项都要匹配。最典型的报错就是“Invalid module format”,十有八九是头文件版本和当前内核不一致。比如你uname -r看到5.15.0,却装了6.8的headers,那编译出来的模块在这个内核上永远不可能加载成功。这也是为什么命令里一定要写$(uname -r),而不是手打一个版本号。

顺便提一句,如果你是在虚拟机里开发,建议保留和宿主机一致的内核版本,不要没事就apt upgrade更新内核。内核一升级,之前编译好的模块全部作废,需要重新编译。别问我怎么知道的,问就是“今天早上刚升级过内核,下午客户电话就来了”。

2. 第一个字符设备驱动:从空模块到完整的read接口

2.1 模块骨架:module_init与module_exit

任何内核模块都必须有两个入口:加载入口和卸载入口。虽然名字可以随便起,但注册时必须明确告诉内核。看一段最简代码:

#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> static int __init demo_init(void) { pr_info("demo_drv: module loaded\n"); return 0; } static void __exit demo_exit(void) { pr_info("demo_drv: module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A minimal demo driver"); MODULE_VERSION("0.1");

这里有个很关键的习惯:用pr_info代替printk。pr_info本质上就是printk(KERN_INFO ...)的简写,但更清晰。开发阶段打印信息一定要有,debug全靠它。MODULE_LICENSE("GPL")不是应付事儿的,很多内核符号只对GPL模块开放,如果不声明,加载时可能报“Unknown symbol”或者符号被拒绝。

编译这一小段,需要一个Makefile,这段配置后面专门讲。现在你只需要知道,编译成功后当前目录会出现demo_drv.ko,然后执行:

sudo insmod demo_drv.ko sudo rmmod demo_drv dmesg | tail

这里有一个不同点:rmmod后面的参数是模块名,不是文件名。模块名默认就是删除.ko后剩下的那一段。dmesg里应该能看到两条加载和卸载的日志。如果看不到,可能是printk的日志级别被过滤了,后面调试部分会专门说。

2.2 设备号、设备类与自动创建设备节点

空模块没有任何外围功能,设备根本不可见。要让用户态程序通过open/read/write访问驱动,必须先注册一个设备号,再创建设备节点。

老式写法是register_chrdev,一次注册一整段设备号,简单但不灵活,而且不能和设备框架良好结合。现代驱动推荐的写法是alloc_chrdev_region加cdev_add,动态分配设备号,主设备号由内核分配,不容易冲突。

#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #include <linux/string.h> #define DEV_NAME "demo_dev" #define CLASS_NAME "demo_class" static dev_t dev_num; static struct class *demo_class; static struct cdev demo_cdev; static char demo_buf[] = "hello from kernel\n";

在init函数里:

ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME); if (ret < 0) { pr_err("alloc_chrdev_region failed\n"); return ret; } cdev_init(&demo_cdev, &demo_fops); ret = cdev_add(&demo_cdev, dev_num, 1); if (ret < 0) { unregister_chrdev_region(dev_num, 1); return ret; } demo_class = class_create(CLASS_NAME); if (IS_ERR(demo_class)) { cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(demo_class); } device_create(demo_class, NULL, dev_num, NULL, DEV_NAME);

device_create这里非常关键。它不只是创建一个内核对象,还会通过内核的uevent机制通知用户态的udev,udev在/dev目录下自动生成设备节点。所以只要这一行执行成功,/dev/demo_dev就会自动出现,不需要手动mknod。

这也解释了为什么很多教程里/dev节点老是不出来:他们只注册了设备号,没创建class,udev没有任何信息去生成节点。手动mknod虽然能临时用,但设备号在每次启动时可能变化,生产环境不可能靠手动。

注意一点,不同Ubuntu内核版本对class_create的声明有差异。Ubuntu 24.04这类比较新的内核,直接写class_create(CLASS_NAME),一个参数;老内核要写成class_create(THIS_MODULE, CLASS_NAME),两个参数。如果编译报参数数量不匹配,改成对应版本即可。

2.3 file_operations结构体与copy_to_user

设备节点有了,用户进程还打不开设备,因为驱动没有实现任何文件操作。内核规定,一切皆文件,设备节点也要支持open、read、write等系统调用。这些调用的实现都放在一个file_operations结构体里。

static int demo_open(struct inode *inode, struct file *filp) { pr_info("demo open\n"); return 0; } static int demo_release(struct inode *inode, struct file *filp) { pr_info("demo release\n"); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { size_t len = strlen(demo_buf); if (*ppos >= len) return 0; if (count > len - *ppos) count = len - *ppos; if (copy_to_user(buf, demo_buf + *ppos, count)) return -EFAULT; *ppos += count; return count; } static struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, };

读函数里那几行是标准的字符设备read实现套路:判断是否读到文件末尾、计算剩余长度、copy_to_user拷贝数据、更新文件偏移。这套逻辑和用户态读普通文件完全一致,所以用户态可以用read系统调用直接读。

在这里务必记住一个铁律:内核空间不能直接访问用户空间指针。用户传入的buf是一个用户态虚拟地址,内核里如果直接memcpy,轻则返回错误,重则触发内核oops。正确做法是copy_to_user或者copy_from_user。这两个函数内部会做地址合法性检查,并且根据当前进程的页表进行安全拷贝。

写一个简单的用户态测试程序验证:

#include <stdio.h> #include <fcntl.h> #include <unistd.h> int main(void) { char buf[64] = {0}; int fd = open("/dev/demo_dev", O_RDONLY); if (fd < 0) { perror("open"); return -1; } read(fd, buf, sizeof(buf)); printf("read: %s", buf); close(fd); return 0; }

编译运行后,终端输出“hello from kernel”,这就标志着一个完整可用的字符设备驱动已经跑通了。

3. 从传统模块到设备模型:设备树与platform_driver框架

3.1 设备树到底解决了什么问题

如果你的驱动只在某一款固定硬件上跑,直接把资源写死在代码里问题不大。但现实是同一份内核要跑在无数种主板上,同一个IP核可能被复用到不同地址、不同中断号上。如果每个驱动都把自己需要的地址、中断写死,那改一个硬件平台就要改一次驱动,做一次内核适配。

设备树把“硬件长什么样”和“驱动怎么工作”解耦了。硬件资源由DTS文件描述,驱动通过设备树节点获取资源,而不是在C代码里硬编码。这就像你的设备是一个“既插即用”的外设,驱动只负责处理业务逻辑,基地址、中断号、时钟频率这些信息都从“描述文件”里读。

在Ubuntu下,设备树信息通过/sys和/proc暴露。你可以用ls /proc/device-tree看到系统当前的设备树结构。

设备树文件后缀是.dts和.dtsi,dtsi相当于头文件,dts是具体板卡的描述。编译产物是.dtb,启动时由bootloader传递给内核。对于自定义硬件,调试阶段最简单的方式是使用设备树overlay,不用重新编译内核,改完直接加载即可。

3.2 platform_driver的注册与匹配机制

设备树里一个简单的节点长这样:

/ { my-device { compatible = "vendor,my-device"; reg = <0x1f000000 0x1000>; led-gpio = <&gpio1 15 GPIO_ACTIVE_HIGH>; poll-interval = <100>; }; };

驱动侧通过platform_driver结构体来匹配这个节点:

#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_gpio.h> #include <linux/property.h> static int my_probe(struct platform_device *pdev) { struct resource *res; int gpio; u32 interval; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (res) pr_info("mem: start=0x%llx len=0x%llx\n", (unsigned long long)res->start, (unsigned long long)resource_size(res)); gpio = of_get_named_gpio(pdev->dev.of_node, "led-gpio", 0); pr_info("led gpio: %d\n", gpio); if (!device_property_read_u32(&pdev->dev, "poll-interval", &interval)) pr_info("poll-interval: %d\n", interval); pr_info("my_platform: probe succeeded\n"); return 0; } static int my_remove(struct platform_device *pdev) { pr_info("my_platform: remove\n"); return 0; } static const struct of_device_id my_of_match[] = { { .compatible = "vendor,my-device" }, { } }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_pdrv = { .probe = my_probe, .remove = my_remove, .driver = { .name = "my_platform", .of_match_table = my_of_match, }, }; module_platform_driver(my_pdrv); MODULE_LICENSE("GPL");

注意匹配的关键是compatible字段,驱动里的of_device_id数组中的compatible字符串和设备树节点的compatible完全一致,probe函数才会被调用。这个过程完全由内核的设备模型自动完成,不需要用户态干预。

probe函数的含义是:“驱动和设备匹配成功,现在可以初始化设备了”。所有资源申请、寄存器映射、中断注册都应该放在probe里。remove函数负责反向操作,release资源、注销中断。这种生命周期管理是内核驱动区别于普通C程序的核心特征之一。

3.3 从设备树获取资源的常用API

设备树本质上就是键值对和数组的组合,内核提供了一整套读取接口。我最常用的几个:

  • of_get_named_gpio(node, "led-gpio", 0):读GPIO编号,已经不太推荐,新代码建议用gpiod_get系列。
  • device_property_read_u32(&pdev->dev, "poll-interval", &value):读整数属性,这个接口不只支持设备树,也支持ACPI,属于通用属性接口。
  • platform_get_resource(pdev, IORESOURCE_MEM, 0):读reg里的物理地址范围。
  • devm_ioremap_resource(pdev, res):把物理地址映射成内核虚拟地址,并且自动做资源管理。

em,资源管理,这是另一个重点。devm_前缀的函数我在代码里非常依赖。它意味着资源是跟随设备生命周期管理的,设备卸载时内核自动帮你释放,不需要在remove函数里一个一个手动释放。比如devm_ioremap_resource申请的映射,devm_kzalloc分配的内存,都不需要手动释放。这大大减少了内存泄漏和释放顺序错误的风险。我在实际项目里发现,很多稳定性问题不是逻辑不对,而是某个异常路径上资源没释放。用devm_管理资源后,这类问题基本绝迹。

4. 模块编译与加载:Makefile、内核版本与签名验证

4.1 模块Makefile的标准写法

驱动模块编译不依赖某个IDE,一个Makefile加一条make命令就够了。标准写法:

obj-m := demo_drv.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

这里的原理可以稍微讲一下。-C $(KDIR)意思是切换到内核构建目录下执行make,M=$(PWD)告诉kbuild系统“这里有一个外部模块要编译”。内核构建系统会读取当前目录下的Makefile,找到obj-m变量,把对应的.c文件编译成.ko。

如果你有多个源文件,比如main.c和helper.c共同编译成foo.ko,不能直接写obj-m := foo.o,而要写成:

obj-m := foo.o foo-objs := main.o helper.o

这是一个很容易踩的坑。直接写obj-m := foo.o且只有main.c时会报找不到main.c之类的错误,因为kbuild默认从一个同名.c文件生成目标。

编译时还有一个常见的坑是PWD变量。在Makefile里用$(shell pwd)而不是直接用$(PWD),因为某些环境下PWD环境变量可能指向shell的当前目录,但由于链接或环境变量污染,可能导致M参数指向错误路径。使用$(PWD)也行,但我更喜欢$(CURDIR),这是make内置变量,几乎不会出问题。

4.2 把驱动编进内核镜像

如果你确实需要把驱动编进内核而不是作为模块,那就需要完整的内核源码。先下载对应版本的内核源码,进入 drivers/ 目录下找一个合适的子目录,把你的驱动C文件放进去,然后在目录的Kconfig和Makefile里加两行:

Kconfig里加:

config MY_DRIVER tristate "My custom driver" default n help Demo driver for my custom device.

Makefile里加:

obj-$(CONFIG_MY_DRIVER) += my_driver.o

然后回到内核根目录执行menuconfig,把MY_DRIVER选为M(模块)或者Y(内置),make一下。这里要注意,完整编译内核需要安装libelf-dev、libssl-dev等依赖,耗时可达数小时,开发阶段不推荐。

4.3 SecureBoot、模块签名与加载失败

现在的Ubuntu默认开启UEFI SecureBoot。如果系统开启了SecureBoot,加载没有签名的内核模块会被直接拒绝,错误信息一般是“Operation not permitted”,insmod返回权限错误,但你已经用了sudo。这时候有三个选择。

第一个选择是关闭SecureBoot,在BIOS设置里找到Secure Boot选项禁用。这个最直接,但一些安全要求高的环境不允许。

第二个选择是生成自己的签名密钥,导入到系统MOK列表里,然后用这个密钥给模块签名。大致流程是:

sudo apt install mokutil shim-signed

生成密钥对并导入MOK,然后对.ko文件执行签名的命令类似:

sbsign --key mok.key --cert mok.pem demo_drv.ko

重启后会进入MOK管理界面,按提示导入新密钥。导入一次之后,以后所有用这个密钥签名的模块都能正常加载。

第三个选择是使用DKMS。DKMS是Ubuntu上管理内核模块的利器,内核升级后自动重新编译模块并签名。配置好DKMS之后,就不用手动insmod了,modprobe和自动加载都能正常用。很多第三方驱动比如某些网卡驱动、虚拟化驱动,都通过DKMS分发。

这里提醒一句,模块加载失败的日志位置很重要。不光是dmesg,还要看journalctl -k或journalctl -xe,有些签名验证失败的详细信息在系统日志里。

5. 调试手段与实战排查:如何系统化定位问题

5.1 printk日志级别与动态调试

写驱动第一课就是学会看日志。内核日志和用户态日志完全是两套体系,printk有日志级别,从0到7数字越小越紧急。常见级别:

  • KERN_ERR:错误,对应级别3
  • KERN_WARNING:警告,对应级别4
  • KERN_INFO:信息,对应级别6
  • KERN_DEBUG:调试信息,对应级别7

默认情况下,控制台只显示一定级别的日志,级别太低的看不到。但不管控制台是否显示,日志都会记录到内核环形缓冲区里,用dmesg随时能查。

如果模块里printk很密集,建议用pr_debug而不是pr_info。pr_debug在默认编译配置下一般不输出,需要动态调试开启。这样生产环境不会刷爆日志,调试时又可以通过动态调试机制精准打开:

echo 'file demo_drv.c line 42 +p' > /sys/kernel/debug/dynamic_debug/control

这个机制叫dynamic_debug,比改代码重新编译灵活得多。开发阶段我习惯在关键路径上加pr_debug,后期调试时动态打开,确认没问题再关掉。

不过printk也有明显的局限。printk是在中断上下文里也可能调用的,但如果你的中断处理函数每毫秒打一条日志,那整个系统的性能都会被拖垮。所以printk只适合低频错误和状态变化,不适合高频数据流打印。

5.2 /proc与/sysfs:从内核视角观察设备状态

驱动加载后,可以从几个地方确认设备是否真的注册成功。

cat /proc/devices可以看到字符设备和块设备的主设备号分配情况。如果你的demo_dev出现在列表里,说明设备号注册成功了。

ls /sys/class/demo_class/可以看到你创建的设备类下有哪些设备实例。ls -l /sys/class/demo_class/demo_dev会链接到设备的内核对象,通过这个路径还能看到uevent等信息。

sysfs有另外一个用法,就是给驱动加自定义属性。通过sysfs属性文件,用户态可以直接读写驱动内部状态,不需要专门设计read/write接口。比如我做过一个设备,把固件版本号、运行计数、温度都通过sysfs属性暴露出来,调试时就直接cat一下,简单高效。

sysfs属性实现起来也很简单,在device_attribute里实现show和store函数,然后挂在dev_attr_group下面。每次你写一个属性,都要想清楚这个属性的语义是否适合sysfs:一个属性文件对应一个值,不要把一个属性文件设计成接收复杂参数。

5.3 内核崩溃后的第一手信息抓取

驱动开发中遇到oops或者panic几乎是必修课。oops是一次异常访问的警告,进程可能被杀,但系统还能继续。panic是致命错误,系统直接挂起或者重启。无论哪种,第一件事都是抓日志。

dmesg里的oops信息相当有价值:有异常发生的地址、当前寄存器、调用栈。调用栈里能看到具体是哪个函数触发的。最常见的oops原因:

  • 空指针或野指针解引用
  • 越界访问内存
  • 在原子上下文里睡眠
  • 对已释放内存继续读写

解决oops的思路不复杂:先看调用栈定位函数,再结合代码审查,检查在哪个路径上可能访问了非法内存。实在定位困难时,可以在可疑函数入口加printk打印指针值,对比几次调用,找出规律。

还有一个调试武器是ftrace,用于追踪内核函数调用。Ubuntu上通过tracefs访问。它的作用是在不改代码的情况下看清楚某个函数被什么路径调用,以及执行耗时。做性能分析和调用链路梳理时非常有用。不过目标是先解决功能问题,ftrace可以在后续性能优化阶段再用。

5.4 实测案例:一次“dmesg里看不到printk”的排查

有段时间我写的驱动在别人机器上加载后什么日志都没有,先怀疑驱动没跑起来,但设备功能正常,说明驱动确实在工作。后来发现那台机器的内核日志级别被调高了,控制台级别较高,而printk默认的信息级别在控制台上被过滤掉。dmesg里其实有内容,是用户在控制台上没看到。

排查这类问题有几个固定动作:先dmesg | tail确认内核环形缓冲区里到底有没有日志;再cat /proc/sys/kernel/printk查看当前控制台日志级别;如果希望把所有日志都打到控制台,可以临时调低控制台级别:

echo 8 > /proc/sys/kernel/printk

这个数值越大,能显示在控制台上的日志级别范围越广。排查完记得恢复,否则生产环境控制台会被日志刷爆。

6. 稳定性的关键细节:并发、内存与异常路径

6.1 并发与竞态:驱动开发必修课

驱动在什么情况下会被并发访问?进程上下文里,可能有多个进程同时调用同一个驱动接口;中断上下文里,中断处理函数可能在任意时刻抢占正常流程;SMP环境下,多个CPU核心可能同时执行驱动代码。这三类并发叠加下来,一个没有保护的全局状态变量会以你完全无法预料的方式被踩踏。

我见过最典型的案例:一个驱动里的计数器自增操作,在双核机器上偶尔会丢更新。原因就是count++在CPU里被拆成了读、加、写三步,两个核心同时读旧值,各加一次,最后写回,结果只增加了一次。这就是竞态。

解决竞态的办法是锁。内核提供了多种锁原语,选型原则大致是:

  • 临界区很短,且不会睡眠,用自旋锁spinlock。
  • 临界区可能睡眠,或者需要等待某个条件,用信号量或者互斥锁mutex。
  • 只是操作一个整数、一个位图,用原子操作atomic_t。
static atomic_t used_count = ATOMIC_INIT(0); atomic_inc(&used_count); if (atomic_read(&used_count) > MAX_COUNT) { pr_warn("too many open operations\n"); }

这段代码里atomic_inc一个原子指令完成读改写,不需要锁。对于更复杂的共享数据结构,才需要mutex或者spinlock。

这里有一个原则性的提醒:在任何持锁的路径上都要警惕睡眠。自旋锁持有期间处理器不会让出,代码里一个msleep会直接导致系统卡死,甚至连中断都响应不了。这是新手最容易犯的致命错误。

6.2 内存安全:内核态没有“Segmentation fault”

用户态程序访问非法内存会收到段错误信号,进程退出。内核态没有这个保护机制,非法访问直接触发oops或者panic。所以内存安全是驱动开发的重中之重。

使用动态内存时,kmalloc和kfree必须成对。内核没有垃圾回收,也不保证释放后指针置空,悬空指针是崩溃的主要来源之一。现代代码优先推荐devm_kzalloc,自动跟随设备生命周期释放。比如在一个probe函数里申请了五块内存,后续某块初始化失败,如果没有devm机制,你得手动把前面四块全释放掉,漏掉一块就是泄漏。devm_kzalloc则完全不需要操心这些,设备移除时内核统一释放。

与用户态交互时,内核态的“用户指针”处理尤其严格。上面已经提过copy_to_user/copy_from_user,这里补充一下:不要对用户指针调用任何内核函数,不要尝试直接解引用。用户进程随时可能被信号中断、退出,它的地址空间映射是动态的。正确姿势永远是copy_to_user和copy_from_user,或相关的access_ok机制。

还有一个容易忽略的细节是异常路径。一个驱动的init函数里,如果第三个初始化函数失败,前面两个已经成功注册的资源要不要释放?当然要。但很多人因为错误路径代码量大,直接返回了,结果是rmmod的时候系统崩溃或者资源泄漏。处理方式很简单:给每一步失败定义一个标签,goto到对应的清理位置,一层层往回释放资源。看着笨,但可靠。

6.3 模块生命周期与引用计数

模块不是你想卸载就能卸载的。如果用户进程正打开着设备文件,此时rmmod,模块代码段可能被卸载,但进程手里还有一个指向file_operations的引用,再去read就是访问已释放的代码,直接oops。内核的module引用计数器解决的就是这个问题。

file_operations里的.owner = THIS_MODULE就是这个作用。它让内核在卸载模块时检查是否还有文件引用,只要open着,rmmod就会失败,系统日志提示“Device or resource busy”。这是一种保护,别试图绕过它。

但更微妙的情况是异步回调:比如你注册了一个中断处理函数,设备还在跑,中断随时可能触发。此时模块被卸载,中断处理函数代码已经不在内存里了,中断一来就是非法访问。所以在remove函数里,必须先释放中断、取消工作队列、销毁定时器,确保所有异步路径都不再引用这个模块后,才能让卸载流程继续。顺序反了,就会遇到“卸载后偶发崩溃”的疑难杂症。

7. 常见问题速查表与实战经验补充

7.1 驱动开发常见问题速查

我把这些年遇到的高频问题整理成了一个表格,排查顺序基本也是按这个来:

现象可能原因排查方法
insmod报Invalid module format内核头文件版本与当前内核不一致uname -r检查版本,重装linux-headers-$(uname -r)
insmod报Unknown symbol模块依赖的符号不存在或未导出dmesg查看具体符号,检查依赖模块是否已加载
insmod报Permission deniedSecureBoot拦截未签名模块关闭SecureBoot或使用MOK签名
/dev下没有设备节点驱动未调用class_create和device_create检查dmesg里设备号注册日志,确认udev规则
dmesg里看不到printk日志级别过滤echo 8 > /proc/sys/kernel/printk临时查看
模块加载后系统立即panic初始化代码中有非法内存访问或死锁抓取panic调用栈,定位具体函数
rmmod提示Device or resource busy设备正被进程占用lsof /dev/demo_dev查看占用进程
功能偶发异常,无规律并发竞态或中断丢失代码审查,检查全局变量保护和锁使用

这张表看起来简单,但每一条背后都是实实在在的踩坑记录。比如Unknown symbol这个问题,很多时候不是代码写错了,而是另一个模块没加载。dmesg里会有明确提示,看到类似“Unknown symbol xxx (err 0)”就要先去查这个符号属于哪个模块。

7.2 高效调试的工作习惯

最后分享一些让我少走弯路的工作习惯。第一个原则是模块尽可能做小。调试阶段一个驱动文件几百行就够了,不要把整个子系统都塞进去。模块小,崩溃定位快,加载卸载也快。第二个原则是每次修改只改一件事。驱动调试中变量太多,同时改逻辑和改资源管理方式,出了问题根本无法判断是哪个改动引发的。

第三个原则是在虚拟机里跑驱动。我自己用QEMU/KVM跑Ubuntu虚拟机做驱动测试,快照功能是神器。每次测试前做一个快照,驱动把系统搞崩了,直接回滚到快照,几秒钟就能继续测。在物理机上开发,每次panic都要重启,光等开机就浪费大量时间。

第四个原则是给驱动写一个稳定的用户态测试工具。不用太花哨,能反复调open/read/write/ioctl就行,再加一个自动循环脚本。做个连续1000次打开关闭循环,很多隐藏的资源泄漏和引用计数问题就会暴露出来。

7.3 一个值得尝试的后续方向

字符设备和platform_driver跑通之后,可以尝试把驱动接到更复杂的子系统上,比如工业控制场景经常用到的GPIO子系统、输入子系统,或者网络设备框架。这些子系统帮你解决了大量的样板代码,驱动只需要实现核心硬件操作即可。特别是GPIO子系统和中断子系统,它们提供的标准接口比直接操作寄存器安全得多,而且能和其他内核组件联动。

我个人的感受是,Ubuntu驱动开发入门最大的门槛不是语法,而是理解“内核模块是一套运行在完全不同的执行环境中的程序”。一旦意识到这一点,接下来就是不断积累调试经验和资源管理经验的过程。希望这篇内容能帮你把第一条路走通,后面的路会顺畅很多。

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

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

立即咨询