资讯动态

Linux字符设备驱动开发实战:从框架原理到最小Demo实现

发布时间:2026/10/20 3:06:22 来源:尧图企业网站定制
1. 字符设备驱动到底在做什么Linux 里的设备驱动网上一搜资料铺天盖地但如果你只看标题里“字符设备驱动”这六个字还是会有点懵字符设备是什么、和块设备有什么区别、我为什么需要写一个驱动这篇文章我就按自己的学习路径和实际踩坑经验把这一整套东西拆开讲清楚。所谓字符设备简单理解就是按字节流读写的设备。串口、鼠标、键盘、LED、温度传感器、触摸屏这些在 Linux 里大多归属字符设备。它们的特点是没有复杂的寻址和随机访问需求数据像一个管道一样来一个字节处理一个字节。与之对比硬盘这类块设备是按块读写的内部有缓冲区、有调度算法网卡这类网络设备走的是 socket 接口不直接暴露成 /dev 下的文件节点。三类设备模型的分工不同而字符设备是其中最贴近“文件”概念的一类应用程序通过 open、read、write、ioctl 这些你再熟悉不过的文件接口就可以操作底层硬件。我第一次接触字符设备驱动时的困惑是为什么操作硬件能像操作文件一样后来才明白这正是 Linux 的设计哲学——一切皆文件。驱动的工作就是把硬件的寄存器操作、中断响应、数据搬运这些脏活累活包装成一套标准的文件操作接口让上层应用不需要关心硬件细节。对应用开发者来说他们只看到一个设备文件路径比如 /dev/uart0打开它、读写它如同操作一个普通文件。那这篇东西适合谁看适合刚接触 Linux 驱动、想弄懂字符设备框架的人也适合已经会写一些内核模块但说不清 cdev、file_operations、设备号之间关系的人。通过一个最小 Demo 的完整实现和调试你会看到驱动从编写、编译、加载到应用层联调的每一步。这里面我积累了几个排查思路和“血泪教训”后面都会提到。2. 先看框架驱动靠哪些机制运转2.1 file_operations应用与内核的约定字符设备驱动的核心是 struct file_operations这是在 include/linux/fs.h 里定义的一个回调函数集合。驱动注册它内核在应用层调用 open、read、write 等系统调用时会通过 VFS 层找到对应的驱动函数跳转进去执行。这里的模型很像是写一个接口实现类你承诺实现哪些方法上层就按这套约定来调用你。结构体里常用的成员有这些struct file_operations { struct module *owner; int (*open) (struct inode *, struct file *); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); int (*release) (struct inode *, struct file *); unsigned int (*poll) (struct file *, struct poll_table_struct *); int (*mmap) (struct file *, struct vm_area_struct *); };注意 read 和 write 的用户缓冲区指针都带着 __user 限定符。这个限定符不是摆设它表明那块内存属于用户空间内核代码不能直接访问。我早期写过一次直接用 memcpy 把内核数据拷向用户缓冲区的操作编译能过但跑起来应用读到的数据全是乱的偶尔还直接段错误。后来才知道内核里必须用 copy_to_user / copy_from_user 这类辅助函数它会先做地址合法性检查再真正搬数据。这个坑无数新手都踩过属于字符设备驱动开发里第一课级别的错误。owner 字段一般填 THIS_MODULE作用是指向当前模块保证设备被打开期间模块不会被卸载掉。有些示例代码漏了这一项短时开着没事一旦驱动被 rmmod再操作设备文件内核直接崩。别看它不起眼该填的千万别省。2.2 设备号与 cdev先让内核认识你光有 file_operations 还不够内核需要一种方式把设备和这套操作对应起来。这个对应的钥匙就是设备号由主设备号和次设备号组成。主设备号一般代表驱动类型次设备号代表同类型驱动下的第几个设备实例。打比方说主设备号是楼栋号次设备号是房间号应用层打开 /dev/xxx 时内核靠这两个号码定位到具体房间里的具体驱动函数。设备号有两种注册方式。一种是用 register_chrdev_region 手动指定适合你知道用哪个主设备号的情况另一种是用 alloc_chrdev_region 让内核动态分配不容易冲突也更推荐。核心代码通常长这样dev_t dev_num; int ret; ret alloc_chrdev_region(dev_num, 0, 1, demo); if (ret 0) { printk(KERN_ERR alloc device number failed\n); return ret; } int major MAJOR(dev_num); int minor MINOR(dev_num);拿到设备号后还要初始化一个 cdev 结构把它和 file_operations 绑定再通过 cdev_add 告诉内核设备已经准备好了cdev_init(demo_cdev, demo_fops); ret cdev_add(demo_cdev, dev_num, 1);这里顺序很重要。我曾试过先 device_create 再 cdev_add结果设备节点在 /dev 下提前冒出来应用一打开内核还没就绪系统直接崩溃。正确顺序应该是分配设备号 → cdev_init → cdev_add → 最后才是 device_create 创建设备节点。设备节点的创建并非内核自动完成的在基于 udev 的现代 Linux 系统里需要配合 class_create 和 device_createudev 收到内核 uevent 事件后才会在 /dev 下生成对应节点。2.3 read、write、ioctl 怎么写才靠谱我习惯把几个关键回调函数的“标准动作”固化成模板写驱动时往里面套能少走很多弯路。open 里一般做这些事检查次设备号是否合法、初始化私有数据、可能需要递增模块引用计数、做权限校验。release 则是逆操作释放 open 阶段申请的资源。read 要做的是把内核缓冲区的数据搬运到用户空间注意一次 read 不一定能读完所有数据返回值是什么就代表实际读了多少字节。write 则是把用户空间的数据搬到内核缓冲区然后文火慢炖地触发硬件动作。ioctl 就更有意思了它是应用层向设备发“命令”的入口。内核自 2.6 时代就开始使用 unlocked_ioctl 取代老的 ioctl 字段原因很简单老的 ioctl 由内核统一加锁性能差新版本把并发控制的责任下放给驱动自己。驱动里用 switch 分支处理不同的命令码常见格式是 _IO、_IOW、_IOR 这类宏来构造命令码这样能区分是读方向、写方向还是无数据传输的命令。不管实现哪个回调都要清楚一件事驱动运行在内核态任何非法内存访问都会导致 Oops一个 bug 可能让整个系统挂掉而不是像应用层那样只是进程崩溃。这也是驱动开发比应用开发需要更谨慎的原因。2.4 真正碰硬件还得会这几招上面讲的还是纯软件层面的字符设备框架实际产品里驱动的目的是驱动硬件。和硬件打交道有几招是绕不开的第一招是寄存器访问。芯片手册里给的是物理地址但内核里跑的肯定是虚拟地址所以要先用 ioremap 把物理地址映射到内核虚拟地址空间之后用 readl / writel 读写寄存器void __iomem *base; base ioremap(0x11000000, 0x1000); if (!base) { printk(KERN_ERR ioremap failed\n); return -ENOMEM; } writel(0x1, base 0x04);这里有个容易被忽视的问题ioremap 的地址和长度最好按页对齐否则映射范围可能不完整。我实际项目中遇到过一次访问寄存器导致系统死机的情况查到最后就是物理地址映射长度没对齐后面越界访问了一块根本不存在的地址空间。第二招是中断。硬件事件比如数据到达、按键按下都会产生中断。驱动里要用 request_irq 注册中断处理函数。中断上下文有严格限制不能睡眠、不能调用可能睡眠的函数所以真正耗时的数据搬运通常要推到下半部执行常见手段包括工作队列、tasklet 或线程化中断。第三招是平台设备与设备树。现代嵌入式 Linux 里设备信息往往写在设备树里驱动通过 platform_driver 注册自己的匹配表当设备树节点和驱动匹配上时probe 函数才会被调用。这么做的好处是驱动和具体硬件配置解耦换个板子不用改代码只改设备树就行。字符设备驱动如果只是简单的模块加载可以不用设备树但只要硬件资源一多寄存器地址、中断号、DMA 通道这些信息都裸露在代码里维护起来相当痛苦。3. 一步步手写一个 Demo 驱动理论说了一堆不如直接动手。下面我按自己平时搭开发环境的方式从零写一个最小可用的字符设备驱动。这个 Demo 本身不具备什么实际硬件功能它维护一块内核缓冲区应用层可以写数据进去、再把数据读出来还支持一个 ioctl 命令读驱动版本。麻雀虽小五脏俱全通路全部打通之后你再看真实驱动会轻松很多。3.1 准备环境与工程目录首先准备一台装有 Linux 的机器或者像我一样用虚拟机再装好内核头文件。以 Debian/Ubuntu 系列为例sudo apt update sudo apt install build-essential linux-headers-$(uname -r)编译内核模块需要用内核自身的构建系统而不是普通 gcc 直接编译。工程我建议保持最小化就三个文件demo_drv/ ├── Makefile ├── demo_drv.c └── app_test.c如果内核对签名校验或模块版本校验有严格要求可能还需要用对应内核的配置生成签名模块。不过大多数发行版内核加载本地自编模块时是放行的真遇到 Operation not permitted 或 Required key not available 报错再单独查证书签名方案。3.2 核心驱动代码与逐段说明下面是完整的最小驱动代码我加了详细注释#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #include linux/slab.h #define DEMO_BUF_SIZE 4096 #define DEMO_IOCTL_VERSION _IO(D, 0x01) static dev_t demo_devno; static struct cdev demo_cdev; static char *demo_buf; static unsigned long demo_data_len; static int demo_open(struct inode *inode, struct file *filp) { /* 可以在这里做设备实例私有数据的初始化 */ printk(KERN_INFO demo: open\n); return 0; } static int demo_release(struct inode *inode, struct file *filp) { printk(KERN_INFO demo: release\n); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { if (demo_data_len 0) return 0; if (count demo_data_len) count demo_data_len; if (copy_to_user(buf, demo_buf, count)) return -EFAULT; demo_data_len 0; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { if (count DEMO_BUF_SIZE) count DEMO_BUF_SIZE; if (copy_from_user(demo_buf, buf, count)) return -EFAULT; demo_data_len count; return count; } static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case DEMO_IOCTL_VERSION: if (copy_to_user((char __user *)arg, demo1.0, 7)) return -EFAULT; break; default: return -ENOTTY; } return 0; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .release demo_release, .read demo_read, .write demo_write, .unlocked_ioctl demo_ioctl, }; static int __init demo_init(void) { int ret; ret alloc_chrdev_region(demo_devno, 0, 1, demo); if (ret 0) { printk(KERN_ERR demo: alloc chrdev failed\n); return ret; } demo_buf kzalloc(DEMO_BUF_SIZE, GFP_KERNEL); if (!demo_buf) { unregister_chrdev_region(demo_devno, 1); return -ENOMEM; } cdev_init(demo_cdev, demo_fops); ret cdev_add(demo_cdev, demo_devno, 1); if (ret 0) { kfree(demo_buf); unregister_chrdev_region(demo_devno, 1); return ret; } printk(KERN_INFO demo: ok, major%d\n, MAJOR(demo_devno)); return 0; } static void __exit demo_exit(void) { cdev_del(demo_cdev); kfree(demo_buf); unregister_chrdev_region(demo_devno, 1); printk(KERN_INFO demo: bye\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(A Developer); MODULE_DESCRIPTION(A simple char device demo);逐段拆一下重点。demo_init 里先是分配设备号这里用的是动态分配方式所以每次加载主设备号可能不同具体值在 dmesg 里能看到。然后分配内核缓冲区注意 kzalloc 会用 0 填充避免残留脏数据。接着 cdev_init 绑定操作函数集cdev_add 注册到内核。资源申请一定要按顺序做一旦后面某一步失败前面申请过的资源要全部回滚否则残留资源会导致后续加载出问题。demo_read 里如果缓冲区没有数据就返回 0。对应用层来说read 返回 0 表示 EOF这个语义在很多代码里都会用到。copy_to_user 失败返回 -EFAULT这是标准做法。read 之后把数据库长度清零也就是说这个 Demo 只支持读一次再读就读不到东西了属于最简单的“一次性消费”模型。demo_write 把应用层数据拷进内核缓冲区然后记录长度。这里的长度限制是 DEMO_BUF_SIZE超过就截断。实际驱动里通常需要更精细的缓冲管理但对学习 Demo 来说已经够用。ioctl 命令用了宏 _IO(D, 0x01)第一个参数是魔数用来区分不同驱动避免命令码撞车第二个是序号。应用层和驱动里必须保持一致不一致时 ioctl 可能返回 -ENOTTY查找问题时会很困惑。3.3 测试应用怎么配合驱动写完应用层也得配合。测试程序作的事情很简单打开设备、写入字符串、读回来、发一个 ioctl、最后关闭。#include stdio.h #include string.h #include fcntl.h #include unistd.h #include sys/ioctl.h #define DEMO_IOCTL_VERSION _IO(D, 0x01) int main(void) { int fd open(/dev/demo, O_RDWR); if (fd 0) { perror(open); return 1; } const char *msg hello from user space; printf(write: %s\n, msg); if (write(fd, msg, strlen(msg)) 0) { perror(write); close(fd); return 1; } char buf[256] {0}; ssize_t n read(fd, buf, sizeof(buf) - 1); if (n 0) { perror(read); close(fd); return 1; } buf[n] \0; printf(read: %s\n, buf); char ver[16] {0}; if (ioctl(fd, DEMO_IOCTL_VERSION, ver) 0) { printf(version: %s\n, ver); } close(fd); return 0; }编译测试程序用普通 gcc 就行不需要任何特殊链接库。注意应用层 open 的是绝对路径 /dev/demo这个节点必须已经存在否则 open 会失败。3.4 编译、加载、创建设备节点驱动模块的 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) clean在工程目录执行 make成功后目录里会多出 demo_drv.ko 文件。接着加载模块sudo insmod demo_drv.ko dmesg | taildmesg 里会出现 “demo: ok, majorxxx”那个 xxx 就是内核动态分配的主设备号。有了设备号还不够还需要创建设备节点sudo mknod /dev/demo c 240 0 sudo chmod 666 /dev/demo这里的 240 要以 dmesg 打印的实际主设备号为准。如果你在 /dev 下已经有 udev 机制并且驱动里实现了 class_create device_create那么这一步可以省略系统会自动创建节点。3.5 跑起来看效果万事俱备编译并运行测试程序gcc -o app_test app_test.c ./app_test正常输出是这样write: hello from user space read: hello from user space version: demo1.0看到这个结果说明 open、write、read、ioctl 四条系统调用链路全部打通。这整个流程是后续所有字符设备开发的基础骨架即使驱动最终要操作的是串口、GPIO、DMA代码结构也基本不会跳出这个框。4. 踩坑实录常见问题与排查思路驱动开发最让人头疼的不是代码本身而是出了问题不知道从哪里查起。这里把我在字符设备驱动学习中遇到的几类典型问题整理出来供大家参考。4.1 设备节点不见了怎么办模块加载成功dmesg 也正常但 /dev/demo 就是不存在。原因多半是驱动里只做了 cdev_add没有调用 device_create设备节点只存在于内核内部用户层看不到。要么手动 mknod要么在驱动里补上 class_create device_createstatic struct class *demo_class; demo_class class_create(THIS_MODULE, demo_class); device_create(demo_class, NULL, demo_devno, NULL, demo);用 udev 的系统里device_create 会触发 ueventudev 根据事件在 /dev 下生成名为 “demo” 的节点。设备节点生成后设备文件的大小信息里的主次设备号也能协助排查。regenerate 也可能和 udev 规则有关但调试阶段最简单的方式还是手动 mknod。4.2 open 返回 -1 但 dmesg 没有任何报错这种情况我在群里见过太多回了。先确认设备节点的主次设备号用 ls -l /dev/demo 查看再用 cat /proc/devices 查看内核注册的主设备号两者不一致就会 open 失败。动态分配设备号会导致每次加载主设备号都可能变化如果 mknod 时手写了上一个加载的主设备号下一次必然对不上。所以要么用 udev要么脚本里动态解析 /proc/devices。权限问题也常见。设备节点默认归 root 所有普通用户 open 一定会得到 Permission denied。调试阶段我直接 chmod 666省心正式产品则要有明确的安全策略。4.3 一操作设备系统就崩溃系统崩溃、重启、Kernel Oops这些基本都是内核空间非法内存访问惹的祸。最常见的原因是 copy_to_user / copy_from_user 用错对用户指针直接 memcpy或者内核缓冲区越界访问再或者 ioremap 映射了不存在的物理地址。遇到 Oops先别慌dmesg 里其实记录了大量现场信息出错的函数、地址、调用栈。按顺序排查用户缓冲区操作是否都走了 uaccess API缓冲区申请是否成功映射的物理地址是否真实存在如果条件允许打开 CONFIG_KASAN 这类调试选项能帮你快速定位越界访问的具体位置。内核调试是慢功夫但排查思路无非是把嫌疑范围一步步缩小。4.4 多进程访问数据错乱Demo 驱动没有做并发保护两个进程同时 write 时后写的会覆盖先写的read 时读到什么也无从谈起。真实场景下这绝不允许。最基础的方案是加互斥锁static struct mutex demo_lock; // init 里 mutex_init(demo_lock); // read/write 里 mutex_lock(demo_lock); ... /* 临界区 */ mutex_unlock(demo_lock);更高级的模型是阻塞 I/Oread 没有数据时把进程挂到等待队列上write 写入数据后唤醒等待队列里的进程。这是字符设备驱动绕不开的核心知识点。应用层配合 select / poll 或 epoll就可以实现事件驱动模型性能和体验完全不同。5. 从 Demo 到真实项目还需要补的课5.1 并发、中断与阻塞模型我见过不少人跑通上面的 Hello World 式 Demo 之后就觉得自己会写驱动了结果一接触真实项目立刻栽跟头。真实硬件驱动通常要面对三个大问题并发、中断、资源管理。并发方面Linux 内核里可能同时存在多个进程、中断、底半部在访问同一个设备。像 Demo 里不加锁的数据缓冲区随时可能被破坏。自旋锁适合保护很短小的临界区互斥锁适合可能睡眠的场景。内核并发模型和用户态多线程不在一个数量级一个持有自旋锁的临界区如果调用睡眠函数整个系统可能直接卡死。中断方面如果设备数据到达是事件驱动的那么中断处理函数里通常不适合做大量拷贝工作。更常见的做法是维护一个环形缓冲区中断处理函数只负责把硬件 FIFO 里的数据搬到这个缓冲区剩余的事情交给被唤醒的进程或者内核线程处理。这个生产者-消费者模型是串口、网卡、音频采集这类驱动的基础。5.2 从裸驱动到工程化单纯一个字符设备框架只是骨架到真实项目里还需要走一套工程化的路子硬件资源用设备树描述驱动走 platform_driver 框架probe 函数里解析寄存器地址、中断号、DMA 通道代码层次清晰。read/write 之外poll、mmap、llseek 这些接口按需补齐。比如音频设备通常用 mmap 映射 DMA 缓冲区减少内核到用户态的拷贝。错误处理要完整任何一次资源申请失败都要有回滚路径。内核打印要分级info、err、debug 分级使用配合动态打印机制避免生产环境 dmesg 被刷屏。驱动要支持多设备实例别把全局变量当成单例使用。每个 open 帧的 file 结构体里保存各自独立的私有数据指针具体的做法是把私有数据结构挂在 filp-private_data 上。我自己写驱动的习惯是先把静态链路打通模块加载、设备节点出现、open、close、卸载全链路顺畅后再去充实 read、write、ioctl。这样定位问题时永远不会陷入“到底硬件没响应还是设备根本没注册”的两难境地。调试驱动时还有一个笨但有效的方法在关键函数入口加一行 printk配合 dmesg 观察调用顺序。虽然现在有很多动态追踪工具但最小 Demo 阶段 printk 往往最直接。生产代码里再切换成 dev_err、dev_info配合 tracepoint 或 ftrace 做更系统级的性能分析。5.3 给新手的下一步建议如果你是零基础我建议按这条路线推进先跑通本文的 Demo再尝试加一个硬件控制功能比如操作开发板上的一个 LED或者读取一个 GPIO 按键的状态。把硬件动作掺进来后你会被迫去弄懂 GPIO 子系统、中断申请、可能还有定时器和延迟接口。这个过程中会踩的坑很多但每个坑都是最有价值的学习材料。等你能在一个真实的小硬件上实现输入输出之后再去读内核源码里已有的简单字符设备驱动比如某些伪随机数生成器、零设备或简单的传感器驱动你会发现它们的结构和本文的 Demo 非常相似只是多了状态机、并发控制和性能考量。从字符设备驱动入手学习 Linux 驱动开发几乎是所有内核开发者的必经之路。这套框架理解扎实了后面看块设备驱动、网络驱动、USB 驱动、PCIe 驱动时很多底层概念都是通用的。希望我这篇实务向的经验总结能帮你减少一些自己摸索的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑