目录
[📝 字符设备驱动开发五步法](#📝 字符设备驱动开发五步法)
[1. 分配设备号 (Alloc Device Number)](#1. 分配设备号 (Alloc Device Number))
[2. 初始化 cdev 结构体 (Initialize cdev)](#2. 初始化 cdev 结构体 (Initialize cdev))
[3. 注册 cdev (Add cdev)](#3. 注册 cdev (Add cdev))
[4. 自动创建设备节点 (Create Device Node)](#4. 自动创建设备节点 (Create Device Node))
[5. 实现 file_operations (Implement Operations)](#5. 实现 file_operations (Implement Operations))
[🔄 模块卸载流程(逆向操作)](#🔄 模块卸载流程(逆向操作))
[📊 流程图解与代码骨架](#📊 流程图解与代码骨架)
[💡 核心提示](#💡 核心提示)
[二、file_operations 结构体常用接口](#二、file_operations 结构体常用接口)
[🛠️ 核心接口详解](#🛠️ 核心接口详解)
[💡 总结](#💡 总结)
[三、copy_from_user / copy_to_user 作用,为什么不能直接 memcpy](#三、copy_from_user / copy_to_user 作用,为什么不能直接 memcpy)
[❓ 为什么不能直接用 memcpy?](#❓ 为什么不能直接用 memcpy?)
[1. 地址有效性检查(防止"野指针")](#1. 地址有效性检查(防止“野指针”))
[2. 内存分页与交换(Swap)](#2. 内存分页与交换(Swap))
[3. 硬件架构限制(PAN 机制)](#3. 硬件架构限制(PAN 机制))
[📊 对比总结](#📊 对比总结)
[💡 最佳实践](#💡 最佳实践)
[四、ioctl 的作用与使用场景](#四、ioctl 的作用与使用场景)
[🛠️ 核心作用:一句话总结](#🛠️ 核心作用:一句话总结)
[📌 关键点速览](#📌 关键点速览)
[💡 总结](#💡 总结)
五、自动创建设备节点(class_create/device_create)
[🛠️ 核心流程与函数](#🛠️ 核心流程与函数)
[1. 创建类 (class_create)](#1. 创建类 (class_create))
[2. 创建设备 (device_create)](#2. 创建设备 (device_create))
[💻 代码实现示例](#💻 代码实现示例)
[六、udev/mdev 作用](#六、udev/mdev 作用)
[⚖️ udev 与 mdev 的区别](#⚖️ udev 与 mdev 的区别)
[⚙️ 工作流程对比](#⚙️ 工作流程对比)
[udev 流程(异步、守护进程)](#udev 流程(异步、守护进程))
[mdev 流程(同步、直接调用)](#mdev 流程(同步、直接调用))
一、字符设备驱动开发基本流程
总流程:分配设备号 → 初始化 cdev → 注册 cdev → 实现 file_operations
📝 字符设备驱动开发五步法
1. 分配设备号 (Alloc Device Number)
内核通过设备号来识别设备。你需要向内核申请一个或多个设备号。
- 动态申请(推荐) :使用
alloc_chrdev_region()。内核会自动分配一个未使用的主设备号,避免冲突。 - 静态申请 :使用
register_chrdev_region()。指定具体的主设备号,如果该号码已被占用则失败。 - 关键点 :得到一个
dev_t类型的变量,包含主设备号和次设备号。
2. 初始化 cdev 结构体 (Initialize cdev)
cdev 结构体是内核中描述字符设备的核心数据结构。
- 操作 :使用
cdev_init()函数。 - 关联 :在这一步,你需要将
cdev结构体与文件操作结构体file_operations关联起来。这样内核就知道当用户对该设备进行read或write时,该调用哪个函数。
3. 注册 cdev (Add cdev)
初始化完成后,设备还只是存在于内存变量中,并未被内核的字符设备管理层知晓。
- 操作 :使用
cdev_add()。 - 作用 :将这个
cdev对象添加到内核的哈希表中,并绑定之前申请的设备号。此时,驱动在内核层面已经"注册"成功。
4. 自动创建设备节点 (Create Device Node)
虽然驱动已经注册,但用户空间的 /dev 目录下还没有对应的文件。
- 传统方式 :手动使用
mknod命令创建(不推荐,重启后失效)。 - 现代方式(推荐) :
- 使用
class_create()创建一个设备类(出现在/sys/class/)。 - 使用
device_create()自动在/dev下创建设备文件。
- 使用
- 作用:这一步打通了用户空间访问驱动的"入口"。
5. 实现 file_operations (Implement Operations)
这是驱动的"灵魂",定义了应用程序如何与硬件交互。
- 核心结构体 :
struct file_operations。 - 关键函数指针 :
.open:打开设备时调用(初始化硬件、分配资源)。.release:关闭设备时调用(释放资源)。.read/.write:数据读写(需使用copy_from_user/copy_to_user进行用户空间与内核空间的数据交换)。.unlocked_ioctl:用于控制设备(如设置波特率、配置参数)。
🔄 模块卸载流程(逆向操作)
为了保证系统稳定,卸载驱动时必须严格按照相反顺序释放资源:
- 删除设备节点 :
device_destroy()。 - 销毁设备类 :
class_destroy()。 - 移除 cdev :
cdev_del()(从内核中注销设备)。 - 释放设备号 :
unregister_chrdev_region()(把号码还给系统)。
📊 流程图解与代码骨架
为了更直观地理解,我整理了一个基于现代内核(使用 cdev 接口)的代码骨架:
| 步骤 | 关键函数/宏 | 作用 |
|---|---|---|
| 1. 申请号码 | alloc_chrdev_region(&dev, 0, 1, "mydev") |
获取主/次设备号 |
| 2. 初始化 | cdev_init(&my_cdev, &my_fops) |
绑定操作集 |
| 3. 注册 | cdev_add(&my_cdev, dev, 1) |
告知内核 |
| 4. 创建节点 | class_create & device_create |
生成 /dev/mydev |
| 5. 业务逻辑 | copy_from_user / copy_to_user |
数据传输 |
代码示例片段:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static dev_t dev_num; // 设备号
static struct cdev my_cdev; // 字符设备结构体
static struct class *my_class; // 设备类
// 1. 实现 file_operations (步骤 5)
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
// 2. 模块入口 (步骤 1-4)
static int __init my_driver_init(void) {
// 步骤 1: 动态分配设备号
if (alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev") < 0) {
return -1;
}
// 步骤 2: 初始化 cdev
cdev_init(&my_cdev, &my_fops);
// 步骤 3: 注册 cdev
if (cdev_add(&my_cdev, dev_num, 1) < 0) {
unregister_chrdev_region(dev_num, 1); // 失败需回滚
return -1;
}
// 步骤 4: 自动创建设备节点
my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, dev_num, NULL, "my_char_dev");
return 0;
}
// 3. 模块出口 (逆向流程)
static void __exit my_driver_exit(void) {
device_destroy(my_class, dev_num); // 销毁节点
class_destroy(my_class); // 销毁类
cdev_del(&my_cdev); // 移除 cdev
unregister_chrdev_region(dev_num, 1); // 释放设备号
}
module_init(my_driver_init);
module_exit(my_driver_exit);
💡 核心提示
- 数据交换安全 :在
read和write函数中,严禁 直接访问用户空间的指针。必须使用copy_from_user()和copy_to_user()API,因为用户空间的内存可能会被换出到磁盘,直接访问会导致内核崩溃(Page Fault)。 - 并发控制:如果多个应用程序同时打开设备,你需要使用自旋锁(Spinlock)或互斥锁(Mutex)来保护共享资源(如全局缓冲区)。
二、file_operations 结构体常用接口
在 Linux 字符设备驱动开发中,struct file_operations 是连接用户空间应用程序 与内核驱动程序的桥梁。它本质上是一个函数指针的结构体集合,定义了应用程序对设备文件进行各种操作时,内核应该调用哪些具体的驱动函数。
🛠️ 核心接口详解
| 接口成员 | 对应系统调用 | 核心功能 | 关键注意点 |
|---|---|---|---|
| open | open() |
初始化设备,分配资源 | 处理并发打开,绑定私有数据 |
| release | close() |
释放资源,关闭设备 | 对应 open 的清理工作 |
| read | read() |
内核 → 用户 (数据读取) | 必须使用 copy_to_user |
| write | write() |
用户 → 内核 (数据写入) | 必须使用 copy_from_user |
| ioctl | ioctl() |
设备控制 (非数据流) | 现代内核推荐用 unlocked_ioctl |
| llseek | lseek() |
修改读写偏移量 | 不支持随机访问的设备需设为 no_llseek |
💡 总结
file_operations 结构体是驱动开发的"骨架"。
- open/release 负责生命周期管理;
- read/write 负责数据流传输(注意内核/用户空间拷贝);
- ioctl 负责控制流;
- llseek 负责位置控制(视设备特性而定)。
三、copy_from_user / copy_to_user 作用,为什么不能直接 memcpy
在 Linux 驱动开发中,copy_from_user() 和 copy_to_user() 是用于在内核空间 和用户空间之间安全传输数据的专用函数。
简单来说,它们的作用相当于**"带有安全检查的专用搬运工"** ,而 memcpy 只是一个**"盲目的搬运工"**。
❓ 为什么不能直接用 memcpy?
虽然 memcpy 在普通的 C 语言编程中非常好用,但在驱动开发中直接用它拷贝用户空间指针是绝对禁止的,原因主要有以下三点:
1. 地址有效性检查(防止"野指针")
- 用户空间不可信:用户程序传进来的指针可能是非法的(比如未分配的地址、空指针、或者已经释放的内存)。
memcpy的缺陷 :它只管拷贝,不检查地址是否合法。如果memcpy尝试访问一个无效的用户空间地址,CPU 会产生缺页异常(Page Fault) 。由于此时 CPU 处于内核态(Ring 0),内核无法像处理用户态错误那样优雅地捕获这个异常,结果通常是直接导致内核崩溃(Kernel Panic/Oops),系统死机。- 专用函数的优势 :
copy_from_user内部会调用access_ok()检查地址是否在用户空间的有效范围内。如果地址非法,它会直接返回错误(未拷贝的字节数),而不会让内核崩溃。
2. 内存分页与交换(Swap)
- 用户内存可能被换出:用户空间的内存页可能被操作系统暂时交换(Swap)到了硬盘上,此时物理内存中没有对应的数据。
memcpy的缺陷 :如果memcpy访问了被换出的页面,会触发缺页异常。虽然内核通常能处理缺页,但在某些原子上下文(如中断处理、自旋锁持有期间)中,处理缺页是非法的,会导致系统崩溃。- 专用函数的优势 :
copy_from_user具备处理缺页异常的机制(Exception Fixup)。如果数据不在物理内存中,它会触发内核的缺页处理流程把数据读回来,或者在无法处理时安全地返回错误,而不是让系统崩溃。
3. 硬件架构限制(PAN 机制)
- ARM64 等新架构的限制 :现代 CPU(如 ARMv8)引入了 PAN (Privileged Access Never) 功能。开启 PAN 后,CPU 在内核态运行时,硬件上禁止直接访问用户空间的地址。
- 结果 :如果在开启了 PAN 的平台上使用
memcpy访问用户指针,CPU 会直接抛出异常。而copy_from_user等函数内部包含了开启/关闭 PAN 的汇编指令,能够合法地跨越这个硬件限制。
📊 对比总结
| 特性 | memcpy |
copy_from_user/copy_to_user |
|---|---|---|
| 适用场景 | 内核空间内部 或 用户空间内部 | 跨越 内核空间与用户空间 |
| 地址检查 | 无(盲目拷贝) | 有(检查地址是否属于用户空间) |
| 缺页处理 | 无法处理(导致 Kernel Panic) | 能处理(捕获异常并返回错误码) |
| 硬件兼容 | 在开启 PAN 的架构上不可用 | 兼容(自动处理 PAN 开关) |
| 返回值 | 无(void*) | 返回未拷贝的字节数(0 表示成功) |
💡 最佳实践
在驱动程序的 read 和 write 实现中,永远遵循以下模式:
// 写操作示例
static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos)
{
char kernel_buffer[128];
int ret;
// 1. 限制拷贝长度,防止溢出
if (count > sizeof(kernel_buffer))
count = sizeof(kernel_buffer);
// 2. 使用 copy_from_user 安全拷贝
// 如果返回值非 0,说明拷贝失败(如地址非法)
if (copy_from_user(kernel_buffer, buf, count)) {
return -EFAULT; // 返回错误码
}
// 3. 处理数据...
printk("Received: %s\n", kernel_buffer);
return count;
}
四、ioctl 的作用与使用场景
🛠️ 核心作用:一句话总结
ioctl 是驱动开发的"万能遥控器",专门用于处理 read/write 无法完成的设备控制、参数配置和状态查询。
📌 关键点速览
使用场景(做什么?)
- 配置参数:设置波特率、音量、IP 地址、分辨率(不是传数据,是改模式)。
- 查询状态:获取设备能力、剩余空间、传感器校准值。
- 执行动作:复位设备、弹出光驱、触发 GPIO 脉冲(一次性操作)。
核心机制(怎么做?)
- 命令码(Request) :
- 用户通过 命令码 告诉驱动"做什么"。
- 必须使用宏定义来生成唯一码:
_IO(无数据)、_IOR(读)、_IOW(写)、_IOWR(读写)。
- 参数传递(Arg) :
- 第三个参数通常是指针,用于传递复杂结构体。
- 铁律 :内核驱动中严禁直接解引用 用户指针,必须用
copy_from_user或copy_to_user。
驱动实现(写哪里?)
- 函数指针 :在
file_operations中实现unlocked_ioctl(不要用老旧的ioctl)。 - 处理逻辑 :使用
switch(cmd)语句根据命令码分发处理逻辑。
优缺点
- 优点:通用性强,几乎所有驱动都支持,结构清晰。
- 缺点 :接口不统一(每个驱动的
cmd定义不同),用户层代码移植性差(不像read那样通用)。
💡 总结
ioctl 是 Linux 驱动中用于实现设备特定控制的通用接口。它将复杂的硬件控制逻辑抽象为一个个结构化的命令,使得用户程序能够以一种统一、安全的方式与各种各样的硬件设备进行深度交互,是"一切皆文件"哲学在复杂设备控制场景下的重要补充。
五、自动创建设备节点(class_create/device_create)
在 Linux 驱动开发中,仅注册字符设备(cdev_add)是不够的,用户空间无法直接访问。为了让系统自动生成 /dev 下的设备节点,必须利用设备模型 配合 udev 机制。
这主要通过两个核心函数实现:class_create 和 device_create。
🛠️ 核心流程与函数
实现自动创建节点的标准步骤如下:
1. 创建类 (class_create)
-
作用 :在
/sys/class/目录下创建一个类别文件夹。这是设备节点的"逻辑归属"。 -
函数原型 :
struct class *class_create(struct module *owner, const char *name); -
参数 :
owner:通常为THIS_MODULE。name:类名(例如 "my_dev"),会在/sys/class/下生成同名目录。
-
结果 :执行后,你可以在
/sys/class/my_dev/看到对应的目录。
2. 创建设备 (device_create)
-
作用 :在刚才创建的类下注册一个具体的设备实例。这会触发内核向用户空间发送
uevent事件,通知udev守护进程在/dev下创建实际的节点文件。 -
函数原型 :
struct device *device_create(struct class *class, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...); -
参数 :
class:指向刚才class_create返回的类指针。parent:父设备,通常填NULL。devt:设备号(由MKDEV生成)。drvdata:私有数据,通常填NULL。fmt:设备名称(例如 "my_led%d"),决定了/dev下生成的文件名。
💻 代码实现示例
#include <linux/device.h> // 必须包含此头文件
static struct class *my_class;
static struct device *my_device;
static dev_t dev_num; // 假设已经通过 alloc_chrdev_region 获取了设备号
// --- 在驱动入口函数中 ---
static int __init my_driver_init(void)
{
// 1. 分配设备号、初始化 cdev、cdev_add ... (省略)
// 2. 创建类
// 在 /sys/class/ 下创建 my_dev_class 目录
my_class = class_create(THIS_MODULE, "my_dev_class");
if (IS_ERR(my_class)) {
return PTR_ERR(my_class);
}
// 3. 创建设备
// 在 /sys/class/my_dev_class/ 下创建设备,并通知 udev 在 /dev 下生成 my_dev_node
my_device = device_create(my_class, NULL, dev_num, NULL, "my_dev_node");
if (IS_ERR(my_device)) {
class_destroy(my_class); // 失败需回滚
return PTR_ERR(my_device);
}
return 0;
}
// --- 在驱动出口函数中 (必须清理) ---
static void __exit my_driver_exit(void)
{
// 注意顺序:先销毁设备,再销毁类
device_destroy(my_class, dev_num); // 删除 /dev/my_dev_node
class_destroy(my_class); // 删除 /sys/class/my_dev_class
// cdev_del, unregister_chrdev_region ... (省略)
}
六、udev/mdev 作用
在 Linux 驱动开发中,udev 和 mdev 的核心作用非常明确:它们是连接内核驱动与用户空间的桥梁,负责自动管理 /dev 目录下的设备节点。
简单来说,当你的驱动程序通过 class_create 和 device_create 注册设备后,udev(或 mdev)负责在 /dev 下自动生成对应的设备文件 (如 /dev/my_led),并设置相应的权限。
没有它们,你就需要手动使用 mknod 命令来创建设备节点,且重启后失效。
⚖️ udev 与 mdev 的区别
虽然功能相似,但它们适用的场景完全不同。
| 特性 | udev | mdev |
|---|---|---|
| 全称 | Userspace /dev |
Minimal /dev |
| 定位 | 功能强大、灵活的用户空间设备管理器 | 轻量级、简化版的设备管理器 |
| 所属 | 通常属于 systemd 项目的一部分 |
集成在 BusyBox 中 |
| 工作机制 | 守护进程 (udevd):常驻后台,通过 Netlink 套接字异步监听内核事件。 |
触发式 :利用内核的 uevent_helper 机制,由内核直接调用执行。 |
| 配置复杂度 | 复杂,支持复杂的规则脚本(/etc/udev/rules.d/)。 |
简单,配置文件为 /etc/mdev.conf,功能有限。 |
| 适用场景 | PC、服务器、复杂的嵌入式 Linux(如树莓派桌面版)。 | 资源受限的嵌入式系统(如路由器、工控板)。 |
| 性能 | 占用内存较多,但处理复杂事件效率高。 | 占用内存极小,启动速度快。 |
⚙️ 工作流程对比
udev 流程(异步、守护进程)
- 驱动调用
device_create。 - 内核发送
uevent消息到 Netlink 套接字。 udevd守护进程捕获消息。udevd读取/etc/udev/rules.d/下的规则。udevd在/dev下创建节点,并执行自定义脚本(如自动挂载 U 盘)。
mdev 流程(同步、直接调用)
- 驱动调用
device_create。 - 内核发送
uevent。 - 内核直接执行
/sbin/mdev(通过/proc/sys/kernel/hotplug指定)。 mdev扫描/sys目录,读取设备信息。mdev根据/etc/mdev.conf创建节点。
注:文章随手记录,如有错误,评论区交流