“字符设备驱动框架实验”这个题目,我见过太多人把它做成填空题:照着模板改改设备名、改改主设备号、编译通过就算完事。可一旦你真的把这个模块insmod进去,然后打开设备文件去读写,马上就会发现,背过的结构体并不能帮你解决任何问题。这个实验真正训练的不是记 API,而是理解一条“从用户态的open()到内核驱动回调函数”的完整路径,以及设备号、cdev、file_operations这三样东西是怎么绑成一个整体的。
如果你是刚开始学嵌入式 Linux,或者刚进公司被分到一个字符设备驱动的需求,这篇内容会比翻一堆内核源码笔记更有用。我会按我自己带新人做这个实验的流程来讲:先把框架原理说透,再给一份最小可编译的驱动代码,然后讲用户态怎么验证、出了问题怎么定位,最后说几句把它扩展成真实驱动的方向。全程不绕概念,直接给操作路径。
1. 字符设备在Linux里到底是被怎样“看见”的
1.1 一条用户态open调用背后发生了什么
用户态执行int fd = open("/dev/mydev", O_RDWR);时,看起来是打开一个“文件”,但这个文件背后没有磁盘上的数据块,它指向的是内核里注册过的一个字符设备。内核的 VFS(虚拟文件系统)会先解析设备文件的 inode,拿到里面记录的主设备号和次设备号;再用设备号去全局的设备表里找到对应的cdev对象;最后由chrdev_open这类通用逻辑调用注册在file_operations里的open回调。
这条链路并不复杂,但很多人漏了关键一点:通用内核代码做的事情是“找到你”,真正响应打开、读取、写入、关闭动作的,全部都是你自己实现的那几个回调函数。所以框架实验的第一步,不是用 GPIO 或者寄存器点亮什么,而是先让这条“查找链”能走到你的函数里。为了验证这一点,可以在每个回调开头加一行printk,然后用dmesg观察调用顺序,这是最直观的入门手段。
字符设备和块设备的区别也可以从这里理解:块设备以块为单位读写,有缓存、有请求队列,内核可以重排 I/O;字符设备则完全是顺序、流式的,你read多少字节,驱动就尽量给多少字节,内核不会替你排队。很多初学驱动的人一上来就纠结“我这个设备算什么类型”,其实只要想清楚硬件是“按字节流访问”还是“按数据块访问”,分类就出来了。
1.2 设备号、cdev、file_operations:三个必须绑在一起的东西
打个比方。设备号是一套房子的地址:主设备号告诉系统这套房属于哪个小区,次设备号告诉系统具体是哪一栋哪一户;cdev是物业的住户登记表,里面记录了这个地址对应着哪一组服务电话;file_operations就是那组服务电话的通讯录,每一项对应“开门、取东西、放东西、关门”这些动作。
Linux 里每个字符设备都必须同时具备这三样:一个合法的设备号、一个已经添加到内核的cdev、一张填写完整的file_operations表。三者的关系用表格看更清楚:
| 要素 | 内核对象/类型 | 作用 | 类比 |
|---|---|---|---|
| 设备号 | dev_t | 标识设备在系统中的唯一位置 | 门牌地址 |
| cdev | struct cdev | 将设备号与驱动操作绑定 | 住户登记表 |
| file_operations | struct file_operations | 提供 open/read/write 等回调 | 服务通讯录 |
设备号由主设备号(major)和次设备号(minor)组成。主设备号用来找驱动程序,次设备号用来区分同一驱动下挂着的多个设备实例。比如一个 USB 转串口驱动可以支持 4 个串口,主设备号相同,但次设备号分别是 0、1、2、3。你可以在/proc/devices里看到系统已经注册的主设备号列表,但次设备号并不会出现在这里,它只体现在设备文件本身,比如ls -l /dev/ttyS0看到的4, 64就是主次设备号。
1.3 先做“影子设备”,再接真实硬件
实验阶段我强烈建议别急着接真实硬件,可以把一块内核态缓冲区当成设备的“寄存器区”。一个只有内存缓冲区的字符设备,读写逻辑和真实硬件设备的驱动在框架层面没有区别:open初始化资源、read把内核缓冲区拷贝到用户态、write把用户态数据拷入内核缓冲区、release清理。先把这条链路跑通,再去替换成ioremap之后的寄存器地址,你会发现框架完全不用动。
把硬件暂时抽象成内存,还有一个好处:可以让问题边界变得清晰。如果驱动读不到数据,可能真的是硬件没数据;也可能仅仅是你没把该返回的数据放进缓冲区。实验阶段用影子设备,出问题时第一个被怀疑的对象一定是你自己的代码,而不是板子。我见过不少新人一接硬件就慌,查了半天寄存器,最后才发现是file_operations某个回调压根没注册上。先用内存跑通框架,再谈硬件,是效率最高的学习路线。
2. 动手前先搭环境:这里踩的坑比写代码多
2.1 内核源码/头文件版本必须匹配运行内核
写内核模块不是在用户态写程序,编译产物是.ko文件,加载时内核会检查模块的 vermagic 信息,版本一不一致直接拒绝。新手最常见的错误就是随便找个内核源码目录编出.ko,拿到另一台机器上加载,然后盯着Invalid module format发呆。
要确认三件事:
- 查看当前运行内核的版本:
uname -r,这个版本号必须和编译模块用的源码版本一致。 - 编译模块时,
KDIR指向的必须是当前运行内核对应的源码树或头文件树。如果用发行版内核,就安装和内核版本完全对应的头文件包;如果自己编译过内核,直接用那份源码树。 - 如果做交叉编译,还要确认架构一致。x86 上编译的
.ko拿到 ARM 板子上加载,会报Exec format error,这不是代码问题,是目标架构不匹配。
验证手段也很简单:编译完成后执行modinfo demo_drv.ko,看vermagic一行的内容是不是和uname -r对得上。对不上就别费劲加载了,直接回去检查KDIR。
2.2 Makefile的KDIR不是随便填的
字符设备驱动实验的 Makefile 通常长这样,但能真正理解每一行含义的人并不多:
obj-m := demo_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) cleanobj-m告诉 kbuild 系统,把demo_drv.c编成一个外部模块;KDIR是内核的编译目录,/lib/modules/$(shell uname -r)/build在大多数发行版上是指向真实内核源码树的软链接,前提是你安装好了对应的头文件包;M=$(PWD)表示“到当前目录去构建外部模块”。这里的PWD我习惯用$(shell pwd)动态获取,而不是写死/home/xxx/project,这样项目目录随便挪都不用改 Makefile。
还有一个容易踩的坑:编译时直接敲make,系统如果找不到默认的 Makefile 会报No rule to make target。命令行最好写成make modules,或者 Makefile 里把all目标放前面也行。编完以后,同目录下会生成.ko文件,这就说明模块产物已经出来了。
2.3 开发期用模块加载,别急着编进内核镜像
字符设备驱动开发通常用insmod/rmmod动态加载和卸载模块,好处有两个:第一,不需要为了加一个驱动而重新编译整个内核镜像,省时间;第二,模块加载失败最多是设备不能用,不会让系统直接起不来。在init_module里如果没有正确回滚资源,甚至可能导致崩溃,但那也只是当前模块的问题,调试起来比整个内核起不来要好得多。
建议养成一套固定流程:加载前先dmesg -C清空内核日志,然后insmod demo_drv.ko,没有输出但不代表失败,立刻dmesg | tail看日志;再lsmod | grep demo确认模块在列表里。卸载时用rmmod demo_drv,如果提示Module is in use,说明有进程还持有打开的句柄,关掉对应进程或测试程序后再卸载。
实验阶段不要在 Makefile 里配置“编进内核”的选项,先老老实实用模块方式验证。很多人在这里犯的错是加载后发现/dev下没有同名设备文件,于是误以为驱动没工作。其实模块加载和设备节点创建是两回事,节点可以手动建,也可以靠内核的class机制自动生成,这一点下面会专门展开。
3. 写一个最小化的字符设备驱动,代码一行一行拆开看
3.1 完整demo源码与编译步骤
下面这段代码是这个实验的核心,功能被我压到最薄:用户态写入什么字符串,read 就原样读出来。它没有真实硬件、没有自动创建节点,但这恰好能让你把框架的每一个环节看清楚。
#include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #define DEV_NAME "demo_dev" #define BUFFER_SIZE 256 static dev_t demo_devno; static struct cdev demo_cdev; static char kernel_buffer[BUFFER_SIZE]; static int demo_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo: open() called\n"); return 0; } static int demo_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "demo: release() called\n"); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { size_t len = strlen(kernel_buffer); if (len == 0) return 0; if (count < len) len = count; if (copy_to_user(buf, kernel_buffer, len)) return -EFAULT; return len; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { size_t size = count; if (size >= BUFFER_SIZE) size = BUFFER_SIZE - 1; if (copy_from_user(kernel_buffer, buf, size)) return -EFAULT; kernel_buffer[size] = '\0'; return size; } static struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, .write = demo_write, .release = demo_release, }; static int __init demo_init(void) { int ret; ret = alloc_chrdev_region(&demo_devno, 0, 1, DEV_NAME); if (ret < 0) return ret; cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, demo_devno, 1); if (ret < 0) { unregister_chrdev_region(demo_devno, 1); return ret; } printk(KERN_INFO "demo: initialized, major=%d minor=%d\n", MAJOR(demo_devno), MINOR(demo_devno)); return 0; } static void __exit demo_exit(void) { cdev_del(&demo_cdev); unregister_chrdev_region(demo_devno, 1); printk(KERN_INFO "demo: exited\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");我故意不在这个版本里加入class_create/device_create,因为自动生成/dev节点是锦上添花,不是字符设备框架的本质。先把设备号、cdev、file_operations的最小闭环跑通,之后想怎么加节点都容易。编译和加载三步走:make得到demo_drv.ko;sudo insmod demo_drv.ko;dmesg | tail查看初始化日志,记下主设备号。
3.2 初始化顺序为什么必须是“分配、填充、上架”
alloc_chrdev_region、cdev_init、cdev_add这三个函数执行的顺序不是随意的,我拆开讲:
alloc_chrdev_region(&demo_devno, 0, 1, DEV_NAME):向内核申请一个有效设备号范围。第一个参数是输出参数,返回分配好的dev_t;第二个参数表示希望从次设备号 0 开始;第三个参数是连续申请的数量;第四个参数是设备名字,会显示在/proc/devices里。cdev_init(&demo_cdev, &demo_fops):把cdev对象和file_operations绑定。此时只是初始化一个内核对象,还没有把它登记到系统的设备表里。cdev_add(&demo_cdev, demo_devno, 1):把cdev真正添加到内核中。从这一行起,只要用户打开主设备号匹配的设备文件,就会走到demo_fops里的回调。
如果在cdev_add之前就有人打开设备文件,内核根本找不到你;如果cdev_add失败,必须把alloc_chrdev_region申请到的设备号释放掉,否则一次insmod失败就会泄漏一个设备号。同理,exit函数要逆序释放:先cdev_del,再unregister_chrdev_region。这种“申请成功就记下来,失败路径逐级回滚”的写法,是驱动 init 代码最能拉开质量差距的地方。
3.3 read/write回调里的用户态指针,别手抖
新手最容易犯的错误,是在内核回调里直接写memcpy(kernel_buffer, buf, count),而buf是用户态传进来的指针。内核态不能直接解引用用户态地址,因为进程地址空间是隔离的,而且现代 CPU 还有 SMAP 之类的保护机制,不通过专用函数访问用户态内存,轻则返回垃圾数据,重则直接触发内核异常。
必须用copy_from_user把数据拷进内核,用copy_to_user把数据拷回用户态,并检查返回值。copy_*_user返回 0 表示全部拷贝成功,返回非 0 表示有 N 个字节没拷完。失败时习惯性返回-EFAULT,用户态会看到errno等于 14。另一个容易忽略的点是read的返回值语义:返回 0 表示 EOF,用户态read()会认为读到文件末尾;返回实际拷贝的字节数,但可能比请求的少。这是正常行为,不要为了“凑长度”而返回一个假数字。
4. 用户态验证环节:从echo到C测试程序
4.1 创建设备节点的两种方式
上面的驱动代码没有自动生成/dev/demo_dev,所以 insmod 之后要先手动建节点:
sudo mknod /dev/demo_dev c 主设备号 0 sudo chmod 666 /dev/demo_dev主设备号从dmesg的输出里拿,也可以用cat /proc/devices在 Character devices 列表里找demo_dev。千万不要直接抄网上的固定号码,因为alloc_chrdev_region是动态分配主设备号,每次加载可能都不一样,尤其是在你多次卸载重装之后,号码很容易漂移。
mknod里的c表示创建一个字符设备文件,后面依次是主设备号、次设备号。权限设成 666 只是方便实验,真实产品里设备节点权限要按用户组严格控制,否则任意进程都能访问硬件设备。如果你用的系统带 udev,驱动里注册class后节点会自动出现,这部分会在第 6 章补上。
4.2 shell命令快速验证调用链
设备节点建好后,用三个命令就能验证驱动框架是否跑通:
echo "hello" > /dev/demo_dev cat /dev/demo_dev dmesg | tail -n 20预期结果是cat输出hello,并且dmesg里能看到open() called、write() called、release() called这类日志。这一步说明用户态的文件操作已经成功映射到了驱动回调里,字符设备框架的最小闭环成立。
有个细节要注意:echo命令默认会在字符串末尾加一个换行符,所以写进内核缓冲区的实际上是hello\n,读出来也会带换行。如果觉得这个换行碍事,可以用printf "hello" > /dev/demo_dev方式写入。如果cat什么都没输出,先别急着改代码,看一眼内核缓冲区是不是空的,以及read返回的是不是 0。
4.3 一个把错误看得清清楚楚的测试程序
shell 命令能验证通路,但不方便看errno。我建议每个做驱动实验的人都写一个最小 C 测试程序,把open/read/write的返回值和errno全打出来:
#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> #define DEV_PATH "/dev/demo_dev" int main(void) { int fd; char buf[128] = {0}; ssize_t n; fd = open(DEV_PATH, O_RDWR); if (fd < 0) { perror("open"); return 1; } n = write(fd, "hello", 5); printf("write ret=%zd errno=%d (%s)\n", n, errno, strerror(errno)); n = read(fd, buf, sizeof(buf) - 1); printf("read ret=%zd buf=\"%s\" errno=%d (%s)\n", n, buf, errno, strerror(errno)); n = read(fd, buf, sizeof(buf) - 1); printf("second read ret=%zd errno=%d (%s)\n", n, errno, strerror(errno)); close(fd); return 0; }编译运行:gcc test_demo.c -o test_demo && ./test_demo。重点看两个返回值:第一次 read 应该返回 5,字符串是hello;第二次 read 返回 0,表示缓冲区已经被读空,用户态把它当成文件末尾。如果 open 失败,请先确认节点存在、权限正确、驱动已加载、主设备号匹配,这四件事任何一件出问题,perror给的提示都不一样。
5. 实验中最容易翻车的四个场景:排查链路记录
5.1 insmod失败,先看dmesg而不是改代码
insmod demo_drv.ko命令本身只有一句话,要么成功要么报一个模糊的错误。第一时间要做的就是dmesg | tail -n 30,内核日志会告诉你真正的拒绝原因。下面是我整理的最高频几类:
| dmesg 中的关键信息 | 实际问题 | 处理方向 |
|---|---|---|
| version magic mismatch / invalid module format | 编译用的内核树和运行内核不一致 | 换正确的 KDIR 重新编译 |
| Exec format error | 架构不匹配 | 检查交叉编译工具链 |
| Unknown symbol | 依赖的符号未导出,或依赖模块没加载 | 先加载依赖模块,或检查符号导出 |
| Module license ... taints kernel | license 缺失或非标准 | 补上MODULE_LICENSE("GPL") |
我在带人时经常发现,他们 insmod 失败以后第一反应是去改驱动代码,改了一圈毫无效果。其实绝大多数加载失败都是环境问题,跟业务逻辑没关系。把日志拍到证据,再逐条比对,比盲目改代码高效得多。
5.2 设备节点和主设备号对不上
如果驱动已经成功加载,但open("/dev/demo_dev", ...)一直失败,要分清两种报错:No such file or directory表示节点不存在;No such device表示节点存在,但节点里的主设备号没有对应的已注册驱动。后者经常发生在动态分配设备号的场景。
排查路径很固定:先cat /proc/devices | grep demo_dev,拿到内核当前真正分配的主设备号;再ls -l /dev/demo_dev,看节点里的主设备号是多少。不一致就rm掉旧节点,用新号码重新mknod。实验阶段建议每次 insmod 后都重新确认一遍号码,不要指望它固定不动。
5.3 read/write返回值搞得程序行为怪异
很多看起来“驱动 bug”的问题,其实是返回值不符合字符设备的语义。比如read返回 0 会让cat立刻结束,这是 EOF 语义;write返回的值小于请求长度,用户态可能认为写入失败或触发重试。如果你在驱动里随意 return,测试程序的行为就会变得很难解释。
我的建议是守规矩:
read时缓冲区有多少数据就返回多少,数据为空就返回 0。write时返回真正接收并保存的字节数。- 缓冲区已满或参数不合法,返回
-ENOSPC或-EINVAL,并在代码注释里写清楚。 - 测试程序不要把返回值当成“一定等于请求长度”,因为内核态和用户态之间可能发生部分拷贝。
这样写会让驱动行为符合预期,排查问题的时候也更容易定位。
5.4 printk打了一堆日志却什么都看不到
printk的输出要受日志级别控制。实验阶段我统一用KERN_INFO或KERN_ERR,不要用KERN_DEBUG,因为很多系统默认的 console loglevel 不会把 debug 级别打到控制台或 dmesg 里。如果你发现自己加了日志但看不到,先执行cat /proc/sys/kernel/printk看当前级别配置。
在嵌入式开发板上,如果日志级别大于 console_loglevel,printk会直接刷到串口,这对调试非常方便,但也会拖慢高频调用路径。所以正式驱动里不要在read/write这种可能被频繁调用的函数里放肆打日志。实验阶段可以打,跑通框架之后要逐步删掉。
6. 从实验骨架往真实驱动迈出的一小步
6.1 自动创建设备节点:class_create与device_create
手动mknod在开发初期够用,但到了真实项目里,设备节点通常是 udev/mdev 依据内核发出来的 uevent 自动创建的。驱动里要做的是注册一个class,再为该设备创建一个device:
static struct class *demo_class; /* 在 cdev_add 成功后执行 */ demo_class = class_create(THIS_MODULE, "demo_class"); if (IS_ERR(demo_class)) { ret = PTR_ERR(demo_class); goto out_cdev_del; } device_create(demo_class, NULL, demo_devno, NULL, "demo_dev");exit时先device_destroy(demo_class, demo_devno);再class_destroy(demo_class);。注意不同内核版本的class_create原型有差异,以你自己源码里的头文件为准。有了这两步,加载驱动后/dev/demo_dev通常会自动出现,不再需要手动节点,权限也可以通过 udev 规则统一控制。
6.2 加ioctl:给设备一个“控制通道”
read/write适合搬数据,不适合下命令。比如设置波特率、切换工作模式、读取状态,这类操作更适合用ioctl。驱动里要定义命令码,通常用内核提供的宏生成:
#define DEMO_IOC_SET_BUF _IOW('D', 1, int) #define DEMO_IOC_GET_BUF _IOR('D', 2, int) static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case DEMO_IOC_SET_BUF: /* 用 copy_from_user 获取参数 */ break; case DEMO_IOC_GET_BUF: /* 用 copy_to_user 返回参数 */ break; default: return -ENOTTY; } return 0; }然后在file_operations里加.unlocked_ioctl = demo_ioctl。用户态用ioctl(fd, DEMO_IOC_SET_BUF, &value)调用。_IO/_IOR/_IOW/_IOWR这些宏把命令编码成包含方向、参数大小和魔数的整数,能在一定程度上避免不同驱动之间命令号冲突。这也是为什么实验做到后面一定要加 ioctl——它是驱动从“数据管道”变成“可管理设备”的分水岭。
6.3 并发访问:至少给共享缓冲区加一把锁
上面那份最小代码完全没有防并发:两个进程同时open、同时write,同一个kernel_buffer会被互相覆盖,read也说不清读到谁的数据。真实驱动里共享资源几乎都需要并发控制。最简单的是在模块里放一把互斥锁:
static DEFINE_MUTEX(demo_lock); static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { ssize_t ret; mutex_lock(&demo_lock); /* 拷贝与更新缓冲区 */ mutex_unlock(&demo_lock); return ret; }读多写少的场景可以换atomic_t计数器或读写锁,但实验阶段先把互斥锁加上,再写一个多线程测试程序同时读写,观察会不会出现乱序或数据错乱。这一步做完,这个驱动骨架才算具备最基本的抗并发能力。
我在实际带人做这个实验时,通常要求他们先别急着加自动节点、ioctl、锁,而是把第一版压到最小:一个设备号、一个cdev、四个fops回调、能 insmod、能 mknod、能 echo/cat 通。等这条链路在脑海和日志里都完全清晰了,再往上加东西。很多看起来复杂的驱动问题,追究到底都是某个基础环节没打通。你把这个小实验做透了,后面无论是接 I2C、SPI 还是 PCIe 设备,底层的字符设备框架认知都不会再翻车。