嵌软面试每日一阅----Linux驱动之字符设备驱动

目录

一、字符设备驱动开发基本流程

[📝 字符设备驱动开发五步法](#📝 字符设备驱动开发五步法)

[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 关联起来。这样内核就知道当用户对该设备进行 readwrite 时,该调用哪个函数。
3. 注册 cdev (Add cdev)

初始化完成后,设备还只是存在于内存变量中,并未被内核的字符设备管理层知晓。

  • 操作 :使用 cdev_add()
  • 作用 :将这个 cdev 对象添加到内核的哈希表中,并绑定之前申请的设备号。此时,驱动在内核层面已经"注册"成功。
4. 自动创建设备节点 (Create Device Node)

虽然驱动已经注册,但用户空间的 /dev 目录下还没有对应的文件。

  • 传统方式 :手动使用 mknod 命令创建(不推荐,重启后失效)。
  • 现代方式(推荐)
    1. 使用 class_create() 创建一个设备类(出现在 /sys/class/)。
    2. 使用 device_create() 自动在 /dev 下创建设备文件。
  • 作用:这一步打通了用户空间访问驱动的"入口"。
5. 实现 file_operations (Implement Operations)

这是驱动的"灵魂",定义了应用程序如何与硬件交互。

  • 核心结构体struct file_operations
  • 关键函数指针
    • .open:打开设备时调用(初始化硬件、分配资源)。
    • .release:关闭设备时调用(释放资源)。
    • .read / .write:数据读写(需使用 copy_from_user / copy_to_user 进行用户空间与内核空间的数据交换)。
    • .unlocked_ioctl:用于控制设备(如设置波特率、配置参数)。

🔄 模块卸载流程(逆向操作)

为了保证系统稳定,卸载驱动时必须严格按照相反顺序释放资源:

  1. 删除设备节点device_destroy()
  2. 销毁设备类class_destroy()
  3. 移除 cdevcdev_del()(从内核中注销设备)。
  4. 释放设备号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);

💡 核心提示

  • 数据交换安全 :在 readwrite 函数中,严禁 直接访问用户空间的指针。必须使用 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 表示成功)

💡 最佳实践

在驱动程序的 readwrite 实现中,永远遵循以下模式:

复制代码
// 写操作示例
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_usercopy_to_user
驱动实现(写哪里?)
  • 函数指针 :在 file_operations 中实现 unlocked_ioctl (不要用老旧的 ioctl)。
  • 处理逻辑 :使用 switch(cmd) 语句根据命令码分发处理逻辑。
优缺点
  • 优点:通用性强,几乎所有驱动都支持,结构清晰。
  • 缺点 :接口不统一(每个驱动的 cmd 定义不同),用户层代码移植性差(不像 read 那样通用)。

💡 总结

ioctl 是 Linux 驱动中用于实现设备特定控制的通用接口。它将复杂的硬件控制逻辑抽象为一个个结构化的命令,使得用户程序能够以一种统一、安全的方式与各种各样的硬件设备进行深度交互,是"一切皆文件"哲学在复杂设备控制场景下的重要补充。

五、自动创建设备节点(class_create/device_create

在 Linux 驱动开发中,仅注册字符设备(cdev_add)是不够的,用户空间无法直接访问。为了让系统自动生成 /dev 下的设备节点,必须利用设备模型 配合 udev 机制。

这主要通过两个核心函数实现:class_createdevice_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 驱动开发中,udevmdev 的核心作用非常明确:它们是连接内核驱动与用户空间的桥梁,负责自动管理 /dev 目录下的设备节点。

简单来说,当你的驱动程序通过 class_createdevice_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 流程(异步、守护进程)
  1. 驱动调用 device_create
  2. 内核发送 uevent 消息到 Netlink 套接字。
  3. udevd 守护进程捕获消息。
  4. udevd 读取 /etc/udev/rules.d/ 下的规则。
  5. udevd/dev 下创建节点,并执行自定义脚本(如自动挂载 U 盘)。
mdev 流程(同步、直接调用)
  1. 驱动调用 device_create
  2. 内核发送 uevent
  3. 内核直接执行 /sbin/mdev(通过 /proc/sys/kernel/hotplug 指定)。
  4. mdev 扫描 /sys 目录,读取设备信息。
  5. mdev 根据 /etc/mdev.conf 创建节点。

注:文章随手记录,如有错误,评论区交流

相关推荐
东离与糖宝2 小时前
HashMap从入门到源码:Java7/8/21区别+面试陷阱+高频追问合集
java·人工智能·面试
赵民勇2 小时前
Linux桌面/usr/share/menu目录详解
linux
charlie1145141912 小时前
嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(5):调试进阶篇 —— 从 printf 到完整 GDB 调试环境
linux·c++·单片机·学习·嵌入式·c
一根狗尾巴草2 小时前
【Linux】linux软链接硬链接区别
linux·运维·服务器
wang09072 小时前
Linux性能优化之CPU利用率
java·linux·运维
wen__xvn2 小时前
力扣洛谷模拟题刷题2
算法·leetcode·职场和发展
梦年华12 小时前
Dell 避风港实验环境部署(四)CyberRecovery配置与恢复演练
linux·运维·centos
大卡片2 小时前
环境变量配置
linux
酉鬼女又兒3 小时前
零基础快速入门前端ES6 核心特性详解与蓝桥杯 Web 考点实践(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·职场和发展·蓝桥杯·es6·css3·html5