"想写个Linux驱动,但打开文档一大堆结构体,完全不知道从哪下手”——这是我被问过最多的一句话。字符设备驱动是几乎所有Linux驱动开发者的第一站,它最简单,却又覆盖了驱动开发最核心的骨架:模块加载、设备号、file_operations、用户态与内核态的数据交换。这篇就以字符设备为主线,把从零写一个可用可测、能自动创建设备节点、能正常收发数据的驱动所需的全部知识讲透。适合正在学驱动开发、或者工作中第一次被分到驱动任务的朋友,看完可以直接在自己的开发机上跑起来。
我不会堆概念,所有讲解都围绕一个具体的模拟项目X——一个叫demo_chrdev的字符设备驱动展开,代码给了,坑也踩了,照着做就能看到效果。
1. 先从内核态和用户态的“信息差”说起
1.1 字符设备的作用:用户程序为什么没法直接碰硬件
我们平时写的应用代码运行在用户态,受CPU和内核双重保护,访问地址是虚拟地址空间的一部分。硬件寄存器、物理内存、中断控制器这些资源处在内核态管辖之下,用户态程序没有权限直接访问。强行访问的结果就是段错误,甚至直接把系统搞崩。
驱动的本质就是填补这个信息差:内核态驱动负责操作硬件,用户态程序通过统一接口来使用驱动,最终间接操作硬件。字符设备是最常见的一种驱动形态,特点是数据以字节流方式顺序读写,像串口、GPIO、按键、传感器这类设备,基本都是字符设备驱动。
你打开/dev目录看到的那些节点,比如/dev/ttyS0、/dev/input/event0,背后都对应着一个字符设备驱动。用户程序对这些文件做open、read、write,实际上就是在和内核里的驱动打交道,这是理解驱动开发的第一个关键认知。
1.2 设备文件与主次设备号的分工
设备文件本身不包含任何数据,它只是一个入口,真正的实体是设备号。设备号分为主设备号和次设备号两部分,主设备号用来定位“这是哪一类设备,对应哪个驱动”,次设备号用来区分“同一驱动下的第几个具体设备”。
我拿一个直观的例子说明:假设你主板上有两个串口,它们都由同一个串口驱动管理,驱动的主设备号是相同的,两个串口各自的设备节点通过不同的次设备号区分。/dev/ttyS0和/dev/ttyS1的次设备号分别是0和1,就这样一一对应。
主次设备号合起来占32位,Linux内核里用dev_t类型表示。高12位是主设备号,低20位是次设备号。主设备号范围为0到4095,这个数字并不是随便写的,内核里有全局的设备号注册表,如果申请了重复的号,驱动加载会直接失败。
1.3 一句话给驱动定一个“最小交付标准”
做一个能用的字符设备驱动,至少得完成四件事:
- 内核能识别这个驱动,也就是模块加载和卸载正常;
- 申请并注册一个设备号;
- 完成
file_operations中必要的操作函数,让用户程序能open、read、write; - 系统能自动创建对应的设备节点,用户程序能直接打开这个节点进行读写。
后面所有内容都围绕这四件事展开。
2. 环境准备与模块骨架
2.1 开发环境怎么搭才省心
调试字符设备驱动,最推荐的是普通PC上的Linux发行版,比如Ubuntu或Debian的笔记本或虚拟机,性能足够、驱动不用交叉编译,直接在本地加载,比嵌入式开发板调试快得多。
建议使用长期支持版内核,避免太新或太旧导致工具链问题。需要安装编译工具链和内核头文件包:
sudo apt update sudo apt install build-essential linux-headers-$(uname -r)安装完后可以验证一下头文件路径:
ls /lib/modules/$(uname -r)/build看到这个目录存在,说明内核头文件装好了,编译模块的准备工作完成。嵌入式环境则要额外准备交叉编译工具链和对应的内核源码,步骤会多一些,但驱动代码本身是一样的。
关于内核源码,这里有个容易让新手迷糊的地方:编译内核模块不需要完整的源码树,只需要头文件和Module.symvers等构建产物。但如果你要调试、要编译内核自带的驱动,就需要完整源码。多数发行版会把头文件单独打包,所以我们这里用头文件包就足够了。
2.2 最基础的模块长什么样
所有驱动最开始都是一个内核模块。用insmod或modprobe的命令插入内核,用rmmod卸载。写模块需要两个必备函数:模块加载时执行的函数和卸载时执行的函数。
一个最简模块的完整代码:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init demo_init(void) { printk(KERN_INFO "demo_chrdev: module loaded\n"); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO "demo_chrdev: module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Demo Author"); MODULE_DESCRIPTION("A minimal char device driver");__init标记告诉内核,这个函数只在初始化阶段使用,模块加载完成后这段内存可以被释放。__exit同理,这种标记不是可有可无的装饰,它能让内核更高效地管理内存。MODULE_LICENSE("GPL")必须保留,否则加载时内核会报“module license 'unspecified' taints kernel”,虽然也可以强制加载,但会污染内核,而且某些内核功能会对非GPL模块关闭,所以写驱动的人都应该带上这行声明。
2.3 Makefile里那两行“魔法”到底做了什么
直接把上面的代码保存为demo_chrdev.c,在同目录新建Makefile:
obj-m := demo_chrdev.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第一次看到这个Makefile的人都会觉得奇怪:obj-m := demo_chrdev.o这一行就够了吗?内核构建系统到底是怎么把它编译成.ko文件的?
其实-C $(KDIR)的意思是“切换到内核源码目录执行make”,M=$(PWD)表示“我管理的模块源码在这个目录”。内核的Kbuild系统读到obj-m后,就知道要把demo_chrdev.c编译成demo_chrdev.ko。整个编译过程实际上是在内核的构建环境里发生的,编译规则、头文件路径都由内核构建系统提供。理解了这一点,你以后看到各种交叉编译的Makefile就不会发怵了,变的只是KDIR的路径和一个工具链前缀。
编译完成后执行:
make sudo insmod demo_chrdev.ko dmesg | tail能看到demo_chrdev: module loaded的输出,就说明模块成功加载了。到这里,驱动前面的路已经通了一半。
3. 把模块升级成真正的字符设备
光有一个模块还不能算驱动,因为没有设备号、没有设备节点、没有任何操作函数。这一节把模块填充成完整的字符设备。
3.1 注册设备号:为什么优先用动态分配
设备号注册有两种方式:register_chrdev_region静态指定、alloc_chrdev_region动态分配。
静态指定的用法是“我已经知道想用什么主设备号”,比如某些固定习惯使用的号。问题在于系统里有大量驱动,你很难保证你指定的号没被占用,一旦冲突,加载失败,还得换号重新编译。
动态分配则是让内核从空闲的主设备号里挑一个给你,缺点是主设备号不固定,但配合后面要讲的device_create机制,用户根本不需要关心主设备号是什么,因为系统会自动创建设备节点。
所以新写驱动时,优先使用动态分配,省去管理设备号的精力,也让代码更通用。
#include <linux/cdev.h> #include <linux/device.h> #include <linux/fs.h> #define DEV_NAME "demo_chrdev" #define DEV_COUNT 1 static dev_t demo_dev; static struct cdev demo_cdev; static struct class *demo_class; static int __init demo_init(void) { int ret; ret = alloc_chrdev_region(&demo_dev, 0, DEV_COUNT, DEV_NAME); if (ret < 0) { pr_err("alloc_chrdev_region failed: %d\n", ret); return ret; } pr_info("demo_chrdev: major=%d minor=%d\n", MAJOR(demo_dev), MINOR(demo_dev)); return 0; }alloc_chrdev_region的四个参数分别是:输出的设备号变量、起始次设备号、连续申请的设备数量、设备名称。名称主要用来在/proc/devices里显示,方便排查。
3.2 cdev_init和cdev_add:把操作函数绑定到设备号
有了设备号,还得有具体的操作函数。内核用struct cdev来描述一个字符设备,把cdev和设备号绑定后,用户态的open、read、write才能真正落到我们写的函数上。
static struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, }; cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, demo_dev, DEV_COUNT); if (ret < 0) { unregister_chrdev_region(demo_dev, DEV_COUNT); pr_err("cdev_add failed: %d\n", ret); return ret; }cdev_add成功之后,这个驱动就已经“内核可见”了。此刻如果手写一个mknod命令手动创建设备节点,然后用cat访问设备文件,就已经能触发驱动的open函数。
我要特别提醒一个新手常犯的错误:cdev_add的时机。它应该在所有初始化资源准备好了之后再调用,因为一旦注册成功,内核就可能立刻分发open请求进来,如果这时候驱动内部的数据还没初始化好,就会出现空指针访问。这也是为什么cdev_add要放在初始化流程比较靠后的位置。
3.3 class_create和device_create:设备节点自动生成
手动mknod创建设备节点太原始,而且在嵌入式设备上根本没法用,因为用户根本不知道主设备号是多少。现代驱动都会借助设备模型自动创建设备节点。
demo_class = class_create(DEV_NAME); if (IS_ERR(demo_class)) { cdev_del(&demo_cdev); unregister_chrdev_region(demo_dev, DEV_COUNT); return PTR_ERR(demo_class); } device_create(demo_class, NULL, demo_dev, NULL, DEV_NAME);class_create创建了一个设备类,本质是内核里的一种分类组织。device_create则是在/dev下创建名为DEV_NAME的设备节点,并且通过内核的uevent机制让用户态的udev程序自动补全节点权限。
节点创建成功后,/dev/demo_chrdev就出现了。整个流程其实是一个“内核态创建node + 用户态守护进程补权限”的协作,缺一哪个环,设备节点都会有问题。
很多人问:device_create的父设备参数为什么填NULL?这个参数是为了建立设备在/sys下的层级关系,如果不属于某个具体的总线设备,填NULL就行。别小看视这个参数,当你的设备是挂在某个总线(比如USB)上时,填上真正的struct device *会让设备模型的层级关系清晰很多,卸载时的顺序也更可控。
4. file_operations:驱动真正的业务层
4.1 open和release:不是简单地返回0
用户程序执行open("/dev/demo_chrdev", O_RDWR),内核最终会调用到驱动的open回调。驱动代码里是最容易出问题的地方,因为很多人以为open只是占个名字,其实它的职责是确认设备状态、初始化本次会话需要的数据、检查访问权限。
static int demo_open(struct inode *inode, struct file *file) { /* 检查设备是否就绪 */ if (!demo_device_ready) { pr_err("demo_chrdev: device not ready\n"); return -ENODEV; } /* 防止重复打开,控制访问计数 */ if (!atomic_dec_and_test(&open_count)) { atomic_inc(&open_count); return -EBUSY; } pr_info("demo_chrdev: opened\n"); return 0; }这里用原子操作atomic_dec_and_test实现了一个“只允许一个进程打开”的信号量效果。第一次open时计数从1变0,返回真,继续执行;第二次open时计数从0变-1,返回假,于是恢复计数并返回-EBUSY。
为什么不用普通的if (open_flag)?因为并发。两个进程几乎同时open时,普通标志位判断可能两个都能通过,造成设备被打开两次。原子操作保证这条判断在CPU层面是不可拆分的。
release函数在文件关闭时调用,职责是把open里的操作反过来:恢复状态、释放资源。
static int demo_release(struct inode *inode, struct file *file) { atomic_inc(&open_count); pr_info("demo_chrdev: released\n"); return 0; }release的返回值虽然有意义,但用户态的close永远不检查它,所以写错也很难发现。我习惯把release里该做的回收工作放在函数最前面,确保即使后面出错,资源也已经被释放了。
4.2 read和write之间隔着一道“不能跨越的墙”
read和write是驱动最核心的业务逻辑。我认为理解这个,最关键的一步就是弄明白:用户态缓冲区指针和内核态缓冲区指针不能直接互相读写。
内核态拥有最高权限,可以直接访问用户态的虚拟地址空间,但是如果用户传入的指针本身是无效的,或者指向的页面还没被映射进来,内核直接访问就会引发页错误,这个错误发生在内核态,会造成系统异常,不能像用户态那样靠段错误捕获。
所以内核提供了一组安全拷贝函数:copy_to_user和copy_from_user。它们会先检查用户指针指向的区间是否合法、页面是否可读可写,然后再执行实际拷贝。如果检查失败,返回未拷贝的字节数。
static char demo_buffer[128]; static size_t data_len; static ssize_t demo_read(struct file *file, char __user *user_buf, size_t count, loff_t *ppos) { size_t bytes_to_read; if (*ppos >= data_len) return 0; bytes_to_read = min(count, data_len - *ppos); if (copy_to_user(user_buf, demo_buffer + *ppos, bytes_to_read)) { pr_err("demo_chrdev: copy_to_user failed\n"); return -EFAULT; } *ppos += bytes_to_read; pr_info("demo_chrdev: read %zu bytes\n", bytes_to_read); return bytes_to_read; }loff_t *ppos是当前的读写位置,内核用这个支持用户空间的lseek。read返回0表示读到文件结尾,用户态的read会返回0,while((n = read(...)) > 0)这样的循环就会正确结束。这一套经典的读逻辑值得背下来,所有字符设备驱动都长这样。
write方向的逻辑完全对称,但要额外检查传入数据长度是否超过缓冲区:
static ssize_t demo_write(struct file *file, const char __user *user_buf, size_t count, loff_t *ppos) { if (count >= sizeof(demo_buffer)) { pr_err("demo_chrdev: write too large\n"); return -EMSGSIZE; } if (copy_from_user(demo_buffer, user_buf, count)) { pr_err("demo_chrdev: copy_from_user failed\n"); return -EFAULT; } data_len = count; *ppos = 0; pr_info("demo_chrdev: got %zu bytes\n", count); return count; }这里没接入真正的硬件,缓冲区就是个普通内核数组,但整个数据链路已经完整。后面换成硬件寄存器读写,这个框架完全不需要变,只是把demo_buffer替换成从硬件读出的数据而已。
4.3 ioctl:什么时候该用,什么时候别用
read和write只是字节流通道,想控制设备就要传“命令”,比如设置波特率、切换工作模式。这类操作传统做法是用ioctl。
#include <linux/uaccess.h> #define DEMO_IOC_MAGIC 'D' #define DEMO_CMD_SET_MODE _IOW(DEMO_IOC_MAGIC, 1, int) #define DEMO_CMD_GET_MODE _IOR(DEMO_IOC_MAGIC, 2, int) static long demo_unlocked_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { int mode; switch (cmd) { case DEMO_CMD_SET_MODE: if (copy_from_user(&mode, (void __user *)arg, sizeof(mode))) return -EFAULT; pr_info("demo_chrdev: set mode to %d\n", mode); break; case DEMO_CMD_GET_MODE: mode = current_mode; if (copy_to_user((void __user *)arg, &mode, sizeof(mode))) return -EFAULT; break; default: return -ENOTTY; } return 0; }把.unlocked_ioctl = demo_unlocked_ioctl赋给file_operations就可以了。
ioctl有一个很惹人烦的问题:命令号魔数管理。现在内核社区越来越推荐用sysfs属性替代简单的ioctl——一个文件就对应一个配置项,用户态用echo和cat就能操作,不需要写专门的用户程序。但ioctl在复杂控制场景仍然不可替代,比如一次性传一个结构体配置多项参数时就比一堆sysfs文件好用很多。
我的建议是:单参数、低频配置用sysfs;多参数联动、需要事务性更新的用ioctl。
5. 并发与生命周期:新手版驱动最常翻车的地方
5.1 并发访问:不锁起来会很惨
写完上面的demo,驱动可以用了,但如果你做高并发测试,比如两个进程同时写同一个缓冲区,你很快会看到数据互相覆盖、read读到一半的数据。
为什么?因为demo_write里copy_from_user的整个过程不是原子的。进程A写了一半,进程B也进来写,两个人同时改demo_buffer和data_len,数据就坏了。
最经典的解法是互斥锁:
static DEFINE_MUTEX(demo_lock); static ssize_t demo_write(struct file *file, const char __user *user_buf, size_t count, loff_t *ppos) { ssize_t ret; mutex_lock(&demo_lock); if (count >= sizeof(demo_buffer)) { pr_err("demo_chrdev: write too large\n"); ret = -EMSGSIZE; goto out; } if (copy_from_user(demo_buffer, user_buf, count)) { pr_err("demo_chrdev: copy_from_user failed\n"); ret = -EFAULT; goto out; } data_len = count; *ppos = 0; ret = count; out: mutex_unlock(&demo_lock); return ret; }这里我故意用了goto out的统一出口写法。驱动代码里锁的路径很多,每一处错误分支都要记得解锁,把解锁放在统一出口是最省心的方式,少一半的错误。
还有一点:不要在持锁状态下调用可能睡眠的函数吗?这里的copy_from_user本身就可能睡眠,但我们的锁是普通互斥锁,允许睡眠,所以没问题。如果你是持着自旋锁去调它,那才叫灾难——好在这个场景在字符设备驱动里不多见。
5.2 生命周期:设备被卸载了,open请求还在怎么办
字符设备驱动有一个非常隐蔽的坑:cdev_del之后,驱动已经不再响应新open了,但已经在open状态的文件呢?如果这时模块已经被rmmod卸载了,文件close时调用的release函数会指向已卸载的代码地址,直接触发内核崩溃。
这也就是为什么几乎每个驱动函数开头都要加一行.owner = THIS_MODULE。它让内核在文件持有期间自动帮你做一个引用计数,只有所有打开着该设备的文件都关闭后,模块的引用计数才会降到0,rmmod才会成功。反过来,如果你的驱动已经是cdev_add状态,却在卸载时忽略了cdev_del,那系统里会残留一个指向已卸载模块的入口,后果比不做owner更严重。
卸载的顺序同样重要。标准顺序是:
- 先删除用户空间可见的入口:
device_destroy、class_destroy; - 再删除设备入口:
cdev_del; - 最后释放设备号:
unregister_chrdev_region。
在实际调用device_destroy之前,用户态进程理论上还能打开这个设备文件,所以很多更严谨的做法是在驱动的remove路径里先添加一个“不能再用”的状态标志,让open在设备销毁过程中返回-ENODEV。上面的demo_open检查demo_device_ready,就是为这个准备的。
我们看完整的卸载函数:
static void __exit demo_exit(void) { device_destroy(demo_class, demo_dev); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(demo_dev, DEV_COUNT); pr_info("demo_chrdev: module unloaded\n"); }这个顺序千万不能打乱。我在调试时就犯过一次顺序错误:先unregister_chrdev_region再device_destroy,结果/dev节点还挂在那边指向一个已经失效的设备号,用户态open返回诡异的错误码,排查半天才反应过来是卸载顺序错了。
5.3 内存与资源回收:看着小、实际坑大的细节
如果驱动里有kmalloc,必须在设备关闭或模块卸载时对称释放。C语言没有垃圾回收,驱动里泄漏内存的后果比用户态严重得多,内核内存不足会直接触发系统卡死,而不是仅仅报一个“无法分配内存”的错误。
建议在所有驱动里都遵守这个习惯:写alloc时立刻写对应的free,不管它是kmalloc/kfree、alloc_chrdev_region/unregister_chrdev_region,还是class_create/class_destroy。我在项目X的开发中就遇到过类似的问题:cdev_add失败后直接返回,忘了unregister_chrdev_region,结果设备号一直被占着,后续反复加载测试时总提示设备号冲突。
6. 调试手段与实战排错记录
6.1 printk和dmesg:驱动调试的“第一工具”
驱动没有标准输出,printf用不了。最基础也最有效的调试方法就是printk。它默认会在内核日志里留下记录,用户态用dmesg命令查看。
printk的日志等级从高到低包括KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG几种。日常调试习惯用KERN_INFO,出错用KERN_ERR,频繁调用的函数里尽量用pr_debug,因为默认编译级别下调试信息不会刷屏,需要时再打开。
我在read/write里加了pr_info,但是正式驱动不建议这样干。高频率读写时每条都打日志,日志量会暴涨,严重影响驱动性能。真实项目里我一般只在open/release、错误分支、初始化/退出路径上留日志,读写函数内部最多留一个pr_debug。
6.2 我遇到过的三个经典问题
第一个问题是模块加载时报Operation not permitted。原因大多数是模块签名验证或Secure Boot没关。检查手段是先看dmesg尾部,如果有module verification failed字样,基本就是签名问题。开发机上关掉Secure Boot或使用自签名模块,这个问题就能解决。
第二个问题是设备节点没有自动创建。insmod成功了,但/dev下找不到节点。我当时的排查顺序是:
dmesg查驱动加载日志,确认device_create有没有执行;ls /sys/class/demo_chrdev/,确认类有没有创建成功;- 检查用户态
udev服务状态,有时虚拟机里udev没起来,节点自然不会出现。
第三种情况很值得注意:虚拟机和容器里常常会缺少udev守护进程,导致device_create创建的内核节点不会自动映射到/dev目录。遇到这种环境,要么手动启动udev,要么直接用mknod手动创建节点验证驱动,先不要让节点自动化问题卡住主线。
第三个问题是rmmod提示Module is in use。这说明有进程还持有设备文件没关闭。排查方式:
lsof /dev/demo_chrdev找到占用进程,把它结束掉或者让它关闭文件,再卸载模块就正常了。
6.3 几个值得沿用的排查习惯
在驱动开发中,我养成了一套固定的排查动作,分享几个好用的:
- 每次操作后立刻看
dmesg,而不是等到出错回头看,日志会告诉你答案,dmesg -w可以实时监听日志; - 加载前先用已经存在的
/proc/devices确认主设备号没被占; - 在
open函数里加唯一的日志打印,可以快速确认用户态调用是否真正到达驱动; - 不要嫌
pr_err在错误分支里多打几行,宁可信息冗余,也不要排查时无迹可寻。
另外,/sys也是排查的好帮手。cdev_add成功后,在/sys/class/或者/sys/devices/下能看到对应的设备目录,设备树和驱动注册关系一目了然。
还有一个小建议:在自己的开发环境里,可以给dmesg加颜色过滤,或者配置一个别名,让错误信息高亮显示。长年累月调试下来,这个小细节能帮你节省很多时间。
最后说一点我的个人体会:驱动开发和业务开发最大的不同,就是每一行代码都有真实的物理后果。一个错误的解锁,一次错误的卸载顺序,可能不会当场崩溃,但会在某个特定并发条件下爆发。写驱动的人必须养成一种“防御性编码”的习惯:申请资源时就想好释放路径,加锁时就规划好所有出口,给用户态返回数据前先怀疑它给的指针是不是合法。
我的建议是,把这套demo_chrdev的流程完整跑通之后,再去给它加一个定时器、加一个等待队列、加一个中断处理函数。当你把字符设备驱动这条主线吃透,后面的平台驱动、总线驱动、设备树这些内容学起来都会顺畅很多。驱动开发不是背结构体,而是理解内核与硬件的协作逻辑,字符设备驱动就是你进入这个世界最合适的一扇门。