写Linux内核模块这件事,第一脚最难迈。如果你已经翻过几本书,大概率会在某个地方卡住:书里那些平台设备、设备树、各种子系统动辄上千行代码,根本不知道该从哪里下手。我的建议一直是同一个——先写一个模拟字符设备驱动。它没有真实硬件,不做中断,不做DMA,只用一块内存模拟出一台“设备”的行为,却能让你把字符设备驱动框架里的核心环节完整走一遍:设备号申请、cdev注册、file_operations实现、设备节点生成,以及用户态通过文件接口跟驱动交互的完整链路。这篇博文就把这套框架拆开讲透,适合已经会基础C语言、想入门Linux设备驱动开发的开发者,也适合想快速回顾字符设备驱动框架的老手。
1. 驱动框架是什么:先建立整体认知
1.1 字符设备驱动在整个Linux设备生态中的位置
Linux设备驱动大体上分三类:字符设备、块设备、网络设备。三者面向的使用场景完全不同:
| 设备类型 | 典型代表 | 数据模型 | 驱动特征 |
|---|---|---|---|
| 字符设备 | 串口、GPIO、LED、触摸屏 | 字节流 | 按字节读写,操作简单,最接近“文件” |
| 块设备 | 硬盘、U盘、SSD | 固定大小数据块 | 有缓冲区、请求队列、调度算法 |
| 网络设备 | 网卡、无线网卡 | 数据包 | 不走文件接口,走socket协议栈 |
从入门角度看,字符设备是绝佳的起点。它不需要理解页缓存、请求队列、协议栈这些复杂子系统,内核给它的抽象就是一组文件操作函数。你写一个字符设备驱动,本质上就是给内核提供一套针对该设备的“读写说明书”。等到这套思路吃透了,再往块设备、网络设备或者更上层的platform总线驱动走,心里就有底了。
我见过不少初学者一上来就啃完整的USB驱动或者PCIe驱动,结果被urb、dma_map、端点描述符这些东西搞得晕头转向。说实话,那些驱动的基本框架依然是字符设备驱动那套东西,只是在字符设备之上又叠了总线枚举、电源管理、中断处理等一大层。先把基础骨架立起来,比任何进阶知识都重要。
1.2 设备文件与“一切皆文件”
Linux有个著名的设计哲学:一切皆文件。用户态的程序想操作设备,并不需要知道设备在哪个地址、用什么寄存器,它只需要打开一个设备文件(device file),然后像读写普通文件一样调用open、read、write、close。这套系统调用会在内核里经由VFS(虚拟文件系统)层层转发,最终落到驱动注册的函数上。
拿本项目的模拟设备举例。模块加载成功后,系统会在/dev/mydemo路径下生成一个设备节点。用户态执行cat /dev/mydemo时,实际发生的路径是:
- open("/dev/mydemo"),VFS根据设备节点上的主设备号、次设备号找到对应的字符设备。
- 内核查到这个设备注册的cdev结构,cdev里保存着file_operations操作表。
- VFS调用操作表中对应的read实现,也就是驱动开发者写的那个read函数。
- read函数负责把内核缓冲区里的数据复制到用户态的缓冲区,然后返回给进程。
这个机制解释了为什么驱动开发看起来像在写文件系统插件:你根本没有操作真实硬件,你只是在实现文件操作接口。而这个项目里的“硬件”是一块内存,操作这块内存的读写函数,就是完整的模拟字符设备驱动。
1.3 设备号是驱动注册的第一道手续
设备号是内核识别设备类型的编号,由主设备号(major)和次设备号(minor)共同组成。主设备号标识设备对应的驱动,次设备号标识同一个驱动管理下的具体设备实例。比如一个驱动管理两个串口,两个串口共用同一个主设备号,但次设备号不同。
旧式写法喜欢用register_chrdev一次性完成设备号申请和cdev注册,代码确实短,但它把太多步骤揉在了一起,出了问题很难定位到底失败在哪一步。现代内核推荐的方式是分开来做:先用alloc_chrdev_region动态申请设备号,再用cdev_init和cdev_add完成注册。动态申请的好处是省去你查空闲主设备号的工作,避免跟系统里已有的设备号冲突。
申请成功后,可以从/proc/devices里看到这条记录。例如:
Character devices: 1 mem 4 /dev/vc/0 5 tty 10 misc 511 mydemo这个mydemo就是你申请的模拟设备。记住这个编号,后面排查设备节点问题时要用到。
2. 看懂驱动骨架里的三个核心角色
2.1 file_operations:用户态操作与驱动实现的对应关系
file_operations是字符设备驱动最核心的结构体,没有之一。它本质上是一张函数指针表,告诉内核:当用户态打开这个设备时,调用哪个函数;当用户态读取时,调用哪个函数。每个成员对应一个系统调用。
基础版驱动最常用的几个成员:
| 成员 | 对应系统调用 | 作用 |
|---|---|---|
| .owner | - | 指向模块自身,防止模块被卸载时仍有进程持有设备 |
| .open | open | 设备被打开时调用,常用于初始化私有数据 |
| .release | close | 最后一个fd关闭时调用,常用于释放资源 |
| .read | read | 从设备读取数据到用户空间 |
| .write | write | 从用户空间写入数据到设备 |
| .unlocked_ioctl | ioctl | 设备控制命令下发,如设置波特率、复位设备 |
注意read和write的函数签名:
ssize_t (*read) (struct file *filp, char __user *buf, size_t count, loff_t *offset); ssize_t (*write)(struct file *filp, const char __user *buf, size_t count, loff_t *offset);这里有一个新手常犯的理解错误:read里的buf是用户态传入的用户空间地址,不是内核地址。内核驱动不能直接解引用这个指针,必须通过copy_to_user安全拷贝。这个细节我放到2.3单独讲。
另外read的返回值是“实际读取的字节数”,返回0代表读到文件末尾(EOF);write的返回值是“实际写入的字节数”。用户态的系统调用拿到这个返回值后,才知道一次读写完成了多少。如果你的驱动返回值跟实际操作不符,上层函数的表现就会很诡异。
不一定要把所有回调都实现。比如驱动不支持ioctl时,把.unlocked_ioctl留空即可,用户态调用ioctl时会得到一个错误码。但open、release这种基础回调最好都写上,一方面便于打日志排查,另一方面很多驱动确实需要在打开时初始化状态。
2.2 cdev、class、device:从内核注册到/dev节点自动生成
cdev是内核中表示字符设备的数据结构。驱动要做两件事:cdev_init把file_operations绑定到cdev上,再cdev_add把cdev注册进内核的设备表里。注册完成后,内核就已经知道这个设备号对应哪组操作函数。
但到这里,/dev/mydemo还不一定会自动出现。设备节点本质上是用户态可以访问的“入口”,它需要由设备管理器(udev)根据内核发送的uevent事件来创建。想要内核自动发送这个事件,就需要两个辅助结构:
- class(设备类):通过
class_create创建,作用是给设备分组。比如所有GPIO设备归入gpio类,所有输入设备归入input类。 - device(设备):通过
device_create创建,参数里带上设备号和名称,内核会生成uevent,udev收到后自动在/dev下生成对应名称的设备节点。
所以完整骨架是这样的:
alloc_chrdev_region -> 申请设备号 cdev_init -> 绑定操作函数 cdev_add -> 注册字符设备 class_create -> 创建设备类 device_create -> 创建可视设备,触发udev生成/dev节点清理顺序恰好相反:device_destroy、class_destroy、cdev_del、unregister_chrdev_region。驱动代码里必须成对出现,少一个就会留下脏痕迹,最典型的就是下次insmod时提示设备号仍然被占用。
这里有个内核版本差异需要提醒:在新版内核(6.4及以上)中,class_create的owner参数被移除了,变成class_create(name)单参数形式。老内核是class_create(THIS_MODULE, name)双参数形式。如果编译时遇到class_create参数个数不匹配,把第一个参数删掉即可。
2.3 数据搬运:为什么必须用copy_to_user / copy_from_user
很多初学者第一次看到copy_from_user都会困惑:内核态访问用户态内存,不就是一个memcpy吗?为什么要搞个专门的函数?
原因很简单:用户空间的指针可能是无效的。用户进程传入一个地址,但如果这个地址对应的页面被换出到磁盘,或者用户根本传了一个野指针、一个越界地址,内核直接访问就会触发缺页异常,甚至导致内核崩溃。copy_to_user和copy_from_user专门处理这类问题,它会在拷贝过程中捕获缺页异常、做地址合法性校验,必要时暂时睡眠等待页面恢复。
把内核数据写到用户内存用copy_to_user,把用户数据读进内核用copy_from_user。这两个函数的返回值不是0表示成功、非0表示失败这么简单——它们的返回值是“未能拷贝的字节数”。所以正确用法是:
if (copy_to_user(buf, kernel_data, len)) { return -EFAULT; }只要返回值非0,就说明有数据没拷完,此时直接向上层返回-EFAULT(地址错误),用户态errno会变成EFAULT。但不少驱动为了图省事,也会只把这个非0值当成错误标志,不精确处理剩余字节数。基础驱动里我们只需要判断是否为0即可。
还有一个容易踩的坑:永远不要在内核态直接memset用户态的buf,也不要直接memcpy用户态数据到内核缓冲区。就算你测量出某次地址恰好可用,那也是运气好,碰到缺页或并发访问就是一场灾难。
3. 完整实现一个模拟字符设备驱动
3.1 源码实现:一个基于内存缓冲区的字符设备
下面给出一个完整的模拟字符设备驱动。设备的行为很简单:内核维护一块1024字节的内存缓冲区,你把数据写进去,再读出来,就像一个记事本设备。它没有真实硬件,但驱动框架一点不少。
// mydemo.c #include <linux/init.h> #include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/slab.h> #include <linux/uaccess.h> #define DEVICE_NAME "mydemo" #define CLASS_NAME "mydemo_class" #define BUFFER_SIZE 1024 static struct cdev my_cdev; static dev_t my_dev_no; static int major; static struct class *my_class; static struct device *my_device; static char *buffer_sink; // 打开设备:基础版只打印日志,不做额外处理 static int my_open(struct inode *inode, struct file *filp) { pr_info("mydemo: open invoked\n"); return 0; } // 关闭设备:与open对应 static int my_release(struct inode *inode, struct file *filp) { pr_info("mydemo: release invoked\n"); return 0; } // 从设备读取数据到用户空间 static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t available; size_t to_copy; // 偏移到达缓冲区末尾,返回0表示EOF if (*offset >= BUFFER_SIZE) return 0; // 取count与剩余空间的较小值,防止越界 available = BUFFER_SIZE - *offset; to_copy = (count < available) ? count : available; if (copy_to_user(buf, buffer_sink + *offset, to_copy)) return -EFAULT; // 更新文件偏移,否则用户态总是读到第一段数据 *offset += to_copy; pr_info("mydemo: read %zu bytes, offset=%lld\n", to_copy, *offset); return to_copy; } // 从用户空间写入数据到设备 static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { size_t available; size_t to_copy; if (*offset >= BUFFER_SIZE) return 0; available = BUFFER_SIZE - *offset; to_copy = (count < available) ? count : available; if (copy_from_user(buffer_sink + *offset, buf, to_copy)) return -EFAULT; *offset += to_copy; pr_info("mydemo: write %zu bytes, offset=%lld\n", to_copy, *offset); return to_copy; } static struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .release = my_release, .read = my_read, .write = my_write, }; static int __init my_init(void) { int ret; // 1. 动态申请设备号 ret = alloc_chrdev_region(&my_dev_no, 0, 1, DEVICE_NAME); if (ret) { pr_err("mydemo: alloc_chrdev_region failed, ret=%d\n", ret); return ret; } major = MAJOR(my_dev_no); pr_info("mydemo: major=%d minor=%d\n", major, MINOR(my_dev_no)); // 2. 初始化cdev并注册到内核 cdev_init(&my_cdev, &my_fops); my_cdev.owner = THIS_MODULE; ret = cdev_add(&my_cdev, my_dev_no, 1); if (ret) { pr_err("mydemo: cdev_add failed, ret=%d\n", ret); goto err_unregister_region; } // 3. 为模拟设备分配一块内核缓冲区 buffer_sink = kmalloc(BUFFER_SIZE, GFP_KERNEL); if (!buffer_sink) { ret = -ENOMEM; goto err_cdev_del; } memset(buffer_sink, 0, BUFFER_SIZE); // 4. 创建设备类 my_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { ret = PTR_ERR(my_class); goto err_free_buffer; } // 5. 创建/dev下的设备节点 my_device = device_create(my_class, NULL, my_dev_no, NULL, DEVICE_NAME); if (IS_ERR(my_device)) { ret = PTR_ERR(my_device); goto err_class_destroy; } pr_info("mydemo: module loaded successfully\n"); return 0; err_class_destroy: class_destroy(my_class); err_free_buffer: kfree(buffer_sink); err_cdev_del: cdev_del(&my_cdev); err_unregister_region: unregister_chrdev_region(my_dev_no, 1); return ret; } static void __exit my_exit(void) { // 逆序清理:节点 -> 类 -> 缓冲区 -> cdev -> 设备号 if (my_device) device_destroy(my_class, my_dev_no); if (my_class) class_destroy(my_class); if (buffer_sink) kfree(buffer_sink); cdev_del(&my_cdev); unregister_chrdev_region(my_dev_no, 1); pr_info("mydemo: module unloaded\n"); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Kernel Demo Author"); MODULE_DESCRIPTION("Simulated character device driver demo"); MODULE_VERSION("1.0");代码并不长,但每一块的顺序都值得琢磨。init里必须先申请设备号,因为cdev_add需要用到;必须在创建device之前创建class,因为device要挂在class下面。同理,exit里的清理顺序是严格逆序的,这是驱动开发里的一条铁律:谁先注册,谁后注销。
read和write里的偏移处理是很多人容易漏掉的细节。每次读取或写入后,都要更新*offset。如果你不更新,用户态调用cat时会发现读了半天读到的永远是缓冲区开头那段数据,甚至可能出现死循环,因为read每次返回的字节数不为0,cat就以为还有数据。
3.2 Makefile与编译加载流程
内核模块不能像普通程序一样直接gcc编译,它必须使用内核提供的头文件和构建系统。Makefile是标准模板:
obj-m := mydemo.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) cleanobj-m := mydemo.o的意思是最终要生成一个名为mydemo.ko的内核模块,源码是mydemo.c。KDIR指向当前内核的构建目录,如果你的系统安装了linux-headers包,这个路径通常不需要改。编译命令:
make编译结束后目录下会出现mydemo.ko文件。如果报错说找不到/lib/modules/$(uname -r)/build,说明没有安装内核头文件,Debian/Ubuntu系用apt install linux-headers-$(uname -r)装一下即可。
加载模块需要root权限:
sudo insmod mydemo.ko加载后立刻看日志:
dmesg | tail -20正常情况下能看到类似下面的输出:
mydemo: major=511 minor=0 mydemo: module loaded successfully同时看一眼设备节点:
ls -l /dev/mydemo crw-rw-rw- 1 root root 511, 0 ... /dev/mydemo设备节点能自动出现,是因为驱动里调用了device_create,内核向用户态发出了uevent事件,udev接管后自动创建了这个节点。如果你的系统没有udev(比如某些精简的容器环境),设备节点是不会自动出现的,这时候需要手动创建,方法我在第4章讲。
3.3 用户态读写验证
驱动装好了,设备节点也出现了,验证一下整套链路是否通畅。先向缓冲设备写入一段内容:
echo "hello kernel driver" > /dev/mydemo再读取出来:
cat /dev/mydemo你会看到终端输出了刚才写入的字符串,后面可能跟着一串空字符或空白,这是因为缓冲区被整个读了出来,剩下未写入的区域全是零。这个现象符合预期:模拟设备只有1024字节,而cat会一直读到EOF为止。
整个过程对应的内核日志:
mydemo: open invoked mydemo: write 19 bytes, offset=19 mydemo: release invoked mydemo: open invoked mydemo: read 1024 bytes, offset=1024 mydemo: read 0 bytes, offset=1024 mydemo: release invoked注意第二次读取时返回了0字节,这是因为offset已经到达缓冲区末尾,驱动返回0表示EOF。cat拿到0就停止读取了。如果你在代码里忘了if (*offset >= BUFFER_SIZE) return 0;这个判断,cat会一直读下去,表现就是终端停不下来,甚至刷屏。
如果想用C程序做更精确的读写验证,可以写个小测试程序,但基础阶段用echo和cat已经足够验证驱动框架是否成立。关键是理解read、write两次系统调用分别进入了驱动实现的对应函数。
测试结束后卸载模块:
sudo rmmod mydemo卸载后/dev/mydemo节点会自动消失,因为device_destroy把节点一并清掉了。
3.4 这个框架如何迁移到真实硬件
本项目里的“设备”是一块kmalloc出来的内存,代码里处处都是buffer_sink。真实的字符设备驱动,比如操作一个GPIO、操作一个串口,骨架其实完全一样,差别出现在数据访问层面:
- 真实硬件通常映射到一段物理地址,驱动里要用
ioremap把它映射成内核虚拟地址,然后用ioread32、iowrite32等接口读写寄存器。 - 真实硬件往往需要中断配合,这时要在probe函数里调用
request_irq注册中断处理函数。 - 真实硬件的打开、关闭通常要操作电源管理、时钟使能,这些逻辑放在open和release里。
- 如果你的设备属于某个总线上(比如I2C、SPI、PCI),驱动需要额外实现总线相关的接口,但注册字符设备的核心步骤仍然离不开cdev这套机制。
等这个基础框架吃透了,你再看led驱动、gpio驱动或者其他杂项字符设备驱动的源码,会发现它们只是在这个骨架上加了不同的血肉。
4. 常见问题与排查技巧实录
4.1 模块加载失败:症状、日志与对策
模块加载失败很常见,下面是几种我实际遇到过的情况:
| 症状 | 常见原因 | 排查方法 |
|---|---|---|
| insmod提示File exists | 模块已经加载过,或者设备号仍被占用 | insmod前先rmmod;查dmesg确认是否上次卸载不干净 |
| insmod提示Operation not permitted | 权限不足,或secure boot拦截 | sudo执行;检查内核是否允许加载第三方模块 |
| insmod提示Unknown symbol | 当前内核版本与编译环境不一致 | 确认uname -r和编译用的KDIR一致;重新编译 |
| 模块加载成功,但dmesg里没有日志 | printk优先级低于控制台默认级别 | 用dmesg而不是终端看日志;或者调高日志级别 |
排查驱动加载问题有一个黄金动作:加载失败后立刻执行dmesg | tail -30。驱动里每个错误路径都打印了pr_err,日志会直接告诉你是在设备号申请阶段失败,还是在cdev_add阶段失败,省去大量猜测。
还有一点要特别注意:rmmod卸载模块前,确保没有进程还在使用设备文件。如果有进程以cat /dev/mydemo卡在那里,rmmod会提示Device or resource busy。找到那个进程并杀掉,或者先Ctrl+C终止读写,再执行卸载。
4.2 设备节点不出现、打开失败或权限不足
设备节点不自动出现有几种可能。最常见的是系统没有udev或devtmpfs支持,这在精简的构建环境、容器里很常见。解决办法是用mknod手动创建:
sudo mknod /dev/mydemo c <major> 0 sudo chmod 666 /dev/mydemo<major>替换成dmesg里看到的实际主设备号。c代表字符设备,0是次设备号。注意:mknod创建的是临时节点,重启或重新加载模块后需要重新创建,所以正常开发流程还是优先解决udev的配置问题。
打开设备时报Permission denied,多半是设备节点权限不对。默认udev会根据内核提供的默认规则建节点,权限通常是读写。如果不慎用了mknod,默认权限是0600,只有root能访问。可以直接临时chmod 666 /dev/mydemo,或者写udev规则固定权限。嵌入式开发中我最常做的就是把当前用户加入dialout组,省得每次sudo。
打开设备返回No such file or directory,说明节点根本不存在,或者主设备号不匹配。先确认模块已加载,再确认mknod填的major值和/proc/devices里的一致。配套排查命令:
ls -l /dev/mydemo cat /proc/devices | grep mydemo4.3 读写数据异常:四类高频问题
第一类:数据读出来总是第一段。原因几乎都是read里没有更新*offset。每次read返回后,用户态的cat或read系统调用会认为数据位置前进了,但驱动里的offset纹丝不动,下次读又是从头开始。记住,read和write的最后一步一定要*offset += to_copy。
第二类:写入内容超过缓冲区导致内存越界。这个问题的根源是在my_write里没有做边界判断,直接把count大小的数据往固定大小缓冲区里塞。我在代码里取count和available的较小值,就是为了防止越界。一旦越界,可能悄悄破坏内核的heap元数据,到崩溃时才会暴露,非常难查。驱动代码里,缓冲区边界问题永远要放在最前面处理。
第三类:返回值语义错误导致用户态行为异常。比如read返回了负错误码,但用户态拿到的read返回值看成是字节数,导致应用逻辑错乱。内核里面约定:负数代表错误,正数代表成功长度,0在read里是EOF。不要混着用。
第四类:多个进程同时读写同一块buffer产生数据竞争。基础版驱动没有加锁,两个进程同时write同一块区域时,后写的会覆盖先写的,因为内存是共享的。这个问题在入门阶段可以先不深究,但你要心里有数。真正做产品级驱动,缓冲区并发访问基本都要通过互斥锁或者原子操作来保护。
4.4 驱动调试三板斧
第一板斧是dmesg。内核日志是驱动唯一的日记本,每次调用的进出、每个错误分支,都要靠printk家族函数记录。我习惯在每一项关键操作后打一条日志,哪怕是“进入某个函数”这种简单的,也能在问题发生时快速定位。
第二板斧是strace。调试用户态程序与设备的交互时,strace能显示进程发出了哪些系统调用、传入的参数和返回值。比如你想确认cat /dev/mydemo到底read了几次,每次读了多少字节,直接strace cat /dev/mydemo就能看到完整调用链。这能在不碰驱动代码的情况下先确认用户态的行为是否符合预期。
第三板斧是调整printk输出级别。有时候驱动里的pr_info在控制台不显示,是因为控制台默认日志级别挡住了它。临时调高控制台级别:
echo 8 > /proc/sys/kernel/printk或者干脆只依赖dmesg看日志。dmesg把内核的printk buffer都存下来了,不管优先级高低,基本都能看到,是最稳妥的查看方式。
最后再分享一点个人的体会
这个模拟字符设备驱动虽然代码量小,但它把Linux字符设备驱动框架的骨干完整走了一遍:设备号申请、cdev注册、file_operations实现、class/device节点生成、用户态数据安全拷贝、错误路径的资源回收。我教过几个完全没接触过内核的同学,只要耐下心把这个驱动抄一遍、编译一遍、跑通一遍,再去看真实驱动的源码,基本都能顺着代码理出“注册了什么、操作了什么、清理了什么”这条主线。
后续扩展的方向其实已经藏在代码里了:给write加个写保护,就能演示只读设备;加一个ioctl命令,就能演示控制命令下发;把缓冲区换成原子变量加等待队列,就能把一个字符设备改造成一个最简消息队列;再把open里初始化私有数据的逻辑补上,就是一个标准硬件驱动的雏形。框架不需要换,往里面添东西就是了。
第一次编译内核模块,卡在环境配置上是再正常不过的事。别被Makefile和内核头文件的报错劝退,这些问题几乎所有人都踩过。把基础框架跑通之后,你会发现Linux驱动开发并没有想象中那么神秘,它只是另一套有规矩、有套路的系统编程。