第一次把编译出来的.ko用 insmod 送进内核,然后 dmesg 里干干净净、/dev 底下也没有冒出新节点——这是刚接触 Linux 设备驱动程序时最典型的开场。我当时以为是自己代码写错了,反复检查了二十分钟,最后发现只是忘了看printk的日志级别,驱动其实早就加载成功了。后来陆陆续续又踩了主设备号冲突、copy_to_user返回值判断写反、read不推进偏移量导致 cat 卡死这一堆坑,才慢慢摸清这个"简单"二字背后的实际门槛。
这篇内容就围绕一个能实际跑起来的最小字符设备驱动展开,把从内核态与用户态的分工、代码骨架、Makefile 编译、模块加载、用户态验证,一直到常见排错链路完整走一遍。适合已经在用户态写过 C、想往内核方向迈第一步的人,也适合手头有个板子或者虚拟机、想快速验证一个想法的朋友。所有代码都在常见的内核版本上验证过,替换名字就能直接用。
1. 设备驱动在内核里究竟占了哪一层位置
1.1 用户态 open("/dev/xxx") 到内核 fops 的一次完整握手
把设备驱动想成一个办事窗口会更直观。你在用户态敲echo "hi" > /dev/demo_chr,shell 会调用open()和write(),这两个都是标准 C 库函数,最终通过系统调用陷入内核。内核不关心你写的是文件还是设备,它只看路径解析出来的 inode 上挂着什么:如果是普通文件,就交给文件系统;如果是设备文件,就根据 inode 里记录的设备号去找对应的字符设备驱动。找到了,就把struct file_operations里那一组函数指针拿出来,调用其中的.write。
这个"找"的过程靠的是cdev_map这张全局表,注册驱动时cdev_add()把dev_t和struct cdev的对应关系写进去,内核在open时用kobj_lookup()查表。所以设备驱动做的事情,本质是把一组函数指针登记进内核,让内核在正确的时机回调你。理解这一层之后再看代码,file_operations、cdev、dev_t这三样东西为什么会同时出现,逻辑就通了。
与用户态编程最大的差别在于内存边界。用户态传进来的buf指针是用户空间地址,内核代码直接memcpy会因为页表不同而崩掉或者拿到脏数据,必须走copy_to_user/copy_from_user,这两个函数内部会做地址合法性检查和缺页处理。刚接触驱动的人十有八九在这里翻车——编译通过、加载成功、一执行就内核恐慌。
提示:所有
*_user结尾的函数都会返回"未能拷贝的字节数",返回 0 才代表成功。判断写成if (ret == 0) return -EFAULT是完全反向的逻辑,而且编译器不会报错。
1.2 字符设备、主次设备号与设备文件的关系
Linux 把设备粗分成三类:字符设备、块设备、网络设备。我们写的这个 demo 是字符设备,特征是数据按字节流顺序访问,不经过页缓存,read/write直接落到你的回调函数里。串口、按键、LED、多数传感器都是这个路子。块设备面向随机访问的存储介质,网络设备则走net_device那套完全不同的接口,没有/dev节点。
主设备号标识"用哪个驱动",次设备号标识"这个驱动管理的第几个设备实例"。比如一个驱动管四个串口,可以申请主设备号 240、次设备号 0~3,对应四个/dev节点,全都落到同一份file_operations上,靠iminor(inode)区分具体是哪一个。驱动内部用次设备号作为数组下标去取各自的数据结构,这是最常规的写法。
主设备号的获取方式值得单独说一下。早期入门教程爱用register_chrdev(0, ...),它会一次性占用 0~255 全部次设备号,简单粗暴但浪费;现在更推荐alloc_chrdev_region()让内核动态分配一段,用完unregister_chrdev_region()归还。动态分配的好处是不会和别人写死的号撞车,缺点是每次加载号码可能不同,所以必须配合下文要讲的自动创建节点,或者自己去/proc/devices里查。
设备文件本身在磁盘上只是一个 inode,里面存着"这是字符设备"以及主次设备号,不占实际存储空间。这就是为什么用mknod手工建一个节点、指向已经注册好的驱动,就能立刻工作——节点和设备之间唯一的联系就是那个号。
2. 一个能跑的最小字符设备驱动:五段骨架代码
2.1 头文件、模块声明与全局缓冲区
先把完整代码贴出来,后面逐块拆。文件命名demo_chr.c:
#include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #include <linux/device.h> #include <linux/version.h> #define DEV_NAME "demo_chr" #define BUF_SIZE 256 static dev_t demo_devno; static struct cdev demo_cdev; static struct class *demo_class; static struct device *demo_device; static char demo_buf[BUF_SIZE]; static int demo_len; static int demo_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo_chr: open minor=%d\n", iminor(inode)); return 0; } static int demo_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo_chr: release\n"); return 0; }头文件里linux/fs.h提供file_operations和dev_t相关定义,linux/cdev.h提供cdev结构,linux/uaccess.h是copy_to_user的家,linux/device.h提供class_create和device_create,linux/version.h是为了后面做版本判断。少一个都会在编译时报未定义,而且报错信息通常指向使用处而不是 include 处,容易看晕。
全局缓冲区这里用的是静态数组,好处是最小化依赖,坏处是只支持单实例、不能动态扩容。真要上生产,把demo_buf和demo_len塞进一个自定义结构体,open时kzalloc一份挂到filp->private_data上,release时释放,这样才能做到多进程互不干扰。入门阶段先用静态的,把流程跑通比什么都重要。
demo_open里用iminor(inode)打印次设备号,这句话看着没用,实际上是验证"我的驱动真的被调到了"最直接的手段。很多人第一次加载完驱动就去cat /dev/xxx,结果什么都没输出,也分不清是驱动没被调用还是 read 里逻辑有问题。有了 open 的日志,至少能知道前半段链路是通的。
2.2 file_operations 与 read/write 的数据搬运
static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { size_t avail; unsigned long not_copied; if (*ppos >= demo_len) return 0; /* 读到末尾,返回 0 表示 EOF */ avail = demo_len - *ppos; if (count > avail) count = avail; not_copied = copy_to_user(buf, demo_buf + *ppos, count); if (not_copied) return -EFAULT; *ppos += count; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { unsigned long not_copied; if (count > BUF_SIZE - 1) count = BUF_SIZE - 1; /* 留一个字节放结尾 0 */ not_copied = copy_from_user(demo_buf, buf, count); if (not_copied) return -EFAULT; demo_buf[count] = '\0'; demo_len = count; return count; } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, };read里那三行是核心。第一,*ppos >= demo_len时返回 0,这是 POSIX 规定的"没有更多数据",用户态的cat靠它判断该收工了。第二,count是用户请求读多少,avail是驱动还剩多少,取小值才不会把缓冲区外面的内存泄露给用户态。第三,copy_to_user之后一定要*ppos += count,否则下次进来还是从老位置读,cat会一直读到同样的内容但永不返回 0,表现为终端卡死,需要 Ctrl+C。这是最高频的入门 bug。
write里BUF_SIZE - 1而不是BUF_SIZE,是为了给demo_buf[count] = '\0'留位置。如果写满 256 字节再补一个\0,就正好越界一个字节,内核里越界写不会像用户态那样大概率没事,它可能正好踩到相邻的静态变量,表现为"偶发地改完一个参数另一个参数也跟着变了"这类灵异现象。这类问题用 KASAN 能抓出来,但入门阶段最好的办法是养成从边界倒推的习惯。
.owner = THIS_MODULE这一行容易被忘。它的作用是在文件被打开期间给模块计数加一,防止用户还在读的时候有人把模块 rmmod 掉。少了它,rmmod会直接执行demo_exit,释放掉demo_buf和cdev,而用户态的read还握着一个指向已释放内存的函数指针,后果可想而知。
2.3 模块加载函数里的设备号、cdev 与自动建节点
static int __init demo_init(void) { int ret; ret = alloc_chrdev_region(&demo_devno, 0, 1, DEV_NAME); if (ret < 0) { printk(KERN_ERR "demo_chr: alloc devno failed %d\n", ret); return ret; } cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, demo_devno, 1); if (ret < 0) { printk(KERN_ERR "demo_chr: cdev_add failed %d\n", ret); goto err_unreg; } #if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 4, 0) demo_class = class_create(DEV_NAME); #else demo_class = class_create(THIS_MODULE, DEV_NAME); #endif if (IS_ERR(demo_class)) { ret = PTR_ERR(demo_class); goto err_cdev; } demo_device = device_create(demo_class, NULL, demo_devno, NULL, DEV_NAME); if (IS_ERR(demo_device)) { ret = PTR_ERR(demo_device); goto err_class; } printk(KERN_INFO "demo_chr: loaded major=%d minor=%d\n", MAJOR(demo_devno), MINOR(demo_devno)); return 0; err_class: class_destroy(demo_class); err_cdev: cdev_del(&demo_cdev); err_unreg: unregister_chrdev_region(demo_devno, 1); return ret; } static void __exit demo_exit(void) { device_destroy(demo_class, demo_devno); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(demo_devno, 1); printk(KERN_INFO "demo_chr: unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("demo"); MODULE_DESCRIPTION("minimal char device");alloc_chrdev_region的第一个参数传出分配到的dev_t,第二个是起始次设备号,第三个是申请的数量。这里只申请一个,所以传(0, 1)。
cdev_init只是把 fops 绑到 cdev 上,真正"挂到内核"的是cdev_add。这两步分开是有历史原因的:老代码常用register_chrdev一步完成,现在拆开是为了让你能精确控制设备号范围。
class_create加device_create的组合负责让 udev 自动在/dev下建节点。没有这两步,insmod之后你得自己去/proc/devices查主设备号,再mknod /dev/demo_chr c 主 次,麻烦且容易忘。class_create的签名在 5.4 前后改过一次(去掉了owner参数),所以上面加了版本判断。如果编译报 "too many arguments to function",就是版本判断没生效或者内核版本边界算错了,直接去看/lib/modules/$(uname -r)/build/include/linux/device.h里的声明比猜快得多。
那串goto错误处理不能省。内核模块里任何一步失败都必须把前面已经申请的资源还回去,否则卸载时要么泄漏要么 double free 触发 BUG。内核编程没有异常机制,错误处理全靠这种阶梯式回滚,写多了会形成肌肉记忆。
3. 编译与加载:Makefile 和外置模块构建的实际操作
3.1 obj-m 与 KERNELDIR 的含义
模块不能像普通程序那样gcc demo_chr.c -o demo_chr,它必须链接内核提供的那套符号,并且要生成特定格式的.ko。所以采用 kbuild 的外置模块构建方式:
obj-m += demo_chr.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注意all下面的命令行是 Tab 缩进,不是空格,这是 Makefile 的硬性要求,复制粘贴时最容易丢。
obj-m += demo_chr.o告诉 kbuild"这个目标编成模块"。如果源文件不止一个,写成demo_chr-objs := a.o b.o,再obj-m += demo_chr.o,kbuild 会自动链接成demo_chr.ko。
-C $(KERNELDIR)是切换到内核源码树目录执行 make,M=$(PWD)告诉 kbuild 模块源码在外面。/lib/modules/$(uname -r)/build一般是指向内核头文件包的软链接。跑make之后应该看到demo_chr.ko和一堆.o、.mod.c、.mod.o中间文件,如果只生成了.o没有.ko,通常说明obj-m写错了符号,或者文件中模块入口的函数签名不对。
一个很实际的细节:如果当前用户对内核源码树没有写权限,make在某些配置下会在源码树里生成临时文件然后失败。这种情况直接sudo make也能过,但更稳妥的做法是确认你的账户对$(PWD)有写权限、对源码树只读即可,kbuild 本身支持只读源码树。
3.2 insmod 之后的三项自检
加载:
sudo insmod demo_chr.ko敲完回车,正常情况什么提示都没有。这时候依次做三件事:
dmesg | tail -20—— 应该能看到demo_chr: loaded major=240 minor=0这一行,主设备号是多少记录下来。ls -l /dev/demo_chr—— 存在的话说明device_create成功,udev 也正常响应了。lsmod | grep demo_chr—— 确认模块确实在列表里,而不是加载失败被静默丢弃。
如果lsmod里没有,说明 insmod 报错了但你漏看了输出。常见的几种:
| 报错信息 | 真实原因 | 处理方式 |
|---|---|---|
Invalid module format | 编译时用的内核头文件与当前运行内核不一致 | 确认/lib/modules/$(uname -r)/build存在且版本匹配,重新编译 |
Unknown symbol in module | 用了未导出的内核符号,或忘了MODULE_LICENSE("GPL") | 检查符号是否是EXPORT_SYMBOL_GPL,补上 License 声明 |
Operation not permitted | 没加 sudo,或者内核开启了模块签名强制 | 用 root 加载;签名问题需要处理密钥 |
File exists | 模块名重复,上一次没卸干净 | 先rmmod,或lsmod确认后重启 |
MODULE_LICENSE("GPL")不是形式主义。内核里有大量符号只对 GPL 模块导出,不写这一行虽然能编译,加载时可能直接报Unknown symbol,而且内核会标记为 tainted,后续排查别的问题时会被这个标记干扰判断。
4. 用户态跑通验证:从 echo 到 cat 的闭环
4.1 手动 mknod 与 udev 自动创建的差别
正常情况下device_create之后/dev/demo_chr就自动出现了。如果没有,先确认系统里 udev 服务在跑,再看/sys/class/demo_chr/目录是否存在——这个目录一定存在,因为它由 class 机制在 sysfs 里创建,与 udev 无关。如果 sysfs 里有而 /dev 里没有,问题就在 udev 规则或者服务状态上,不是驱动本身的锅。
手工兜底的做法:
sudo mknod /dev/demo_chr c 240 0 sudo chmod 666 /dev/demo_chrc表示字符设备,240 0换成你 dmesg 里看到的实际主次设备号。chmod 666是为了让普通用户也能读写,否则默认是 root 独占,用当前账户测试会报 Permission denied。
注意:
device_create生成的节点默认权限通常是 600。想永久放开,得写 udev 规则,在/etc/udev/rules.d/下建一个.rules文件,匹配KERNEL=="demo_chr"并设置MODE="0666"。改完udevadm control --reload生效,重新加载模块验证。
这里有个隐性的坑:手工 mknod 建出来的节点,模块卸载后不会自动消失,下次加载如果主设备号变了,这个旧节点就指向一个不存在的驱动,open直接返回ENXIO。所以建议每次卸载后顺手确认/dev下有没有残留,或者干脆把手建的节点记录在脚本里统一清理。
4.2 一次 read/write 的日志追踪
用最朴素的方式验证:
echo "hello driver" | sudo tee /dev/demo_chr sudo cat /dev/demo_chr sudo dmesg | tail -5tee是因为 shell 的>重定向在某些配置下对设备文件的处理不一致,用 tee 更稳。执行完cat,终端应该原样打印hello driver,dmesg 里应该能看到:
demo_chr: open minor=0 demo_chr: release demo_chr: open minor=0 demo_chr: release两次 open/release 分别对应写入和读取。如果只看到一次,说明有一个操作没走到你的驱动上,可能是节点指向了别的设备。
一个更细致的验证手段是写个小程序,控制每次读的字节数,看*ppos是否按预期推进:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> int main(void) { char buf[8]; int fd = open("/dev/demo_chr", O_RDONLY); ssize_t n; if (fd < 0) { perror("open"); return 1; } while ((n = read(fd, buf, sizeof(buf) - 1)) > 0) { buf[n] = '\0'; printf("got %zd bytes: [%s]\n", n, buf); } close(fd); return 0; }跑完如果看到每 7 个字节一段地输出直到结束,说明ppos推进正确、EOF 返回 0 也正确。如果只输出一段就停了,通常是read里忘了*ppos += count;如果一直输出同样的内容停不下来,那是*ppos >= demo_len这个判断漏了或写错了。
这个验证程序比cat有价值的地方在于它把每次读到的长度都打印出来了。驱动里的分片逻辑、边界处理对不对,一目了然。
5. 六个真实踩过的坑与排查链路
5.1 加载成功但 dmesg 一片空白
这是我最早遇到的。insmod返回 0,lsmod里也有,就是看不到任何日志。排查顺序是这样走的:先确认dmesg命令本身有没有被限制(某些发行版对普通用户读dmesg有限制,需要 root),排除后确认printk用的级别。KERN_INFO是 6,而有的系统把控制台日志级别设成了 4,只显示 warning 以上,所以串口或者终端上不打印,但dmesg缓冲区里是有的。如果dmesg里确实一条都没有,那就往回调函数里加一句pr_info("enter\n"),用新宏试试——老版本printk(KERN_INFO ...)和pr_info行为一致,但如果连它都不出,就说明入口函数根本没执行,问题在module_init的声明上。
有一种情况特别隐蔽:文件里同时写了module_init(demo_init)和自定义的init_module。链接器可能会以某个为准,导致你改demo_init里的代码但行为没变。统一只用module_init/module_exit宏,别混用。
5.2 cdev_add 失败却没回滚设备号
第一版代码里我在cdev_add失败后直接return ret,忘记unregister_chrdev_region。表现是:连续 insmod/rmmod 几十次之后,alloc_chrdev_region开始返回-EBUSY或者分配到的号跳得越来越远。因为每次失败的加载都泄漏了一段设备号,内核以为它还被占着。
这类问题的排查方式是从/proc/devices看当前被占用的号段,数目明显多于实际加载的模块数,就说明有泄漏。修复方式就是严格按逆序回滚,goto阶梯虽然不好看,但在内核代码里是最清晰的。
5.3 cat 卡死不返回
现象是cat /dev/demo_chr刷屏或者干脆没输出也不退出。原因基本逃不出两个:read在*ppos >= demo_len时没返回 0,或者忘记推进*ppos。我当时的代码是if (*ppos > demo_len) return 0;,用了大于号。当*ppos正好等于demo_len时,走不到返回 0 的分支,avail算出 0,copy_to_user拷贝 0 字节返回 0,然后*ppos += 0,return 0。看起来返回值也是 0,但用户态拿到的count是 0 而驱动还是继续被调用,形成忙等。
正确的判断必须是>=。这类边界问题靠代码审查很容易漏,最好的办法是写上面那个分片读取的小程序,把每个返回值打出来。
5.4 copy_from_user 的返回值判断写反
copy_from_user返回未拷贝的字节数,全成功时返回 0。我第一版写成if (ret == 0) return -EFAULT;,结果每次正常写入都报错,而越界写入反而"成功"。这种反向逻辑的问题在于它仍然能编译、能加载,直到你真的去写一次才暴露,而且报的错误码看着像别的原因,容易往权限或者节点方向查。
排查这类问题有个通用思路:在回调函数入口处把参数和返回值都打出来,包括count和*ppos,看一眼就知道是逻辑错了还是被调用错了。别一上来就怀疑内核版本或者环境。
5.5 卸载时内核报 BUG 或者 rmmod 卡住
rmmod卡住一般是引用计数没归零。.owner = THIS_MODULE设了之后,每次open加一,每次release减一。如果程序打开设备后没 close,计数就一直不归零,rmmod会一直等到进程结束。用ls -l /proc/<pid>/fd找到谁还开着这个设备。
另一种是卸载时崩了,报BUG: unable to handle kernel NULL pointer dereference。多半是demo_exit里的释放顺序反了:必须先device_destroy、再class_destroy,先cdev_del、再unregister_chrdev_region。顺序错了,内核可能会在销毁 class 的时候还去引用 device,访问到已经释放的内存。
还有一处容易忽略:如果demo_init在中途失败返回错误,内核不会调用demo_exit,所以失败路径里的清理必须自己做完,不能指望卸载函数兜底。
5.6 换台机器编译就报找不到头文件
/lib/modules/$(uname -r)/build这个路径在装完内核头文件包之后才会有,而且它对应的是"当前运行内核"的版本。如果你换了一台内核版本不同的机器,或者本机更新过内核没重启,uname -r和实际源码树版本对不上,编译能过但 insmod 会报Invalid module format。
判断方法很直接:uname -r和ls /lib/modules/对比一下,目录名完全一致才行。不一致就先重启切换到对应内核,或者去装匹配的头文件包。这个坑在虚拟机里做内核实验时特别高频,因为升级内核不会自动重启。
6. 从 demo 往下走:ioctl、并发保护与平台驱动
6.1 用 unlocked_ioctl 传控制命令
read/write适合传数据流,但"设置波特率""清零计数器"这类控制操作不适合用它们硬凑。标准做法是实现unlocked_ioctl:
#define DEMO_IOC_MAGIC 'd' #define DEMO_GET_LEN _IOR(DEMO_IOC_MAGIC, 1, int) static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { int len = demo_len; switch (cmd) { case DEMO_GET_LEN: if (copy_to_user((int __user *)arg, &len, sizeof(len))) return -EFAULT; return 0; default: return -ENOTTY; } }然后在 fops 里加.unlocked_ioctl = demo_ioctl,。名字里的 unlocked 指的是"调用时不持有大内核锁",参数检查、并发保护都得自己负责。_IOR这类宏会编码方向、大小和序号,能在编译期做部分检查,比自己定魔数可靠。default分支返回-ENOTTY是约定,别返回 0,否则用户态传错命令也感知不到。
6.2 多个进程同时读写时的保护手段
现在这个 demo 完全没有并发保护,两个进程同时write会互相覆盖demo_buf,demo_len也可能更新到一半被读到。内核里最容易复现并发问题的方法就是开两个终端同时猛写,几秒钟就能看到数据错乱。
最简单的保护是加一把互斥锁:
#include <linux/mutex.h> static DEFINE_MUTEX(demo_lock); static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { unsigned long not_copied; mutex_lock(&demo_lock); if (count > BUF_SIZE - 1) count = BUF_SIZE - 1; not_copied = copy_from_user(demo_buf, buf, count); if (!not_copied) { demo_buf[count] = '\0'; demo_len = count; } mutex_unlock(&demo_lock); return not_copied ? -EFAULT : count; }选mutex而不是spinlock,因为copy_from_user可能睡眠,不能在持有自旋锁的上下文里调用。这是选型上的一个硬约束,不是偏好问题。如果临界区里确实只做内存拷贝这种不会睡眠的操作,才考虑自旋锁;一旦涉及 kmalloc(带 GFP_KERNEL)、copy_*_user、调度,就老老实实用 mutex。
还有一处要注意:open里如果要做"只允许一个进程打开"的限制,用一个原子变量做计数,但release一定会被调用(哪怕进程崩溃),所以计数必须放在release里减。我曾经过度依赖open里的检查,结果程序异常退出时节点一直处于"被占用"状态,只能重启模块。
6.3 从字符设备转向平台驱动的时机
上面这套是字符设备的通用模板,适合按键、LED、传感器这类可以直接对着/dev节点操作的外设。但如果设备挂在片上总线上、需要匹配设备树节点、需要处理中断和电源管理,就该转向平台驱动模型:用platform_driver注册、.of_match_table匹配设备树、probe里拿资源。字符设备的那套 fops 依然可以保留,两者不冲突——平台驱动负责"发现硬件、申请资源",字符设备负责"对上暴露接口"。
判断标准很简单:如果你的设备地址是写死在代码里的,用它入门没问题;如果地址要从设备树里读、上电顺序要和时钟控制器配合,那继续用裸字符设备就是在给自己埋雷。入门阶段先把字符设备吃透,理解dev_t、cdev、fops这三者的关系,后面学平台驱动会顺很多。
实际做下来最大的体会是,这个"简单"的驱动真正的价值不在于它能读写 256 字节,而在于它把内核模块开发的完整链路压缩成了一个能被完整看懂的样本:从module_init到printk,从设备号申请到节点生成,从copy_*_user到错误码返回。把这份代码的每一行都搞清楚为什么这么写之后,再去看内核树里drivers/char/下面那些真实的驱动,就不会一打开就是几千行看不懂了。