Linux字符设备驱动开发实战:从hello_chardev理解内核驱动框架
刚接触嵌入式或者操作系统课程的人十有八九会问一句什么是Linux驱动网上搜一圈答案大多是“驱动就是控制硬件的软件”这话没错但基本等于没说。真正做驱动开发的人都知道难点从来不在于“控制硬件”这四个字而在于整套约定俗成的框架你的代码要运行在内核态要被系统调用记住要通过设备节点暴露给应用层还要处理并发、中断、内存映射这一堆麻烦事。这篇文章我不想站在课本角度重新讲一遍概念而是从一个最普通的字符设备驱动写起把一个hello_chardev从无到有跑起来的过程掰开讲看看驱动在Linux里到底扮演什么角色。适合想搞懂驱动本质、或者正准备写第一个驱动的朋友也适合想弄明白“为什么应用层一个open()能影响到硬件”的读者。先说清楚一个底层事实你自己写一个驱动文件放在那不代表系统就认识它。驱动必须被“注册”进内核告诉内核“我负责某个设备号请把对它的操作交给我”。这个注册、分发、回调的过程才是理解Linux驱动的钥匙。为了把这把钥匙讲明白以下内容我会顺着代码走一遍该给的配置给全该解释的原理讲透希望你读完能自己动手复现一个最小的字符设备驱动。1. 驱动不是“控制硬件的代码”那么玄它填补的是系统调用和设备之间的断层1.1 用户态进程碰到硬件中间隔了多少层先看一个最简单的场景你在终端里敲了cat /dev/hello_chardev这条命令背后到底发生了什么事进程在用户态发起open()和read()这两个是系统调用不是普通函数。系统调用进入内核后VFS虚拟文件系统层会根据文件路径找到对应的inode再发现这个inode指向一个字符设备节点。字符设备节点里记录着主设备号和次设备号内核拿着主设备号去字符设备表里找找到挂在对应设备号上的cdev结构体而cdev里保存着这个驱动的file_operations结构体。到这一步调用才真正被交给你的驱动代码。整个链路可以压缩成一条线系统调用open/read/write/close→ VFS 按路径查 inode → 字符设备按设备号查 cdev → 驱动 file_operations 被回调 → 驱动访问硬件或自身维护的缓存如果换成块设备或者网络设备中间走的路径会变成block layer或socket协议栈但用户态看到的“文件”外观仍然是保留的。这正是Linux“一切皆文件”思想的关键内核用统一的文件接口把千奇百怪的硬件差异藏在驱动层。驱动从来不是一个孤立程序它是这条链中的一个环节。很多初学者直接打开一个驱动源码看到一堆结构体和宏定义却不知道这些函数到底被谁调用就是因为脑子里没有这条链路。有个类比可以帮助记忆用户态进程是一个打电话的人系统调用是总机设备号是分机号驱动是分机旁边负责处理事务的工作人员。你只看到总机把电话接过去但具体事务由分机工作人员处理中间还隔着总机的规则和交换机线路。这个类比虽然粗糙但“回调”和“分发”的概念已经出来了。1.2 设备节点、设备号、驱动三者不是一回事很多人有个误解只要/dev/hello_chardev这个文件存在就说明驱动装好了。不是的设备节点、设备号、驱动是三个独立的东西缺一个都不行。设备节点是/dev下的一个文件入口它本身不包含任何硬件逻辑只是保存了主设备号和次设备号。主设备号用来标识驱动程序告诉内核“哪个驱动处理这个设备”次设备号用来标识同类硬件中的第几个实例比如两个串口通常是同一个主设备号用次设备号区分彼此。驱动是一段内核模块代码加载时向系统声明“我负责这个主设备号”并挂上一组读写函数。只有节点、没有驱动应用层open()时会返回-ENODEV或-ENXIO反过来驱动加载了但没有节点应用层连入口都找不到。在早期Linux里设备节点需要管理员用mknod命令手动创建非常容易出错。后来内核引入了sysfs和uevent机制驱动在注册设备时创建一个class和device用户空间的守护进程收到内核发来的uevent事件后会自动在/dev下生成对应的设备节点。这套机制在绝大多数现代发行版上都是默认生效的也是我们下面代码里class_create和device_create存在的原因。理解了这三个概念的分工再去看驱动的注册代码你会觉得每一行都有明确目的——它不是在“创造设备”而是在“向系统申报设备并挂接操作接口”。2. 最小骨架hello_chardev 从模块加载到自动创建设备节点2.1 模块的出生与死亡module_init 和 module_exit写一个最小字符设备驱动先别想复杂的寄存器操作把数据通道跑通就行。下面是完整代码我建议你亲手敲一遍理解每一行的作用再粘到编辑器里。#include linux/init.h #include linux/module.h #include linux/kdev_t.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME hello_chardev #define CLASS_NAME hello_class static dev_t dev_num; static struct cdev hello_cdev; static struct class *hello_class; static struct device *hello_device; static char device_buf[1024]; static int buf_len; static int hello_open(struct inode *inode, struct file *filp) { pr_info(hello_chardev: open called\n); return 0; } static int hello_release(struct inode *inode, struct file *filp) { pr_info(hello_chardev: release called\n); return 0; } static ssize_t hello_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t to_read; if (*offset buf_len) return 0; to_read min(count, (size_t)(buf_len - *offset)); if (copy_to_user(buf, device_buf *offset, to_read)) return -EFAULT; *offset to_read; return to_read; } static ssize_t hello_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { if (count sizeof(device_buf)) count sizeof(device_buf) - 1; if (copy_from_user(device_buf, buf, count)) return -EFAULT; device_buf[count] \0; buf_len count; *offset 0; return count; } static struct file_operations hello_fops { .owner THIS_MODULE, .open hello_open, .release hello_release, .read hello_read, .write hello_write, }; static int __init hello_init(void) { int ret; ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(hello_chardev: failed to alloc region\n); return ret; } cdev_init(hello_cdev, hello_fops); hello_cdev.owner THIS_MODULE; ret cdev_add(hello_cdev, dev_num, 1); if (ret) { unregister_chrdev_region(dev_num, 1); return ret; } hello_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(hello_class)) { cdev_del(hello_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(hello_class); } hello_device device_create(hello_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(hello_device)) { class_destroy(hello_class); cdev_del(hello_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(hello_device); } pr_info(hello_chardev: registered, major%d, minor%d\n, MAJOR(dev_num), MINOR(dev_num)); return 0; } static void __exit hello_exit(void) { device_destroy(hello_class, dev_num); class_destroy(hello_class); cdev_del(hello_cdev); unregister_chrdev_region(dev_num, 1); pr_info(hello_chardev: unregistered\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL);module_init和module_exit是Linux内核模块的入口宏它们的作用不是把函数直接拉出来执行而是告诉内核“这个模块加载时调用hello_init()卸载时调用hello_exit()”。hello_init()干的事情可以概括为四步先申请一段设备号再把字符设备结构体挂到系统表里然后创建class和device最后打印注册日志。每一步都在为“用户态能找到这个设备”做准备。MODULE_LICENSE(GPL)这行不是摆设。你不写也能编译但内核会把模块标记为“污染”状态tainted出问题时内核开发者通常不愿意帮你分析。开源的驱动模块最好都写明许可证这既是规则也是惯例。这个hello_chardev并没有操作任何硬件寄存器它读写的是一个内核内存缓冲区。但这不影响它的价值——它的流程和真正的外设驱动完全一致申请资源、注册回调、暴露设备节点。很多外设驱动第一版就是这个样子先把数据传输通道打通再往read/write里加真正的硬件访问。2.2 设备号申请和自动创建节点代码里用的是alloc_chrdev_region()而不是register_chrdev_region()这个选择是刻意的。前者让内核动态分配一个可用的主设备号后者要求你自己指定主设备号。自己指定看着很酷但很容易撞车你选的主设备号可能已经被某个已有驱动占用加载时直接报Device or resource busy。动态分配省心得多代价是你需要从/proc/devices查一下实际拿到的主设备号。这个文件是内核导出的设备号列表字符设备那一项里就能看到你注册的设备名和主设备号。cdev_init()和cdev_add()是两件事很多新手混在一起。cdev_init()只是把hello_cdev这个结构体和hello_fops关联起来像一个接线前的准备动作cdev_add()才是真正把字符设备挂到系统字符设备表里挂了之后内核按主设备号找 cdev 时才能找到它。顺序不能颠倒少了任意一步设备都不可用。class_create()和device_create()的作用是配合用户空间的守护进程自动生成设备节点。驱动创建了一个设备类再在该类下创建一个设备实例内核会通过sysfs发出uevent事件守护进程收到之后就在/dev下创建对应的节点。如果你的嵌入式环境里没有这套用户空间机制就得自己用mknod /dev/hello_chardev c 主设备号 0手动补一个节点这也是为什么老教程里总有mknod的身影。环境不同自动化的程度不同但内核这边的注册逻辑是不变的。3. file_operations 这条链用户态 read() 在内核态到底跑了多远3.1 open/write/read对应到哪里file_operations的挂接驱动里的file_operations结构体是一堆函数指针它定义的是“这个设备支持哪些文件操作”。应用层每个open()、read()、write()、close()最终都会走到对应函数指针指向的驱动函数。你在代码里看到的struct file_operations hello_fops { .owner, .open, .release, .read, .write }本质上是一张回调表内核在合适的时机回调你注册的函数。没用到的字段保持NULL即可比如没有实现mmap时用户态调用mmap()会得到一个合理的错误而不会崩内核。这里有个必须留意的点驱动里的read/write返回值语义和普通用户态编程不一样。read返回0表示EOF返回正数表示本次实际读取的字节数返回负数表示错误码write返回正数代表写了多少字节返回负数代表错误。用户态库函数拿到这些返回值直接当成系统调用的结果。如果驱动开发者不遵守这套约定应用层的行为就会变得很奇怪比如read()永远返回0程序就会觉得文件是空的。我在很多学习项目中见到的第一个驱动bug就是read函数忘记判断文件偏移offset导致应用层cat一个设备节点时陷入死循环——因为内核认为每次read都有新数据。学会管理offset和返回值比记住结构体里所有字段重要得多。3.2 copy_to_user为什么不能换成memcpy这是新手最容易栽的坑。有人看了上面的代码会问为什么不能直接在驱动里写memcpy(buf, device_buf, count)因为buf是一个用户空间指针驱动运行在内核态用户空间的内存页可能根本没有映射到内核地址空间。直接解引用一个用户指针轻则触发缺页异常重则导致整个系统崩溃或产生严重的安全漏洞。内核之所以要区分“用户指针”和“内核指针”是因为现代CPU有特权级别隔离操作系统利用这种隔离把用户进程和内核分开。用户进程不能随意访问内核内存内核也不能理所当然地替用户进程读写任意地址。copy_to_user()和copy_from_user()就是专门负责跨越这个边界的接口它们会先检查用户缓冲区是否合法可访问再逐页处理可能的缺页然后才执行拷贝。使用它们时还要注意返回值成功时返回0失败时返回未拷贝成功的字节数。所以常见写法是if (copy_to_user(...)) return -EFAULT;这里的-EFAULT是“地址错误”的标准错误码。类似地如果驱动需要把内核缓冲区里的数据发给用户也要用copy_to_user。内核态和用户态之间的“搬运”必须经过这些专用接口这是驱动开发的基本规矩不是可选项。3.3 先打通数据通道再谈控制寄存器很多刚学驱动的同学会有一种执念驱动里一定要看到寄存器地址、ioremap、中断处理否则就不算驱动。实际上外设驱动开发的常规路径恰恰是从“先跑通数据通道”开始的。最开始要确保应用层能往内核态写入一段数据内核态也能把数据回传给应用层。数据通道通了什么命令字节都可以通过这个通道下发接下来才是在write里根据命令去操作寄存器或者在read里把硬件状态打包返回。中断处理、等待队列、内核线程全都是后面根据自己的设备特性逐步加进去的。这种“先通数据、再控硬件”的思路在工业项目里非常常见。寄存器操作需要查阅芯片手册中断逻辑需要梳理硬件时序如果一开始就把这些全塞进驱动出了问题根本不知道是数据通道的问题还是硬件操作的问题。把 hello_chardev 这样的骨架先跑通相当于先搭好一条干净的路再往路上加各种车辆。4. 编译、加载、验证insmod 到 dmesg 的完整闭环4.1 Makefile与Kbuild为什么不能拿gcc直接编内核模块不是普通的可执行文件它必须使用内核提供的头文件、Kbuild脚本和编译参数进行编译。最常用的Makefile是这样写的obj-m : hello_chardev.o KERNELDIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean这里藏着很多初学者看不懂的两阶段套路。第一次执行make时KERNELRELEASE这个变量还没有被定义所以Makefile走到else分支用-C切到内核源码目录再通过M$(PWD)指定当前目录让内核的Kbuild系统重新读取这个Makefile。第二次读取时KERNELRELEASE已经有值了此时obj-m : hello_chardev.o才生效Kbuild会正式编译模块。如果你用gcc直接编译.c文件大概率会得到一堆 “找不到linux/module.h” 的头文件错误这就是因为你绕过了Kbuild。两个容易忽略的前提一是必须安装内核开发包否则/lib/modules/$(uname -r)/build可能只是一个空链接或完全不存在二是本机编译的模块不要指望能直接丢到另一个内核版本的机器上insmod模块有版本校验机制编译时会写入内核版本信息加载时版本不匹配会报错。这种“换机器就挂”的情况本质上不是代码问题而是编译环境和运行环境不一致。4.2 加载卸载与日志观察insmod后系统的变化编译成功会生成hello_chardev.ko接下来就是完整的加载验证流程。我建议按下面这批命令逐步执行每条命令都去观察输出sudo insmod hello_chardev.ko dmesg | tail cat /proc/devices | grep hello ls -l /dev/hello_chardevinsmod会触发module_initdmesg | tail里能看到“registered, majorxxx, minor0”的日志major就是内核动态分配的主设备号。cat /proc/devices | grep hello能看到hello_chardev出现在字符设备列表中说明注册成功。ls -l /dev/hello_chardev能看到设备节点已经自动生成这一条能自动出现就验证了class_create和device_create那几步确实起了作用。卸载时执行sudo rmmod hello_chardev再看dmesg应该能看到“unregistered”的日志。这里顺便说一下insmod和modprobe的区别modprobe会去/lib/modules下搜索模块并自动解决依赖适合系统管理insmod直接加载指定路径的.ko文件不做依赖分析适合开发调试。我们写在当前目录的模块用insmod更直观。4.3 echo和cat测试驱动时的行为差异数据通道是否真的通了要跑到应用层去看。测试命令非常简单sudo sh -c printf hello /dev/hello_chardev cat /dev/hello_chardev第一条命令执行时shell 会依次打开设备节点、写入6个字节、关闭节点open、write、release三个回调依次触发字符串通过copy_from_user进入内核缓冲区。第二条命令执行时open、read回调被触发cat会反复调用read直到返回0于是看到输出“hello”。如果你用echo hello /dev/hello_chardev测试写入的实际上是“hello\n”多一个换行符输出效果看起来也一样但字节长度不同。这个测试里有一个值得注意的行为我们的hello_write每次都是从缓冲区头部开始覆盖写入。所以第二次写一个更短的内容之前的长内容会被部分覆盖。如果你希望驱动支持追加写、支持lseek等行为都需要驱动开发者自己去实现。这也是理解驱动的一个关键点设备节点看起来像文件但它不是普通文件文件系统提供的那些语义全靠驱动自己实现。同样的echo和cat在不同驱动上的表现可以完全不同因为底层代码是“自定义”的。5. 驱动从“能跑”到“能扛”初学者最该避开的几个坑5.1 模块删不掉、设备号被占加载与卸载的常见摩擦调试驱动的过程中遇到rmmod报“Module XXX is in use”是家常便饭。这个错误的意思是模块的引用计数不为0通常是有进程还open着设备节点或者内核中还有其他对象引用着该模块。先执行fuser -v /dev/hello_chardev或者lsof看看到底谁占用了设备把占用进程关闭后再卸载。不要在进程占用期间强行卸载就算某些情况下能卸载也会留下一个悬空引用后续访问容易崩溃。反过来insmod报Device or resource busy大概率是设备号冲突。如果你在代码里用了固定主设备号而它恰好已经被其他驱动占用了就会是这个错。去/proc/devices里查一下哪些号被用了或者直接改用动态分配。还有一种情况是模块加载时报Operation not permitted那往往和内核签名验证或权限有关需要检查系统是否开启了强制模块签名校验这种情况和代码本身没关系别一头扎进代码里改半天。5.2 Oops和段错误内核崩溃信息怎么看驱动崩了不像用户态程序崩了那么“温柔”。用户态空指针会得到一个Segmentation fault进程挂掉系统没事内核态空指针解引用常见结果是系统日志里出现一大段Oops信息运气好系统还半死不活地能执行命令运气差直接卡死或重启。内核态没有轻巧的恢复机制这是驱动开发最刺激的一点也是最需要谨慎的一点。万一你的驱动出了问题先学会读Oops信息。找几行关键内容BUG: unable to handle kernel NULL pointer dereference at ...告诉你出错时的地址RIP那一行能看出具体是哪个函数出错Call Trace是内核态的函数调用栈能帮你定位调用路径。配合驱动里多打的pr_info日志把范围缩小到某个函数再用addr2line或objdump反汇编模块文件往往就能找到是哪一行出了问题。调试驱动的基本功就是打日志。pr_info、pr_err、pr_debug这些接口都值得用起来。加载和卸载时打日志open和close时打日志读写时记录关键参数出问题时把这些日志拼接起来很多问题当场就露馅了。5.3 API随内核版本变化老驱动不能“直接拿来编译”Linux内核迭代速度非常快驱动API处于持续变化之中。最典型的一个例子就是class_create老内核需要传入两个参数class_create(THIS_MODULE, name)而新内核版本把第一个参数去掉了只保留name。只改这一处还相对简单更头疼的是像“ioctl从有锁版本变成unlocked_ioctl”“proc_create的参数换了又换”“设备树接口在不同大版本里行为不同”这类变动。网上搜到几年前的驱动代码直接放在新内核里编译报一屏错误是很多人经历过的事。应对这个问题最重要的一条经验是只看当前内核版本的源码。你跑的是哪个版本的内核就去看对应版本的include/linux/fs.h、include/linux/cdev.h、include/linux/device.h里的定义再看看内核源码树里现成的驱动样例是怎么写的。虽然老代码起参考作用但把它当成“直接可编译”的答案会浪费大量时间。写驱动本质上是在和内核的规则打交道只要规则变了代码就得跟着变。5.4 多进程同时访问同一个设备并发会撕碎你的缓冲区我们最初的 hello_chardev 虽然能跑但最多算一个“教学骨架”因为它完全没有考虑并发。如果两个进程同时往设备写入数据两个write可能交错执行buf_len和缓冲区的状态会变得一团糟。数据被撕裂还只是逻辑问题更严重的是共享资源没有任何保护机制对整个系统来说这是一个不稳定的隐患。要让驱动真正“能扛”下一步就是给共享资源加锁。对字符设备驱动来说最基本的做法是加一个mutex或者自旋锁让write和read在访问缓冲区时互相排斥。做完这一步再往后面走你还会遇到中断处理期间的资源共享、进程休眠唤醒、原子操作这些更复杂的话题。它们是驱动开发的必经之路但已经不是“什么是驱动”这个入门问题能覆盖的范围。我自己第一次做驱动时就是先把这个 hello_chardev 跑通然后依次加了mutex、wait_event和ioremap从字符设备一路走到平台驱动。回头再看“什么是Linux驱动”这个最初的问题答案其实很朴素它就是在系统调用和硬件之间按照内核规则提供一组合法操作的内核代码。你把这套规则摸熟了剩下的就是读芯片手册和写状态机的经验活了。