
“字符设备驱动框架实验”这个题目我见过太多人把它做成填空题照着模板改改设备名、改改主设备号、编译通过就算完事。可一旦你真的把这个模块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标识设备在系统中的唯一位置门牌地址cdevstruct cdev将设备号与驱动操作绑定住户登记表file_operationsstruct 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.kosudo insmod demo_drv.kodmesg | 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 kernellicense 缺失或非标准补上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_loglevelprintk会直接刷到串口这对调试非常方便但也会拖慢高频调用路径。所以正式驱动里不要在read/write这种可能被频繁调用的函数里放肆打日志。实验阶段可以打跑通框架之后要逐步删掉。6. 从实验骨架往真实驱动迈出的一小步6.1 自动创建设备节点class_create与device_create手动mknod在开发初期够用但到了真实项目里设备节点通常是 udev/mdev 依据内核发出来的 uevent 自动创建的。驱动里要做的是注册一个class再为该设备创建一个devicestatic 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 设备底层的字符设备框架认知都不会再翻车。