【Linux驱动开发】第5天:字符设备驱动核心原理:主次设备号+cdev+数据拷贝全解

目录

  1. 核心概念:主设备号+次设备号+cdev结构体
    1.1 主次设备号:用快递系统一秒懂
    1.2 cdev结构体:字符设备的"身份证"
    1.3 字符设备注册完整流程图
  2. 传统非设备树版:完整字符设备驱动模板
    2.1 完整源码(注释全覆盖)
    2.2 编译&测试步骤
  3. 核心API:copy_to_user/copy_from_user详解
    3.1 为什么绝对不能用memcpy?
    3.2 函数原型与使用场景
    3.3 必守的5条使用规则
    3.4 常见崩溃问题与修正方案
  4. 静态vs动态设备号:优缺点对比+现代推荐写法
    4.1 核心对比表
    4.2 两种写法代码示例
    4.3 现代驱动最佳实践
  5. 核心总结+面试必背考点

1. 核心概念:主设备号+次设备号+cdev结构体

1.1 主次设备号:用快递系统一秒懂

Linux系统中,所有设备都以文件 的形式存在于/dev/目录下,而设备号就是内核识别设备的唯一标识,分为主设备号和次设备号两部分。

大白话类比(快递系统)
设备号组成 快递系统对应 作用
主设备号 快递公司 标识驱动类型,同一类设备(所有串口、所有硬盘)共享同一个主设备号
次设备号 快递员编号 标识同类型下的不同具体设备,比如串口1、串口2,硬盘sda、sdb

举个例子:

  • /dev/ttyS0(串口1):主设备号4,次设备号0
  • /dev/ttyS1(串口2):主设备号4,次设备号1
  • 它们用同一个串口驱动(主设备号4),但次设备号区分不同的硬件端口
技术细节
  • 设备号用dev_t类型表示(32位无符号整数)

  • 高12位 = 主设备号,低20位 = 次设备号

  • 内核提供三个宏来操作设备号:

    c 复制代码
    #define MKDEV(major, minor)  ((major) << 20 | (minor))  // 合成设备号
    #define MAJOR(dev)          ((dev) >> 20)              // 提取主设备号
    #define MINOR(dev)          ((dev) & 0xfffff)          // 提取次设备号

1.2 cdev结构体:字符设备的"身份证"

struct cdev是Linux内核用来抽象字符设备的核心结构体,你可以把它理解为字符设备的"身份证",里面记录了这个设备的所有关键信息:

  • 设备号(dev_t dev
  • 设备的操作函数集(struct file_operations *ops
  • 所属的内核模块(struct module *owner
  • 引用计数等管理信息

简单说:内核通过cdev结构体,把"设备号"和"驱动的操作函数"关联起来 。当用户操作/dev/xxx设备文件时,内核会根据设备号找到对应的cdev,然后调用cdev里注册的file_operations函数。

1.3 字符设备注册完整流程图

模块初始化
申请设备号

alloc_chrdev_region
初始化cdev结构体

cdev_init
绑定file_operations操作集
向内核注册cdev

cdev_add
创建设备类class_create
生成/dev/设备文件device_create
驱动加载完成,对外提供服务


2. 传统非设备树版:完整字符设备驱动模板

这是工业界通用的传统字符设备驱动标准模板 ,完整实现了open/read/write/release四个核心接口,注释覆盖每一行关键代码,可直接作为你后续驱动开发的基础框架。

2.1 完整源码(注释全覆盖)

c 复制代码
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/slab.h>

// 模块信息
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux驱动学习");
MODULE_DESCRIPTION("传统非设备树版完整字符设备驱动");
MODULE_VERSION("1.0");

// 自定义配置
#define DEV_NAME    "my_full_chrdev"  // 设备文件名 /dev/my_full_chrdev
#define CLASS_NAME  "my_chr_class"    // 设备类名
#define BUF_SIZE    1024              // 内核缓冲区大小

// 全局变量
static dev_t dev_num;                // 设备号
static struct cdev chr_cdev;         // 字符设备结构体
static struct class *dev_class;      // 设备类
static struct device *dev_device;    // 设备
static char *kernel_buf;             // 内核数据缓冲区

// ===================== 核心文件操作接口 =====================
/**
 * @brief 设备打开函数,对应应用层open()
 * @param inode 内核inode结构体,包含设备号信息
 * @param file 文件结构体,代表打开的文件实例
 * @return 0成功,负数失败
 */
static int chr_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "[%s] 设备被打开\n", DEV_NAME);
    // 这里可以做:硬件初始化、缓冲区初始化、权限检查等
    return 0;
}

/**
 * @brief 设备读取函数,对应应用层read()
 * @param file 文件结构体
 * @param buf 用户态缓冲区指针(__user标记表示这是用户态地址)
 * @param len 用户请求读取的字节数
 * @param offset 文件偏移量
 * @return 成功返回读取的字节数,失败返回负数
 */
static ssize_t chr_read(struct file *file, char __user *buf, size_t len, loff_t *offset)
{
    int ret;
    size_t read_len = min(len, (size_t)BUF_SIZE);

    printk(KERN_INFO "[%s] 用户请求读取%zu字节\n", DEV_NAME, len);

    // 1. 校验偏移量
    if (*offset >= BUF_SIZE)
        return 0; // 已经读到文件末尾

    // 2. 调整实际读取长度
    if (*offset + read_len > BUF_SIZE)
        read_len = BUF_SIZE - *offset;

    // 3. 内核数据拷贝到用户态
    ret = copy_to_user(buf, kernel_buf + *offset, read_len);
    if (ret > 0) {
        printk(KERN_ERR "[%s] copy_to_user失败,未拷贝%d字节\n", DEV_NAME, ret);
        return -EFAULT;
    }

    // 4. 更新文件偏移量
    *offset += read_len;
    printk(KERN_INFO "[%s] 成功读取%zu字节\n", DEV_NAME, read_len);

    return read_len;
}

/**
 * @brief 设备写入函数,对应应用层write()
 * @param file 文件结构体
 * @param buf 用户态缓冲区指针
 * @param len 用户请求写入的字节数
 * @param offset 文件偏移量
 * @return 成功返回写入的字节数,失败返回负数
 */
static ssize_t chr_write(struct file *file, const char __user *buf, size_t len, loff_t *offset)
{
    int ret;
    size_t write_len = min(len, (size_t)BUF_SIZE);

    printk(KERN_INFO "[%s] 用户请求写入%zu字节\n", DEV_NAME, len);

    // 1. 校验偏移量
    if (*offset >= BUF_SIZE)
        return -ENOSPC; // 缓冲区已满

    // 2. 调整实际写入长度
    if (*offset + write_len > BUF_SIZE)
        write_len = BUF_SIZE - *offset;

    // 3. 用户态数据拷贝到内核态
    ret = copy_from_user(kernel_buf + *offset, buf, write_len);
    if (ret > 0) {
        printk(KERN_ERR "[%s] copy_from_user失败,未拷贝%d字节\n", DEV_NAME, ret);
        return -EFAULT;
    }

    // 4. 更新文件偏移量
    *offset += write_len;
    printk(KERN_INFO "[%s] 成功写入%zu字节,内核缓冲区内容:%s\n", DEV_NAME, write_len, kernel_buf);

    return write_len;
}

/**
 * @brief 设备关闭函数,对应应用层close()
 * @param inode 内核inode结构体
 * @param file 文件结构体
 * @return 0成功
 */
static int chr_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "[%s] 设备被关闭\n", DEV_NAME);
    // 这里可以做:资源释放、硬件复位等
    return 0;
}

// 文件操作集:绑定应用层系统调用和内核驱动函数
static struct file_operations chr_fops = {
    .owner   = THIS_MODULE,  // 必须设置为THIS_MODULE,防止模块被意外卸载
    .open    = chr_open,     // 对应open()
    .read    = chr_read,     // 对应read()
    .write   = chr_write,    // 对应write()
    .release = chr_release,  // 对应close()
};

// ===================== 模块入口与出口 =====================
static int __init chr_drv_init(void)
{
    int ret;

    printk(KERN_INFO "[%s] 驱动开始初始化\n", DEV_NAME);

    // 1. 动态申请设备号(现代驱动推荐写法)
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME);
    if (ret < 0) {
        printk(KERN_ERR "[%s] 动态申请设备号失败,ret=%d\n", DEV_NAME, ret);
        return ret;
    }
    printk(KERN_INFO "[%s] 申请设备号成功:主=%d,次=%d\n", 
           DEV_NAME, MAJOR(dev_num), MINOR(dev_num));

    // 2. 初始化cdev结构体,绑定文件操作集
    cdev_init(&chr_cdev, &chr_fops);
    chr_cdev.owner = THIS_MODULE;

    // 3. 向内核注册cdev字符设备
    ret = cdev_add(&chr_cdev, dev_num, 1);
    if (ret < 0) {
        printk(KERN_ERR "[%s] 注册cdev失败,ret=%d\n", DEV_NAME, ret);
        goto err_unregister_chrdev;
    }

    // 4. 创建设备类
    dev_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(dev_class)) {
        printk(KERN_ERR "[%s] 创建设备类失败\n", DEV_NAME);
        ret = PTR_ERR(dev_class);
        goto err_cdev_del;
    }

    // 5. 自动生成/dev/设备文件
    dev_device = device_create(dev_class, NULL, dev_num, NULL, DEV_NAME);
    if (IS_ERR(dev_device)) {
        printk(KERN_ERR "[%s] 创建设备文件失败\n", DEV_NAME);
        ret = PTR_ERR(dev_device);
        goto err_class_destroy;
    }

    // 6. 分配内核数据缓冲区
    kernel_buf = kzalloc(BUF_SIZE, GFP_KERNEL);
    if (!kernel_buf) {
        printk(KERN_ERR "[%s] 分配内核缓冲区失败\n", DEV_NAME);
        ret = -ENOMEM;
        goto err_device_destroy;
    }

    printk(KERN_INFO "[%s] 驱动初始化完成\n", DEV_NAME);
    return 0;

// 错误处理:反向释放已申请的资源(goto是内核错误处理的标准写法)
err_device_destroy:
    device_destroy(dev_class, dev_num);
err_class_destroy:
    class_destroy(dev_class);
err_cdev_del:
    cdev_del(&chr_cdev);
err_unregister_chrdev:
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

static void __exit chr_drv_exit(void)
{
    printk(KERN_INFO "[%s] 驱动开始卸载\n", DEV_NAME);

    // 反向释放所有资源
    kfree(kernel_buf);
    device_destroy(dev_class, dev_num);
    class_destroy(dev_class);
    cdev_del(&chr_cdev);
    unregister_chrdev_region(dev_num, 1);

    printk(KERN_INFO "[%s] 驱动卸载完成\n", DEV_NAME);
}

module_init(chr_drv_init);
module_exit(chr_drv_exit);

2.2 编译&测试步骤

1. Makefile
makefile 复制代码
obj-m += full_chr_dev.o
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) clean
2. 编译加载
bash 复制代码
make
sudo insmod full_chr_dev.ko
dmesg | tail  # 查看驱动初始化日志
ls /dev/my_full_chrdev  # 确认设备文件已生成
3. 测试读写功能
bash 复制代码
# 写入数据到设备
echo "Hello Linux Driver!" | sudo tee /dev/my_full_chrdev

# 从设备读取数据
cat /dev/my_full_chrdev

# 查看内核日志
dmesg | tail
4. 卸载驱动
bash 复制代码
sudo rmmod full_chr_dev
make clean

3. 核心API:copy_to_user/copy_from_user详解

这两个函数是用户态与内核态数据交互的唯一安全方式,也是驱动开发中最容易写错、最容易导致崩溃的地方。

3.1 为什么绝对不能用memcpy?

很多新手会问:都是内存拷贝,为什么不能直接用memcpy?核心原因是用户态与内核态内存完全隔离

  1. 地址空间不同:用户态地址是虚拟地址,每个进程独立;内核态地址是全局虚拟地址
  2. 权限检查:用户态地址可能无效、未映射、没有读写权限,直接访问会触发内核Oops崩溃
  3. 安全防护copy_to/from_user会做完整的地址合法性检查和权限校验,防止恶意攻击

一句话总结:memcpy是同一个地址空间内的内存拷贝;copy_to/from_user是两个隔离地址空间之间的安全拷贝。

3.2 函数原型与使用场景

1. copy_to_user
c 复制代码
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
  • 作用:将内核态数据拷贝到用户态缓冲区
  • 使用场景 :驱动的read()函数中,把硬件数据/内核数据返回给用户态应用
  • 参数
    • to:用户态缓冲区指针(必须加__user标记)
    • from:内核态缓冲区指针
    • n:要拷贝的字节数
  • 返回值 :成功返回0,失败返回未拷贝成功的字节数
2. copy_from_user
c 复制代码
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
  • 作用:将用户态数据拷贝到内核态缓冲区
  • 使用场景 :驱动的write()函数中,接收用户态应用发送的数据
  • 参数
    • to:内核态缓冲区指针
    • from:用户态缓冲区指针(必须加__user标记)
    • n:要拷贝的字节数
  • 返回值 :成功返回0,失败返回未拷贝成功的字节数

3.3 必守的5条使用规则

  1. 必须加__user标记:标记用户态地址,告诉编译器这是用户态指针,做额外的安全检查
  2. 必须检查返回值 :返回值>0表示有部分数据未拷贝成功,必须返回-EFAULT错误
  3. 必须校验缓冲区大小:防止用户态传入过大的长度,导致内核缓冲区溢出
  4. 不能在中断上下文使用:这两个函数可能睡眠,只能在进程上下文使用
  5. 不能拷贝内核地址到内核地址 :同一个地址空间用memcpy,不要用这两个函数

3.4 常见崩溃问题与修正方案

❌ 问题1:忘记加__user标记

错误写法

c 复制代码
static ssize_t chr_read(struct file *file, char *buf, size_t len, loff_t *offset)

正确写法

c 复制代码
static ssize_t chr_read(struct file *file, char __user *buf, size_t len, loff_t *offset)

后果:编译警告,运行时可能触发内核Oops崩溃。

❌ 问题2:不检查返回值

错误写法

c 复制代码
copy_to_user(buf, kernel_buf, len); // 不检查返回值
return len;

正确写法

c 复制代码
int ret = copy_to_user(buf, kernel_buf, len);
if (ret > 0) {
    printk(KERN_ERR "拷贝失败,未拷贝%d字节\n", ret);
    return -EFAULT;
}
return len - ret;

后果:用户态地址无效时,内核直接崩溃。

❌ 问题3:缓冲区越界

错误写法

c 复制代码
// 直接用用户传入的len,不检查内核缓冲区大小
copy_from_user(kernel_buf, buf, len);

正确写法

c 复制代码
size_t write_len = min(len, (size_t)BUF_SIZE);
copy_from_user(kernel_buf, buf, write_len);

后果:内核缓冲区溢出,覆盖其他内核数据,导致系统崩溃。

❌ 问题4:直接访问用户态指针

错误写法

c 复制代码
// 直接解引用用户态指针
char c = *buf;

正确写法

c 复制代码
char c;
if (copy_from_user(&c, buf, 1))
    return -EFAULT;

后果:用户态地址无效时,内核直接Oops崩溃。


4. 静态vs动态设备号:优缺点对比+现代推荐写法

4.1 核心对比表

对比项 静态设备号 动态设备号
分配方式 开发者手动指定主设备号 内核自动分配空闲的主设备号
核心API register_chrdev_region() alloc_chrdev_region()
优点 设备号固定,方便管理 不会冲突,自动管理,兼容性好
缺点 容易和其他驱动冲突,需要提前申请 设备号不固定,每次加载可能不同
适用场景 内核自带的标准驱动(如串口、硬盘) 所有第三方驱动、自定义驱动
现代推荐度 ⭐⭐ ⭐⭐⭐⭐⭐

4.2 两种写法代码示例

1. 静态设备号写法(不推荐)
c 复制代码
// 手动指定主设备号,注意不要和系统已有的冲突
#define STATIC_MAJOR 200
#define STATIC_MINOR 0

dev_num = MKDEV(STATIC_MAJOR, STATIC_MINOR);
ret = register_chrdev_region(dev_num, 1, DEV_NAME);
if (ret < 0) {
    printk(KERN_ERR "静态注册设备号失败\n");
    return ret;
}
2. 动态设备号写法(推荐)
c 复制代码
// 内核自动分配空闲的主设备号,次设备号从0开始
ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME);
if (ret < 0) {
    printk(KERN_ERR "动态申请设备号失败\n");
    return ret;
}
printk(KERN_INFO "分配到的设备号:主=%d,次=%d\n", MAJOR(dev_num), MINOR(dev_num));

4.3 现代驱动最佳实践

  1. 优先使用动态设备号:这是Linux内核社区推荐的标准写法,避免设备号冲突问题
  2. 不要硬编码主设备号:硬编码的主设备号在不同系统、不同内核版本上很容易冲突
  3. 使用udev自动管理设备文件 :通过class_createdevice_create自动生成/dev/设备文件,不需要手动mknod
  4. 静态设备号仅用于内核标准驱动:只有内核自带的、已经分配了固定主设备号的驱动才使用静态方式

5. 核心总结+面试必背考点

核心总结

  1. 主设备号 标识驱动类型,次设备号标识同类型下的不同具体设备
  2. cdev结构体是字符设备的抽象,把设备号和驱动的操作函数集关联起来
  3. 字符设备注册流程:申请设备号 → 初始化cdev → 注册cdev → 创建设备类 → 生成设备文件
  4. copy_to/from_user是用户态与内核态数据交互的唯一安全方式,必须严格遵守使用规则
  5. 现代驱动优先使用动态设备号,避免设备号冲突,兼容性更好

面试必背考点

  1. 主设备号和次设备号的作用是什么?如何合成和提取?
  2. cdev结构体的作用是什么?字符设备注册的完整流程是什么?
  3. 为什么不能用memcpy在用户态和内核态之间拷贝数据?
  4. copy_to_usercopy_from_user的返回值是什么含义?
  5. 静态设备号和动态设备号的优缺点是什么?现代驱动推荐用哪种?
  6. 驱动的错误处理为什么要用goto?有什么好处?
  7. file_operations结构体中.owner = THIS_MODULE的作用是什么?
相关推荐
玄尺5 小时前
jenkins安装和使用
运维·jenkins
剑神一笑5 小时前
Linux xargs 命令深度解析:从管道到命令构建的桥梁
linux·运维·chrome
Cisyam^5 小时前
Bright Data Web Scraper 实战:构建 TikTok 与 LinkedIn Web Scraping 自动化 Skill(2026)
运维·前端·自动化
程序员大辉5 小时前
Rufus中文版(U盘引导盘制作工具)v4.14.2377,PE U盘启动工具,重装系统必备的软件工具
运维·windows
量子炒饭大师6 小时前
【Linux系统编程】Cyberpunk在霓虹丛林中构建堡垒 ——【关于shell命令及其运行原理】
linux·运维·服务器·shell
夜猫子ing6 小时前
《嵌入式 Linux 控制服务从零搭建(一):项目立意与架构总览》
linux·嵌入式硬件
hjc_0420436 小时前
xtrabackup来备份恢复数据
运维·adb
IT大白鼠6 小时前
主流Linux文件系统稳定性及性能分析
linux·运维·服务器·文件系统
南境十里·墨染春水6 小时前
linux学习进展 I/O复用函数初步
linux·运维·学习