资讯动态

从零手写Linux字符设备驱动:核心原理与实战解析

发布时间:2026/10/20 11:43:29 来源:尧图企业网站定制
想写个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在同目录新建Makefileobj-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会返回0while((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。它让内核在文件持有期间自动帮你做一个引用计数只有所有打开着该设备的文件都关闭后模块的引用计数才会降到0rmmod才会成功。反过来如果你的驱动已经是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的流程完整跑通之后再去给它加一个定时器、加一个等待队列、加一个中断处理函数。当你把字符设备驱动这条主线吃透后面的平台驱动、总线驱动、设备树这些内容学起来都会顺畅很多。驱动开发不是背结构体而是理解内核与硬件的协作逻辑字符设备驱动就是你进入这个世界最合适的一扇门。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑