十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux设备驱动开发全攻略:从内核模块到字符设备实战

Linux设备驱动开发全攻略:从内核模块到字符设备实战 如果你的电脑跑过 Linux却感觉内核和驱动的大门始终没有真正敞开如果你已经能熟练操作ls、cd、grep但面对/dev目录下的设备文件、面对insmod加载的.ko模块时依然觉得朦胧如果你在嵌入式 Linux 项目里反复被“内核态、字符设备、中断、并发”这些词卡住那么设备驱动开发就是你从“会用 Linux”跨向“懂 Linux”的那道分水岭。最近《手把手教你学Linux设备驱动开发》正式出版很多读者把它称为“硬核宝典”。书籍的价值在于把零散的内核知识体系化但真正能落地的还是你自己动手写出来的第一个驱动。这篇文章不打算只谈书的内容而是围绕 Linux 设备驱动开发整理一套从环境搭建、原理拆解到完整字符设备实战、常见报错排查的闭环路径。文中会给出可复制的代码和 Makefile也会讲清楚每一步为什么这样做。无论你是嵌入式方向的学生、正在转行 Linux 开发的工程师还是工作中需要接触内核模块的服务端开发者都可以跟着走一遍。1. 设备驱动先搞懂这 4 个背景概念1.1 什么是设备驱动用一句通俗的话说设备驱动是操作系统与硬件设备之间的“翻译官”。应用层程序不会直接去读写寄存器、操作中断控制器它只需要调用open、read、write、ioctl这些标准接口驱动则在内核态把这些调用翻译成硬件能够理解的寄存器操作、时序控制、数据收发动作。从 Linux 内核源码树的视角看drivers/目录是整个内核中体量最大的子系统之一里面包含了gpio、i2c、spi、usb、net、input、char等大量细分目录。这也说明设备驱动不是一种单一的编写技巧而是围绕“内核如何管理硬件”的一整套工程方法。1.2 用户态与内核态的边界驱动代码运行在内核态这带来两个关键差别访问权限不同内核态可以直接访问物理地址、中断寄存器、DMA 控制器用户态只能通过系统调用进入内核。出错后果不同用户态程序段错误通常只影响自身进程内核态代码一旦崩溃大概率直接 panic整个系统重启。因此在学习驱动开发时你写的每一行代码都要比普通应用代码更谨慎。最常见的心态变化是从“只要能跑就行”变成“先确认有没有锁、有没有内存泄漏、有没有非法指针”。1.3 设备文件与主次设备号驱动加载成功后系统需要在/dev下创建设备文件应用程序通过读写这个文件来访问硬件。设备文件本身不存储数据它只是一个访问入口。设备文件通过主设备号区分驱动类型通过次设备号区分同一类驱动下的不同设备实例。例如常见的ls -l /dev/ttyS0输出中会有类似c 4 64的字样c表示字符设备4是主设备号64是次设备号。理解主次设备号之后再看mknod、udev规则、/proc/devices这些内容就会自然串联起来。1.4 设备驱动的三种基本类型Linux 设备模型把设备驱动分为三种基本类型类型数据访问特征典型设备常见接口字符设备按字节流顺序读写串口、GPIO、传感器open/read/write/ioctl块设备按数据块随机读写硬盘、SD 卡、eMMCsubmit_bio/request网络设备以数据包为单位收发网卡、WiFi 模块net_device_ops对于初学者字符设备是性价比最高的入门方向。它逻辑清晰、调试方便、可以直接用echo和cat验证效果。本文的实战部分也以字符设备驱动为例。2. 环境准备与版本说明2.1 开发机与目标机的选择设备驱动开发通常采用“交叉开发”模式开发主机安装 Linux 的 PC 或虚拟机负责编写代码、编译内核模块。目标机运行 Linux 的开发板也可以是同一台 PC 上的 Linux 系统用于加载和测试模块。如果暂时没有开发板完全可以在自己的 Linux 主机上完成本文示例。字符设备驱动不依赖特定硬件可以在通用 x86_64 Linux 上编译加载。若你的目标是嵌入式场景再无缝切换到交叉编译工具链。需要注意驱动模块必须与当前运行内核的版本、配置完全匹配否则insmod会报version magic错误。2.2 内核头文件与编译工具编译内核模块不需要完整内核源码但必须安装与当前内核版本完全对应的头文件包。以 Ubuntu/Debian 系为例uname -r sudo apt-get update sudo apt-get install linux-headers-$(uname -r) sudo apt-get install build-essential如果桌面版内核头文件包不完整可以额外安装sudo apt-get install linux-headers-genericCentOS/RHEL/Fedora 系使用uname -r sudo yum install kernel-devel kernel-headers这里的核心是保证/lib/modules/$(uname -r)/build这个软链接指向有效的内核头文件目录。编译模块时Kbuild 系统会通过它找到内核源码树。2.3 内核源码是否必须完整下载如果你的目的只是编译一个独立模块那么只安装linux-headers-*就足够了。但是如果你需要修改内核配置、编译整个内核、调试内核本身那么需要下载完整内核源码wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.tar.xz tar -xf linux-6.6.tar.xz cd linux-6.6 make menuconfig make -j$(nproc)如果暂时无法下载或编译整棵树不要卡在这一步。本文后面的实战只需要内核头文件就能运行。2.4 本文环境说明为了演示代码后续所有命令默认在以下环境执行操作系统Ubuntu 22.04 LTSx86_64内核版本5.15 系列以实际uname -r输出为准编译器gcc 11不需要开发板不需要完整内核源码版本不必和我完全一致重点在于方法。只要你的内核头文件安装正确示例代码都可以复现。3. Linux 设备驱动开发核心原理解拆解3.1 内核模块的加载与卸载Linux 支持在运行时动态加载模块模块文件通常以.kokernel object结尾。最简单的内核模块包含两个入口module_init模块加载时执行的初始化函数。module_exit模块卸载时执行的清理函数。一个“空跑”的模块如下// 文件路径hello_drv/hello.c #include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello_init: module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello_exit: module removed\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello world kernel module);这里有三个细节值得新手注意__init和__exit宏用于告诉内核这些函数在初始化/卸载完成后可以释放内存降低内存占用。printk不是printf它输出到内核日志而不是终端。查看方式通常是dmesg或journalctl -k。MODULE_LICENSE(GPL)不是可选项如果不声明模块加载时会提示module license unspecified taints kernel。它影响内核符号导出、以及部分 GPL 导出符号的使用。对应的 Makefile# 文件路径hello_drv/Makefile obj-m hello.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 hello.ko dmesg | tail -5 sudo rmmod hello dmesg | tail -5预期输出中可以看到hello_init: module loaded和hello_exit: module removed。这就是驱动的“最小可运行闭环”。3.2 字符设备驱动的核心数据结构实际设备驱动不会只打印日志它需要让应用程序能够通过文件接口访问。这就要用到两个核心结构体struct file_operations描述驱动支持的open、read、write、release、unlocked_ioctl等操作。struct cdev字符设备在内核中的抽象对象。梳理它们的关系应用层 open(/dev/mydrv) → 内核虚拟文件系统 → 找到字符设备 cdev → 调用 cdev 中记录的 file_operations 回调 → 最终执行驱动中实现的 xxx_open()所以在驱动初始化时需要完成三件事分配设备号alloc_chrdev_region或register_chrdev_region。初始化 cdevcdev_init。添加 cdev 到内核cdev_add。卸载时顺序相反cdev_del后释放设备号。3.3 设备号的分配方式设备号分为动态分配和静态指定两种方式动态分配调用alloc_chrdev_region由内核自动分配一个可用的主设备号。优点是不会冲突缺点是设备号不固定需要读取/proc/devices获取。静态指定调用register_chrdev_region自己指定主设备号。优点是可以固定/dev节点缺点是如果设备号被占用注册会失败。对于学习和产品原型优先使用动态分配。这样可以在多台环境上无冲突加载逻辑也更干净。3.4 file_operations 中的 read 和 write 语义当用户在应用层执行read(fd, buf, count)时内核最终会调用驱动里的.read方法。这个方法有几个约束数据从内核态拷贝到用户态必须使用copy_to_user不能直接memcpy。返回值表示实际读到的字节数如果为 0应用层会认为读到文件末尾。如果暂时没有数据可读驱动可以选择阻塞睡眠或返回错误码。同理write方法需要调用copy_from_user把用户数据拷贝到内核空间。原因是内核态不能直接访问用户态指针必须经过安全检查防止用户传入非法地址导致内核崩溃。3.5 从字符设备到 platform 驱动的进阶路径字符设备只是“接口层”。真实硬件驱动通常还要和设备总线、设备树打交道这就引入platform_driver。它把一个驱动与设备树中的compatible字符串匹配匹配成功后调用probe函数完成硬件初始化。学习顺序建议先写纯字符设备驱动不涉及具体硬件把 file_operations 机制吃透。再写 platform 驱动让驱动和设备树产生关联。然后接触中断、内核定时器、tasklet、工作队列。最后进阶到具体子系统输入子系统、IIO 子系统、网络驱动等。这条路很长但第一步永远是“写一个能在开发板上加载的字符设备”。4. 完整实战手写一个可读写的字符设备驱动下面进入本文的主菜实现一个字符设备驱动它内部维护一个 4KB 的缓冲区支持read、write、open、release并且支持通过ioctl清空缓冲区。这个例子不依赖任何具体硬件因此在 PC 和开发板上都可以运行。4.1 数据结构设计驱动的核心是一个缓冲区以及用于保护并发访问的互斥锁#define BUF_LEN 4096 static char device_buf[BUF_LEN]; static int buf_len 0; static int major 0; static struct cdev my_cdev; static struct class *my_class NULL; static DEFINE_MUTEX(my_mutex);DEFINE_MUTEX是内核提供的静态互斥锁定义宏。之所以要加锁是因为read和write可能在多进程下并发调用如果两个进程同时写缓冲区如果没有锁保护可能会互相覆盖产生竞态。4.2 完整驱动代码// 文件路径mychr_drv/mychr.c #include linux/init.h #include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/mutex.h #include linux/slab.h #define BUF_LEN 4096 #define MY_IOCTL_CLEAR _IO(0xAA, 0x01) static char *device_buf; static int buf_len 0; static int major 0; static struct cdev my_cdev; static struct class *my_class NULL; static DEFINE_MUTEX(my_mutex); static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO mychr: open\n); return 0; } static int my_release(struct inode *inode, struct file *filp) { printk(KERN_INFO mychr: release\n); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { ssize_t ret; if (mutex_lock_interruptible(my_mutex)) return -ERESTARTSYS; if (*ppos buf_len) { mutex_unlock(my_mutex); return 0; } if (count buf_len - *ppos) count buf_len - *ppos; if (copy_to_user(buf, device_buf *ppos, count)) { mutex_unlock(my_mutex); return -EFAULT; } *ppos count; ret count; mutex_unlock(my_mutex); return ret; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { if (mutex_lock_interruptible(my_mutex)) return -ERESTARTSYS; if (count BUF_LEN - *ppos) count BUF_LEN - *ppos; if (copy_from_user(device_buf *ppos, buf, count)) { mutex_unlock(my_mutex); return -EFAULT; } *ppos count; if (*ppos buf_len) buf_len *ppos; mutex_unlock(my_mutex); return count; } static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case MY_IOCTL_CLEAR: mutex_lock(my_mutex); memset(device_buf, 0, BUF_LEN); buf_len 0; mutex_unlock(my_mutex); printk(KERN_INFO mychr: buffer cleared\n); break; default: return -EINVAL; } return 0; } static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .release my_release, .read my_read, .write my_write, .unlocked_ioctl my_ioctl, }; static int __init mychr_init(void) { dev_t dev; if (alloc_chrdev_region(dev, 0, 1, mychr) 0) { printk(KERN_ERR mychr: alloc_chrdev_region failed\n); return -1; } major MAJOR(dev); cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; if (cdev_add(my_cdev, dev, 1) 0) { printk(KERN_ERR mychr: cdev_add failed\n); unregister_chrdev_region(dev, 1); return -1; } my_class class_create(THIS_MODULE, mychr_class); if (IS_ERR(my_class)) { printk(KERN_ERR mychr: class_create failed\n); cdev_del(my_cdev); unregister_chrdev_region(dev, 1); return PTR_ERR(my_class); } device_create(my_class, NULL, dev, NULL, mychr); device_buf kzalloc(BUF_LEN, GFP_KERNEL); if (!device_buf) { printk(KERN_ERR mychr: kzalloc failed\n); device_destroy(my_class, dev); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev, 1); return -ENOMEM; } printk(KERN_INFO mychr: init success, major%d\n, major); return 0; } static void __exit mychr_exit(void) { dev_t dev MKDEV(major, 0); kfree(device_buf); device_destroy(my_class, dev); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev, 1); printk(KERN_INFO mychr: exit\n); } module_init(mychr_init); module_exit(mychr_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Linux Drv Learner); MODULE_DESCRIPTION(A simple char device driver with ioctl);4.3 Makefile 与编译# 文件路径mychr_drv/Makefile obj-m : mychr.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如果编译成功当前目录下会出现mychr.ko文件。4.4 加载驱动并创建设备节点由于驱动代码中使用了class_create和device_create在桌面 Linux 环境下udev会自动在/dev/mychr创建设备节点。但如果你在最小系统或开发板上没有 udev也可以手动创建sudo insmod mychr.ko cat /proc/devices | grep mychr假设输出得到主设备号为240实际数字以你的系统为准然后手动创建设备节点sudo mknod /dev/mychr c 240 0 sudo chmod 666 /dev/mychr如果 udev 正常工作可以直接检查ls -l /dev/mychr4.5 编写用户态测试程序驱动加载成功后使用一个简单的 C 程序验证读写和 ioctl// 文件路径mychr_drv/test_mychr.c #include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include sys/ioctl.h #define MY_IOCTL_CLEAR _IO(0xAA, 0x01) int main(void) { int fd; char wbuf[] hello, linux device driver!; char rbuf[128] {0}; ssize_t n; fd open(/dev/mychr, O_RDWR); if (fd 0) { perror(open); return 1; } n write(fd, wbuf, strlen(wbuf)); printf(write %zd bytes\n, n); lseek(fd, 0, SEEK_SET); n read(fd, rbuf, sizeof(rbuf) - 1); printf(read %zd bytes: %s\n, n, rbuf); ioctl(fd, MY_IOCTL_CLEAR, 0); lseek(fd, 0, SEEK_SET); n read(fd, rbuf, sizeof(rbuf) - 1); printf(after clear, read %zd bytes\n, n); close(fd); return 0; }编译并运行gcc -o test_mychr test_mychr.c ./test_mychr预期输出类似write 28 bytes read 28 bytes: hello, linux device driver! after clear, read 0 bytes到这里你已经基本掌握了字符设备驱动的完整套路从模块框架到设备号分配、cdev 注册、文件操作实现、用户态交互、ioctl 清空操作。4.6 运行验证与日志查看如果读写结果不符合预期优先查看内核日志dmesg | tail -20你会看到驱动中printk输出的各类提示。这里的KERN_INFO、KERN_ERR只是日志级别它们结合系统日志服务最终显示/存储会根据配置而定不要依赖printf的方式去理解内核日志。5. Linux 驱动开发常见问题与排查思路驱动开发中最大的障碍往往不是语法而是“编译不过”和“加载失败”这类环境问题。以下整理了新手最常见的几类问题。5.1 编译阶段报错问题现象常见原因解决思路linux/xxx.h: No such file or directory内核头文件未安装执行apt-get install linux-headers-$(uname -r)version magic 5.15.0-... should be ...模块头文件版本与当前内核不一致重新检查linux-headers包版本编译时函数不存在内核 API 版本差异使用grep在内核头文件中确认 API 是否存在make: *** No rule to make target modulesKDIR路径无效检查/lib/modules/$(uname -r)/build软链接5.2 加载阶段报错问题现象常见原因解决思路insmod: ERROR: could not insert module mychr.ko: Operation not permitted缺少 root 权限或 Secure Boot 限制使用sudo关闭 Secure Boot 或签名模块insmod: ERROR: could not insert module: Unknown symbol依赖的其他模块未加载先加载依赖模块或检查Module.symversinsmod: ERROR: could not insert module: Invalid module format模块架构与内核架构不匹配确认是否误用了交叉编译出来的模块加载后没有/dev/mychr没有 udev 或设备创建失败查看dmesg手动mknod5.3 运行阶段异常问题现象常见原因解决思路copy_to_user返回非 0用户态指针无效检查应用层传入的 buf 是否有效、count 是否过大read 返回-EFAULT内存拷贝失败确认 buf 有足够空间并检查指针生命周期write 后 read 读到空文件偏移没有复位用户程序在 read 前执行lseek(fd, 0, SEEK_SET)多个进程同时读写数据错乱未加锁或锁使用不当使用mutex、spinlock或atomic保护共享数据5.4 系统日志的使用建议驱动开发过程中printk是你最直接的调试手段。但注意生产环境不要留下过多无意义日志。调试阶段可以临时使用printk(KERN_DEBUG xxx\n)发布前建议删除或降级。查看日志的常用命令dmesg dmesg | grep mychr journalctl -k -f如果日志太多干扰可以只查看当前模块日志dmesg -w | grep -E mychr6. 设备驱动开发最佳实践与工程建议6.1 内核编程规范内核代码遵循 Linux kernel coding style。以下几点是初学者最容易忽略的缩进用 Tab而不是空格。函数名使用小写加下划线。结构体通常小写加下划线例如my_drv_data。注释风格使用/* ... */而不是//。虽然内核从 C99 开始也放宽了一些约定但整体上保持旧风格能让代码更容易被内核社区接受。更重要的是内核社区对代码格式的审查很严格如果你未来想向上游提交补丁格式是第一道关卡。6.2 错误处理与资源释放驱动的初始化函数中一旦某个步骤失败要确保已经申请的资源全部释放。典型错误是cdev_add成功了但后续device_create失败时忘记cdev_del导致模块卸载后/dev或设备号残留。一个标准做法是使用goto式错误处理static int __init mychr_init(void) { dev_t dev; int ret; ret alloc_chrdev_region(dev, 0, 1, mychr); if (ret 0) return ret; cdev_init(my_cdev, my_fops); ret cdev_add(my_cdev, dev, 1); if (ret 0) goto err_unregister_region; my_class class_create(THIS_MODULE, mychr_class); if (IS_ERR(my_class)) { ret PTR_ERR(my_class); goto err_cdev_del; } device_buf kzalloc(BUF_LEN, GFP_KERNEL); if (!device_buf) { ret -ENOMEM; goto err_class_destroy; } device_create(my_class, dev, NULL, mychr); major MAJOR(dev); return 0; err_class_destroy: class_destroy(my_class); err_cdev_del: cdev_del(my_cdev); err_unregister_region: unregister_chrdev_region(dev, 1); return ret; }这种写法的好处是错误处理和正常流程分离后续增加新的初始化步骤时不容易遗漏释放。6.3 并发与锁的选择驱动可能运行在多个上下文中进程上下文、中断上下文、软中断等。不要想当然地认为只有一个进程在调用驱动。保护普通读写的临界区使用mutex。如果临界区可能在中断上下文中执行使用spinlock。对简单计数操作优先atomic_t。原则是互斥锁mutex可以睡眠适合进程上下文自旋锁spinlock不能睡眠适合短临界区和中断上下文。选错锁会导致死锁或崩溃。6.4 内存管理内核内存分配和释放必须匹配kmalloc对应kfree。kzalloc对应kfree。vmalloc对应vfree。分配 DMA 缓冲区时使用dma_alloc_coherent对应dma_free_coherent。不要在驱动中混用这些接口。此外内核态分配内存时建议明确GFP_KERNEL还是GFP_ATOMIC进程上下文可以睡眠使用GFP_KERNEL中断上下文不能睡眠使用GFP_ATOMIC。6.5 设备树与可移植性从长远角度看现代 Linux 设备驱动越来越依赖设备树Device Tree。设备树描述硬件资源的位置和属性驱动在probe函数中读取设备树节点来获取寄存器地址、中断号等信息而不是写死资源。这样的驱动更容易移植到不同主板上。设备树中的compatible属性承担驱动与设备匹配的“身份证”作用mychr { compatible vendor,mychr; reg 0x0 0x1000; };驱动侧对应声明static const struct of_device_id mychr_of_match[] { { .compatible vendor,mychr, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mychr_of_match);当你在开发板上运行驱动时这比手动加载模块更符合工程习惯。6.6 调试与日志分级调试驱动的常用手段包括使用printk分级打印。使用ftrace跟踪内核函数调用。使用perf分析性能。使用/proc或debugfs导出运行时状态。使用kgdb在内核中打断点。对于初学者printk和dmesg已经足够解决绝大多数问题。正确做法是一开始就规划好转打印驱动加载/卸载打一次open/read/write/ioctl关键路径打一次错误路径必须打。之后通过日志级别把调试信息区分开。7. 学习路线与资源建议《手把手教你学Linux设备驱动开发》这本书的价值在于它把入门需要的内核知识压缩成一条相对清晰的学习路径。但如果只是买书而不动手效果会大打折扣。结合本文的实战建议你按以下路线推进第 1 周理解内核模块机制跑通 hello 模块并尝试修改模块参数。第 2 周掌握字符设备驱动框架写出带read/write/ioctl的完整驱动。第 3-4 周在开发板上运行驱动学习设备树基础理解probe流程。第 5-8 周学习中断、内核定时器、工作队列尝试编写 GPIO/按键驱动。第 9 周以后进入具体子系统例如输入子系统、IIO 子系统、LCD、触摸屏、网络驱动等。这期间需要反复查阅的资料Linux 内核源码尤其是drivers/内同类型驱动的实现。Documentation/driver-api目录下的内核文档。Kernel Newbies 网站上的 API 变更信息。驱动开发最忌“只读不写”。每学一个机制就在内核源码里找一个相似驱动把它的结构抄下来改成自己的功能。等你完整写过 3 到 5 个不重样的字符驱动、platform 驱动后再回头看书中的中断、并发、内存管理章节会发现概念开始“落地”了。这也正是“硬核宝典”这类系统化书籍的意义它们能帮你省去在博客和文档碎片之间来回跳转的时间但你仍然要亲手敲完每一行代码。设备驱动开发的路上没有太多捷径但有一条明确的主线先跑通最小模块再深入机制最后研究框架。本文从环境、原理到实战、排错给出了一条可执行的起步路径如果你的手边已经有一台 Linux 机器现在就可以从第 2 节开始安装内核头文件然后编译第一个 hello 模块。真正写过一个驱动之后你会发现自己对 Linux 的理解已经向前迈了一大步。
返回列表