1. 这个实验到底在做什么:设备号、cdev与用户空间视角
第一次做"注册字符设备"这个实验的时候,我其实有点不以为然。当时课程资料给了一段现成代码,看起来就是分配设备号、初始化cdev、然后cdev_add三步,insmod一加载就完事。直到后来自己动手把字符设备从注册到读写全部调通,才意识到这个实验真正要你去理解的,是三个相互关联的抽象:设备号(dev_t)、字符设备对象(struct cdev)、以及用户空间那个/dev/xxx节点背后的一整条调用链。
先说清楚字符设备到底是什么。Linux下设备文件分两大类,块设备按块读写,比如硬盘;字符设备按字节流读写,比如串口、GPIO、以及大多数教学实验里自己造的这个"假设备"。你写一个内核模块,注册一个字符设备,本质上就是告诉内核:从某个设备号开始,这块区域归我管,用户对它的open/read/write操作由我的函数来处理。
那"注册"到底注册了什么?你可以把整个机制类比成一个物业管理中心。用户程序想找服务,先看门牌号(设备号 dev_t),门牌号指向一栋房子(struct cdev),房子门口站着服务人员(file_operations结构体里的那些回调函数)。用户程序调用open("/dev/demo_char", ...)时,虚拟文件系统(VFS)根据设备节点的设备号找到 cdev,再找到对应的 service 人员,也就是你的demo_open、demo_read这些函数。整个过程不需要用户知道内核模块具体在哪,只要设备节点和设备号对应上,就能用普通文件操作的方式访问内核功能。
这个实验适合谁做?正在学 Linux 驱动开发的初学者,或者操作系统课程里第一次写内核模块的人。它看起来简单,但实际上把三个最重要的概念串在了一起:设备号的管理方式、cdev 对象的生命周期、以及 file_operations 回调机制。把这些吃透,后面再学 platform 设备驱动、设备树匹配,会发现底层还是同样的套路——注册、绑定、回调。
2. 设备号分配:静态指定与动态申请怎么选
设备号是字符设备的"身份证",在 Linux 内核里用一个dev_t类型表示。dev_t 本质上是32位整数,高12位是主设备号(major),低20位是次设备号(minor)。主设备号区分设备类型或者说区分驱动程序,次设备号用来区分同一个驱动程序管理下的多个设备实例。
操作设备号有四个宏必须记住:
dev_t dev = MKDEV(major, minor); // 把主次设备号拼成 dev_t int major = MAJOR(dev); // 从 dev_t 中取出主设备号 int minor = MINOR(dev); // 从 dev_t 中取出次设备号2.1 两种分配方式对比
分配设备号有两条路可走:静态指定和动态申请。我把它们的区别整理成了表格:
| 方式 | 接口 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 静态指定 | register_chrdev_region(dev, count, name) | 已知主设备号,且该号空闲 | 设备号固定,方便预知 | 可能冲突,需要查 /proc/devices |
| 动态申请 | alloc_chrdev_region(&dev, firstminor, count, name) | 不关心具体主设备号 | 自动分配空闲号,避免冲突 | 主设备号不固定,需要看日志 |
实验里我建议直接用alloc_chrdev_region。为什么?因为我自己第一次用静态方式时,随便挑了一个主设备号,结果 insmod 一直报错,后来才知道那一段已经被别的驱动占了。内核的主设备号是有登记表的,光靠猜不可靠。动态申请让内核帮你挑一个空闲号,省心得多。
还要注意alloc_chrdev_region的第二个参数firstminor,表示从哪个次设备号开始分配。例如alloc_chrdev_region(&dev_num, 0, 3, "demo_char")表示分配3个连续设备号,次设备号分别是0、1、2。
2.2 分配失败的排查
这一步最容易忽略的是错误处理。alloc_chrdev_region返回负数时说明分配失败,常见原因是count参数过大或者 name 与现有设备冲突。我第一次实验时没检查返回值,insmod 显示成功,但 dmesg 里全是错误,设备号也没分配出来。所以内核代码里每个可能失败的调用,都要养成if (ret < 0) goto err的习惯,否则后面 cdev_add 拿着一个无效的 dev_t 去注册,行为不可预期。
另外一个细节:name参数会在/proc/devices里显示出来。你注册成功后cat /proc/devices,能看到类似247 demo_char这样的行。但注意,注册了设备号不等于设备节点存在,用户空间想访问还差一个/dev下的节点,这个后面实验部分会讲。
3. cdev的初始化与添加顺序:为什么init必须在add之前
拿到设备号只是第一步,真正要注册的是struct cdev这个内核对象。cdev 是字符设备在内核里的核心结构,它把设备号段和 file_operations 绑定在一起。
初始化 cdev 有两种典型做法:一种是直接定义一个静态的struct cdev demo_cdev;,用cdev_init(&demo_cdev, &demo_fops);初始化;另一种是用cdev_alloc()动态分配。实验代码里用静态方式更直观,因为它把 cdev 的生命周期和模块绑定在一起,省去释放内存的麻烦。
3.1 cdev_init 到 cdev_add 之间发生了什么
cdev_init做的事情很单纯:把 fops 指针塞进 cdev 里,同时把 cdev 的自己人(kobj)初始化好。这里容易踩坑的是,有些同学只调了cdev_init忘了cdev_add,结果加载模块后设备号有了,但用户程序 open 设备节点时系统直接报错。
cdev_init之后,这个 cdev 只是一个"还没上户口"的对象,内核不认识它。必须cdev_add(&demo_cdev, dev_num, 1)之后,内核的设备表里才有这个设备的登记信息。从cdev_add返回那一刻起,设备就是"活的"——只要有人打开对应的设备节点,内核就可能立刻调用你的 open、read。所以,在 cdev_add 之前,所有需要用的资源(缓冲区、锁、硬件寄存器映射)都应该准备好。这是很多初版实验代码的隐性bug来源:先 cdev_add 再 kzalloc,一旦用户操作来得快,回调函数访问空指针,内核直接 panic。
3.2 owner 字段和引用计数
初始化 cdev 时有一行经常被漏掉:
demo_cdev.owner = THIS_MODULE;这行字段指向当前模块。它的作用是:当用户已经打开这个设备文件、文件描述符还持有引用时,内核会通过这个字段让模块的引用计数加一。如果没有设置 owner,或者设成 NULL,rmmod卸载模块时即使设备正被占用,也可能直接卸载成功,之后用户程序再操作设备,内核就会访问到已经释放的代码,大概率会崩溃或者触发保护错误。
我实验时专门试过删掉这行代码:终端开着cat /dev/demo_char不退出,另一个终端执行rmmod,模块照样被卸载了,cat 进程瞬间报 Segment Fault。所以这行不是摆设,是安全机制。
3.3 cdev_add 失败怎么办
cdev_add返回负数意味着注册失败,最常见的几个原因:
- 设备号段已经被其他 cdev 占用(重复注册同一段设备号)
- dev_t 的 firstminor 加 count 超过了20位次设备号范围
- 模块初始化时传递了无效的设备号
失败后必须在出错处理路径里unregister_chrdev_region释放设备号。这一步不做的话,设备号资源就泄漏了,虽然重启能恢复,但在运行中的系统上,这个主设备号段会一直被占用。
4. file_operations与数据通路:字符设备如何真正读写数据
注册字符设备的最终目的,是让用户空间的程序能像读文件一样和设备交互。这个"像"的前提,就是你实现了struct file_operations里的回调函数。实验里最少要实现 open、release、read、write 这四个,不然用户程序 open 或读写时会得到一个没有实现的错误。
4.1 回调函数的签名到底意味着什么
看一下四个关键回调的原型:
int (*open)(struct inode *inode, struct file *filp); ssize_t (*read)(struct file *filp, char __user *buf, size_t count, loff_t *ppos); ssize_t (*write)(struct file *filp, const char __user *buf, size_t count, loff_t *ppos); int (*release)(struct inode *inode, struct file *filp);struct file不是内核里那个文件对象,而是用户打开设备节点后在内核里对应的抽象。loff_t *ppos是当前读写位置,它每次由 VFS 传进来,read/write 返回后应该更新它。但注意,更新的责任在你写的回调函数里,内核不会自动帮你移动位置指针。
以 read 为例,语义是:从 ppos 位置开始,最多读 count 字节到用户缓冲区 buf。如果返回 0,表示读到文件末尾。write 类似,返回实际写入的字节数。这些返回值会被 VFS 直接反馈给用户空间的read()和write()系统调用。返回值返回错了,用户程序的行为就会变得莫名其妙。
4.2 为什么必须用 copy_to_user / copy_from_user
这是实验里最容易想当然的地方。刚学的时候我觉得,既然传进来一个用户空间的指针 buf,那我直接*buf = xxx不就行了?结果一跑就出问题。原因很直接:内核态不能直接解引用用户空间指针。原因有两层:
第一,用户空间的地址是进程相关的,内核在访问用户地址时,必须通过当前进程的页表做地址转换,直接拿用户指针当内核指针访问,很可能访问到完全无关的内存。第二,安全问题更重要:用户可以通过系统调用把一个非法地址传进来,如果内核不对地址做校验就写入,等于给用户开了一个任意写内核内存的漏洞。
copy_to_user和copy_from_user这两个函数会在复制前检查用户地址的合法性,并处理缺页等情况。实际上它们内部是通过访问异常表机制来保证:如果目标地址非法,就返回未复制的字节数,内核代码不至于崩溃。
用的时候记得看返回值:这两个函数返回的是未复制成功的字节数,不是错误码。复制完全成功时返回0。我见过有人写:
if (copy_to_user(buf, data, count)) return -1;这样其实也能用,但更严谨的写法是返回-EFAULT:
if (copy_to_user(buf, data, count)) return -EFAULT;因为用户空间的 read 系统调用拿到-EFATAL之后会知道是地址非法,而不是笼统的 IO 错误。
4.3 一个简单的环形缓冲思路
实验里通常不需要多复杂,维护一个固定大小的内核缓冲区就行。写入时copy_from_user进来,读出时copy_to_user出去。但要注意,真实的驱动很少这样直接用同一块缓冲区既读又写,因为牵扯到并发访问。实验阶段可以先用一个简单策略:write 总是覆盖缓冲区,read 从缓冲区按位置读。这样 echo 和 cat 测试时行为是符合直觉的。我在代码里就用了kzalloc申请的 128 字节缓冲区,write 时把用户数据拷进来,read 时从缓冲区拷出去。
5. 完整实验代码与加载测试流程
理论部分说完了,下面是我在实验里实际跑的代码。为了可读性我做了简化,去掉了一些调试打印,但功能是完整的:注册一个字符设备,支持读写,卸载时正常释放资源。
5.1 驱动源码 demo_char.c
#include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #include <linux/slab.h> #define DEVICE_NAME "demo_char" #define BUFFER_SIZE 128 static dev_t dev_num; static struct cdev demo_cdev; static char *kernel_buffer; static int demo_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo_char: open called\n"); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { int len = strlen(kernel_buffer); if (*ppos >= len) return 0; if (count > len - *ppos) count = len - *ppos; if (copy_to_user(buf, kernel_buffer + *ppos, count)) return -EFAULT; *ppos += count; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { if (count > BUFFER_SIZE - 1) count = BUFFER_SIZE - 1; if (copy_from_user(kernel_buffer, buf, count)) return -EFAULT; kernel_buffer[count] = '\0'; *ppos += count; return count; } static int demo_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo_char: release called\n"); return 0; } static struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, .write = demo_write, .release = demo_release, }; static int __init demo_init(void) { int ret; kernel_buffer = kzalloc(BUFFER_SIZE, GFP_KERNEL); if (!kernel_buffer) return -ENOMEM; ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { pr_err("demo_char: alloc_chrdev_region failed, ret=%d\n", ret); kfree(kernel_buffer); return ret; } pr_info("demo_char: major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num)); cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, dev_num, 1); if (ret < 0) { pr_err("demo_char: cdev_add failed, ret=%d\n", ret); unregister_chrdev_region(dev_num, 1); kfree(kernel_buffer); return ret; } pr_info("demo_char: registered successfully\n"); return 0; } static void __exit demo_exit(void) { cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); kfree(kernel_buffer); pr_info("demo_char: unregistered\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Student"); MODULE_DESCRIPTION("A simple character device driver");5.2 配套 Makefile
obj-m := demo_char.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编译前确认系统装了内核头文件。/lib/modules/$(uname -r)/build这个目录存在且能正常编译,环境才算就绪。我见过不少实验卡在第一步就是没装 linux-headers 包。
5.3 加载、创建设备节点、读写测试
编译出 demo_char.ko 之后,按下面的顺序操作:
# 加载模块 sudo insmod demo_char.ko # 查看内核日志,确认分配的主设备号 dmesg | tail # 查看已注册的设备号 cat /proc/devices | grep demo_chardmesg里会打印出类似demo_char: major=247 minor=0的信息。记下这个主设备号,然后手动创建设备节点:
# c 表示字符设备,主设备号247要替换成你实际看到的数字,次设备号0 sudo mknod /dev/demo_char c 247 0然后测试读写:
echo "hello kernel" > /dev/demo_char cat /dev/demo_char正常情况下 cat 会输出hello kernel。整个过程不需要写任何用户代码,shell 的重定向和 cat 本身就是最直接的测试工具。因为写再读不是同一个文件描述符,cat 新 open 时 ppos 是0,所以能从头读到写入的内容。
5.4 用一小段用户程序验证
如果想验证 ppos 的语义,可以写个简单的 C 程序:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main(void) { int fd = open("/dev/demo_char", O_RDWR); char buf[64] = {0}; if (fd < 0) { perror("open"); return 1; } write(fd, "hello from userspace", 20); lseek(fd, 0, SEEK_SET); read(fd, buf, sizeof(buf) - 1); printf("read: %s\n", buf); close(fd); return 0; }注意中间那个lseek(fd, 0, SEEK_SET)。因为 write 之后文件位置指针已经到了20,不回到0的话 read 会认为已经到末尾,返回0。这个细节恰好印证了我前面说的:内核不会帮你维护 ppos,它是你的回调函数自己负责的。
5.5 卸载模块
sudo rmmod demo_char如果提示Module demo_char is in use,说明有进程还开着设备文件。把那个进程关掉或者执行fuser -k /dev/demo_char之后再卸载。这就是owner = THIS_MODULE在起作用。
6. 注销的对称性工程:最容易丢分的清理环节
实验评分里最常见的扣分点,不是注册没成功,而是卸载时资源没释放干净。内核模块和用户程序最大的差别之一就是,模块一旦加载,它的生命周期一直到卸载才结束,中间的每一步都可能出问题,而错误处理路径必须把已经申请的资源全部归还。
6.1 每步注册都有对应的注销
我习惯把注册和注销写成一张对照表,按照初始化的逆序来清理:
| 注册阶段 | 注销接口 | 对应资源 |
|---|---|---|
| alloc_chrdev_region | unregister_chrdev_region | 设备号 |
| cdev_init + cdev_add | cdev_del | cdev 对象 |
| kzalloc | kfree | 缓冲区内存 |
顺序也很重要。先cdev_del把设备从内核设备表中摘掉,再unregister_chrdev_region释放设备号,最后kfree释放缓冲区。如果先释放缓冲区,此时设备还没摘除,万一还有人在用,回调函数访问的就是已释放的内存,这就是典型的 use-after-free。反过来如果先 unregister 设备号再 cdev_del 也行,但习惯上cdev_del优先更直观——先把服务停掉,再撤门牌号,最后拆房子。
6.2 初始化失败路径也要对称清理
这只是正常卸载的情况。更难的是初始化到一半失败的场景。比如alloc_chrdev_region成功了,但cdev_add失败了。这时候不能直接 return,因为设备号已经占用了,必须手动调用unregister_chrdev_region释放。这种"部分成功"的错误处理是一个完整的技能:如何在失败时知道到底哪几步成功了,进而只清理那几步。
简化但好用的做法是goto err标签逐级回退。我在上面的代码里就是这么写的:分配缓冲区失败直接返回;注册设备号失败则释放缓冲区;cdev_add 失败则释放设备号和缓冲区。每一步都精确回退到自己已经申请的资源,不多释放也不少释放。
6.3 设备节点要不要删?
有人会问,mknod 创建的/dev/demo_char节点在卸载模块后还在不在?在,但它变成了一点意义都没有的驻留文件。你会看到ls -l /dev/demo_char还是显示对应的主次设备号,但打开它时内核找不到对应的 cdev,返回 ENODEV。所以在实验报告或者实际操作中,删设备节点的操作一般放在卸载之后:
sudo rmmod demo_char sudo rm -f /dev/demo_char当然这只是手动管理设备节点的做法。真实驱动中会有 class_create 和 device_create 来自动生成节点,配合 udev 规则,不需要手动 mknod。那是进阶内容,实验阶段先把 mknod 手动流程跑通,对理解设备节点和设备号的关系很有帮助。
7. 踩坑实录与排查方法
写驱动最痛苦的不是写代码,而是出问题以后不知道去哪查。用户程序还能用 gdb,内核模块出了问题轻则 insmod 报错,重则整个系统卡死。下面这些坑我基本都踩过,按出现频率排序。
7.1 insmod 提示 Invalid parameters
这个提示很误导人。insmod demo_char.ko返回Invalid parameters,很多人以为是命令行参数不对。实际上,内核模块加载失败时都会返回负数错误码,insmod 会把常见的错误码翻译成对应的错误字符串。Invalid parameters对应的是-EINVAL,而在这个场景下,多半是模块 init 函数里某个操作失败了,然后返回了错误码。
怎么定位?看 dmesg:
dmesg | tail -20我在代码里每个失败分支都加了pr_err打印,所以alloc_chrdev_region failed还是cdev_add failed,一目了然。有些人写实验代码时不加任何日志,insmod 失败就懵了,这其实是编写习惯问题——内核模块必须打日志,而且要打得有信息量,不只是 "init failed",而是要把失败函数名和返回值都打出来。
7.2 mknod 报 File exists 或者备用设备号冲突
如果/dev/demo_char已经存在,mknod 会报File exists。删掉重新建就行。这个还好,更隐蔽的是设备号冲突。如果你用了register_chrdev_region静态分配,而主设备号已经被别的驱动占用了,insmod 时内核日志会提示分配失败。用动态申请就不会有这个问题,所以我前面反复强调,新手实验用alloc_chrdev_region更省心。
7.3 echo 写入时 Permission denied 或 No such device
Permission denied通常是设备节点的权限问题。mknod 默认创建的节点权限是crw-r--r--,普通用户没有写权限,所以测试要加sudo。No such device则说明设备节点的主次设备号对不上当前内核里注册的号段,多半是你 mknod 时用了错误的设备号。记住一点:设备节点的设备号只是一个标签,真正起作用的是内核里 cdev 注册的那个号段。两者不匹配,打开就会报错。
7.4 读写没有生效,read 永远返回0
这个坑在实验里很经典。read 返回0说明代码认为已经到文件末尾了。如果你刚 write 过,马上在同一个文件描述符上 read,会发现什么都读不到。原因就是字里行间说过多次的 ppos。解决办法两个:要么在驱动里设计 read 永远从缓冲区开头读,忽略 ppos(教学简化版可以这么做);要么用户程序写完后 lseek 到0再 read。我用的是后者,因为这样更符合 POSIX 语义,以后写真实驱动不会带上坏习惯。
7.5 rmmod 卸载不了,提示 Device or resource busy
这个错误说明模块被占用了。检查两件事:一是 ppos 引用计数,看是不是有进程还开着/dev/demo_char;二是进程是否还持有这个设备的文件描述符,比如后台在跑cat /dev/demo_char。用fuser -v /dev/demo_char能看到占用进程,kill 掉就能卸载了。如果这个模块用了 mknod 但没设 owner,卸载时不会报错,但后续访问设备节点会崩溃——这种情况下设备节点要删掉,否则系统行为不可预测。
7.6 排查工具小结
dmesg:内核日志,一切罪证的来源,出问题先看它/proc/devices:当前注册的主设备号和驱动程序名,核对设备号冲突ls -l /dev/demo_char:确认设备节点存在及其类型、主次设备号fuser /dev/demo_char:查谁占用了设备modinfo demo_char.ko:查看模块元信息,确认加载的是否是编译好的版本
做实验时我有个习惯,每步操作后都立刻 dmesg 看一眼。内核模块不像用户程序那样有标准输出,日志几乎是唯一的观察窗口。很多同学 insmod 成功就以为万事大吉,其实内核那边的错误早就打印出来了。
8. 我做完这个实验后的几点体会
最后分享几个实验之外的想法。这个实验虽然叫"注册字符设备",但它的核心价值不在于那几个 API 怎么调用,而在于让你第一次以"内核对象生命周期"的思维写代码。用户程序申请内存可以不管释放,反正进程结束系统回收;但内核模块不是这样,你加载进去,所有资源都要自己记账,卸载时一笔一笔地还,错一步系统就多一个隐患。
另外,字符设备注册的框架思维是可以迁移的。后面学杂项设备(miscdevice)、平台驱动,你会发现它们内部还是 cdev 那一套,只是包了一层更高级的封装。把alloc_chrdev_region -> cdev_init -> cdev_add这条链路理解透,后续不管看什么驱动代码,都能很快定位到"注册点"在哪里,这比背 API 有用得多。
如果做完这个实验想继续深入,我建议自己动手改三件事:把固定缓冲区改成环形缓冲,体会并发读写时位置管理有多麻烦;加一个 class_create 和 device_create 让设备节点自动生成,感受一下 udev 机制;最后再看一遍源码里fs/char_dev.c的实现,你会发现cdev_add内部就是在设备号范围内做了个 radix tree 的索引插入。看到那一层,这个实验才算真正吃透了。