ARTICLE DETAIL

资讯详情

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

字符设备驱动开发实战:从设备号分配到用户态数据拷贝

字符设备驱动开发实战:从设备号分配到用户态数据拷贝 简介这是一份面向嵌入式驱动初学者的简单字符设备驱动实验资源完整覆盖Linux 3.14环境下的驱动编写、测试与应用层调用流程。资源内容围绕实验要求展开定义含1024字节buffer和count计数器的全局结构体open中判空分配并初始化release中按count值决定释放或自减read/write分别实现buffer读取与赋值同时提供测试程序验证驱动功能。资源包共5个文件包括实验报告docx、2个C源码文件驱动与测试程序和2张运行效果截图压缩包整体约465KB结构与说明清晰。已有475人学习浏览适合正在学习Linux驱动开发、需要参考实验报告与源码实现细节的高校学生。通过这份资料可快速对照实验原理理解字符设备驱动框架与用户态-内核态交互方式代码可直接修改复用完成类似实验。 字符设备驱动是嵌入式 Linux 开发中最基础的模块类型也是内核和硬件交互的经典入口。我当年在做XDU这门课的实验一时正好卡在“设备号分配、file_operations注册、用户态与内核态数据拷贝”这一整套流程上。后来把驱动的框架彻底捋顺之后才发现简单字符设备驱动其实是理解整个内核驱动模型的一把钥匙无论是LED、按键、GPIO还是后续的platform总线设备底层思路都是一脉相承的。这篇文章我就以实验一为主线把字符设备驱动的设计思路、实现细节、测试方法以及我实际踩过的坑全部拆开讲一遍。1. 内容整体设计与思路拆解1.1 从零理解字符设备驱动在内核中的位置很多初学者一上来就闷头写代码结果连“驱动到底是什么”都没搞清楚。字符设备是 Linux 设备中三大类字符设备、块设备、网络设备里最基础的一种数据按字节流顺序读写典型代表有串口、LED、按键、I2C 传感器等。而驱动的作用本质上就是把内核的通用接口和具体的硬件操作绑定起来。在 XDU 这个实验里要求往往不是去操作某个真实硬件而是写一个虚拟的字符设备用户程序可以通过read、write、open、release等系统调用来访问这个设备。这个设计的妙处在于它把内核态机制和硬件无关性剥离开了你只需要关注驱动框架本身不用纠结具体硬件时序。这其实非常贴近真实项目里的做法——很多驱动开发的第一步就是先跑通一个虚拟设备验证机制正常后再挂硬件。从内核的视角看字符设备驱动的工作可以拆成四层设备号管理告诉内核这个设备叫什么主设备号 次设备号。file_operations提供系统调用和驱动逻辑之间的函数映射表。设备节点通过/dev下的文件让用户程序“像操作文件一样操作硬件”。数据交互在用户空间和内核空间之间安全地搬运数据。1.2 实验方案选型主设备号动态分配还是静态指定在实验代码里有两类设备号分配方式可供选择。第一类是手动指定一个主设备号调用register_chrdev注册第二类是让内核动态分配调用alloc_chrdev_region拿到一个空闲主设备号。我的建议是实验直接用动态分配原因很现实。静态指定主设备号在真实项目中很容易和设备号冲突比如你用了 240别人也用了 240两个驱动模块一加载就直接报错。动态分配让内核从空闲的设备号池里挑一个给你再用mknod手动创建设备节点即可。很多教材为了简化逻辑直接用register_chrdev虽然省事但这种方式在 2.6 内核之后就不推荐了因为它内部帮你做了大量隐藏工作反而掩盖了设备号、cdev、设备节点之间的关系。实验课上如果时间有限用 register_chrdev 也能跑通但期末或面试的时候一旦被问到“设备号怎么分配、cdev_init 和 register_chrdev 的区别”一下就露馅了。这里我给出一个稳妥的组合方案alloc_chrdev_region cdev_init cdev_add加上 class_create 和 device_create 自动生成设备节点。这样既覆盖了完整框架也避免了手动 mknod 时敲错主设备号的尴尬。1.3 为什么说 file_operations 是驱动的“灵魂”设备驱动能被用户态程序使用本质上是靠struct file_operations这个结构体把系统调用和内核函数串联起来。你写一个.open my_open, .read my_read, .write my_write并不是说这些函数被直接调用了而是当用户程序执行open(/dev/mydev, O_RDWR)时虚拟文件系统VFS根据设备节点的设备号找到对应的 cdev再从 cdev 里取出 file_operations 这张表调用对应的函数指针。这个设计非常像 C 语言里用 struct 模拟面向对象的“接口”——file_operations 就是接口定义你的驱动函数就是具体实现。很多嵌入式工程师后来去读内核里的复杂驱动比如 i2c 驱动、platform 驱动会发现它们的核心依然是填充一个又一个的操作结构体。所以实验一里你把这个结构体彻底吃透后面看什么驱动都不慌。2. 核心细节解析与实操要点2.1 关键数据结构cdev、device、class写字符设备驱动绕不开三个重要结构体理解它们的关系是关键。第一个是struct cdev它表征内核里的一个字符设备。你可以把 cdev 理解为一个“设备对象”它包含设备号、file_operations 指针以及一个内核链表节点用来把设备串起来管理。第二个是struct class它用于在 sysfs 中创建一个类。设备模型的思路是“同类设备归类管理”比如/sys/class/leds下面挂的都是 LED 设备。通过 class 可以自动创建设备节点这也是udev或mdev动态创建设备节点的前提。第三个是struct device它代表一个具体的设备实例注册到 class 下内核会通过device_create在/dev下生成对应的设备文件。入门阶段最容易混淆的是这几行代码的次序alloc_chrdev_region(dev_num, 0, 1, my_char_dev); cdev_init(cdev, fops); cdev_add(cdev, dev_num, 1); class_create(THIS_MODULE, my_char_class); device_create(my_class, NULL, dev_num, NULL, my_char_dev);注意device_create和cdev_add之间有顺序讲究。先cdev_add再device_create设备节点生成时驱动已经就绪如果顺序反了设备节点提前出现但 cdev 还没注册用户程序一 open 就可能找不着设备。这个细节在实际调试中很容易踩到。2.2 用户态与内核态的数据拷贝copy_to_user / copy_from_user我在第一次写这个实验时想当然地直接在read回调里用memcpy把内核缓冲区的数据拷给用户空间的指针结果运行的时候程序报错内核直接崩了。原因很明确用户态指针不能在内核态直接被解引用因为两个地址空间是隔离的直接访问会导致段错误甚至内核 panic。正确做法是用内核提供的专用接口copy_to_user(to, from, count)把内核数据拷贝到用户空间。copy_from_user(to, from, count)把用户数据拷贝到内核空间。这两个函数内部会先做地址合法性检查access_ok再逐字节拷贝遇到非法地址会返回未拷贝的字节数而不是直接崩溃。所以我的习惯是每次拷贝完都检查返回值如果非零就返回 -EFAULT。代码里看起来多了几行判断但在调试阶段能省下一大堆诡异问题的时间。ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *offset) { unsigned long ret; if (count sizeof(kernel_buf)) count sizeof(kernel_buf); ret copy_to_user(buf, kernel_buf, count); if (ret ! 0) return -EFAULT; return count; }2.3 并发与原子操作实验里容易忽略但面试必问的点实验一如果用简单变量做全局数据比如一个int global_count有并发风险吗当然有。如果两个进程同时 open 这个设备同时对global_count做自增理论上会出现竞态问题。实验范围内不容易触发但这正是驱动开发和普通 C 程序最大的区别之一——内核态运行在异步上下文随时可能被中断抢占也可能被多核 CPU 并行访问。解决办法有很多原子变量、自旋锁、信号量、互斥锁。实验阶段最简单的是用struct mutex或者atomic_t。比如把计数器变成static atomic_t open_count ATOMIC_INIT(0);在 open 里atomic_inc(open_count)release 里atomic_dec(open_count)。这个改动只要几行代码但会让你的实验报告在老师眼里直接升华一个层次——因为你已经不是“能跑就行”的水平而是开始考虑真正的设备场景了。3. 实操过程与核心环节实现3.1 实验环境准备与内核模块编译基础我做实验时用的是 Ubuntu 交叉编译工具链目标板是 ARM 开发板。不过实验一这种纯软件逻辑的字符设备其实也可以在 x86 虚拟机里直接跑编译出来的 .ko 模块加载到本机内核即可。两种方式的差异只在于内核头文件路径和交叉编译工具链。在开始编写代码前必须确保宿主机上安装了对应版本的内核头文件。如果是 Ubuntu直接 apt 安装 linux-headers-$(uname -r) 即可。需要清楚一点模块编译并不需要完整的内核源码树只要内核头文件和 Makefile 框架就足够了。但如果需要修改内核配置选项、编译内核镜像那才需要完整源码。很多初学者把这个概念搞混下载了一整份几个 GB 的内核源码其实并不必要。编译内核模块最标准的做法是写一个 Makefile利用内核的 kbuild 系统。核心内容如下obj-m : my_char_dev.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这里-C是指定进入内核源码目录执行 makeM$(PWD)告诉 kbuild 当前目录下有我们要编译的模块。初次接触时容易把这两行的含义忽略一旦换到自己交叉编译环境就知道这里的灵活之处了——只要替换 KDIR 路径和目标架构就能适配不同的开发板。3.2 完整代码实现一个最简字符设备驱动下面给出一个可直接编译运行的示例注释尽量详细供实验参考。#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/slab.h #define DEVICE_NAME xdu_char #define CLASS_NAME xdu_class static dev_t dev_num; static struct cdev *my_cdev; static struct class *my_class; static struct device *my_device; static char *kernel_buf; #define BUF_SIZE 128 static int my_open(struct inode *inode, struct file *filp) { pr_info(my_char_dev: open() called\n); return 0; } static int my_release(struct inode *inode, struct file *filp) { pr_info(my_char_dev: release() called\n); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t len BUF_SIZE; unsigned long ret; if (*offset len) return 0; if (count len - *offset) count len - *offset; ret copy_to_user(buf, kernel_buf *offset, count); if (ret) return -EFAULT; *offset count; pr_info(my_char_dev: read %zu bytes\n, count); return count; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { unsigned long ret; if (count BUF_SIZE - *offset) count BUF_SIZE - *offset; ret copy_from_user(kernel_buf *offset, buf, count); if (ret) return -EFAULT; *offset count; pr_info(my_char_dev: wrote %zu bytes\n, count); return count; } static struct file_operations fops { .owner THIS_MODULE, .open my_open, .release my_release, .read my_read, .write my_write, }; static int __init my_init(void) { int ret; ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(failed to allocate device number\n); return ret; } pr_info(my_char_dev: major %d, minor %d\n, MAJOR(dev_num), MINOR(dev_num)); my_cdev cdev_alloc(); if (!my_cdev) { unregister_chrdev_region(dev_num, 1); return -ENOMEM; } cdev_init(my_cdev, fops); my_cdev-owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return ret; } my_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_class); } my_device device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(my_device)) { class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_device); } kernel_buf kzalloc(BUF_SIZE, GFP_KERNEL); if (!kernel_buf) { device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } pr_info(my_char_dev: initialized successfully\n); return 0; } static void __exit my_exit(void) { kfree(kernel_buf); device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); pr_info(my_char_dev: exited\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(XDU Student); MODULE_DESCRIPTION(A simple character device driver for lab 1);这段代码里我刻意把每一步的错误处理都补全了因为这是实验报告评分的重要关注点。内核模块开发里错误处理不是可有可无的装饰。一旦某一个中间步骤失败必须把之前申请的资源全部释放否则模块卸载时会残留设备号或者 cdev下次加载直接冲突。3.3 编译、加载、测试、卸载全流程编译命令很简单make注意看输出信息如果没有报错当前目录下会生成my_char_dev.ko文件。加载步骤sudo insmod my_char_dev.ko dmesg | tail -20用dmesg查看内核日志非常重要因为pr_info打印的信息都在这里。正常的输出应该能看到主设备号类似my_char_dev: major 240, minor 0因为分配是动态的这个主设备号在不同环境下不一样别照抄。之后检查设备节点是否生成ls -l /dev/xdu_char如果能看到设备文件就可以直接用命令测试读写echo hello xdu /dev/xdu_char cat /dev/xdu_char这里有个细节值得注意echo写入会带一个换行符所以read读回来的数据里会包含这个换行初学者经常误以为是驱动写错了。我的建议是用dd或者写一个很小的 C 测试程序用明确的字节数去验证避免终端行为干扰判断。一个简单的用户态测试程序如下#include stdio.h #include fcntl.h #include unistd.h #include string.h int main(void) { int fd; char buf[64] {0}; const char *msg XDU char driver test; fd open(/dev/xdu_char, O_RDWR); if (fd 0) { perror(open); return -1; } write(fd, msg, strlen(msg) 1); lseek(fd, 0, SEEK_SET); read(fd, buf, sizeof(buf)); printf(read from driver: %s\n, buf); close(fd); return 0; }最后卸载sudo rmmod my_char_dev dmesg | tail -5如果一切正常日志里会看到my_char_dev: exited。3.4 设备节点不会自动生成时的后备方案在部分精简根文件系统或容器环境下udev/mdev可能没有运行device_create生成的设备节点未必会显现在/dev下。实验现场经常因为这个卡住导致用户测试程序报“No such file or directory”。后备方案是手动创建cat /proc/devices | grep xdu_char sudo mknod /dev/xdu_char c 240 0这里的 240 换成实际看到的主设备号。手动创建的节点在系统重启后会丢失不过实验环境下完全没问题。搞清楚这个机制的底层关系之后你就明白为什么很多真实项目里的驱动加载脚本会附带一段mknod命令了——嵌入式产品上通常没有 udev设备节点的创建常常由 init 脚本维护。4. 常见问题与排查技巧实录4.1 编译阶段遇到 “No such file or directory” 的典型原因在编译内核模块时报错找不到linux/init.h或linux/module.h大概率是内核头文件没装好或者 Makefile 里的 KDIR 路径不对。先确认一下ls /lib/modules/$(uname -r)/build如果这个目录不存在说明内核头文件缺失。Ubuntu 下执行sudo apt-get install linux-headers-$(uname -r)如果是在交叉编译环境问题往往是 KDIR 指向的路径里没有 build 软链接。很多 SDK 里需要手动执行make scripts或先编译一次内核才会生成完整的头文件结构。遇到这类问题不要急着改代码先确认环境。另外还有一个常见的低级错误Makefile 里obj-m : my_char_dev.o写成了my_char_dev.c.o或者源文件名和模块名不一致kbuild 会直接找不到源文件。检查源文件是否存在文件名是否和 Makefile 匹配这是最基础的排错思路。4.2 加载时报错 “Operation not permitted”做好安全审核这个问题的常见原因有几个。如果系统开启了 Secure Boot未签名的内核模块是不允许加载的报错信息通常是module verification failed: signature and/or required key丢失解决思路有两个方向要么给模块签名要么在 BIOS 里关闭 Secure Boot。实验机上直接关掉最省事但要注意数据备份。还有一种可能是模块本身编译的架构和当前内核不一致也会导致加载被拒绝需要确认目标架构是否正确。4.3 设备节点生成了但 open 失败设备节点存在主次设备号也正确但用户态open(/dev/xdu_char, O_RDWR)返回失败。这时候优先查看内核日志dmesg | tail如果看到No such device or address说明cdev_add没有成功或者设备节点号和 cdev 注册的设备号不一致。用cat /proc/devices查看实际分配的主设备号对比/dev下的节点号。如果设备号一致但依然 open 失败检查cdev_add的返回值很可能在模块加载函数里这个步骤就报错了只是日志被刷过去了。4.4 读写数据异常字节数不对或内容带脏数据如果read返回的字节数比写入的多或者读出来的内容里多了一堆乱码八成是缓冲区没有清零。我在代码里用了kzalloc分配内存后自动清零。如果用kmalloc申请的内存里是随机值不主动初始化就会出现脏数据。这也是我在实验报告中特别标注了“内核内存必须显式初始化”的原因——这一条在后续项目中会反复出现。还有一个经典问题是lseek偏移量和write偏移量混用。比如用户程序先write了 20 字节再read20 字节期望读到的是刚才写入的内容却发现读出来全是旧数据或者空值。原因是write之后文件偏移量已经在 20 的位置了read会从偏移 20 开始读。用户态需要先lseek(fd, 0, SEEK_SET)把偏移量调整回来。这个现象和驱动本身没关系属于测试程序的设计问题但在实验里非常常见。4.5 使用 dmesg 和 printk 调试驱动的实战心得内核模块调试不像用户程序可以用 gdb 断点。在实验阶段最有效的调试手段就是printk。不同级别的日志输出到不同文件pr_infoKERN_INFO一般进/var/log/kern.log或者 dmesgpr_errKERN_ERR优先级更高。建议在 open、release、read、write 的入口和出口都加一行日志把关键参数打出来比如pr_info(my_read: count%zu, offset%lld\n, count, *offset);这样用户程序一跑你就能通过dmesg精确看到每次系统调用的参数流转过程排查问题效率极高。生产环境当然不会留这么多日志但实验阶段“日志打得越细定位越快”。5. 从实验一到真实项目的思维跃迁很多同学做完这个实验就丢到一边了非常可惜。字符设备驱动的意义绝对不只是“拿到学分”它背后带出了一整条学习脉络文件系统抽象、设备模型、内存管理、并发控制、内核模块机制。这些知识点对后续深入学习嵌入式驱动开发尤为重要。如果学有余力我建议在这个实验基础上做几个扩展收获会非常大加入llseek回调使设备支持任意位置读写同时理解*offset在驱动中的作用。加入ioctl命令实现类似于“获取设备版本号”“设置缓冲区大小”等控制功能这非常接近真实驱动里的操作方式。把这个设备改造成环形缓冲区模型理解生产和消费的同步问题。用wait_queue_head_t实现阻塞式读写再配合用户态select/poll这就直接联系到中断和事件驱动模型了。这些扩展点在嵌入式面试里出现的频率非常高。我当时在准备面试时把实验一的设备驱动框架跟 Linux 设备模型的知识串了一遍效果比单纯背八股文好得多。另外一个很值得养成的习惯是每次在开发板上操作设备时都随手记录设备号、设备树信息、内核日志的特征。这些琐碎的信息在排障时往往能让你快速锁定问题所在。很多嵌入式老手能一眼定位问题靠的就是长期积累的对“正常状态”的熟悉感——这也是一种宝贵的项目经验。最后分享一个小技巧如果你在实验报告里写“通过 alloc_chrdev_region 实现设备号动态分配并用 device_create 自动创建设备节点相较于手动 mknod 降低了人为配置错误的风险”这就能体现出对整个设备模型机制的理解而不仅仅是照着代码抄了一遍。这个细节对于课程设计和面试中的项目介绍都会是加分项。做完实验一你已经走通了“内核模块 — 字符设备 — 用户态访问”的完整闭环。后面无论遇到多复杂的驱动本质都是在这个骨架上生长出来的。把框架里的每个结构体、每个函数、每次资源分配的来龙去脉理清楚再难的驱动也拿得下来。本文还有配套的精品资源点击获取
返回列表