搞Linux驱动开发的人大概都有过这种体验:半年前写的字符设备驱动,回头再看,设备号怎么申请的、cdev 挂哪儿、出错分支谁先释放,全得从头推一遍。更麻烦的是换台机器或者换块板子,Makefile 里的内核路径一改,编译报错能扯出一串。字符设备驱动模板和配套 Makefile 解决的正是这个问题——它把一套已经跑通的注册、读写、卸载骨架固定下来,下次开新项目直接复制改名,十分钟就能出一个能 insmod 的最小驱动。这篇内容适合刚接触内核模块、想搞清楚设备号与 file_operations 绑定关系的人,也适合已经写过几个驱动、但工程组织一直比较随意的朋友。我会把模板里每一处取舍的理由讲透,包括 Makefile 为什么不能写成普通工程那种样子,以及交叉编译时那几个最容易被忽略的变量。
1. 先把需求摸清楚:字符设备驱动到底在解决什么
1.1 从一次“写三遍忘三遍”说起
字符设备在 Linux 的抽象里是最朴素的一类:字节流,顺序访问,不涉及块设备的缓冲调度,也没有网络设备的协议栈。用户态用 open 拿到文件描述符,read/write 往里灌数据或者取数据,close 释放,中间可能夹一点 ioctl 做控制。整个模型简单到几十行代码就能跑起来,但恰恰因为简单,很多人第一次写的时候全靠抄,抄完能跑就不再深究,结果就是每次重来都要重新踩一遍坑。
我见过不少类似的情况。某个开发者第一次写驱动,把register_chrdev用上了,跑得挺欢;第二次换了个新内核,发现这个接口拿不到次设备号,又得改成alloc_chrdev_region加cdev_add的组合。第三次要在一个系统里挂三个同类设备,设备号分配直接乱套。问题的根子不在技术难度,而在于手里没有一份“想清楚了”的模板,每次都在重新发明轮子,而且发明得还不一样。
模板的价值就在这里。它不是把代码抄一遍存起来,而是把那些一次性决策提前做完:设备号用动态还是静态、cdev 和 device_create 的调用顺序、错误分支的释放路径、缓冲区用不用锁、模块参数怎么暴露。这些决策做对一次,后面所有同类项目都省事。
1.2 一套模板应该固化的东西
我把模板需要覆盖的内容分成四层,从下往上分别是设备号管理、字符设备对象、用户接口、工程构建。
设备号管理是最底层。主设备号要么内核动态分配,要么自己静态指定。动态分配的好处是不会撞车,代价是用户态拿不到固定名字,得靠 udev 或 mdev 生成节点;静态指定的好处是节点名固定、调试方便,坏处是容易和已有驱动冲突。模板里我倾向于动态为主、静态作为可选宏,这样默认安全,需要固定的时候再打开。
字符设备对象这一层是struct cdev和struct file_operations的绑定。这里的关键不是会写,而是知道谁拥有谁:cdev 里存着 fops 指针,fops 是常量结构,cdev 是运行时对象,两者生命周期不同,释放顺序错了就是 use-after-free。
用户接口层就是 open/read/write/ioctl/release 这几个回调。模板要给出每个回调的职责边界,尤其是返回值语义——read 返回 0 表示 EOF,返回负数是错误码,返回正数是实际字节数,这三者混淆是新手最常见的 bug 来源。
工程构建层是 Makefile。这一层最容易被轻视,因为它看起来只是几行命令,实际上 Kbuild 的规则和普通 Makefile 差别很大,写错了轻则重复编译,重则把内核源码树污染。
注意:模板不是让你不动脑子地复制。每一处宏定义、每一个错误分支都要能说出为什么,否则出了问题你连日志都看不懂。
2. 驱动骨架的取舍:设备号、cdev 与 file_operations
2.1 设备号申请:动态分配和静态指定的分界线
设备号是dev_t类型,32 位,高 12 位主设备号,低 20 位次设备号。主设备号标识“哪一类驱动”,次设备号标识“这一类里的第几个设备”。这个划分方式决定了:一个驱动如果需要管理多个同类设备,主设备号共享,次设备号递增。
申请方式有两条路。第一条是register_chrdev_region(devno, count, name),前提是你已经知道要用的起始设备号,比如MKDEV(240, 0)。第二条是alloc_chrdev_region(&devno, baseminor, count, name),让内核从空闲区间里挑一段给你。
我一般默认用第二条。理由很实际:静态指定 240 这类数字,在自己机器上可能没事,换一台机器如果已经被别的驱动占了,register_chrdev_region直接返回-EBUSY,而且这种冲突在部署阶段才暴露,排查起来很烦。动态分配虽然拿到的号不固定,但模板里可以通过device_create自动生成/dev节点,用户态根本不关心主设备号是多少。
那什么时候必须静态?两种情况。一是设备节点需要在没有 udev 的精简根文件系统里手工mknod,这时候主设备号必须可预期;二是某些老代码或者测试脚本硬编码了设备号。模板里的处理方式是给一个编译开关:
#define MYCHAR_USE_STATIC_DEVNO 0 #define MYCHAR_MAJOR 240 #define MYCHAR_MINOR_BASE 0 #define MYCHAR_DEV_COUNT 1 static int mychar_setup_devno(void) { int ret; #if MYCHAR_USE_STATIC_DEVNO devno = MKDEV(MYCHAR_MAJOR, MYCHAR_MINOR_BASE); ret = register_chrdev_region(devno, MYCHAR_DEV_COUNT, DEV_NAME); #else ret = alloc_chrdev_region(&devno, MYCHAR_MINOR_BASE, MYCHAR_DEV_COUNT, DEV_NAME); #endif if (ret < 0) { pr_err("mychar: register devno failed, ret=%d\n", ret); return ret; } pr_info("mychar: major=%d, minor_base=%d\n", MAJOR(devno), MINOR(devno)); return 0; }这段代码里有个细节值得说:alloc_chrdev_region的第二个参数是baseminor,不是“从 0 开始随便挑”,而是“次设备号从这个值开始往上找”。传 0 表示次设备号从 0 开始,内核会找一个能满足count个连续次设备号的主设备号。如果你传了 5,那拿到的次设备号就是从 5 起。
释放用unregister_chrdev_region(devno, count),注意count必须和申请时一致,写成 1 但申请了 4 个,剩下三个永远泄漏,cat /proc/devices里也看不出来。
2.2 cdev 挂载与 file_operations 的绑定顺序
拿到设备号只是拿到了一串数字,真正让内核知道“这个号对应哪个驱动”的是struct cdev。标准的初始化序列是这样的:
cdev_init(&mychar_cdev, &mychar_fops); mychar_cdev.owner = THIS_MODULE; ret = cdev_add(&mychar_cdev, devno, MYCHAR_DEV_COUNT);三行的顺序不能乱。cdev_init会把cdev->ops指向你的 fops,同时初始化内部链表节点;owner必须手动赋值,这一行漏掉的后果是模块卸载时引用计数不减少,rmmod会报 “Module is in use”;cdev_add才是真正把 cdev 挂进内核的 cdev_map。
owner = THIS_MODULE这个赋值经常被解释成“防止模块在使用中被卸载”,但更准确的机制是:内核在open字符设备时会调用try_module_get(cdev->owner),成功后模块引用计数加一,release时module_put减一。如果没有这一行,owner 是 NULL,try_module_get(NULL)返回 true 但不加计数,等于完全没有保护,rmmod能直接把正在被打开的设备驱动卸掉,后面用户态再 read 就是内核崩。
卸载顺序则严格反过来:
cdev_del(&mychar_cdev); unregister_chrdev_region(devno, MYCHAR_DEV_COUNT);先摘 cdev 再还设备号,因为 cdev_del 内部会处理已打开文件的情况。反过来的话,从cdev_del到unregister之间有个窗口,理论上新 open 可能拿不到正确的映射,虽然实际很难触发,但顺序对了不费事。
如果用了device_create生成节点,卸载时还要在前面加两步:
device_destroy(mychar_class, devno); class_destroy(mychar_class);顺序是先销毁具体设备节点,再销毁类。类销毁时如果还有设备挂在上头,内核会报 warning 并拒绝,所以这两个不能颠倒。
2.3 内核态与用户态的数据搬运边界
read 和 write 的实现里最容易出错的是copy_to_user/copy_from_user的返回值判断。它们的返回值语义是“没有拷贝成功的字节数”,不是错误码。所以判断方式必须是:
if (copy_to_user(ubuf, kbuf, n)) return -EFAULT;而不是if (copy_to_user(...) < 0)。后者永远为假,因为返回值是size_t类型的无符号数。
另一个高频问题是*ppos的处理。字符设备的 read/write 会收到一个loff_t *ppos,表示当前文件偏移。模板里我统一按“顺序流”处理:
- read 时如果
*ppos >= 已写入长度,返回 0,告诉用户态到末尾了; - 否则拷贝
min(count, 剩余长度)字节,然后*ppos += 实际拷贝字节数; - write 时如果
*ppos + count > 缓冲区容量,返回-ENOSPC,或者只写能放下的部分并返回实际写入量。
这里有个选择:write 超出容量是报错还是截断。我选报错,因为截断会让调用方以为数据完整写入了,echo场景下感觉不出来,但用程序写结构化数据时会静默丢数据,属于最难查的一类 bug。
缓冲区本身用kmalloc分配,模板里给一个模块参数控制大小:
static unsigned int buf_size = 1024; module_param(buf_size, uint, 0644); MODULE_PARM_DESC(buf_size, "internal buffer size in bytes, default 1024");module_param的第三个参数是 sysfs 权限,0644 表示可以在/sys/module/mychar/parameters/buf_size里读写。注意这个参数在模块加载时就生效,加载后再改只影响变量值,不会重新分配缓冲区——想支持动态调整,得额外做一套 resize 逻辑,模板里不做,因为那属于业务需求不是骨架需求。
并发访问要加锁。字符设备天然可能被多个进程同时打开,两个进程同时 write,缓冲区内容会交错。模板用DEFINE_MUTEX加互斥锁,在每个回调入口mutex_lock_interruptible,出口 unlock。用可中断版本是为了让进程在等锁时能被信号打断,否则Ctrl+C都杀不掉。
3. Makefile 与 Kbuild:编译体系别写成一次性脚本
3.1 最小可用模块 Makefile 的逐行拆解
内核模块的编译和普通 C 程序完全是两套体系。普通程序是“编译器 + 源文件 → 可执行文件”,模块是“内核构建系统 + 源文件 → .ko”,其中内核构建系统需要知道内核源码在哪、用哪套配置、目标架构是什么。所以模块 Makefile 的核心不是自己写编译命令,而是调用内核的 Kbuild。
最小可用版本就是六行:
obj-m += mychar.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逐行说。obj-m += mychar.o是给 Kbuild 的指令,意思是“把 mychar.o 编译成模块”。这里的名字必须和源文件对应,mychar.o会自动去找同目录下的mychar.c。如果你的驱动拆成了多个源文件,比如core.c和ops.c,那要写成:
obj-m += mychar.o mychar-objs := core.o ops.o注意mychar-objs里的mychar必须和obj-m里的目标名一致,而且不能有一个真实的mychar.c,否则会冲突报错。这个规则我第一次踩的时候完全没头绪,报错信息是 “multiple definition”,看起来像代码问题,其实是 Kbuild 的命名冲突。
KDIR指向内核构建目录。/lib/modules/$(uname -r)/build通常是个符号链接,指向当前内核对应的源码或头文件目录。用?=而不是=是为了允许外部覆盖,交叉编译时会用到。
PWD := $(shell pwd)用:=立即展开,避免在递归调用中被覆盖。这里必须用绝对路径,因为-C $(KDIR)会切换工作目录,之后再执行M=$(PWD)时如果 PWD 是相对路径就找不到了。有些环境下$(PWD)是 shell 内置变量且可能为空,所以我习惯显式写$(shell pwd)。
all和clean这两个目标体必须用Tab 缩进,不是空格。这个坑每年都要坑一批人,报错是 “missing separator”,但肉眼看不出区别。Makefile 里永远不要在命令前用空格。
3.2 工程化变量:把路径、目标名、编译选项抽出来
六行版本能用,但一旦项目里多几个模块,或者要在 CI 里跑,就得把变量抽出来。我的模板长这样:
MODULE_NAME := mychar obj-m += $(MODULE_NAME).o KERNEL_VER ?= $(shell uname -r) KDIR ?= /lib/modules/$(KERNEL_VER)/build PWD := $(shell pwd) EXTRA_CFLAGS += -DDEBUG_MYCHAR ccflags-y += -Wall -Wextra all: @echo "Building $(MODULE_NAME) against $(KDIR)" $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean install: $(MAKE) -C $(KDIR) M=$(PWD) modules_install depmod -a $(KERNEL_VER) reload: all sudo rmmod $(MODULE_NAME) 2>/dev/null || true sudo insmod ./$(MODULE_NAME).ko .PHONY: all clean install reload几个地方值得单独拎出来。
obj-m += $(MODULE_NAME).o用变量是可以的,Kbuild 在解析时会展开。但要注意MODULE_NAME必须用:=赋值,用=的延迟展开在某些递归场景下会出问题。
EXTRA_CFLAGS和ccflags-y的区别:ccflags-y是当前目录下所有目标共享的编译选项,EXTRA_CFLAGS是老式写法但仍然有效,主要作用是给 Kbuild 传额外的定义。我两个都用,因为不同内核版本对它们的处理略有差异,双保险。
reload目标是为了省事。每次改完代码要rmmod再insmod,手工敲两行很烦,做成目标之后make reload一步到位。rmmod前加2>/dev/null || true是因为第一次加载时模块不存在,直接rmmod会报错中断。
.PHONY声明是必须的。如果不声明,当目录下恰好有个叫clean的文件时,make clean会认为目标已是最新,什么也不做。这种情况不多见但一旦遇到极其迷惑。
提示:
install目标里的depmod -a不要省。少这一步,模块虽然拷进了/lib/modules/.../extra/,但modprobe找不到它,表现为“模块明明装了却提示 not found”。
3.3 交叉编译与多内核版本共存的配置
交叉编译是模板必须支持的场景。关键是把ARCH和CROSS_COMPILE传进 Kbuild 的调用里,而不是设置环境变量就完事:
ARCH ?= arm64 CROSS_COMPILE ?= aarch64-none-linux-gnu- KDIR ?= /opt/kernel/linux-6.1 all: $(MAKE) -C $(KDIR) M=$(PWD) \ ARCH=$(ARCH) \ CROSS_COMPILE=$(CROSS_COMPILE) \ modulesARCH的取值要和内核源码的arch/目录名一致,常见的是 arm、arm64、x86 等等。CROSS_COMPILE是工具链前缀,注意结尾的短横线不能少,少了会拼出aarch64-none-linux-gnugcc这种东西然后报 “command not found”。
KDIR指向的必须是配置过并且编译过的内核源码树。只用头文件包不够,因为模块编译过程中会引用Module.symvers,这个文件只有编译过内核才会生成,缺了它会报一堆 “undefined symbol” 的警告,虽然模块可能还是能编出来,但版本校验信息不全。
多内核版本共存的做法是给每个版本准备一个独立的构建目录,用变量切换:
KVER ?= 5.15 KDIR_5_15 := /opt/kernel/linux-5.15 KDIR_6_1 := /opt/kernel/linux-6.1 KDIR := $(KDIR_$(subst .,_,$(KVER)))这里$(subst .,_,$(KVER))把5.15变成5_15,拼成KDIR_5_15,再做变量间接引用。这套写法在需要同时维护多个目标平台时很省心,make KVER=6.1就能切过去。
还有一个跟内核版本相关的兼容点。class_create在 6.4 之前的签名是class_create(owner, name),6.4 开始去掉了 owner 参数。模板里用版本宏处理:
#include <linux/version.h> #if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0) mychar_class = class_create(CLASS_NAME); #else mychar_class = class_create(THIS_MODULE, CLASS_NAME); #endifIS_ERR()判断一样要做,返回值不是 NULL 而是错误指针。很多人写if (!mychar_class),判断永远为假,后面device_create传个错误指针进去,崩得莫名其妙。
4. 从零跑通:模板的完整实操流程
4.1 环境确认:内核源码与工具链的前置检查
动手之前先确认三件事,能省掉后面一大半的排查时间。
第一件,uname -r的输出和/lib/modules/$(uname -r)/build是否指向有效目录。跑一句:
ls -l /lib/modules/$(uname -r)/build正常输出是个符号链接,指向/usr/src/linux-headers-xxx之类的路径。如果提示不存在,说明没装内核头文件,需要先补上。注意头文件包和内核源码包不是一回事,前者够编模块,后者才能改内核配置。
第二件,确认内核版本和头文件版本一致。跑了很久没重启的机器,可能已经更新了内核但还跑着旧版本,uname -r是新版本而头文件还是旧的,编译出来的模块加载时报 “invalid module format”。检查方法:
uname -r ls /lib/modules/两个列表对不上就要注意了。
第三件,确认Module.symvers存在:
ls $(KDIR)/Module.symvers没有这个文件,说明内核源码树没编译过,模块能编但会有一堆符号警告,而且insmod时可能因为vermagic不匹配被拒。
工具链方面,本机编译只需要gcc、make、flex、bison、libssl-dev这些,交叉编译则是把gcc换成对应前缀的交叉编译器。确认工具链能跑:
aarch64-none-linux-gnu-gcc --version版本能打出来就行,具体版本号不必和内核完全一致,差几个小版本通常没事。
4.2 驱动代码逐段实现
完整代码放在一个mychar.c文件里,分五段看。
第一段是头文件和宏定义。除了必备的module.h、fs.h、cdev.h、uaccess.h,还要注意slab.h(kmalloc)、mutex.h、ioctl.h、version.h。
#include <linux/module.h> #include <linux/kernel.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> #include <linux/version.h> #define DEV_NAME "mychar" #define CLASS_NAME "mychar_class" #define MYCHAR_DEF_BUF_SIZE 1024 #define MYCHAR_IOC_MAGIC 'x' #define MYCHAR_IOC_CLEAR _IO(MYCHAR_IOC_MAGIC, 0) #define MYCHAR_IOC_LEN _IOR(MYCHAR_IOC_MAGIC, 1, int)ioctl 的_IO/_IOR宏值得说一下。它们把方向、幻数、序号、数据类型大小编码进一个 32 位整数。幻数用一个字符(这里是 'x'),理论上应该全局唯一,实际开发中撞了也不会有大问题,因为 ioctl 命令只在对应设备上有效。_IOR表示“从设备读数据到用户态”,第三个参数是数据类型,编译期会算进命令码里,如果用户态传的 size 对不上,内核会直接返回-ENOTTY而不是默默执行。
第二段是全局变量和 fops 前置声明。static关键字一个都不能少,模块里的全局符号默认是导出的,不加 static 会污染内核符号表。
第三段是文件操作函数。open 和 release 基本是空实现,但 release 里可以做点统计,比如打印一下累计读写次数,调试时很有用。
read 的实现前面已经给过,write 对称:
static ssize_t mychar_write(struct file *filp, const char __user *ubuf, size_t count, loff_t *ppos) { size_t n; int ret; if (mutex_lock_interruptible(&mychar_lock)) return -ERESTARTSYS; if (*ppos >= mychar_buf_size) { ret = -ENOSPC; goto out; } n = min(count, (size_t)(mychar_buf_size - (size_t)*ppos)); if (copy_from_user(mychar_buf + *ppos, ubuf, n)) { ret = -EFAULT; goto out; } *ppos += n; if ((size_t)*ppos > mychar_len) mychar_len = (size_t)*ppos; ret = n; out: mutex_unlock(&mychar_lock); return ret; }mychar_len记录有效数据长度,每次写完取max(原长度, 新位置),这样echo -n "abc" > /dev/mychar之后cat能读到正确内容。
ioctl 的实现要处理命令不匹配的情况,返回-ENOTTY:
static long mychar_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { int len; mutex_lock(&mychar_lock); switch (cmd) { case MYCHAR_IOC_CLEAR: memset(mychar_buf, 0, mychar_buf_size); mychar_len = 0; break; case MYCHAR_IOC_LEN: len = (int)mychar_len; mutex_unlock(&mychar_lock); if (copy_to_user((int __user *)arg, &len, sizeof(len))) return -EFAULT; return 0; default: mutex_unlock(&mychar_lock); return -ENOTTY; } mutex_unlock(&mychar_lock); return 0; }这里LEN分支里要先把锁放掉再copy_to_user,因为用户态地址操作可能触发缺页,持锁期间睡眠是不合适的。这个细节在模板里直接固化掉,省得每次重想。
第四段是 init。顺序是:分配缓冲区 → 申请设备号 → cdev_init → cdev_add → class_create → device_create。每一步失败都要回滚前面已经成功的步骤,这里用goto链最清晰:
static int __init mychar_init(void) { int ret; mychar_buf = kzalloc(mychar_buf_size, GFP_KERNEL); if (!mychar_buf) return -ENOMEM; ret = mychar_setup_devno(); if (ret) goto err_buf; cdev_init(&mychar_cdev, &mychar_fops); mychar_cdev.owner = THIS_MODULE; ret = cdev_add(&mychar_cdev, devno, MYCHAR_DEV_COUNT); if (ret) goto err_devno; #if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0) mychar_class = class_create(CLASS_NAME); #else mychar_class = class_create(THIS_MODULE, CLASS_NAME); #endif if (IS_ERR(mychar_class)) { ret = PTR_ERR(mychar_class); goto err_cdev; } mychar_device = device_create(mychar_class, NULL, devno, NULL, DEV_NAME); if (IS_ERR(mychar_device)) { ret = PTR_ERR(mychar_device); goto err_class; } pr_info("mychar: init done, buf_size=%u\n", mychar_buf_size); return 0; err_class: class_destroy(mychar_class); err_cdev: cdev_del(&mychar_cdev); err_devno: unregister_chrdev_region(devno, MYCHAR_DEV_COUNT); err_buf: kfree(mychar_buf); return ret; }第五段是 exit,严格倒序,和 init 的错误分支一一对应。
4.3 编译、加载、验证的完整链路
编译:
make KDIR=/lib/modules/$(uname -r)/build输出里应该能看到CC [M] /path/mychar.o和LD [M] /path/mychar.ko。生成的附属文件有.ko、.o、.mod.c、.mod.o、Module.symvers,其中真正要分发的是.ko。
加载:
sudo insmod ./mychar.ko加载后立刻看日志:
dmesg | tail -20应该能看到打印的 major 号。然后确认设备节点:
ls -l /dev/mychar如果节点没出现,先看dmesg有没有device_create相关的报错,再确认系统里 udev 或 mdev 是否在跑。
功能验证分三步。写数据:
echo -n "hello driver" > /dev/mychar注意echo -n的-n,不加的话会多写一个换行符,读出来长度对不上会以为有 bug。
读数据:
cat /dev/mychar第一次 cat 会读到内容并把文件偏移推到末尾,第二次 cat 就读不到了,因为*ppos已经到mychar_len,返回 0 表示 EOF。这符合普通文件的语义,但很多人第一次见会觉得“数据丢了”。想看多次,用dd指定偏移,或者cat完close再重新open——shell 每次重定向都是新 open,所以cat /dev/mychar; cat /dev/mychar两次都能读,因为偏移是每次 open 重置的。
ioctl 验证需要写个小程序:
#include <stdio.h> #include <fcntl.h> #include <sys/ioctl.h> #include <unistd.h> #define MYCHAR_IOC_MAGIC 'x' #define MYCHAR_IOC_LEN _IOR(MYCHAR_IOC_MAGIC, 1, int) int main(void) { int fd = open("/dev/mychar", O_RDWR); int len = 0; if (fd < 0) { perror("open"); return 1; } if (ioctl(fd, MYCHAR_IOC_LEN, &len) < 0) { perror("ioctl"); close(fd); return 1; } printf("current length: %d\n", len); close(fd); return 0; }用户态头文件里的命令码定义必须和驱动里完全一致,包括幻数、序号、大小。这个文件建议单独抽成mychar_ioctl.h,驱动和用户程序都 include 它,避免手工同步出错。
卸载:
sudo rmmod mychar如果提示 “Module is in use”,说明还有进程持有设备文件,lsof /dev/mychar查一下。
4.4 用脚本把验证动作固化下来
手工敲命令验证一遍可以,验证十遍就烦了。写个脚本:
#!/bin/bash set -e MODULE="mychar" DEV="/dev/mychar" echo "[1/4] build" make echo "[2/4] reload" sudo rmmod "${MODULE}" 2>/dev/null || true sudo insmod "./${MODULE}.ko" sleep 0.3 echo "[3/4] check node" if [ ! -c "${DEV}" ]; then echo "device node missing" dmesg | tail -20 exit 1 fi ls -l "${DEV}" echo "[4/4] rw test" echo -n "hello driver" > "${DEV}" OUT=$(cat "${DEV}") echo "read back: ${OUT}" [ "${OUT}" = "hello driver" ] || { echo "mismatch"; exit 1; } echo "all passed"set -e让任何一步失败都立刻退出,sleep 0.3是等 udev 把节点创建完,虽然在大多数机器上不需要,但容器环境里偶发节点延迟出现,加一下更稳。这个脚本可以直接放进 CI,每次提交跑一遍,比人工回归靠谱。
5. 常见问题与排查实录
5.1 编译期问题速查
编译阶段的问题基本都能从报错第一行定位,但有些报错信息会把人带偏。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| missing separator | Makefile 命令行用了空格缩进 | 改成 Tab |
| No such file or directory: linux/module.h | KDIR 路径错或头文件没装 | 检查/lib/modules/$(uname -r)/build |
| undefined symbol: xxx | 用了未导出的内核符号,或没包含对应头文件 | 查Module.symvers,确认函数是否EXPORT_SYMBOL |
multiple definition ofxxx | obj-m目标和-objs里的名字撞了 | 改名或合并源文件 |
| warning: implicit declaration | 少 include,尤其linux/uaccess.h | 补头文件 |
| error: too few arguments to function 'class_create' | 内核版本 6.4+ 签名变了 | 加版本宏判断 |
“undefined symbol” 这个尤其容易误判。有人看到copy_to_user未定义,第一反应是头文件没包含,实际是头文件包含了但没链接到内核符号——这种情况在模块里几乎不会发生,因为copy_to_user是标准导出符号。更常见的是自己写了个函数想给别的模块用,忘了EXPORT_SYMBOL,或者用了某个没有导出的内部函数。
另一个坑是编译出来一堆警告但.ko正常生成。默认情况下警告不阻断编译,但某些内核开了CONFIG_WERROR=y,警告就变错误。遇到这种情况不要急着关 Werror,先看警告内容,绝大多数是真问题。
5.2 加载期与运行期问题速查
| 现象 | 排查方向 | 常见根因 |
|---|---|---|
| insmod: invalid module format | 内核版本不匹配 | 编译用的 KDIR 和运行内核不一致 |
| insmod: unknown symbol in module | 依赖模块没加载 | modinfo看 depends,先加载依赖 |
| insmod: Permission denied | 安全启动或签名校验 | 检查内核是否开启模块签名强制 |
| 节点没生成 | udev/mdev 没跑或 device_create 失败 | dmesg查报错,手工mknod验证 |
| open 返回 No such device | cdev_add 失败或设备号不对 | cat /proc/devices看主设备号是否注册 |
| read 一直返回 0 | 逻辑里 ppos 初始就 >= len | 检查 write 是否真的写进去了 |
| write 返回 EFAULT | 用户态指针无效 | 检查 copy_from_user 的目标地址 |
| rmmod 提示 in use | 有进程持有设备 | lsof或fuser查 |
“节点没生成”是我遇到最多的问题。分两种情况:一是device_create报错,dmesg里会有明确信息,常见原因是 class 创建失败或者设备号冲突;二是device_create成功了但 udev 没反应,这时候可以手工mknod /dev/mychar c <major> 0验证功能是否正常,能正常工作就说明是用户态工具的问题。
“rmmod 提示 in use” 除了进程持有,还有一个隐蔽原因:某个错误分支没走cdev_del,导致 cdev 一直挂着。这种情况lsmod看引用计数是 0,但rmmod就是失败。模板里用统一的 goto 链就是为了避免这个——每个错误分支都必须成对,回滚顺序必须严格倒序。
5.3 几条文档里不会写的经验
第一条,缓冲区不要用全局数组。用static char buf[1024]看起来省事,但全局数组占的是 .bss 段,模块大小会变大,而且多个设备实例共享同一块内存。用kmalloc分配,每个实例一份,虽然多几行代码,但扩展成多设备时不用重写。
第二条,日志用pr_info而不是裸printk。pr_info自带模块名前缀和日志级别,dmesg里一眼能看出是谁打的。裸printk要自己拼KERN_INFO,拼错了级别就是错的。另外调试日志建议包一层宏,发布时用#ifdef DEBUG关掉,避免刷屏。
第三条,不要在中断上下文里用 mutex。模板里全是 mutex 是因为字符设备的读写都是进程上下文,可以睡眠。但如果将来加了中断处理,mutex 会导致内核崩溃,那时候要换成 spinlock 或者干脆把中断里的工作丢到 workqueue 里做。
第四条,设备号申请失败不一定是撞号。alloc_chrdev_region返回-ENOMEM说明内核内存紧张,返回-EINVAL通常是 count 传了 0 或者 baseminor 太大。区分错误码比盯着“申请失败”四个字有用得多。
第五条,用户态头文件里的 ioctl 命令码必须和驱动完全一致。我见过一个项目,驱动里_IOR的第三个参数写的是sizeof(int),用户态写的是sizeof(long),在 32 位平台上跑得好好的,换到 64 位就全返回 ENOTTY,查了两天才发现问题。统一用一个头文件是最省事的做法。
第六条,模板要跟着内核版本更新。内核 API 的变化不算频繁但确实存在,class_create是一例,file_operations里 read/write 的签名在 5.6 前后也变过(旧版没有__user标注)。模板里加版本宏不是过度设计,是让同一份代码能横跨几个 LTS 版本,减少重复劳动。
这套模板我自己用了几年,改过很多次。最开始是没有 goto 链的,错误处理全靠函数内直接 return,结果设备号泄漏过一次;后来加了 goto 链,又发现 class 和 device 的销毁顺序写反,卸载时内核报警告;再后来交叉编译,发现 PWD 用相对路径在多层 make 调用里会失效。这些坑现在都固化在模板里了,希望你能少走一遍。