
第一次把编译出来的.ko用 insmod 送进内核然后 dmesg 里干干净净、/dev 底下也没有冒出新节点——这是刚接触 Linux 设备驱动程序时最典型的开场。我当时以为是自己代码写错了反复检查了二十分钟最后发现只是忘了看printk的日志级别驱动其实早就加载成功了。后来陆陆续续又踩了主设备号冲突、copy_to_user返回值判断写反、read不推进偏移量导致 cat 卡死这一堆坑才慢慢摸清这个简单二字背后的实际门槛。这篇内容就围绕一个能实际跑起来的最小字符设备驱动展开把从内核态与用户态的分工、代码骨架、Makefile 编译、模块加载、用户态验证一直到常见排错链路完整走一遍。适合已经在用户态写过 C、想往内核方向迈第一步的人也适合手头有个板子或者虚拟机、想快速验证一个想法的朋友。所有代码都在常见的内核版本上验证过替换名字就能直接用。1. 设备驱动在内核里究竟占了哪一层位置1.1 用户态 open(/dev/xxx) 到内核 fops 的一次完整握手把设备驱动想成一个办事窗口会更直观。你在用户态敲echo hi /dev/demo_chrshell 会调用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_createlinux/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表现为终端卡死需要 CtrlC。这是最高频的入门 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.okbuild 会自动链接成demo_chr.ko。-C $(KERNELDIR)是切换到内核源码树目录执行 makeM$(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 major240 minor0这一行主设备号是多少记录下来。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文件匹配KERNELdemo_chr并设置MODE0666。改完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 driverdmesg 里应该能看到demo_chr: open minor0 demo_chr: release demo_chr: open minor0 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返回 0lsmod里也有就是看不到任何日志。排查顺序是这样走的先确认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算出 0copy_to_user拷贝 0 字节返回 0然后*ppos 0return 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_bufdemo_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/下面那些真实的驱动就不会一打开就是几千行看不懂了。