简介:这是一份面向嵌入式Linux开发初学者的字符设备驱动框架详解资料,系统拆解驱动模型中的核心组成,包括struct file_operations操作方法集、struct cdev设备对象、设备号动态分配与注销、cdev_init/cdev_add/cdev_del等注册流程,并用最小可编译示例演示从模块加载到设备卸载的完整过程。资源为PDF格式,共1个文件,压缩包大小仅91KB,便于离线阅读和随时查阅。已有729人学习下载,适合正在学习内核模块编程、希望动手编写字符设备驱动的开发者作入门参考。文档还梳理了open/read/write等回调接口的写法、copy_to_user与copy_from_user在用户空间和内核空间之间交换数据的要点,以及设备文件与驱动挂接的原理,能帮助读者少走弯路,建立清晰的字符设备驱动开发脉络。
1. Linux 字符设备驱动框架:为什么它值得你花一下午搭一遍
如果你写过嵌入式 Linux 应用层程序,大概率对 open、read、write 这套系统调用熟得不能再熟。可一旦好奇「open 一个 /dev/xxx 之后,内核里到底发生了什么」,就会发现应用层和内核之间隔着一层你想看又看不透的膜。那层膜就是字符设备驱动框架。字符设备是 Linux 驱动里最基础、也最常用的一类设备——键盘、串口、GPIO、传感器,往下追到底几乎都是字符设备驱动的路子。这个框架解决的核心问题只有两个:让内核认识你这个设备,让应用层能像操作文件一样操作它。本文不打算给你背源码,而是按我实际趟过的路,把这个框架拆成「先立住理论、再动手复现、最后避坑」的完整流程。哪怕你之前没写过内核模块,按照下面的步骤也能在半小时内让一个虚拟字符设备在 /dev 下活过来,并通过应用层程序真正读到数据。这篇笔记适合正在准备 Linux 驱动面试、或者手头要开始写第一个板级驱动的工程师——它能帮你把「知道」变成「会搭」。
2. 从零搭起一个最小字符设备驱动:file_operations 与注册流程
2.1 字符设备驱动的骨架:struct cdev 与 file_operations
字符设备驱动框架的内核对象是struct cdev,它代表一个内核中的字符设备。你可以把它理解成驱动送给 VFS 的一张名片,名片上最关键的信息是一个函数指针表struct file_operations,也就是应用层 open/read/write 时最终会调用到的回调。常见做法是,驱动作者把设备的具体能力写进这些回调,然后把回调表挂到 cdev 上,再把 cdev 注册进内核,整个框架就转起来了。
// 最小字符设备驱动的核心数据结构 struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .read = my_read, .write = my_write, .release = my_release, }; struct cdev my_cdev; dev_t dev_num; // 设备号:主设备号 + 次设备号这段代码里,.owner = THIS_MODULE的作用是防止设备正在使用时模块被卸载。.open、.read、.write这些回调名背后的函数需要我们自己实现。这里有个新手最容易误解的地方:应用层调用read(fd, buf, len)时,最终执行的是内核空间里my_read这个函数,而不是应用层那个 read 系统调用本身。系统调用只是通道,真正的数据搬运和硬件操作都发生在my_read里。所以字符设备框架的本质,就是把内核的调用约定(file_operations)和驱动业务逻辑粘在一起。
2.2 最小模块代码:insmod 之前你要想清楚的三件事
我一般建议先写一个不做任何硬件操作的最小模块,先把框架跑通,再往里填业务。在动手敲代码前,先想清楚三件事:设备号怎么来、设备节点的创建方式、以及模块退出时如何干净地释放资源。
// minimal_chrdev.c —— 最小字符设备驱动 #include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #define DEVICE_NAME "mychrdev" #define BUF_LEN 64 static char kernel_buf[BUF_LEN] = {0}; static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "mychrdev open\n"); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { size_t size = (len > BUF_LEN) ? BUF_LEN : len; if (copy_to_user(buf, kernel_buf, size) != 0) return -EFAULT; return size; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { size_t size = (len > BUF_LEN) ? BUF_LEN : len; if (copy_from_user(kernel_buf, buf, size) != 0) return -EFAULT; return size; } static int my_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "mychrdev release\n"); return 0; } static struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .read = my_read, .write = my_write, .release = my_release, }; static int __init my_init(void) { // 动态分配设备号:主设备号由内核分配,次设备号从 0 开始 if (alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME) < 0) { pr_err("alloc_chrdev_region failed\n"); return -EIO; } cdev_init(&my_cdev, &my_fops); my_cdev.owner = THIS_MODULE; if (cdev_add(&my_cdev, dev_num, 1) < 0) { unregister_chrdev_region(dev_num, 1); pr_err("cdev_add failed\n"); return -EIO; } pr_info("mychrdev init, major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num)); return 0; } static void __exit my_exit(void) { cdev_del(&my_cdev); unregister_chrdev_region(dev_num, 1); pr_info("mychrdev exit\n"); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE("GPL");代码逻辑分三段:my_init负责分配设备号、初始化 cdev 并注册进内核;my_open到my_release是设备操作回调;my_exit做逆序清理。参数说明里最关键的是alloc_chrdev_region的第一个参数传的是指针&dev_num,内核会把分配好的设备号填进去,所以后面cdev_add才能用同一个 dev_num。copy_to_user和copy_from_user是内核态与用户态数据搬运的专用接口,绝对不能直接用memcpy——用户态指针在内核态不可直接解引用,否则会因访问非法地址导致内核崩溃。
编译这个模块需要内核头文件,在开发板上最好用与运行内核版本一致的工具链。主机上如果装了 linux-headers 包,也可以直接 make。最简单的 Makefile 写法是:
obj-m := minimal_chrdev.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean然后执行make,得到minimal_chrdev.ko。这一步验证了模块能通过编译,接下来要解决的是设备节点的问题——否则你 insmod 之后会发现/dev/mychrdev并不存在,应用层无从访问。
3. 设备号的分配与自动创建设备节点:主设备号、次设备号和 udev 的配合
3.1 静态分配还是动态分配:alloc_chrdev_region 的选型
设备号由主设备号和次设备号组成。主设备号用来区分设备类型,次设备号用来区分同类型下的不同实例。内核拿到dev_num后,MAJOR()和MINOR()宏可以从一个 32 位值中拆出这两个部分。选型时,静态分配是register_chrdev_region,需要你自己指定主设备号;动态分配是alloc_chrdev_region,由内核挑一个空闲的主设备号给你。强烈建议用动态分配,原因很简单:静态分配需要你去查阅内核的官方设备号分配表,选一个没被占用的主设备号,这在不同内核版本上并不保证一致,稍有不慎就撞车;动态分配则把这个包袱丢给内核,你只需要在 insmod 后从dmesg或/proc/devices里读出实际拿到的主设备号即可。
# 加载模块后查看设备号分配情况 sudo insmod minimal_chrdev.ko cat /proc/devices # 输出片段示例: # Character devices: # 1 mem # 4 tty # 240 mychrdev/proc/devices里列出了内核注册的字符设备主设备号和名字。看到 mychrdev 出现在这里,说明 cdev 注册成功。但此时/dev/mychrdev还不存在,需要mknod手动创建,或者用 udev 自动创建。手动创建是一次性的,适合调试;自动创建是正规做法。
# 手动创建设备节点(不推荐用于产品,适合验证) sudo mknod /dev/mychrdev c 240 0 sudo chmod 666 /dev/mychrdev这条命令中,c表示字符设备,240是刚才从/proc/devices里读到的实际主设备号,0是次设备号。手动创建的缺点很现实:如果模块重新加载后主设备号变了,节点就和设备对不上。这也是我们要引入 udev 的原因。
3.2 用 class_create 让 /dev 节点自动现身
现代 Linux 发行版和绝大多数嵌入式系统都跑着 udev,它监听内核事件。驱动里只要创建了一个struct class,并在这个 class 下创建设备,udev 就会自动在/dev下生成对应节点。内核里class_create和device_create这两个接口就是干这个的。
// 在 my_init 中增加 class 和 device 创建 #include <linux/device.h> static struct class *my_class; static int __init my_init(void) { // ... 原有的 alloc_chrdev_region 和 cdev_add 逻辑 ... my_class = class_create(THIS_MODULE, "mychrdev_class"); if (IS_ERR(my_class)) { unregister_chrdev_region(dev_num, 1); cdev_del(&my_cdev); return PTR_ERR(my_class); } if (IS_ERR(device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME))) { class_destroy(my_class); unregister_chrdev_region(dev_num, 1); cdev_del(&my_cdev); return -EIO; } return 0; }device_create函数的第二个参数是父设备指针,一般填 NULL;第四个参数是设备号,就是这个设备的招牌;最后一个参数是设备节点名,也就是最终/dev下看到的文件名。这套机制的好处是,驱动加载时device_create会向用户空间发送 uevent,udev 收到后根据 class 和 devnode 的规则自动创建设备节点,你不需要再关心主设备号是多少。卸载驱动时,别忘了在my_exit里逆序处理:先device_destroy,再class_destroy。
3.3 设备号相关的两个常用命令:cat /proc/devices 与 ls -l /dev
搭好框架后,最常用的验证命令就是这一对:cat /proc/devices看内核侧注册结果,ls -l /dev/mychrdev看用户侧节点属性。节点信息里你会看到crw-rw----,第一个字符c说明是字符设备,括号里或者主次设备号区域会显示类似240, 0这样的数字。如果ls -l显示的是240, 0,说明节点与内核注册的设备号对上了;如果显示的还是你之前手动mknod的旧主设备号,而/proc/devices里已经是新号,那就说明节点过期了。用 udev 自动创建后,这个问题会自然消失。
一个常见的调试命令组合是先清掉旧节点再重新加载模块:
sudo rm -f /dev/mychrdev # 清理旧节点 sudo insmod minimal_chrdev.ko sleep 1 ls -l /dev/mychrdev如果节点自动出现了,说明 udev 链路是通的。这一步是整个框架能否被应用层顺利调用的一道闸门,很多驱动「明明 insmod 成功却打不开设备」,问题就出在节点和设备号不匹配上。
4. 把 open/read/write/ioctl 做实:核心回调的设计与参数坑
4.1 read 和 write 的缓冲区参数为什么容易翻车
框架搭好后,真正干活的是回调函数。read和write的原型里,第二个参数是用户态指针,它指向应用层传入的缓冲区。这个指针只能通过copy_to_user/copy_from_user访问,而且要严格控制拷贝长度。常翻车的地方有三处:第一,内核缓冲区长度不足,导致越界;第二,用户态传进来的长度不合法,比如负数,需要显式判断;第三,copy_to_user失败时没有返回-EFAULT,而是继续往上走,导致应用层拿到了脏数据。
static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { size_t size; // 1. 校验长度:超过内核缓冲区则截断 if (len > BUF_LEN) size = BUF_LEN; else size = len; // 2. 判断 copy_to_user 是否成功:非零表示有字节没拷过去 if (copy_to_user(buf, kernel_buf, size) != 0) { pr_err("copy_to_user failed\n"); return -EFAULT; } return size; // 返回实际读取字节数,应用层 read 才能拿到正确值 }这里有个容易踩的坑:read的返回值表示实际读到的字节数。如果返回 0,应用层会认为文件结束;如果返回负数,read 会得到 -1 并设置 errno。所以驱动里一定要确保返回值语义正确。另一个细节是loff_t *off偏移参数,很多简单设备并不支持 lseek,这时可以忽略它,但要注意如果应用层用read循环读取,而驱动不维护偏移,每次读出内容都是从头开始的,这会导致应用层死循环或读到重复数据。很多「串口读数据重复」的问题就是驱动没有处理偏移导致的。
4.2 ioctl 是字符设备驱动的黑匣子,命令号要这样编
除了 read/write,字符设备另一个常用回调是ioctl,它用来传控制命令,比如设置波特率、启动采集、调节音量。ioctl 的命令号不是随便写的整数,需要遵循内核的编码规则:方向(读/写/无数据)、大小、类型和序号。使用_IO、_IOW、_IOR宏可以帮助你规范地生成命令号。
#include <linux/ioctl.h> #define MY_IOCTL_MAGIC 'M' #define MY_IOCTL_GET_DATA _IOR(MY_IOCTL_MAGIC, 1, char *) #define MY_IOCTL_SET_MODE _IOW(MY_IOCTL_MAGIC, 2, int) static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { int mode; switch (cmd) { case MY_IOCTL_GET_DATA: // 把内核缓冲区的数据拷贝给用户 if (copy_to_user((char __user *)arg, kernel_buf, BUF_LEN) != 0) return -EFAULT; break; case MY_IOCTL_SET_MODE: // 从用户取一个 int 类型的参数 if (copy_from_user(&mode, (int __user *)arg, sizeof(mode)) != 0) return -EFAULT; pr_info("set mode = %d\n", mode); break; default: return -ENOTTY; // 未知命令统一返回该错误 } return 0; } // 记得在 file_operations 中增加: // .unlocked_ioctl = my_ioctl,命令号编码这块,用宏的好处是内核能校验命令号的方向和参数大小,并且可以在_IOC层面做隔离。很多人图省事直接case 1:、case 2:,这在小项目里暂时能用,但一旦多个驱动共用一个主设备号或者和别的模块冲突,就会互相串扰。按标准用_IO*宏还有个隐藏好处:内核在fs/ioctl.c里做一些预处理时,能识别出哪些命令不需要拷贝数据,进而走快速路径。虽然这些宏编译后只是整数,但它们保证了命令号的唯一性和可读性。
4.3 并发与原子性:一个简易互斥量的引入
字符设备框架默认不具备并发保护。当两个进程同时open同一个设备,或者一个在read时另一个在write,内核缓冲区可能被写乱。很多新手写驱动时没加任何锁,测试时只有单进程访问,看起来一切正常;一上真实业务就数据错乱,这是并发问题导致的典型翻车。解决手段是老三样:自旋锁、互斥量和原子变量。字符设备里最常用的是互斥量,因为可能发生访问冲突的区域通常能容忍睡眠等待。
#include <linux/mutex.h> static struct mutex my_mutex; static int __init my_init(void) { mutex_init(&my_mutex); // ... 其余初始化 ... } static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { int ret; if (mutex_lock_interruptible(&my_mutex)) return -ERESTARTSYS; // ... 原有读取逻辑 ... mutex_unlock(&my_mutex); return ret; }选mutex_lock_interruptible而不是mutex_lock,是为了让等待锁的进程可以被信号打断,避免因驱动占用锁时间过长导致应用层无法用 Ctrl+C 退出。这是驱动开发中的一个差别细节:mutex_lock一旦等待,进程进入不可中断睡眠,对交互式应用是灾难。如果你的临界区很短,也可以考虑自旋锁;但自旋锁在临界区里不能调用copy_to_user这类可能睡眠的函数,否则会死锁甚至 panic。把互斥量加进read、write、ioctl之后,框架的并发问题算是解决了一大半。
5. 字符设备框架避坑清单:编译、加载与测试中的 5 个常见问题
5.1 加载模块报错 "Unknown symbol"
insmod 时报Unknown symbol xxx或Unknown symbol in module,原因是模块里引用了内核未导出的符号,最常见的是__this_module或某些 GPL 符号,而模块的 MODULE_LICENSE 不是 GPL。现象是 insmod 明确给出符号名,却不知道去哪找。
解决办法分两步。先检查模块源码的MODULE_LICENSE是否设成了GPL或GPL v2,很多驱动代码里只写了MODULE_LICENSE("Dual BSD/GPL")或干脆没写,导致依赖 GPL 导出的符号时加载失败。然后确认内核的CONFIG_MODULE_UNLOAD等配置,以及编译时用的内核头文件版本是否和运行内核一致。版本不一致是最隐蔽的坑:开发机上内核是 6.8,但板子里跑的是 6.6,编译出来的 .ko 能编过却在 insmod 时报Invalid module format或 unknown symbol。解决方式就是用uname -r确认运行内核版本,然后用对应版本的 build 目录去 make。
5.2 设备节点有了,但 open 返回 -ENODEV
/dev/mychrdev存在,ls -l看到的设备号也是内核注册的,但应用层open("/dev/mychrdev", O_RDWR)返回 -1,errno 是 ENODEV。很多人在这个问题上卡一整天甚至怀疑人生,因为从文件系统层面看节点没有任何问题。
原因在于cdev_add虽然成功了,但设备号对应的 cdev 并没有真正被内核关联,最常见的情况是cdev_add使用的设备号和device_create使用的设备号不一致。比如alloc_chrdev_region分配出dev_num,但device_create传的是MKDEV(MAJOR(dev_num), 1),次设备号错位,导致节点指向了一个未注册的次设备。解决方法是把设备号统一为同一个变量,并用MAJOR(dev_num)和MINOR(dev_num)宏打印出来核对。还有一种情况是设备类不匹配:device_create创建的 device 属于mychrdev_class,但该 class 对应的 devnode 规则被 udev 规则覆盖,节点权限或名称异常。排查时用udevadm info /dev/mychrdev查看设备属性,能快速确认节点与设备是否绑定。
5.3 cat /dev/xxx 卡死或反复打印 0
应用层执行cat /dev/mychrdev,终端卡住不动,或者屏幕上不停地输出 0。这是 read 回调没有正确处理偏移量和返回值导致的典型症状。cat会持续调用read,直到read返回 0 才认为文件结束。如果驱动的read每次都返回固定个字节,比如 64,且不修改*off,那么cat会一直读下去,表现为卡死;如果驱动每次都把缓冲区里没被写入的 0 返回给用户,就会看到疯狂刷 0。
解决方式需要分业务场景。如果是模拟设备,应该在偏移超过缓冲区长度后返回 0;如果是硬件设备,比如串口或传感器,每次 read 应该返回新采集到的数据,偏移参数可以忽略,但应用层就不能用cat来测试,而应该用专门的测试程序按包读。区分这两类最简单的方法是在 read 回调里打印*off,观察 cat 的行为。我遇到过一个同事,驱动里用global_pos作为偏移,多个进程共享同一个全局偏移,结果进程 A 读到文件末尾后,进程 B 也直接读到 EOF。正确做法是把偏移保存在struct file的private_data里,或者直接依赖内核传给 read 的*off。
5.4 卸载模块后设备节点残留
模块已经rmmod成功了,cat /proc/devices里也看不到 mychrdev 了,但/dev/mychrdev还躺在那里,应用层 open 返回 ENODEV,或者干脆别的地方出现相同的名字冲突。这是因为你在代码里只做了cdev_del和unregister_chrdev_region,但没有做device_destroy和class_destroy。udev 在设备被销毁时会收到 uevent,然后自动删除节点;如果驱动没发这个 uevent,节点就成了僵尸。
解决方式就是在my_exit里严格按照注册的逆序来:
static void __exit my_exit(void) { device_destroy(my_class, dev_num); // 先销毁设备,触发 udev 删节点 class_destroy(my_class); // 再销毁 class cdev_del(&my_cdev); // 删除 cdev unregister_chrdev_region(dev_num, 1); // 最后释放设备号 }这里有个容易被忽略的细节:device_destroy的第二个参数必须和device_create时传的设备号一致,否则它会去销毁别的设备,留下错乱的节点。我曾经因为把次设备号写死为 0,而在动态分配时获得了次设备号 1,结果卸载后真正的设备节点没删掉,反而误删了 /dev 下的其他设备。所以永远不要手写设备号,一律用注册时保存的dev_num。
5.5 多进程同时读写时数据错乱
设备可以同时被两个进程打开,各自读写同一份内核缓冲区,数据经常互相覆盖。比如进程 A 写入 "hello",进程 B 写入 "world",最后读出来的内容可能是二者拼凑成的乱码,甚至在调试日志里看到交叉的写操作。根本原因是没有加锁,或者只加了锁却只保护了部分路径。
解决方式是统一在read、write、ioctl三个入口加锁,并且保证锁的粒度覆盖完整的「读取参数 + 操作缓冲区 + 返回结果」流程。特别注意一点:千万不要在持锁期间调用copy_to_user和copy_from_user之外的睡眠函数,比如msleep或wait_event,否则可能引发死锁,因为互斥量睡眠时会持有锁,另一个进程再访问时就会一直睡下去。如果业务逻辑确实需要在等待硬件时释放锁,那说明设计上应该用等待队列或 completion 而不是裸互斥量。对于初学者,先把互斥量加对,不要让两条路径同时操作同一块 buffer,基本就够用了。
6. 给框架加一个调试与验证的后手:从 printk 到 udevadm 的排查链条
框架跑通之后,我建议你立刻给自己的驱动加一套「可观测性」手段,而不是等到出问题再去追。内核里最基础的手段是 printk,但 printk 的日志级别不对会导致你看不到输出。调试时统一用pr_info,然后查看dmesg确认日志确实打印了。有一种情况非常迷惑:printk打了日志,但dmesg里看不到,原因是日志级别低于console_loglevel,被过滤了。临时检查可以echo 8 > /proc/sys/kernel/printk把级别拉满,生产环境再调回来。
节点层面的排查,我习惯按这个链条走:先cat /proc/devices确认内核侧注册;再ls -l /dev/mychrdev确认用户侧节点;两者对不上就用udevadm monitor观察加载模块时是否有 uevent 发出。如果你在自己的驱动里手动调用device_create,但又看不到节点生成,多半是 udev 规则没有识别到你的 class 名称,可以用udevadm info /dev/mychrdev看节点的设备号和服务端属性。驱动内部的问题,则依赖在 open/read/write 里加pr_debug,但要注意pr_debug默认不输出,需要定义DEBUG宏,我一般直接改成pr_info,排完错再改回去。
我最常做的一个验证小技巧是写一段 20 行的应用层测试程序,专门测试边界值:
// test_chrdev.c —— 验证驱动的读写和错误返回 #include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> #include <errno.h> int main(void) { int fd = open("/dev/mychrdev", O_RDWR); if (fd < 0) { perror("open"); return 1; } char buf[128] = {0}; char wbuf[] = "hello from userspace"; // 测试写入和回读 write(fd, wbuf, strlen(wbuf)); read(fd, buf, sizeof(buf)); printf("read: %s\n", buf); // 测试非法指针:故意传 0 地址,看驱动是否防御 int ret = read(fd, (char *)0, 100); if (ret < 0) printf("illegal ptr got errno %d\n", errno); close(fd); return 0; }这个程序不测试硬件,只测试框架本身的行为。传 0 地址给 read,如果驱动没有用copy_to_user或者没有检查返回值,内核就会报 oops,而加了防御的驱动会返回-EFAULT。这一段测试价值很大,它能确认你的驱动对非法指针是能扛住的。遇到 oops 也不要慌,把dmesg里的寄存器快照和调用栈贴出来,基本能定位到哪一行出了问题。
这套从 printk 到 udevadm 的排查链路,是我每写一个字符设备驱动都会先铺好的基础设施。框架本身不复杂,复杂的永远是边界情况、并发路径和用户态配合。先花十分钟把这些调试后手埋好,后面出问题时能省下半天。希望这篇文章帮你把那层「膜」捅破,少踩几个我当年踩过的坑。
本文还有配套的精品资源,点击获取