ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux字符设备驱动全解析:从设备号到file_operations实战

Linux字符设备驱动全解析:从设备号到file_operations实战

很多刚入坑 Linux 开发的朋友,前面几篇内核模块的内容还能跟上节奏——module_init、module_exit、printk,逻辑清清楚楚。但一进入字符设备驱动,就开始发懵:设备号到底是干什么用的?为什么驱动的核心是一堆函数指针?应用层调 read、write,跟我写的 demo_read、demo_write 是怎么扯上关系的?这篇我就把字符设备驱动这条链路完整捋一遍,从设备号分配、file_operations 结构体,到一个能真正读写数据的字符设备驱动,最后讲设备节点自动创建和调试排错。适合刚学完内核模块、想进入驱动实战阶段的读者,看完之后照着敲一遍,基本就能理解驱动的整体工作方式。

1. 先搞懂字符设备到底在"解决什么问题"

1.1 设备文件是用户态和内核态的"接驳口"

你可以先在终端里执行ls -l /dev,会看到一大堆名字,比如/dev/tty0、/dev/input/mouse0。这些叫设备文件,也叫设备节点。用户态程序对它们做 open、read、write、close,表面上和你打开一个普通文本文件没有区别,但背后的路径完全不同:普通文件走的是文件系统,设备文件走的是设备驱动。

字符设备驱动就是负责处理这些"按字节流访问"的设备的内核模块。什么叫按字节流?你读一个串口、读一个温度传感器、读一个按键事件,数据就是一个个字节流进来的,没有固定块大小,也不需要像硬盘那样按扇区对齐。正因为这类设备数量庞大、模型最简单,它成了学习内核驱动的最佳起点。可以说,绝大多数外设驱动(GPIO、I2C、SPI、UART、USB、声卡)在 Linux 里最终都可以抽象成字符设备来管理。

1.2 字符设备、块设备和网络设备怎么区分

Linux 的设备驱动大体分三类,新手很容易混,一张表看明白:

类型访问方式典型设备用户态接口
字符设备按字节流,顺序访问串口、GPIO、触摸屏/dev/xxx设备文件
块设备按数据块,随机访问硬盘、SSD、SD 卡/dev/sda,最终走文件系统
网络设备按数据包收发网卡、无线网卡socket 接口,不走设备文件

理解这个分类有个很实际的作用:写驱动前先想清楚你的设备到底属于哪类。比如你要驱动一个 SPI 接口的显示屏,它虽然一块一块往显存里写数据,但对应用层来说依然是一个字节流设备,所以还是字符设备驱动;而如果你写的是存储介质驱动,就得走块设备那套体系,不能混用。

我刚开始学的时候总有一个误解:以为"字符设备"一定是传输字符的。其实不是,这里的"字符"指的是访问的最小单位是字节。你可以把字符设备理解成一个管道,应用层往里塞字节、往外取字节,驱动只管处理这些字节流。搞懂这一点,后面看read、write回调函数就好理解了。

2. 设备号:字符设备在内核里的"户口本"

2.1 dev_t、主设备号和次设备号

任何一个字符设备在内核里都要有一个唯一身份标识,这就是设备号。内核用dev_t类型表示设备号,它实际上是一个整数,被拆成两部分:主设备号和次设备号。主设备号标识设备对应的驱动程序,次设备号标识同一种驱动控制下的第几个设备实例。

内核提供了一组宏来处理设备号:

#include <linux/kdev_t.h> dev_t dev = MKDEV(major, minor); // 由主次设备号拼出 dev_t major = MAJOR(dev); // 取主设备号 minor = MINOR(dev); // 取次设备号

你可以把主设备号理解成小区里的"几号楼",次设备号是"几零几室"。内核找驱动的时候,先用主设备号定位到具体的驱动程序,再用次设备号找到具体的设备实例。比如同一块 SPI 控制芯片上挂了两个传感器,它们可以是同一个主设备号、不同的次设备号。

2.2 静态指定还是动态分配:为什么我推荐动态分配

申请设备号有两种方式。

一种是静态指定——你自己定一个主设备号,然后用register_chrdev_region强行注册:

dev_t dev = MKDEV(240, 0); register_chrdev_region(dev, 1, "demo_char");

这种方式的麻烦在于,你怎么知道 240 这个号没被别人占用?每个系统上已注册的设备号都不同,你写死一个号,换台机器跑可能就直接注册失败。而且主设备号是全局资源,随便占用容易和别人冲突,所以我不推荐新手用静态方式。

另一种是动态分配——让内核帮你找一个没被占用的主设备号:

int ret; dev_t dev; ret = alloc_chrdev_region(&dev, 0, 1, "demo_char");

第一个参数是返回的设备号,第二个参数是起始次设备号,第三个是连续分配的数量,第四个是设备名。驱动加载后,你通过cat /proc/devices就能看到内核给这个驱动分配了哪个主设备号。动态分配的好处就是省心、不冲突,而且代码换到任何一台机器上都能跑。这也是当前驱动开发的主流做法。

2.3 从哪看系统里已有哪些设备号

设备号注册完毕,可以用/proc/devices查看。这个虚拟文件实时列出了系统当前注册的字符设备主设备号和块设备主设备号,输出大概是这样:

Character devices: 1 mem 4 /dev/vc/0 4 tty 5 /dev/tty 5 /dev/console 10 misc 13 input ...

我调试驱动时经常先执行一下这个命令,确认自己的设备号有没有成功分配。如果你做动态分配,dmesg里没有打印的话,/proc/devices是最可靠的信息源。

有一点值得留意:Linux 内核官方文档建议,如果你要提交到内核主线,优先使用动态分配,不要在驱动里写死一个主设备号。社区维护者看到硬编码设备号的补丁,基本都会让你改掉。

3. file_operations:应用层与内核之间的"接口契约"

3.1 函数指针表是如何被调用的

用户态程序执行open("/dev/demo_char", O_RDWR)时,内核的虚拟文件系统(VFS)会根据路径找到对应的设备文件,再根据设备号找到驱动注册的struct cdev,最后调用驱动初始化时注册到file_operations结构体里的 open 函数。

struct file_operations是字符设备驱动的灵魂,它本质上是一张函数指针表:

struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, };

你在这个结构体里填了哪些函数,应用层就能用哪些系统调用。比如你填了.read = demo_read,用户态 read 设备文件时最终会走进 demo_read;你没填.read,用户态 read 这个设备文件就会得到一个错误返回值。

这里特别强调一下.owner = THIS_MODULE。它的作用是标记这个结构体属于哪个内核模块。当有进程正打开着这个设备文件时,THIS_MODULE会让内核增加模块的引用计数,防止你执行rmmod时把还在使用的驱动代码卸掉,导致内核崩溃。

3.2 read/write 的函数签名与代码执行环境

真正写驱动时,read 和 write 的签名是有讲究的:

static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset); static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset);

很多新手第一次看到__user这个标志就懵了。__user只是一个注释性质的宏,告诉内核开发者:这个指针指向用户态内存,不是一个可以直接访问的内核态指针。用户态的内存和内核态的内存处于不同的地址空间,内核不能想去解引用就解引用——轻则触发缺页异常,重则直接导致内核崩溃。

正确的做法是使用专门的安全拷贝函数:

  • copy_to_user(dst_user, src_kernel, count):从内核往用户态拷贝数据
  • copy_from_user(dst_kernel, src_user, count):从用户态往内核拷贝数据

这两个函数内部会做地址合法性校验,并且能处理用户态缺页的情况,返回值是"没有拷贝成功的字节数",返回 0 表示全部成功。

反过来,应用层的read返回时,驱动应该返回实际读到的字节数;如果返回 0,应用层就认为读到了文件末尾。

3.3 哪些回调可以不实现

file_operations里的字段有好几十个,但不需要全部实现。基础阶段你只需要关注open、release、read、write,还有后续会接触到的unlocked_ioctl、llseek、mmap。

open和release其实可以不实现,不实现时内核有默认行为。read和write必须想清楚:如果你的设备是只读或只写的,对应的函数可以不写,应用层操作时自然会得到一个明确的错误。这种"按需填表"的机制对一个驱动来说非常友好,因为接口契约清晰,没实现的字段不会被调用到。

不过要注意:不实现 read/write 时,用户态 read/write 系统调用返回的错误是-EINVAL。我遇到过一个现象,驱动只写 read 忘了写 write,应用层 write 返回 -1 且 errno 是 EINVAL。一开始还以为是应用层的问题,最后查代码才发现是驱动没实现 write 回调。

4. 一个能读能写的字符设备驱动是怎么写出来的

4.1 驱动生命周期与核心对象的关系

写一个完整的字符设备驱动,在 init 函数里要干四件事:分配设备号、初始化cdev并添加到内核、创建设备类、创建设备节点(设备文件)。在 exit 里按相反顺序释放。四个核心对象各有分工:

  • dev_t:设备号,设备在内核中的身份标识
  • struct cdev:描述字符设备本身,包含设备号和 file_operations
  • struct class:设备所属的类别,用于自动创建设备节点
  • struct device:具体的设备实例,最终对应/dev下的一个节点

它们的关系可以类比成:设备号是身份证号,cdev 是户口登记信息,class 是你所属的街道,device 是具体门牌。整个注册流程跑通后,用户才看得到这个设备。

4.2 完整示例代码:一个能存数据的字符设备

下面这个驱动把一块内核缓冲区暴露给用户态,用户写入什么,就能读回什么。虽然功能简单,但五脏俱全,演示了完整的注册、操作、清理流程。

#include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #include <linux/slab.h> #include <linux/mutex.h> #define DEVICE_NAME "demo_char" #define CLASS_NAME "demo_class" static dev_t dev_num; static struct cdev demo_cdev; static struct class *demo_class; static struct device *demo_device; static char *kernel_buf; static struct mutex buf_lock; static int demo_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo_char: open\n"); return 0; } static int demo_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo_char: release\n"); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t available, to_copy; int ret; mutex_lock(&buf_lock); available = strlen(kernel_buf); if (*offset >= available) { mutex_unlock(&buf_lock); return 0; // 读到末尾 } to_copy = count; if (to_copy > available - *offset) to_copy = available - *offset; ret = copy_to_user(buf, kernel_buf + *offset, to_copy); mutex_unlock(&buf_lock); if (ret != 0) { printk(KERN_ERR "demo_char: copy_to_user failed\n"); return -EFAULT; } *offset += to_copy; return to_copy; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { int ret; if (count >= PAGE_SIZE) return -EINVAL; mutex_lock(&buf_lock); ret = copy_from_user(kernel_buf, buf, count); if (ret != 0) { mutex_unlock(&buf_lock); printk(KERN_ERR "demo_char: copy_from_user failed\n"); return -EFAULT; } kernel_buf[count] = '\0'; mutex_unlock(&buf_lock); printk(KERN_INFO "demo_char: received %zu bytes\n", count); return count; } static struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, }; static int __init demo_init(void) { int ret; // 1. 动态分配设备号 ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { printk(KERN_ERR "demo_char: alloc_chrdev_region failed\n"); return ret; } printk(KERN_INFO "demo_char: major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num)); // 2. 初始化并添加 cdev cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, dev_num, 1); if (ret < 0) { printk(KERN_ERR "demo_char: cdev_add failed\n"); goto err_region; } // 3. 创建设备类 demo_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(demo_class)) { printk(KERN_ERR "demo_char: class_create failed\n"); ret = PTR_ERR(demo_class); goto err_cdev; } // 4. 创建设备节点 /dev/demo_char demo_device = device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(demo_device)) { printk(KERN_ERR "demo_char: device_create failed\n"); ret = PTR_ERR(demo_device); goto err_class; } // 分配内核缓冲区并初始化互斥锁 kernel_buf = kzalloc(PAGE_SIZE, GFP_KERNEL); if (!kernel_buf) { ret = -ENOMEM; goto err_device; } mutex_init(&buf_lock); printk(KERN_INFO "demo_char: driver initialized\n"); return 0; err_device: device_destroy(demo_class, dev_num); err_class: class_destroy(demo_class); err_cdev: cdev_del(&demo_cdev); err_region: unregister_chrdev_region(dev_num, 1); return ret; } static void __exit demo_exit(void) { kfree(kernel_buf); device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO "demo_char: driver removed\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("your-name"); MODULE_DESCRIPTION("A simple char device driver demo"); MODULE_VERSION("1.0");

代码里的错误处理全部用的是 goto 方式,这是内核代码里最常见的写法。为什么不用一堆 if else 嵌套?因为驱动 init 里一旦在某一步失败,前面已经申请的资源必须逐级释放。goto 标签天然形成一条"反向销毁链",哪个环节出事,就跳到对应的位置,把已经注册好的资源按相反顺序清理掉。写内核驱动必须养成这个习惯,否则一次加载失败就可能留下残留设备号或 cdev。

4.3 实现 read/write 时最常见的两个错误

第一个错误是忘记维护文件位置*offset。我早期写第一个字符设备驱动时,demo_read 里完全没有处理 offset,每次读取都从头开始返回同样的数据。结果用户态执行cat /dev/demo_char,read 永远有数据,cat 就陷入死循环,终端上疯狂刷屏,只能 Ctrl+C。因为 cat 的判断逻辑很简单:只要 read 返回大于 0,就继续读;read 返回 0 才认为文件结束。所以在 read 里必须根据*offset计算剩余数据量,并在读了之后把它往后移。

第二个错误是直接解引用用户态指针。新手容易写出kernel_buf = buf;这种直拷贝,或者直接strlen(buf)去读用户态字符串。这在某些架构上确实能跑通,但完全不可靠。用户态指针指向的页面可能不在内存中,直接访问会触发缺页;内核态又没有用户态进程的上下文,缺页异常无法正确处理,最终就是内核崩溃。内核里有一条铁律:凡是来自用户态的指针,必须通过copy_to_user/copy_from_user来操作。

我在代码里还加了一把互斥锁保护 kernel_buf,主要考虑多进程同时操作设备文件时,读写同一块缓冲区会互相踩踏。基础阶段用一把大锁是最简单可靠的做法,后续优化再考虑读写锁、原子操作或 per-cpu 缓冲。

4.4 Makefile 与编译加载流程

写驱动的工程文件也很简单,一个 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

obj-m表示把 demo_char.o 编译成内核模块,KDIR指向当前内核版本的构建目录,-C切到内核源码目录执行构建,M=$(PWD)告诉内核构建系统去这个目录编译模块。执行make后生成 demo_char.ko,然后:

sudo insmod demo_char.ko dmesg | tail ls -l /dev/demo_char

如果一切顺利,能看到设备节点自动出现。如果系统内核开启了模块签名校验,insmod 会报Required key not available,这种情况要么关闭 Secure Boot,要么给模块签名,属于环境问题,和驱动代码无关。

4.5 用户态验证:echo、cat 和一段小测试程序

加载驱动后,可以先用 shell 命令验证:

echo "hello driver" > /dev/demo_char cat /dev/demo_char

输出应该是hello driver。这里有个细节:echo会往设备写入包括换行符在内的完整字符串,cat读取时能正常读到换行结尾,所以你看到的输出就是一行干净的文本。如果 read 实现没有处理好 offset,cat 就会无限输出同一行内容。

更可靠的验证方式是用一段小程序测:

#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main(void) { int fd; char buf[128] = {0}; char *msg = "test from app"; fd = open("/dev/demo_char", O_RDWR); if (fd < 0) { perror("open"); return -1; } write(fd, msg, strlen(msg)); read(fd, buf, sizeof(buf) - 1); printf("read: %s\n", buf); close(fd); return 0; }

编译运行后,程序会输出从驱动读回的字符串。到这里,一个最简单的字符设备驱动就完整跑通了。

5. 设备节点自动创建与驱动调试那些事

5.1 为什么设备节点会自动出现在 /dev 下

在早期的 Linux 里,驱动加载成功后不会自动生成设备节点,你需要手动执行mknod /dev/demo_char c 240 0创建,而且主次设备号要和驱动注册的一致。这种方式极度依赖人为记忆,设备多了完全没法维护。

现在的做法是驱动里创建class和device。当调用device_create时,内核的 devtmpfs 会自动在/dev下生成对应的节点,这就是为什么上面的 demo 代码中device_create一句调用后,/dev/demo_char就直接出现了。

device_create的参数需要逐一说清:第一个参数是设备所属的 class;第二个是父设备指针,没有就填 NULL;第三个是设备号;第四个是私有数据指针;第五个是设备节点的名字。还有一个容易被忽略的细节:设备节点名字和传给alloc_chrdev_region的设备名是两个概念,前者出现在/dev下,后者只是设备号在内核里的登记名,可以不一样。

5.2 调试驱动到底看什么

驱动调试最主要的信息来源就是dmesg,也就是内核环形缓冲区的内容。我们代码里用的printk(KERN_INFO ...)、printk(KERN_ERR ...)都会输出到这里。

新手第一次insmod后去看dmesg,很常见的情况是什么也没有。原因多半是 printk 的日志级别低于当前控制台级别。可以在测试环境直接执行dmesg -n 8把控制台日志级别调高,让所有 printk 都直接显示在终端上。不过这只是调试手段,正式驱动的 printk 级别应该按实际情况选择,比如KERN_ERR留给错误,KERN_INFO留给关键流程提示。

驱动的调试手段不止 printk。/proc和/sys下也有很多信息可以利用,比如/proc/devices查设备号分配,/sys/class/demo_class/查设备类信息。我在调驱动时经常顺手写一个 debugfs 文件,把驱动内部状态导出来看,方便很多。这个等后面讲 debugfs 的时候再展开。

5.3 我踩过的启动期和操作期报错

分享几个常见的报错场景,都是有代表性的。

第一个是cdev_add返回-17。这个错误码对应EEXIST,表示设备号冲突。最常见的原因是动态分配后,某个静态驱动已经占用了这个主设备号,或者卸载驱动时没有unregister_chrdev_region,残留了设备号。遇到这种问题,先cat /proc/devices看冲突的号,再排查卸载流程是否完整。

第二个是device_create返回错误指针。注意这个函数返回的不是 NULL,而是一个ERR_PTR编码的错误指针。判断方法要用IS_ERR,取错误码用PTR_ERR。千万不要直接和 NULL 比较,否则你拿到的错误码是一个负数,日志里看起来像莫名其妙的大地址,容易误导排查方向。

第三个是insmod成功了,但/dev下没有设备节点。这个大概率是class_create或device_create某一步失败了,但驱动 init 还是返回成功。我见过有人把device_create的错误忽略掉,设备没生成,应用层 open 设备文件一直报 No such file or directory。建议驱动里每一个关键步骤都必须检查返回值,宁可加载失败也别带病运行。

第四个是操作时报Cannot allocate memory。除了真正的内存分配失败,还有一个隐蔽原因:copy_from_user或copy_to_user返回了非零值,驱动把它原样返回了负数。实际上这两个函数的返回值是"未拷贝的字节数",不是标准 errno。正确做法是只要返回值非零,就返回-EFAULT,而不是返回那个正数。

这些报错都有一个共同点:想快速定位,第一件事永远是看dmesg。驱动代码里的日志信息比应用层崩溃日志可靠得多,因为那是内核自己对问题的直接反馈。

我在实际调这个 demo 驱动时,习惯把每一步的关键参数都打印出来,比如设备号、返回值、上下文字段内容。看起来像是多了几行日志,但排查问题时价值极大。字符设备驱动基础阶段,最重要的事情就是亲手走通一遍数据路径,从应用层 read/write 一路跟进到驱动的回调函数。这一遍走通了,后面再学平台设备、设备树、中断、并发控制,都会有很扎实的底子。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进