手头有块开发板也好,只有一台装了 Linux 的普通机器也好,只要你想往内核方向迈一步,第一个遇到的坎基本都一样:写一个能跑起来的 Linux 设备驱动程序。这件事说难不难,一个字符设备驱动的完整骨架不过一百来行代码;但说简单也真不简单,卡住新手的往往不是 C 语言本身,而是版本对不上的内核头文件、编译时莫名其妙的隐式声明警告、insmod 之后 dmesg 里那句冷冰冰的 "Invalid module format",以及好不容易加载成功却发现 /dev 下面啥也没有。我自己第一次跑通一个能读能写的字符设备,前后折腾了整整两天,其中一半时间花在找一份和当前内核版本对得上的写法上。
这篇内容就是把这几天踩过的东西摊开讲。它面向的是已经会用基本 Linux 命令、写得了普通 C 程序、想弄清楚内核模块到底怎么从一行代码变成 /dev 下一个可读写文件的人。我会从一个"什么都不做"的空模块讲起,一步步补到能 open、能 read、能 write、能自动生成设备节点,最后把并发、ioctl 这些从"能跑"到"稍微能用"的过渡点也过一遍。所有代码都是可以直接抄下来编译的,参数怎么来的、为什么这么选,我也会把推导过程写清楚。
1. 先想清楚:一个"简单驱动"到底简单在哪
动手敲代码之前,有个问题值得先花十分钟想明白:我们说的"简单",是做减法做出来的简单,而不是因为它本身简单。内核驱动这件事的复杂度并没有消失,只是被我们暂时绕开了。理清这一点,后面遇到问题时你才知道自己在哪一层。
1.1 为什么起步适合选字符设备而不是网络或块设备
内核里的驱动大致分三类:字符设备、块设备、网络设备。新手起步几乎都从字符设备开始,原因不是它"高级",恰恰是因为它的抽象最少。块设备要对接页缓存和请求队列,网络设备要接 sk_buff 和协议栈,这两条路上你会被迫先理解一大套上层机制,才能看到自己写的那几行代码起了什么作用。字符设备则是一条直路:用户态 open("/dev/xxx"),内核找到你的 file_operations,调你的 open 函数,就完了。中间没有缓存层,没有调度层,读写就是函数调用,返回什么、拷贝多少,全在你手里。
我一般建议朋友这样理解:字符设备驱动本质上是给用户态提供了一组"文件操作的回调函数"。内核并不关心你的设备是真实硬件还是内存里的一块缓冲区,它只关心你有没有按约定实现那几个函数指针。所以哪怕手边没有硬件,你完全可以先写一个纯内存的字符设备,把 read/write 跑通,等这套流程肌肉记忆了,再把 read 里的 memcpy 换成寄存器读写,那就是真正的硬件驱动了。这个路径的好处是,出错时你能确定问题出在驱动框架还是硬件操作上,排查范围小一半。
1.2 字符设备、杂项设备、platform 驱动怎么选
实际写的时候会发现,同样是字符设备,注册方式还有好几种。用哪种不是风格问题,会直接影响你后面要不要处理设备号、要不要写设备树。我把常见的三种列出来对比一下。
| 方式 | 设备号处理 | 适用场景 | 主要代价 |
|---|---|---|---|
| 标准字符设备(alloc_chrdev_region + cdev) | 自己申请主次设备号,自己建 cdev | 需要多个设备实例、需要独立主设备号 | 代码步骤多,要管 cdev、class、device 三套东西 |
| 杂项设备(misc_register) | 主设备号固定为 10,次设备号自动分配 | 单实例小驱动、调试用、只需要一个设备节点 | 一个主设备号下设备数量有限,不够"正规" |
| platform 驱动 | 由总线匹配后拿到资源,再做字符设备注册 | 真实硬件、需要设备树描述寄存器地址和中断 | 要理解匹配机制和设备树,起步阶段负担重 |
起步阶段我的建议是走标准字符设备那条路,哪怕它啰嗦。原因是它的每一步都是显式的:设备号从哪来、cdev 怎么和 file_operations 绑定、设备节点由谁创建,你都得自己写一遍。写完之后你对整个链路的理解是完整的,后面换成 misc_register 只是做了减法,不会有什么信息丢失。反过来,如果一开始就用 misc_register,省事是省事,但你会长期不清楚 /dev 下那个节点到底是怎么冒出来的。
platform 驱动留到你有真实硬件的时候再上手。它的价值在于把"硬件资源描述"和"驱动逻辑"解耦,设备树里写地址和中断号,驱动里只管拿资源干活。但如果你现在连 cdev 都还没摸过,直接上 platform 只会在匹配失败的时候连从哪查都不知道。
1.3 树外编译还是塞进内核源码树
另一个要提前定的事是编译方式。把驱动文件拷进内核源码树的 drivers/char/ 下面,改 Kconfig 和 Makefile,然后整内核编译——这条路我不推荐新手走。整编一次内核动辄十几二十分钟,改一行代码等一轮,反馈太慢,而且一旦编译挂了,你分不清是驱动写错了还是内核配置动到了别的地方。
树外编译(out-of-tree)就舒服得多:驱动代码放在任意目录,Makefile 里用M=$(PWD)指过去,只编译你这一个模块,通常几秒钟出结果。代价是模块和内核版本强绑定,换个内核要重编,而且这类模块不会被发行版的内核签名机制覆盖,某些开启了强制签名的环境会加载失败。但对于学习和调试,这几秒的反馈速度太重要了。我自己的习惯是建一个~/drv-lab/目录,每个小实验一个子目录,互不干扰,出问题直接删掉重来。
提示:树外模块每次都要针对当前运行内核重新编译,不要把编译产物 .ko 拷到另一台机器上直接用,除非两台机器的内核版本、配置、编译器版本完全一致。这是新手最常见的"在我这儿好好的"来源。
2. 环境准备:这一步没做对,后面全是玄学
很多人写驱动的时间全耗在环境上。我给个判断标准:如果你make的时候没有出现任何关于内核头文件的报错,insmod之后dmesg里能看到你自己写的打印,那环境就算过关了。下面把过关需要的东西逐项确认。
2.1 内核头文件必须和运行内核严格对齐
模块是直接链接进内核空间的二进制,它会引用内核导出的符号(比如cdev_add、copy_to_user)。这些符号的地址和签名跟具体内核版本绑定,所以头文件版本必须和你正在运行的内核完全一致。查当前版本用:
uname -r拿到类似6.8.0-45-generic这样的字符串后,确认对应的构建目录存在:
ls -l /lib/modules/$(uname -r)/build这个build通常是个软链接,指向/usr/src/linux-headers-$(uname -r)。如果这条命令报"没有那个文件或目录",说明头文件没装。Debian/Ubuntu 系装linux-headers-$(uname -r),其它发行版对应各自的包管理命令,思路是一样的:包名里必须带上uname -r的输出,不能图省事装一个笼统的 headers 包。我见过太多人装了最新内核的头文件,却还在跑旧内核,然后对着Invalid module format一脸茫然。
一个容易忽略的点:如果你最近更新过内核但没重启,uname -r报的是老版本,而包管理器装的头文件是新版本。这种"错位"状态下编译出来的模块,加载必失败。解决办法很简单,重启进新内核,或者显式装老版本头文件,别硬扛。
2.2 编译器版本要跟内核构建时用的一致
这一条比头文件更容易被忽略。内核在编译时会记录构建它的编译器版本,加载模块时如果发现你的编译器 major 版本差太多,某些配置下会直接拒绝。检查内核构建信息:
cat /proc/version输出里会带gcc version x.y.z之类的字样。然后看你本地的:
gcc --version两者 major 版本一致基本就没问题。如果不一致,装上对应版本、在 Makefile 里用CC=显式指定即可。这不是吹毛求疵,我在一个容器环境里就遇到过系统默认 gcc 是 12、而内核是用 11 编的,模块能编出来但加载时结构体布局对不上,表现为加载成功、一读就崩,排查了很久才定位到编译器。
2.3 日志观察和权限的几个习惯
调试内核模块,printk加dmesg是主力手段,这条链路要顺手。我建议开一个终端常驻sudo dmesg -w,另一终端做加载卸载操作,打印实时滚出来,比反复敲 dmesg 高效得多。注意dmesg在某些发行版默认限制普通用户读取,加sudo或者临时调kernel.dmesg_restrict参数都行。
加载卸载模块需要 root 权限,这是硬性的。但测试程序读写 /dev 节点时,如果不想每次 sudo,可以在创建设备节点时把权限放出来(后面代码里device_create之后补一句改权限,或者用 udev 规则)。学习阶段我一般直接在测试程序里要求以 root 跑,省得权限问题和逻辑问题混在一起。
注意:搭环境阶段不要同时改多个变量。一次只解决一个问题,比如先只确认头文件路径对,再只确认能编出空模块,再确认能加载。同时改三处,出错了你无法归因。
3. 从空模块到可读写设备:逐段把驱动补起来
这一段是主体。我会按"能加载 → 能创建设备节点 → 能读写 → 能自动建节点"的顺序往上加,每加一层都解释为什么这么写。你按顺序抄,每完成一步就验证一次,比一次性抄完再调试要快得多。
3.1 最小可加载模块:入口、出口和许可证
任何内核模块都有两个必备函数:加载时调用的初始化函数、卸载时调用的清理函数。二者用module_init/module_exit注册,另外必须有一行MODULE_LICENSE,否则内核会认为这是非自由许可证模块,加载时打出一堆 tainted 警告,某些符号也不给你用。
#include <linux/module.h> #include <linux/init.h> #include <linux/kernel.h> static int __init simple_init(void) { pr_info("simpledev: module loaded\n"); return 0; } static void __exit simple_exit(void) { pr_info("simpledev: module unloaded\n"); } module_init(simple_init); module_exit(simple_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("lab"); MODULE_DESCRIPTION("a minimal char device demo");几个细节值得说。__init告诉内核这个函数只在初始化时用,用完那块内存可以回收,__exit同理,但注意如果模块是编译进内核而非做成模块,__exit标注的函数会被丢弃,所以清理函数只写模块卸载逻辑,别放必须执行的代码。pr_info是printk(KERN_INFO ...)的封装,比直接写 printk 少打一堆级别宏,日常调试够用。
编译加载验证:
make sudo insmod simple_drv.ko sudo dmesg | tail -5 sudo rmmod simple_drv看到你写的那两行打印,说明最底层的链路通了。这一步千万别跳过,我见过有人直接从完整驱动开始调,结果连模块能不能加载都不确定,后面所有现象都没法解释。
3.2 设备号:静态指定还是动态申请
用户态要打开一个设备,靠的是设备号(主设备号 + 次设备号),不是文件名。主设备号标识"哪一类驱动",次设备号标识"这一类里的第几个设备"。申请方式有两种,选择逻辑很明确。
静态指定用register_chrdev_region(dev, count, name),你得自己挑一个没被占用的主设备号。内核里有一份已分配主设备号的清单,在 Documentation 里能查到,但手工维护这个太容易撞车,撞了就是-EBUSY。动态申请用alloc_chrdev_region(&dev, 0, count, name),内核帮你找一个空闲的主设备号,返回值告诉你结果。学习阶段和绝大多数实际场景都应该用动态方式,唯一需要静态的场合是某些老设备节点已经固化了主设备号的兼容性需求。
static dev_t dev_num; static int __init simple_init(void) { int ret; ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME); if (ret < 0) { pr_err("simpledev: alloc_chrdev_region failed, ret=%d\n", ret); return ret; } pr_info("simpledev: major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num)); return 0; } static void __exit simple_exit(void) { unregister_chrdev_region(dev_num, 1); }MAJOR/MINOR这两个宏在linux/kdev_t.h里,把 dev_t 拆成两半。打印出来是为了后面手动 mknod 时用得上,同时也是验证申请成功的最直接方式。第二个参数0是次设备号起始值,第三个参数1是数量,表示我们只要一个设备实例。返回值为负数时一律return ret把错误码透传给调用者,不要自己改成-1,用户态靠错误码判断具体原因。
3.3 cdev 与 file_operations:把回调函数挂上去
有设备号还不够,内核还得知道"打开这个设备号时该调哪个函数"。这个桥梁就是struct cdev和struct file_operations。file_operations 是一张函数指针表,你实现了哪个就填哪个,没实现的保持 NULL,内核会在对应操作时报-EINVAL之类的错误。
static struct cdev simple_cdev; static const struct file_operations simple_fops = { .owner = THIS_MODULE, .open = simple_open, .release = simple_release, .read = simple_read, .write = simple_write, }; static int __init simple_init(void) { int ret; ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME); if (ret < 0) return ret; cdev_init(&simple_cdev, &simple_fops); simple_cdev.owner = THIS_MODULE; ret = cdev_add(&simple_cdev, dev_num, 1); if (ret < 0) { pr_err("simpledev: cdev_add failed, ret=%d\n", ret); goto err_cdev; } pr_info("simpledev: major=%d\n", MAJOR(dev_num)); return 0; err_cdev: unregister_chrdev_region(dev_num, 1); return ret; }cdev_init会把 fops 指针存进 cdev,同时做内部初始化;cdev_add才是真正把 cdev 注册进内核的字符设备表。这两个函数都可能失败,但只有后者需要检查返回值。.owner = THIS_MODULE这一行必须写,它的作用是模块被打开期间引用计数加一,防止你在别的进程正用着设备时 rmmod 把代码抽走——那会直接内核崩溃。用goto做错误回滚是内核代码的常规写法,比嵌套 if 清晰。
到这里代码能编译能加载,但 /dev 下还没有对应节点,用户态打不开。先手动验证一下框架是否正常:
sudo mknod /dev/simpledev c <主设备号> 0 sudo chmod 666 /dev/simpledev主设备号从 dmesg 打印里抄。这时echo hello > /dev/simpledev会失败,因为你还没实现 write,返回的是-EINVAL之类的错误。这个失败是预期内的,说明请求确实走到了你的驱动,只是那一步没实现。
3.4 read 与 write:和用户态之间搬数据的关键
内核态和用户态用的是两套地址空间,不能直接把用户态传来的指针当普通指针解引用,必须走copy_to_user/copy_from_user。这是新手最容易犯的错,也是内核 oops 的高发点。用一个 1KB 的内核缓冲区做数据中转:
#define BUF_SIZE 1024 static char *kbuf; static size_t data_len; static DEFINE_MUTEX(simple_lock); static int simple_open(struct inode *inode, struct file *filp) { pr_info("simpledev: open by %s\n", current->comm); return 0; } static int simple_release(struct inode *inode, struct file *filp) { pr_info("simpledev: release\n"); return 0; } static ssize_t simple_read(struct file *filp, char __user *ubuf, size_t count, loff_t *ppos) { ssize_t ret; if (*ppos >= data_len) return 0; if (count > data_len - *ppos) count = data_len - *ppos; mutex_lock(&simple_lock); ret = copy_to_user(ubuf, kbuf + *ppos, count); mutex_unlock(&simple_lock); if (ret) return -EFAULT; *ppos += count; return count; } static ssize_t simple_write(struct file *filp, const char __user *ubuf, size_t count, loff_t *ppos) { ssize_t ret; if (count > BUF_SIZE) count = BUF_SIZE; mutex_lock(&simple_lock); ret = copy_from_user(kbuf, ubuf, count); if (!ret) data_len = count; mutex_unlock(&simple_lock); if (ret) return -EFAULT; return count; }逐点解释几处容易写错的地方。read 里的*ppos是文件偏移,用户态连续读时内核会维护它,cat /dev/simpledev能一次读完就是靠这个:第一次返回全部数据,第二次进来发现偏移已到末尾,返回 0 表示 EOF。如果不更新*ppos,用户态会陷入死循环反复读同一段数据。write 我做了简化处理——每次写入覆盖整个缓冲区,不累加,这样echo一行明文进去再cat出来最容易观察,逻辑也最少。
copy_to_user返回的是"没能拷贝成功的字节数",成功时返回 0。这个语义跟大多数内核函数不同,必须显式判断非零,然后返回-EFAULT。我见过有人把返回值直接当结果返回,结果用户态读到一大堆莫名其妙的字符。另外注意-EFAULT前面必须有减号,内核错误码是负值。
current->comm能拿到当前进程名,调试时非常有用。有一次我发现设备被反复打开,靠这个打印才定位到是某个后台服务在轮询,不是我的测试程序。
3.5 自动创建设备节点:告别 mknod
手动 mknod 麻烦,而且换个机器设备号可能就变了。标准做法是用class_create+device_create,它们会通过 udev(或 mdev)在 /dev 下自动生成节点,权限也能一并设置。
static struct class *simple_class; static struct device *simple_dev; static int __init simple_init(void) { int ret; kbuf = kzalloc(BUF_SIZE, GFP_KERNEL); if (!kbuf) return -ENOMEM; ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME); if (ret) goto err_buf; cdev_init(&simple_cdev, &simple_fops); simple_cdev.owner = THIS_MODULE; ret = cdev_add(&simple_cdev, dev_num, 1); if (ret) goto err_region; simple_class = class_create(DEV_NAME); if (IS_ERR(simple_class)) { ret = PTR_ERR(simple_class); goto err_cdev; } simple_dev = device_create(simple_class, NULL, dev_num, NULL, DEV_NAME); if (IS_ERR(simple_dev)) { ret = PTR_ERR(simple_dev); goto err_class; } pr_info("simpledev: ready at /dev/%s, major=%d\n", DEV_NAME, MAJOR(dev_num)); return 0; err_class: class_destroy(simple_class); err_cdev: cdev_del(&simple_cdev); err_region: unregister_chrdev_region(dev_num, 1); err_buf: kfree(kbuf); return ret; }注意class_create在不同内核版本里参数个数不一样。老版本是三个参数(owner、name、parent),新版本砍成两个,如果你照着老教程写,编译会直接报参数过多。这是版本适配的经典坑,看头文件里的原型最快,别猜。错误处理用IS_ERR判断,因为这类函数返回的是错误指针而不是 NULL,用if (!ptr)判断会漏掉。
退出函数要严格按初始化的逆序清理,顺序错了就是 use-after-free:
static void __exit simple_exit(void) { device_destroy(simple_class, dev_num); class_destroy(simple_class); cdev_del(&simple_cdev); unregister_chrdev_region(dev_num, 1); kfree(kbuf); pr_info("simpledev: unloaded\n"); }这里再提一句DEFINE_MUTEX。为什么一个"简单"驱动也要加锁?因为内核里代码是并发执行的,两个进程同时 open 同一个设备,各自的 read/write 会在不同 CPU 上同时跑进你的函数。data_len和kbuf是共享的,不加锁就可能出现一个进程刚写完、另一个进程读到半截数据的撕裂现象。这类 bug 概率低、复现难、症状怪,远不如一开始就加锁省心。真正高频的场景用自旋锁,这里涉及可能睡眠的拷贝操作,用互斥锁是对的。
4. 编译、加载和验证:完整跑一遍
代码齐了,接下来是把它变成 /dev 下一个可操作的文件。这段流程建议做成固定动作,每次改完代码都照走一遍,形成肌肉记忆。
4.1 Makefile 的写法与常见笔误
树外模块的 Makefile 内容不多,但格式极其严格:命令行的缩进必须是 Tab,不能是空格。
obj-m += simple_drv.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 install: $(MAKE) -C $(KDIR) M=$(PWD) modules_installobj-m表示"把它编成可加载模块",如果你的源文件叫simple_main.c而你想产出simple_drv.ko,得加一行simple_drv-objs := simple_main.o做名字映射。-C $(KDIR)是切到内核构建目录去执行,M=$(PWD)告诉内核"只编译这个外部目录下的模块",这是树外编译的标准写法。
最常见的报错是:
make: *** /lib/modules/.../build: No such file or directory指向头文件没装;或者
*** missing separator就是缩进用了空格。这两种占了新手编译失败的一大半。
4.2 加载、测试、卸载的完整动作
make clean && make sudo insmod simple_drv.ko ls -l /dev/simpledev echo "hello kernel" | sudo tee /dev/simpledev sudo cat /dev/simpledev sudo rmmod simple_drv sudo dmesg | tail -20ls -l /dev/simpledev应该能看到一个c开头的字符设备文件,主次设备号和 dmesg 打印一致。写入时用sudo tee而不是sudo echo >,因为重定向是 shell 做的,sudo 只提权了 echo,重定向那一步仍然以普通用户身份打开设备,会权限不足。这是个很多人卡过的细节。
读出来应该和你写进去的内容一致。如果不一致或者报错,先看 dmesg 里有没有报错打印,再对照下一节排查。
4.3 一个能反复用的用户态测试程序
用 shell 命令测太粗糙,尤其是验证"连续读"、"部分读"这些行为时。写个几十行的测试程序,以后改驱动都能复用:
#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> int main(void) { const char *msg = "hello from user space\n"; char buf[64] = {0}; int fd, n; fd = open("/dev/simpledev", O_RDWR); if (fd < 0) { perror("open"); return 1; } n = write(fd, msg, strlen(msg)); printf("write returned %d (errno=%d)\n", n, errno); lseek(fd, 0, SEEK_SET); n = read(fd, buf, sizeof(buf) - 1); printf("read returned %d: %s\n", n, buf); close(fd); return 0; }这里显式lseek回 0 是必要的,因为 write 之后文件偏移已经到了写入末尾,不重置的话 read 会直接返回 0,看起来像"读不到数据",实际是偏移在作怪。这个现象我建议你亲手复现一次,比看十遍解释都管用。另外注意 read 时缓冲区长度sizeof(buf) - 1,给结尾的\0留位置,否则printf("%s")会越界读。
4.4 用模块参数省去反复改代码
调试阶段经常要改缓冲区大小、改设备名。每次改代码重编重载很烦,可以用module_param把这些变成加载时传参:
static int buf_size = 1024; module_param(buf_size, int, 0644); MODULE_PARM_DESC(buf_size, "size of the internal buffer"); /* 在 init 里改用 buf_size 申请内存 */ kbuf = kzalloc(buf_size, GFP_KERNEL);之后加载时可以写成sudo insmod simple_drv.ko buf_size=4096,不传就用默认值。第三个参数0644是 sysfs 里的权限,决定这个参数能不能在运行时通过/sys/module/模块名/parameters/buf_size读写。buf_size在 init 里只有一次使用机会,所以运行时可写对它没意义,但像调试打印开关这类参数,设成可写就很有用——改一个值就能开关日志,不用重载模块。
5. 常见问题与排查实录
这一节全是实打实踩出来的。我把症状、原因、验证手段整理成表,遇到时按图索骥就行。
5.1 编译阶段的高频报错
| 报错信息 | 原因 | 处理方式 |
|---|---|---|
No such file or directory: .../build | 内核头文件没装或版本不匹配 | 装linux-headers-$(uname -r),确认软链接指向 |
missing separator | Makefile 命令行缩进用了空格 | 改成 Tab |
implicit declaration of function 'copy_to_user' | 少包含linux/uaccess.h | 补头文件,别靠隐式声明 |
class_create参数过多 | 接口在新版内核改了签名 | 对照当前头文件原型调整 |
undefined symbol: xxx(编译期) | 该函数未导出,或内核配置未开启对应功能 | 查EXPORT_SYMBOL,确认配置项 |
warning: ISO C90 forbids mixed declarations | 变量声明写在语句之后 | 移到函数开头,或忽略警告 |
隐式声明那个警告千万别当警告看。C 语言在没看到函数原型时按旧规则推测返回值类型为 int,如果实际函数返回的是指针,在 64 位平台上高 32 位会被截断,运行时崩溃只是时间问题。看到隐式声明,一律补头文件。
5.2 加载阶段:模块进不去
insmod失败的返回信息通常很简短,比如Invalid module format、Unknown symbol in module、Operation not permitted。这三种的排查路径完全不同。
Invalid module format基本就是版本错位,用dmesg看具体原因,通常会告诉你内核版本不符或者 vermagic 不匹配。modinfo simple_drv.ko | grep vermagic能看到模块的 vermagic,和uname -r输出比对即可。
Unknown symbol in module是模块引用了内核找不到的符号,可能是你用了未导出的函数,也可能内核配置里那个功能是模块且没加载。dmesg会打印具体是哪个符号,按名字查就行。
Operation not permitted在启用了安全启动的机器上常见,原因是模块没签名。学习环境中可以在固件设置里关掉安全启动,而不是去折腾签名流程——签名是生产环境的事。
注意:
insmod只接受 .ko 文件路径,modprobe走的是模块搜索目录并会处理依赖。用绝对路径加载本地编出来的模块,用 insmod 更直接。如果 insmod 后报File exists,说明模块名已被加载,先 rmmod 或换个模块名。
5.3 运行阶段:能打开但读写不对
设备节点建出来了,open 也成功,但读写行为异常,这类问题比加载失败更难查,因为没有任何报错。典型症状和对应原因:
cat卡住不返回:read 函数没更新*ppos,或者在没有数据时返回了正数而不是 0。检查偏移更新逻辑,确认到达末尾时返回 0。- 读出来是乱码:
copy_to_user的参数顺序错了(目标是用户指针,源是内核指针),或者缓冲区没初始化就返回了长度。 - 写进去读不出来:write 里
data_len没更新,或者 write 用的是局部缓冲区,函数返回后数据就没了。 rmmod报Device or resource busy:还有进程占着设备没关。用lsof /dev/simpledev或fuser找出来,通常是后台命令没退出,或者你的测试程序忘了 close。- 反复加载后节点重复或消失:初始化失败路径没清理干净,前一次出错留下的 class 或 cdev 没释放。
rmmod失败还有一种是引用计数没降下来。.owner = THIS_MODULE保证 open 期间计数加一,正常 close 会减回去。如果你在 open 里try_module_get却又没在 release 里配对module_put,计数就永远下不来,只能重启。
5.4 内核 oops 的现场保护
一旦出现 oops(内核打印一大段 Call Trace),第一件事是把 dmesg 完整保存下来——重启后可能就没了。看 Call Trace 时从最上面往上读,通常第一个非通用函数的地址就是你的驱动函数名(如果有符号的话)。常见原因:直接解引用用户指针、内存越界写、在原子上下文里睡眠(比如持自旋锁时调了copy_to_user)。我自己的经验是,oops 里出现copy_to_user相关栈帧,八成是把内核指针和用户指针搞混了,或者长度参数算错了。
开发阶段我强烈建议在虚拟机或可随意重启的开发板上做,别拿主力工作机试。一个越界写就足以让文件系统损坏,这种损失和节省的那点时间完全不成比例。
6. 从"能跑"到"稍微能用"的几个台阶
上面那套代码已经可以完整演示驱动的全生命周期。但如果要继续往前走,有三件事值得尽早建立习惯,它们决定了你能不能写真实硬件的驱动。
6.1 并发与竞态:不是可选项
前面提到了互斥锁,这里补充一点判断依据。什么时候用自旋锁、什么时候用互斥锁,核心看"临界区里会不会睡眠"。copy_to_user在缺页时会睡眠,所以它周围只能用互斥锁或信号量;如果是纯内存的几行赋值,且可能在中断上下文里执行,那就用自旋锁。用错了症状很典型:持自旋锁调用了可能睡眠的函数,内核会打印 "scheduling while atomic"。
另外要明白一件事:单核单线程的测试环境几乎测不出竞态。你可以用两个终端同时cat和echo,但概率太低。真正有效的办法是代码层面先保证正确,比如所有共享数据访问都在锁内,而不是靠测试碰运气。我见过一个驱动在开发板上跑了三个月没问题,量产后在多核设备上一天崩几次,根因就是一个没加锁的计数器。
6.2 ioctl:把控制类操作从 read/write 里拆出去
read/write 只适合搬数据。像"清空缓冲区"、"设置工作模式"、"查询状态"这类操作,塞进 read/write 会让协议变得很别扭,正确的位置是 ioctl。核心是命令码的定义,必须用内核提供的宏来生成,避免和其他驱动的命令字冲突:
#define SIMPLE_IOC_MAGIC 'k' #define SIMPLE_IOC_CLEAR _IO(SIMPLE_IOC_MAGIC, 1) #define SIMPLE_IOC_GETLEN _IOR(SIMPLE_IOC_MAGIC, 2, int) static long simple_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { int len; switch (cmd) { case SIMPLE_IOC_CLEAR: mutex_lock(&simple_lock); data_len = 0; mutex_unlock(&simple_lock); return 0; case SIMPLE_IOC_GETLEN: len = data_len; if (copy_to_user((int __user *)arg, &len, sizeof(len))) return -EFAULT; return 0; default: return -ENOTTY; } }_IO用于无参数命令,_IOR/_IOW表示数据的流向(从设备读、向设备写),宏会帮你编码方向、幻数和序号。幻数用一个不常用的字符('k'),序号从 1 开始递增。默认分支返回-ENOTTY是约定,用户态据此判断"这个命令不支持"。别忘了在 fops 里挂上.unlocked_ioctl = simple_ioctl。
6.3 下一步该往哪走
如果你已经能独立把这套代码写出来并调通,接下来比较自然的路线是:先找一个真实硬件,用 GPIO 或者 I2C 的小器件练手,把 read/write 里的内存拷贝换成寄存器操作;然后学 platform 驱动和设备树,理解为什么硬件资源要从设备树里拿而不能写死;再往后是中断处理和 sysfs 属性文件。
每一步都不用等上一步"学透"再开始,能跑通就往前走,回头再看会更清楚。我自己是先写了三个纯内存的字符设备,第四个才开始碰真实硬件,回头看那三个"没什么用"的练习反而是最有价值的,因为它们把所有与硬件无关的框架细节都固化了。
最后分享一个我个人很受用的习惯:每写一个驱动,都在代码顶部用注释写清楚三件事——这个设备对应哪个硬件或哪个虚拟功能、读写的数据格式是什么、并发访问时哪块内存受哪把锁保护。过两周再回来看,这三行注释能省掉你半小时的重新理解。驱动代码本身不长,难的是上下文,而上下文只有你自己当时知道。