news 2026/10/27 22:59:23

从空壳模块到可读写字符设备:Linux字符设备驱动入门实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从空壳模块到可读写字符设备:Linux字符设备驱动入门实战

1. 先把"简单的Linux设备驱动程序"拆开看:它到底由什么拼成

很多人第一次碰Linux驱动,都是从一句insmod xxx.ko开始的。敲完回车,屏幕上什么都没有,dmesg里冒出一行打印,然后就不知道接下来该干嘛了。我最早也是这样,写了个只会printk的空模块,觉得"驱动不过如此",结果真正要往/dev下挂一个能读能写的设备节点时,立刻被设备号、cdev、file_operations、copy_to_user这一堆名词淹没。所以这篇东西不讲大而全的驱动体系,就聊最经典的一类:字符设备驱动,从空壳模块一步步做到用户态能open、read、write、ioctl的完整小设备。它解决的问题很具体——把内核里的一块内存缓冲区,包装成一个用户态可以像操作文件一样操作的设备节点。适合谁看?写过 C、懂一点 Linux 命令行、想搞明白"驱动到底在干什么"的人;完全没碰过内核模块的新手也能跟下来,因为每一步我都会说清楚为什么这么写。

1.1 内核模块机制到底解决了什么问题

在动手写代码之前,得先明白为什么驱动是以"模块"形式存在的。Linux 内核是一大坨编译好的二进制,如果你要把某个硬件的支持代码塞进去,最笨的办法是改内核源码、重新编译整个内核、重启。这在开发阶段几乎不可接受:改一行打印要等十几分钟编译,调一次参数要重启一次机器,效率低到让人放弃。

模块机制把这件事拆开了。驱动被编译成一个独立的.ko文件,它本质上是可动态链接进内核的目标文件,加载时由内核的模块加载器做符号解析和重定位,把你的代码挂到内核的地址空间里运行。所以你能在系统跑着的时候insmod进去,rmmod拿出来,中间不需要重启。这个设计带来的直接好处是迭代快:改代码、make、rmmod、insmod,十几秒一轮,调试体验和写用户态程序差不多。

但代价也要清楚:模块代码运行在内核态,和内核共享同一个地址空间,没有内存保护。用户态程序写越界,顶多自己被SIGSEGV干掉;内核模块写越界,轻则当前进程莫名其妙死掉,重则整个系统直接挂起或者 panic。我见过一个新手在read回调里忘了检查count和缓冲区剩余长度的关系,程序跑起来没事,用dd加大块大小一读,机器当场黑屏。所以后面每一处内存拷贝我都会强调边界检查,这不是啰嗦,是保命。

1.2 字符设备驱动的最小骨架,其实是五块拼图

抛开各种复杂特性,一个能用的字符设备驱动,核心就五块东西,我习惯把它们叫作"五块拼图":

拼图对应内核结构/接口作用
设备号dev_t、MKDEV标识"我是第几号设备",用户态靠它找到你
字符设备对象struct cdev把设备号和你的操作函数集合绑在一起
操作函数集合struct file_operations定义 open/read/write/ioctl 这些动作具体干什么
设备节点/dev/xxx用户态打开文件的入口,本质是"设备号+类型"的记录
生命周期钩子module_init/module_exit加载时注册、卸载时清理

这五块的关系可以这么理解:设备号是门牌号,cdev是把门牌号和"这户人家能提供什么服务"登记到物业(内核)的登记表,file_operations是这户人家的服务清单,/dev/xxx是用户手里的地址簿条目,module_init则是搬家进门的那一下。少任何一块,用户态都敲不开门。

很多人卡住的地方在于把这五块混成一团,写代码时想到哪写到哪。我的建议是初始化函数里严格按"申请设备号 → 初始化 cdev → 添加 cdev → 创建设备节点"的顺序写,退出函数里严格反着来。顺序对了,资源释放就不会漏;顺序错了,rmmod时会报一堆莫名其妙的错误。

1.3 设备号:静态指定还是动态申请,这是个策略问题

设备号dev_t是个 32 位整数,高 12 位是主设备号(major),低 20 位是次设备号(minor)。主设备号对应"哪一类驱动",次设备号对应"这一类里的第几个设备"。比如你有一块采集卡上有 4 个通道,可以申请 1 个主设备号加 4 个连续次设备号,一个驱动管四个节点。

分配方式有两条路:

  • register_chrdev_region(devno, count, name):静态指定主设备号。你得先自己挑一个没被占用的号,比如翻/proc/devices看哪些号空着。
  • alloc_chrdev_region(&devno, baseminor, count, name):让内核动态分配一个空闲的主设备号。

绝大多数情况下我都会选第二种。原因是静态指定在开发机上勉强能用,一旦驱动要发到别人的机器上,主设备号冲突就是必然事件——内核里现成的驱动几百个,你随手挑的 240 很可能已经被占了,insmod直接返回-EBUSY,报错信息还只有一行,非常劝退。动态分配的好处是内核帮你查重,代价是主设备号每次可能不一样,所以不能把设备号硬编码进mknod命令,得靠class_create+device_create自动创建节点,或者加载后去/proc/devices里查一下再用。

这里有个细节值得说:动态分配后主设备号会变,但如果你固定了baseminor为 0,那么后面的 minor 是稳定的。有些项目为了脚本好写,会采用"主设备号动态 + 次设备号固定"的折中做法,脚本里通过cat /proc/devices | grep 名字拿到 major 再拼出完整节点号。这个技巧在实际部署里很实用。

2. 把编译环境伺候好,模块才跑得起来

驱动开发最劝退的环节往往不是写代码,而是第一次make时报的那一堆错。我统计过自己带过的新人踩的坑,八成都集中在环境上:内核头文件版本不匹配、Makefile写成了普通工程的格式、KDIR路径指到了错误的地方。所以这一节我把环境准备单独拎出来讲透,代码反而放到后面。

2.1 工具链与内核头文件:一张必须对齐的清单

编译内核模块,需要的不是普通的gcc加标准库,而是一整套和当前运行内核版本严格对应的构建环境。判断标准很简单:uname -r的输出,和/lib/modules/$(uname -r)/build指向的源码树版本,必须是同一个。对不上,编译出来的模块加载时会报Invalid module format或者version magic不匹配。

需要准备的东西列一下:

  • 编译器:gcc或clang,版本最好和编译当前内核时用的一致,差太多会遇到内核自带的编译选项不认识的情况。
  • 内核头文件或内核源码树:常见发行版装linux-headers-$(uname -r)这个包就够了,它包含构建外部模块所需的Makefile、Module.symvers、配置文件和少量头文件。
  • make和bc、flex、bison、libssl-dev这类辅助工具:头文件包通常已声明依赖,但如果你是从源码树编译,这些得手动装。

验证环境是否就绪,一条命令就够:

ls -l /lib/modules/$(uname -r)/build

这个路径应该是一个有效目录(通常是软链接,指向/usr/src/linux-headers-xxx)。如果它不存在或者是个断链,后面的步骤全部免谈。我遇到过有人内核升级后没重启,uname -r还是旧版本,但头文件包已经被新版本覆盖,结果build链接指向新内核而实际运行的是旧内核,编译出来的模块一直加载失败。这种情况重启一次就好,别在那里死磕代码。

提示:如果你是在容器里开发,容器共享宿主机内核,但容器内通常没有/lib/modules。要么把宿主机的/lib/modules和/usr/src挂载进来,要么直接在宿主机上编译。模块加载必须在内核所在的那台机器上做,容器里insmod是行不通的。

2.2 Makefile 与 Kbuild:两行代码背后的机制

外部模块的Makefile短得让人怀疑,但它背后走的是内核自己的 Kbuild 体系:

obj-m := demo_char.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 .PHONY: all clean

逐行解释一下,因为这里每一行都有坑。obj-m表示"这个目标编译成模块",而不是编进内核(那要用obj-y)。KDIR指向内核构建目录,PWD是当前驱动源码目录。

真正干活的是$(MAKE) -C $(KDIR) M=$(PWD) modules这句:它先cd到内核构建目录,加载内核那套顶层Makefile(这套 Makefile 里定义了编译器、编译选项、头文件搜索路径等一切),然后通过M=$(PWD)告诉 Kbuild"别管内核自己了,去这个目录下找外部模块并编译"。这就是为什么你的驱动代码里能#include <linux/module.h>——头文件路径是内核 Makefile 给的,不是你手动指定的。

几个高频坑:

  • all下面的命令行必须用 Tab 缩进,用空格会报missing separator。这个是 Makefile 的老规矩,但新手用编辑器自动把 Tab 转成空格后就中招。
  • obj-m里的名字必须和源文件名对应。obj-m := demo_char.o意味着目录下要有demo_char.c。如果写成demo.o而文件叫demo_char.c,报错信息是No rule to make target,不太直观。
  • 一个模块由多个源文件组成时,要写成obj-m := demo.o加demo-objs := a.o b.o,注意demo-objs里的名字不能再带.o之外的路径前缀花活。
  • 别在Makefile里写CFLAGS。内核模块的编译选项由 Kbuild 控制,你手动加CFLAGS基本不生效,个别情况下还会破坏内核的选项组合。要加宏定义用ccflags-y += -DXXX。

2.3 加载、卸载与 dmesg:把反馈通道打通

模块编出来是.ko文件,加载和卸载就三条命令:

sudo insmod demo_char.ko # 加载 lsmod | grep demo_char # 确认已加载,看引用计数 sudo rmmod demo_char # 卸载 dmesg | tail -20 # 看内核打印

这里的关键是养成看dmesg的习惯。insmod成功时终端一声不吭,失败时也只给一行错误码,真正的信息全在内核日志里。比如insmod返回-1,dmesg里可能是Unknown symbol in module(符号没找到)、invalid module format(版本不匹配)、Device or resource busy(设备号被占),三者排查方向完全不同。

lsmod输出的第三列是引用计数。模块加载后计数应该是 0,如果有进程正打开着你的设备节点,计数会变成 1,这时候rmmod会报Module is in use。这个设计是保护你:如果强行卸载一个还有用户打开的模块,那些进程手里的文件描述符就会指向已经释放的内存,后续操作直接崩。所以卸载前先确认没有进程占用,用lsof /dev/你的设备名查一下。

注意:rmmod失败时不要用rmmod -f强删。强制卸载会跳过引用计数检查,在你自己的测试模块上可能侥幸没事,但一旦养成习惯,哪天在别人的环境里强删一个有引用的模块,造成的崩溃现场极难定位。

3. 从空壳模块到能读能写的字符设备

环境通了,接下来是正菜。我会按一个可运行的完整驱动来写,从上到下过一遍,每段代码说清楚它在干什么、为什么这么写。

3.1 file_operations 里每个回调的职责边界

struct file_operations是个函数指针表,用户态每一次系统调用,最终都会落到表里对应的那个函数上。名字和系统调用基本一一对应,但要理解两层含义:

第一层,这些函数运行在进程上下文,意味着可以睡眠(比如kmalloc(GFP_KERNEL)、mutex_lock),但不能做太耗时的操作,否则会拖慢调用它的进程。

第二层,参数里的__user指针不能直接解引用。用户态传进来的地址,在内核看来是"另一个地址空间里的地址",直接读会触发缺页或者读到垃圾数据。所有跨越这条边界的读写都必须走copy_to_user/copy_from_user,这两个函数内部做了地址合法性检查和异常处理。

一个最小集合通常只需要四个:open、release、read、write,需要控制命令再加ioctl,需要定位再加llseek。我见过有人把read写成死循环读,或者write里忘了更新文件位置,导致cat一次就卡死,这些都是对"文件位置loff_t *ppos"理解不到位。把它当成用户态read/write的"当前读写偏移",每次成功读写后更新它,就不会出问题。

3.2 设备号申请与 cdev 注册的完整时序

先把头部和全局变量摆出来:

#include <linux/module.h> #include <linux/init.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #include <linux/slab.h> #include <linux/mutex.h> #include <linux/ioctl.h> #define DEMO_NAME "demo_char" #define DEMO_BUF_SIZE 1024 #define DEMO_IOC_MAGIC 'd' #define DEMO_IOC_CLEAR _IO(DEMO_IOC_MAGIC, 0) #define DEMO_IOC_GETLEN _IOR(DEMO_IOC_MAGIC, 1, int) static dev_t demo_devno; static struct cdev demo_cdev; static struct class *demo_class; static char *demo_buf; static size_t demo_len; static DEFINE_MUTEX(demo_lock);

demo_buf是我们设备背后的"存储介质",一块 1KB 的内核内存,用户写进来的数据存在这里,读的时候从这里取。真实硬件驱动里这块会换成寄存器操作或者 DMA 缓冲区,但逻辑骨架完全一样。

初始化函数按固定顺序写:

static int __init demo_init(void) { int ret; demo_buf = kzalloc(DEMO_BUF_SIZE, GFP_KERNEL); if (!demo_buf) { pr_err("demo_char: no memory\n"); return -ENOMEM; } /* 1. 动态申请设备号,只申请 1 个次设备号 */ ret = alloc_chrdev_region(&demo_devno, 0, 1, DEMO_NAME); if (ret) { pr_err("demo_char: alloc_chrdev_region failed: %d\n", ret); goto err_free; } /* 2. 初始化 cdev 并绑定操作函数表 */ cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; /* 3. 把 cdev 加入内核 */ ret = cdev_add(&demo_cdev, demo_devno, 1); if (ret) { pr_err("demo_char: cdev_add failed: %d\n", ret); goto err_unreg; } /* 4. 创建设备类,为自动生成 /dev 节点做准备 */ demo_class = class_create(DEMO_NAME); if (IS_ERR(demo_class)) { ret = PTR_ERR(demo_class); goto err_cdev; } /* 5. 创建设备,udev/mdev 会据此生成 /dev/demo_char */ if (IS_ERR(device_create(demo_class, NULL, demo_devno, NULL, DEMO_NAME))) { ret = -ENODEV; goto err_class; } pr_info("demo_char: 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); err_free: kfree(demo_buf); return ret; }

注意错误处理用的是goto链,一层层往回退。这是内核代码的主流写法,看着不优雅,但能保证每一层失败时把之前申请的资源干净释放。新手常犯的错是某一步失败直接return ret,前面的资源全泄漏,模块再加载就报设备号被占。

还有一个版本兼容的坑:class_create的签名在不同内核版本间变过,老版本是class_create(THIS_MODULE, NAME),新版本去掉了第一个参数。如果你的代码在一台机器上编译通过、另一台上报参数数量不匹配,先看这里。写兼容代码可以判断内核版本用宏区分,但开发阶段更简单的办法是对齐版本。

退出函数严格反序:

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); kfree(demo_buf); pr_info("demo_char: unloaded\n"); }

device_destroy会触发 udev 删掉/dev/demo_char节点,cdev_del断开操作函数表,最后释放设备号和内存。顺序反了会出问题,比如先unregister_chrdev_region再cdev_del,中间这段时间如果有进程打开设备,就会拿到一个已经无效的设备号。

3.3 read/write 的内核缓冲区安全拷贝

这是最容易出安全事故的地方。先说read:

static ssize_t demo_read(struct file *filp, char __user *ubuf, size_t count, loff_t *ppos) { ssize_t ret; size_t len; if (*ppos >= demo_len) return 0; /* 读到末尾,返回 0 表示 EOF */ len = min(count, demo_len - (size_t)*ppos); mutex_lock(&demo_lock); ret = copy_to_user(ubuf, demo_buf + *ppos, len); mutex_unlock(&demo_lock); if (ret) return -EFAULT; /* 返回未拷贝的字节数,非 0 即失败 */ *ppos += len; pr_info("demo_char: read %zu bytes, pos=%lld\n", len, *ppos); return len; }

几个点必须抠清楚。第一,*ppos >= demo_len时返回 0,这是文件语义里的 EOF,cat就是靠这个判断读完的。第二,len取count和剩余长度的小值,绝不能直接用count去拷,否则用户传个 10MB 就能让你读越界。第三,copy_to_user返回的是没拷贝成功的字节数,所以判断条件是"非 0 即失败",返回-EFAULT。

write逻辑对称,但有一处不同:写的时候要限制总长度不超过缓冲区大小。

static ssize_t demo_write(struct file *filp, const char __user *ubuf, size_t count, loff_t *ppos) { ssize_t ret; size_t len; if (*ppos >= DEMO_BUF_SIZE) return -ENOSPC; len = min(count, (size_t)(DEMO_BUF_SIZE - *ppos)); mutex_lock(&demo_lock); ret = copy_from_user(demo_buf + *ppos, ubuf, len); if (!ret && *ppos + len > demo_len) demo_len = *ppos + len; mutex_unlock(&demo_lock); if (ret) return -EFAULT; *ppos += len; pr_info("demo_char: write %zu bytes, pos=%lld len=%zu\n", len, *ppos, demo_len); return len; }

这里demo_len表示"有效数据长度",只在写入超出当前长度时更新,这样连续两次写入不会把长度算错。如果用户用O_APPEND打开,内核会帮你把*ppos设到末尾,这个行为是 VFS 层给的,驱动不用额外处理。

提示:测试的时候用echo "hello" > /dev/demo_char再cat /dev/demo_char,如果第一次cat没输出,试试cat前先echo 0 > /dev/demo_char?不行,那会写数据。正确做法是重新打开设备再读——因为cat打开时ppos是 0,读完一次就 EOF 了。第二次cat是新打开,ppos又归零,能读到。如果你在同一个 fd 上反复读,读到末尾后就得靠lseek(fd, 0, SEEK_SET)复位,这就要求你实现llseek,或者干脆用默认的no_llseek/generic_file_llseek。

3.4 用 ioctl 打开控制面

read/write只适合传数据流,想"清空缓冲区""查询当前长度"这种控制类操作,用ioctl更合适。命令号要用_IO/_IOR/_IOW宏来定义,别自己拍脑袋编数字——这些宏会把方向、大小、类型编码进 32 位整数里,内核和用户态只要用同一份头文件就不会错。

static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { int len; if (_IOC_TYPE(cmd) != DEMO_IOC_MAGIC) return -ENOTTY; if (_IOC_NR(cmd) > 1) return -ENOTTY; switch (cmd) { case DEMO_IOC_CLEAR: mutex_lock(&demo_lock); memset(demo_buf, 0, DEMO_BUF_SIZE); demo_len = 0; mutex_unlock(&demo_lock); pr_info("demo_char: buffer cleared\n"); return 0; case DEMO_IOC_GETLEN: mutex_lock(&demo_lock); len = (int)demo_len; mutex_unlock(&demo_lock); if (put_user(len, (int __user *)arg)) return -EFAULT; return 0; default: return -ENOTTY; } }

_IOC_TYPE校验是防止别的驱动的命令误打误撞进来,_IOC_NR校验序号范围。返回-ENOTTY是约定俗成的"不支持这个命令",用户态收到后会打印Inappropriate ioctl for device。

put_user和get_user是单值版本的跨空间拷贝,比copy_to_user轻量,但同样不能直接解引用指针。

3.5 加一把锁,别让并发把数据踩坏

上面的代码里到处是mutex_lock。为什么?因为demo_len和demo_buf是全局共享状态,两个进程同时写,一个在更新demo_len,另一个在读,很容易读到中间态。更糟的是如果你做的是链表或者环形缓冲区,并发下指针会直接乱掉。

这里选mutex而不是自旋锁,理由是这些操作里包含copy_to_user/copy_from_user,可能触发缺页从而睡眠。自旋锁持有期间不允许睡眠,所以不能用。只有当你保护的是极短的、绝对不睡眠的临界区(比如改一个计数器),才轮到自旋锁出场。

还有一个更省事的选项:给file_operations加.llseek = no_llseek,然后在open里对同一设备做互斥(只允许一个进程打开)。这种做法在简单设备上很常见,一行atomic计数加判断就能实现,能省掉大量并发思考。但代价是用户态不能多进程同时用,得看你的场景能不能接受。

static int demo_open(struct inode *inode, struct file *filp) { filp->private_data = NULL; /* 需要时可挂上下文 */ pr_info("demo_char: opened by pid %d\n", current->pid); return 0; } static int demo_release(struct inode *inode, struct file *filp) { pr_info("demo_char: released\n"); return 0; } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, .unlocked_ioctl = demo_ioctl, }; module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("demo"); MODULE_DESCRIPTION("A minimal char device demo");

MODULE_LICENSE("GPL")这行不是摆设。不写的话模块加载时会标成非 GPL,很多内核导出的符号你用不了,dmesg里还会有一条内核被污染(tainted)的警告。

4. 用户态怎么用:节点创建与测试验证

驱动写完只是半成品,得真的从用户态打开它、读写它,才算跑通。这一步的坑在于"节点"——/dev下那个文件从哪来。

4.1 手动 mknod 与自动创建节点的差别

最原始的办法是mknod手动创建:

# 假设 insmod 后 dmesg 显示 major=240 sudo mknod /dev/demo_char c 240 0 sudo chmod 666 /dev/demo_char

c表示字符设备,240是主设备号,0是次设备号。删掉节点用rm /dev/demo_char,注意这只删节点,不影响已加载的模块。

手动创建的问题很明显:主设备号是动态分配的,每次重启都可能变,脚本里硬编码就会失效。所以我更推荐用class_create+device_create,让系统的设备管理服务(udev 或 mdev)自动在/dev下建节点并设好权限。代码里那两行就是为了这个,写上去之后insmod完直接ls /dev/demo_char就能看到。

如果自动创建没生效,排查方向:确认系统的 udev 服务在跑(systemctl status systemd-udevd)、确认class_create和device_create的返回值都检查过了、dmesg里有没有device_create相关的报错。有些精简系统里没有 udev,那就只能退回手动mknod。

4.2 写一个测试程序把读写跑通

命令行能验证基本功能,但要做连续操作和 ioctl 测试,还是写个小程序靠谱:

#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <errno.h> #define DEMO_IOC_MAGIC 'd' #define DEMO_IOC_CLEAR _IO(DEMO_IOC_MAGIC, 0) #define DEMO_IOC_GETLEN _IOR(DEMO_IOC_MAGIC, 1, int) int main(void) { int fd, len = 0; char rbuf[64] = {0}; const char *msg = "hello driver"; fd = open("/dev/demo_char", O_RDWR); if (fd < 0) { perror("open"); return 1; } if (write(fd, msg, strlen(msg)) < 0) { perror("write"); close(fd); return 1; } /* 回到文件头再读,否则读到的是末尾 */ if (lseek(fd, 0, SEEK_SET) < 0) { perror("lseek"); close(fd); return 1; } if (read(fd, rbuf, sizeof(rbuf) - 1) < 0) { perror("read"); close(fd); return 1; } printf("read back: %s\n", rbuf); if (ioctl(fd, DEMO_IOC_GETLEN, &len) == 0) printf("buffer len: %d\n", len); if (ioctl(fd, DEMO_IOC_CLEAR) == 0) printf("cleared\n"); close(fd); return 0; }

注意那个lseek。写完之后文件位置在末尾,直接read会返回 0(EOF),什么都读不到。要么lseek归零,要么关掉重开。这是新手最常见的"驱动写错了?"的误判现场之一。如果你的驱动没有实现llseek并且用了no_llseek,那lseek会失败,就得采用关掉重开的写法。

编译:gcc -o demo_test demo_test.c,跑之前确认节点存在。预期输出是读到hello driver、长度 12、然后清空成功。

4.3 压测与观察:用 dd、strace 看真实行为

功能通了之后,我习惯做两件事验证健壮性。第一件是用dd加大块:

sudo dd if=/dev/zero of=/dev/demo_char bs=64 count=100 2>&1 | tail -3 dd if=/dev/demo_char of=/tmp/out.bin bs=64 count=100 2>&1 | tail -3 ls -l /tmp/out.bin

正常情况是:写的时候超过 1024 字节后会被部分写入(因为len被截断),dd会继续尝试写直到文件位置超过DEMO_BUF_SIZE,这时候write返回-ENOSPC,dd报No space left on device并退出。能观察到这个现象,说明你的边界处理是对的。如果直接内核崩溃或者卡死,那就回去检查min那几行。

第二件是用strace看系统调用序列:

strace -e trace=openat,read,write,ioctl,lseek ./demo_test

这样能看到你的程序实际发了哪些调用、每次返回什么。有时候程序逻辑没问题,是某个调用的返回值没检查导致后面全错,strace一眼就能看出是哪个。

还有一个实用技巧:驱动里的pr_info打印带上进程号(current->pid)和函数名,多点并发测试时能清楚看出哪个操作来自哪个进程,比单纯打印一句话有用得多。内核有pr_fmt宏可以在编译时给所有打印加统一前缀,比如#define pr_fmt(fmt) "demo_char: " fmt,写起来更省事。

5. 排查实录:从编译失败到运行崩溃

最后这块是我这些年攒下来的问题清单,按阶段分,基本都是文档里不会写、但每次带新人都会遇到的。

5.1 编译期问题速查表

报错关键词常见原因处理方式
missing separatorMakefile 命令行缩进用了空格改成 Tab,编辑器关掉"Tab 转空格"
No rule to make target 'xxx.o'obj-m里的名字和源文件名不一致对齐文件名,多文件用xxx-objs
fatal error: linux/module.h: No such fileKDIR路径不对或头文件包没装检查/lib/modules/$(uname -r)/build是否有效
invalid use of undefined type 'struct class'少了#include <linux/device.h>补头文件
too few arguments to function 'class_create'内核版本间签名变化按当前版本调整参数
Symbol version dump ... not found头文件包不完整,缺Module.symvers装完整的 headers 包或使用内核源码树
warning: passing argument from incompatible pointer typefile_operations某字段签名写错对照头文件里的原型逐个核对

编译警告不要无视,尤其是指针类型不匹配。这类警告在 32 位系统上可能侥幸能跑,换 64 位就变成段错误。我一般编译时加make ... W=1打开额外警告,虽然噪音多,但能提前发现不少隐患。

5.2 加载与运行期问题速查表

现象可能原因排查动作
insmod报Invalid module format模块和运行内核版本不匹配对比modinfo xxx.ko的 vermagic 和uname -r
insmod报Unknown symbol用了未导出符号,或依赖模块未加载错误信息会给符号名,查/proc/kallsyms
insmod报Device or resource busy主设备号被占(静态指定时)改用动态分配,或查/proc/devices换号
加载成功但/dev下没节点udev 未运行,或device_create失败dmesg看报错,临时用mknod顶上
open返回No such device节点的主次设备号不对ls -l /dev/xxx看号,和dmesg里的对比
rmmod报Module is in use有进程持有该设备的 fdlsof /dev/xxx找进程,退出后再卸
read一直返回 0文件位置在末尾,或demo_len为 0先确认写入成功,再lseek归零
write返回No space left on device超过缓冲区上限检查min截断逻辑,属于预期行为
系统卡死或 panic内核里解引用了用户态指针、越界访问逐行检查copy_*_user和数组边界

5.3 几条踩出来的个人经验

第一条,永远先跑通再优化。我刚开始学的时候,一个模块里又想加 procfs 接口、又想支持 mmap、还想做多设备实例,结果卡在cdev_add报错上三天,因为基础没跑通就叠功能,出错时根本不知道是哪一层的问题。现在的做法是先写一个只printk的空模块,加载卸载通了,再往里加read,加完测通,再加write,一步步来。每加一块就验证一次,问题范围永远是最后那一小块。

第二条,把dmesg -w挂在旁边。开发驱动时我基本会开一个终端一直跑dmesg -w,任何加载、读写、报错都会实时滚出来。这比操作完再回头dmesg | tail高效得多,尤其是模块崩溃导致系统重启时,之前的日志可能已经被刷掉了。

第三条,private_data是个好东西。简单驱动里用全局变量没问题,但只要你的设备可能被多个进程同时打开,就该用filp->private_data挂上每个打开实例自己的上下文(通常是kmalloc出来的一块结构体),在open里分配、release里释放。这样不同 fd 之间互不干扰,比到处加锁优雅得多。代价是read/write里每次都要从filp取出来,多一层间接。

第四条,别在中断上下文里做傻事。现在这个 demo 全是进程上下文,能睡眠。等你真去接硬件中断,回调里就不能调kmalloc(GFP_KERNEL)、不能拿mutex,只能GFP_ATOMIC加自旋锁。这个边界意识要早点建立,不然从"简单驱动"往真实硬件走的时候,会踩一堆"为什么这里会卡死"的坑。

第五条,卸载干净比加载成功更重要。很多人的模块insmod一把过,rmmod就报错或者干脆 crash。检查方法很简单:加载卸载循环 20 次,看dmesg里有没有内存泄漏警告,看/proc/devices里设备号有没有残留。能做到反复加载卸载不出问题,这个驱动才算及格。

我个人的体会是,这类"简单驱动程序"的价值不在于它能干什么活,而在于它是一条完整的路径:从编译环境、模块生命周期、内核接口约定,一直到用户态验证和排错方法论,全部走了一遍。等这条路径熟了,再看真实硬件驱动的数据手册和寄存器操作,你会发现难的只是硬件细节,骨架还是这五块拼图。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 22:55:13

WPF Border与Frame底层原理:渲染边界与导航生命周期

1. 这不是“控件说明书”&#xff0c;而是WPF界面布局的底层逻辑课如果你刚从WinForms转来&#xff0c;看到Border和Frame这两个控件&#xff0c;第一反应可能是&#xff1a;“不就是加个边框、套个框架吗&#xff1f;拖进去设个颜色就完事了&#xff1f;”——我当年也是这么想…

作者头像 李华
网站建设 2026/10/27 22:54:35

SpringCloudAlibaba大宗商品交易平台源码拆解:从架构到落地

简介&#xff1a;整合SpringCloudAlibaba组件的Java大宗商品交易平台设计源码&#xff0c;主要面向院校毕业设计、微服务技术学习与电商项目二次开发场景&#xff0c;解决从单体到分布式架构的落地实践问题。资源共235个文件&#xff0c;包含189个Java源码、25个XML配置、9个YA…

作者头像 李华
网站建设 2026/10/27 22:53:58

URL编码与拼接避坑指南:从结构拆解到故障排查

上周帮同事看一个接口问题&#xff0c;日志里两边打印出来的 URL 肉眼看一模一样&#xff0c;可服务端就是返回参数校验失败。折腾了半小时才发现&#xff0c;问题出在请求地址里那个不起眼的%25上——前端把参数编码了一次&#xff0c;拼接时又整体过了一遍编码函数&#xff0…

作者头像 李华
网站建设 2026/10/27 22:51:47

Node.js + Express 从零搭建 RESTful API 服务实战指南

1. 为什么我选 Node.js Express 来搭这个 API 服务1.1 从零搭 API 服务&#xff0c;先想清楚三件事很多人一上来就问“用什么框架”&#xff0c;其实这个问题问早了。搭一个 API 服务&#xff0c;先要回答的是三个更底层的问题&#xff1a;这个服务给谁用、跑在什么环境、后续…

作者头像 李华
网站建设 2026/10/27 22:48:26

函数式响应式编程(FRP)实战:从核心思路到落地避坑指南

函数式响应式编程&#xff08;FRP&#xff09;这几年被提得越来越多&#xff0c;但真正在项目里落地过的人都知道&#xff0c;这东西概念听着优雅&#xff0c;实际用起来坑不少。我第一次接触 FRP 是在一个实时数据看板的项目里&#xff0c;当时界面上的图表要跟着后端推送的数…

作者头像 李华