简介PDF文档详细梳理了Linux字符设备驱动框架的核心知识定位是面向Linux驱动入门者与嵌入式开发者的精简参考资料。内容围绕字符设备的驱动模型展开常见键盘、鼠标、串口等字符设备在/dev下以c标识应用程序通过文件描述符调用VFS最终由struct file_operations中的read、write、open、release等回调执行实际操作。文档重点解释了struct cdev对象、设备号分配与释放方法并强调copy_to_user、copy_from_user在内核与用户空间数据交换中的必要性例如设备号由主、次设备号组成可配合MKDEV、MAJOR、MINOR等宏使用而cdev的owner字段还承担模块引用计数职责。同时给出模块加载/卸载示例帮助理解alloc_chrdev_region、cdev_init、cdev_add、cdev_del、unregister_chrdev_region等API的组合调用。压缩包共1个文件为PDF格式大小仅91KB便于随时查阅。目前已有729人学习下载对刚接触字符设备驱动、想快速建立框架概念的开发者而言是一份高效、实用的入门资料。1. 字符设备驱动到底是干嘛的先搞懂它解决什么问题很多刚接触 Linux 驱动开发的朋友上来就翻内核源码、抄示例代码结果 file_operations 结构体里的回调函数抄了一堆却连这套东西为什么要这么设计都没想明白。我当年也是这么过来的所以这篇想换个顺序先把字符设备驱动框架的设计逻辑讲透再给你能直接用的代码骨架。先回答一个最根本的问题字符设备驱动到底是什么Linux 系统里跑着两种程序一种是用户态程序你的应用层代码一种是内核态程序操作系统自己。用户态程序不能直接访问硬件寄存器也不能直接操作物理内存这是 CPU 和操作系统共同设下的安全边界。但现实需求是你的应用程序要读一个温度传感器的数据、要控制一个 GPIO 引脚的电平、要跟 USB 转串口芯片收发数据这些都属于用户程序想跟硬件对话的场景。字符设备驱动就是专门负责这件事的中间层。它把硬件设备抽象成一个文件应用层通过open()、read()、write()、ioctl()这些再熟悉不过的文件操作接口来访问硬件。这样设计的最大好处是应用层程序员不需要了解硬件细节只需要知道设备的文件路径比如/dev/ttyS0、/dev/gpiochip0然后像读写普通文件一样操作它就行。而字符设备这三个字里的字符指的是数据传输方式是以字节为单位、顺序访问的不像块设备那样可以随机读写整个数据块。你平时用的鼠标、键盘、串口、GPIO、I2C 总线上的传感器基本都是字符设备。那么框架这个词又是什么意思说白了就是内核为字符设备驱动规定好的一套固定套路——你得注册哪些东西、内核在什么时机调用你注册的函数、数据是怎么在用户态和内核态之间搬运的。这套套路一旦搞清楚写任何字符设备驱动都只是往框架里填内容而已。2. 框架的三根支柱设备号、file_operations、struct cdev字符设备驱动框架看起来复杂但核心就三样东西设备号、file_operations 结构体、struct cdev 结构体。把这三者的关系串明白了框架就懂了一半。2.1 设备号设备的身份证号设备号是内核区分不同设备的唯一标识分为主设备号和次设备号两部分。主设备号用来标识设备对应的驱动程序次设备号用来标识同一个驱动管理的不同设备实例。打个比方主设备号就像是快递柜的柜体编号次设备号就是一个柜体里不同的柜格编号。在代码里设备号用dev_t类型表示这是一个 32 位的无符号整数其中高 12 位是主设备号低 20 位是次设备号。内核提供了宏来操作它#include linux/kdev_t.h dev_t devno MKDEV(major, minor); // 通过主次设备号生成 dev_t int major MAJOR(devno); // 从 dev_t 中提取主设备号 int minor MINOR(devno); // 从 dev_t 中提取次设备号分配设备号有两种方式。第一种是静态申请自己指定一个主设备号比如register_chrdev_region(MKDEV(240, 0), 1, my_device)。这种方式的问题是主设备号可能被别的驱动占用冲突了就白忙一场所以在内核里静态申请前最好查一下Documentation/admin-guide/devices.txt确认哪些号是空闲的。第二种是动态分配让内核帮你挑一个没被占用的主设备号#include linux/fs.h int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name);这个函数会用name在/proc/devices里显示一个友好名称方便用户查看。动态分配的好处是彻底避免冲突缺点是你得想办法把分配到的设备号通知到用户空间不然应用层不知道去哪找设备节点。实践中通常靠 udev 规则动态创建设备节点来解决这个问题。2.2 file_operations驱动功能的菜单file_operations结构体是整个框架最核心的部分它定义了一系列函数指针每个指针对应应用层一次系统调用的内核态实现。应用调用read()内核最终调用到你实现的.read回调应用调用ioctl()内核最终调用到你实现的.unlocked_ioctl回调。举个实际例子说明这个映射关系static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .release my_release, .unlocked_ioctl my_ioctl, };这个结构体被放在 cdev 里之后内核就有了应用层操作文件 → 调用驱动函数的桥梁。你只需要根据设备实际功能实现需要的回调函数即可。不需要的功能可以不赋值内核会用默认行为兜底比如没实现.read时返回-EINVAL。需要特别提醒的是.owner THIS_MODULE。这个字段表示该文件操作属于哪个内核模块。当模块正在被使用时比如有进程打开了这个设备的 fd内核会维护模块的引用计数防止你用rmmod强行卸载模块导致内核崩溃。这一个字段漏掉可能在极端情况下酿成系统崩溃的大事。2.3 struct cdev把设备和操作粘在一起struct cdev是内核中描述字符设备的内核对象它把设备号、file_operations 以及设备私有数据绑定在一起。你可以把它理解成设备的档案袋内核看到这个档案袋就知道这个设备是谁、支持哪些操作、数据放哪儿。使用 cdev 的常规流程是#include linux/cdev.h struct cdev my_cdev; cdev_init(my_cdev, my_fops); // 初始化 cdev 并关联 file_operations my_cdev.owner THIS_MODULE; cdev_add(my_cdev, devno, count); // 将 cdev 添加到内核cdev_init把 cdev 和 file_operations 绑在一起cdev_add把设备加入内核的设备链表立刻生效。从cdev_add成功返回的那一刻起只要应用层能打开对应的设备文件就会进入你的回调函数。有个容易忽略的细节cdev_add的调用时机必须在设备真正可用之后而不能在init之前。假如你在设备初始化函数里先cdev_add再初始化硬件中间有一瞬间应用层尝试打开设备你的.open回调执行时硬件可能还没准备好可能直接解引用空指针。我见过不少新手的驱动在加载时偶发崩溃排查半天发现就是这个顺序写反了。3. 一个能跑的极简字符设备驱动完整代码逐段拆解框架原理说完了直接上一份可以编译、加载、验证的极简驱动。下面这个 demo 实现了一个虚拟字符设备它不操作任何真实硬件主要功能是应用层向设备写入一段字符串再从设备读出来。这类似于内存里的一个 FIFO用来演示框架的完整流程。3.1 头文件、设备结构体和全局变量#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/slab.h #include linux/uaccess.h #define DEVICE_NAME demo_dev #define CLASS_NAME demo_class #define BUF_SIZE 4096 static int major; // 主设备号 static struct class *demo_class; // 设备类 static struct device *demo_device; // 设备对象 static struct cdev demo_cdev; // cdev 结构体 struct demo_data { char buffer[BUF_SIZE]; size_t size; struct mutex lock; }; static struct demo_data *demo;设备私有数据struct demo_data是每个真实驱动都要设计的。初学者最容易犯的错误是定义一个全局数组当缓冲区看起来简单一旦设备有多个实例或者需要支持并发访问就会出问题。把设备相关的所有状态装进一个结构体再用container_of从文件指针取回来这是内核开发者的通用做法。内部用了个mutex锁保护 buffer 的并发读写这个我后面会专门讲。3.2 核心回调函数实现static int demo_open(struct inode *inode, struct file *file) { struct demo_data *data container_of(inode-i_cdev, struct demo_data, cdev); file-private_data data; return 0; } static int demo_release(struct inode *inode, struct file *file) { return 0; } static ssize_t demo_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { struct demo_data *data file-private_data; ssize_t ret; if (mutex_lock_interruptible(data-lock)) return -ERESTARTSYS; if (*offset >static int __init demo_init(void) { dev_t devno; int ret; // 第一步动态分配设备号 ret alloc_chrdev_region(devno, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(alloc_chrdev_region failed\n); return ret; } major MAJOR(devno); // 第二步分配并初始化设备私有数据 demo kzalloc(sizeof(*demo), GFP_KERNEL); if (!demo) { ret -ENOMEM; goto err_unregister; } mutex_init(demo-lock); // 第三步初始化 cdev 并添加到内核 cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; ret cdev_add(demo_cdev, devno, 1); if (ret 0) { goto err_free; } // 第四步在 /sys/class 下创建设备类 demo_class class_create(CLASS_NAME); if (IS_ERR(demo_class)) { ret PTR_ERR(demo_class); goto err_cdev_del; } // 第五步创建设备节点 demo_device device_create(demo_class, NULL, devno, NULL, DEVICE_NAME); if (IS_ERR(demo_device)) { ret PTR_ERR(demo_device); goto err_class_destroy; } pr_info(demo driver loaded, major%d\n, major); return 0; err_class_destroy: class_destroy(demo_class); err_cdev_del: cdev_del(demo_cdev); err_free: kfree(demo); err_unregister: unregister_chrdev_region(devno, 1); return ret; } static void __exit demo_exit(void) { dev_t devno MKDEV(major, 0); device_destroy(demo_class, devno); class_destroy(demo_class); cdev_del(demo_cdev); kfree(demo); unregister_chrdev_region(devno, 1); pr_info(demo driver unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal character device driver demo);为了简洁我把错误处理写得比较紧凑但每一步的出错路径都做了对应的清理。这是驱动开发必须养成的习惯你在 init 里成功申请的每一个资源都必须在出错时逐级释放否则模块加载失败后内核里就会残留未清理的资源下次加载就会出各种诡异问题。3.4 编译和用户态测试写一个 Makefile利用内核的 kbuild 系统编译obj-m : demo.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然后依次执行下面的命令make sudo insmod demo.ko cat /proc/devices | grep demo_dev ls -l /dev/demo_dev正常应该看到/dev/demo_dev这个设备节点已经被自动创建了。如果没看到多半是 udev 没及时刷新可以手动创建sudo mknod /dev/demo_dev c major 0最后用一段用户态代码验证功能#include stdio.h #include fcntl.h #include unistd.h #include string.h int main(void) { char buf[128] {0}; int fd open(/dev/demo_dev, O_RDWR); if (fd 0) { perror(open); return -1; } write(fd, Hello, Character Device!, 25); read(fd, buf, sizeof(buf)); printf(Read from device: %s\n, buf); close(fd); return 0; }编译运行后能看到输出Read from device: Hello, Character Device!说明一个完整的字符设备驱动已经跑通了。4. 驱动开发避坑指南我踩过的那些坑你未必会避开框架跑通只是开始实际项目里真正耗时间的往往是各种莫名其妙的坑。下面这几个问题是我在不同项目里真实踩过的说出来给你省点时间。4.1 并发访问导致数据错乱第一个坑跟前面 demo 里的mutex有关。你可能会问我的设备很简单读和写分开有必要加锁吗有必要。考虑这个场景进程 A 调用read()读 buffer 的中间内核把 CPU 让给了进程 B进程 B 此时调用了write()往同一个 buffer 写入了新数据。于是进程 A 读的数据就可能一半是旧数据一半是新数据出现不可预测的错乱。你可以在测试时用两个线程一个疯狂写、一个疯狂读用不了多久就能观察到异常。加锁原则很简单只要共享数据可能被多个执行流同时访问就要保护好。除了 mutex内核里还有自旋锁spinlock_t、读写锁rwlock_t等。具体选哪种核心看临界区的代码是否可能睡眠。如果你的临界区里有copy_to_user这种可能触发缺页异常的操作绝对不能使用自旋锁——那样会在睡眠状态下持有自旋锁内核直接 BUG。4.2 坚持使用 copy_to_user/copy_from_user第二坑就是前面提到过的用memcpy直接拷贝用户态指针。我见过一个社招同事在面试时说内核态访问用户态内存用 memcpy 就行当场我就问了句如果用户传入的是一个非法地址呢他沉默了。copy_to_user和copy_from_user内部会调用access_ok检查地址范围再通过__copy_to_user进行实际拷贝。不经过这一层检查非法地址直接导致内核 Oops严重点是整个系统挂掉。有些老工程师会吐槽这两个函数有轻微的性能损耗但这点损耗比起内核崩溃完全不是一个数量级的代价。现代内核里这两个函数实现已经高度优化正常驱动根本不需要担心性能。4.3 设备节点没自动生成第三个坑很常见insmod成功了/proc/devices里能看到设备但/dev下没有对应的设备节点。这种情况老派做法是手动mknod但更现代的解法是用 udev。udev会监控内核的 uevent 事件当驱动调用device_create()时内核会向用户空间发送一个事件。udev收到事件后根据/etc/udev/rules.d/下的规则自动创建或移除设备节点。所以没有自动生成节点要么是device_create()没被正确调用最常见要么是你的 udev 规则没有涵盖这个设备。为了排查方便可以在代码里打印一下内核发的 ueventudevadm monitor --kernel --property然后insmod你的驱动观察是否有对应的 uevent 输出。如果完全没有 uevent说明你的device_create()根本没走到或者返回值是错误。如果 uevent 有输出但节点没有生成那就去检查 udev 规则。4.4 卸载模块时系统直接卡死或者崩溃第四个坑最吓人表现是rmmod之后系统要么死机要么过一会儿重启。这通常是因为模块的某个回调函数还在被内核其他部分引用而你提前把模块代码从内存中移除了。比如用户态有个进程还open着设备文件同时你执行了rmmod。内核是这样解决这个问题的每次open()设备文件时内核会递增设备所属模块的引用计数close()时递减。rmmod时内核会检查引用计数如果非零就拒绝卸载错误提示 Module is in use。但如果你漏写了.owner THIS_MODULE这个引用计数机制就断了rmmod照样执行模块代码包括 file_operations 里的函数全部从内存消失而用户态的 fd 还连着下一次read()就会跳到一个已经不存在的地址内核直接 Oops。所以那个看起来不起眼的.owner字段真的能救命。5. 从框架到真正的驱动还有这几件事要做如果你能用 demo 驱动打通上述流程说明字符设备驱动的骨架你已经掌握了。但拿去写真实驱动的项目还要在几个方向上深入。5.1 跟硬件打交道的技术栈真实的驱动不可能是内存缓冲区这么简单它需要直接操作硬件寄存器。在 Linux 中访问物理地址需要先把物理地址映射到内核虚拟地址空间常用接口是ioremap现代内核推荐用devm_ioremap_resource或者通过设备树配合platform_driver框架自动完成映射。以 GPIO 为例阅读芯片手册找到 GPIO 控制器的物理基地址计算出某个引脚对应的数据寄存器和方向寄存器的偏移然后void __iomem *gpio_base ioremap(phys_addr, resource_size); u32 val readl(gpio_base GPIO_DATA_OFFSET); val | BIT(5); writel(val, gpio_base GPIO_DATA_OFFSET);readl和writel是内核提供的访问映射后寄存器的标准接口内部的屏障保证了编译器和 CPU 不会乱序执行访存操作。这里再次体现框架的意义你的应用层代码完全不需要知道 GPIO 寄存器在哪只管对/dev/my_gpio下发指令就行。5.2 中断、定时器与数据搬运大多数真实设备不是轮询就能搞定的比如网卡、串口、触摸屏都需要中断通知内核有数据来了。中断处理程序中不能调用任何可能睡眠的函数因为中断上下文没有进程的概念。实际做法是中断处理函数中做最紧急的处理比如清除中断标志、读取硬件 FIFO 到内存缓冲区然后通过tasklet、workqueue或者内核线程把后半段工作推迟到更安全的上下文执行。这就是经典的上半部和下半部机制。如果设备需要大块数据搬运直接每次read()从用户态拷贝性能太差。通常会引入 DMA 或者内核提供的io_uring、mmap等机制来减少拷贝次数。这些内容已经超出本文框架讲解的范畴但属于进阶的必经之路。5.3 调试手段远比编译技巧重要调试驱动的工具和思路也是重中之重。必备的几板斧dmesg看内核日志驱动里多用pr_err、pr_info可以分级输出cat /proc/devices查看设备号分配情况ls -l /dev和设备节点确认挂一个ftrace跟踪函数调用流程perf做性能剖析初学者调试时最常见的困境就是oops 信息一闪而过根本没来得及看。建议在开发机的内核 cmdline 里加上ignore_loglevel或者loglevel8同时用串口如果有的话把内核日志导出来这样即使系统崩溃日志也已经输出到了串口中方便回溯。6. 一个实用的习惯用 ioctl 来扩展控制命令demo 里我们只实现了读写但真实设备往往还需要很多非数据操作。比如修改设备的工作模式、设置波特率、启动/停止某个动作。这些操作不好映射为读或写内核为此专门提供了ioctl系统调用。在 file_operations 里现代内核实现的是.unlocked_ioctl回调早期是.ioctl现在已经被替代。典型写法#define MY_IOCTL_SET_MODE _IOW(M, 1, int) #define MY_IOCTL_GET_INFO _IOR(M, 2, struct info) static long demo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct demo_data *data file-private_data; int mode; struct info info; switch (cmd) { case MY_IOCTL_SET_MODE: if (copy_from_user(mode, (void __user *)arg, sizeof(mode))) return -EFAULT; style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />