Linux字符设备驱动开发基础:从设备号到ioctl的完整实战

1. Linux驱动层次

在嵌入式Linux开发中,驱动是连接硬件与用户空间的桥梁。理解驱动的层次结构,是学习字符设备驱动的基础。Linux驱动整体可分为三个层次:

  • 应用层:用户空间的应用程序,通过标准系统调用(如 open、read、write、ioctl)访问设备。
  • 内核层:包括虚拟文件系统(VFS)、字符设备驱动、块设备驱动、网络设备驱动等,负责具体硬件操作。
  • 硬件层:实际的物理设备,如 LED、按键、传感器、串口等。

应用层通过系统调用进入内核,VFS 根据设备类型将请求分发到对应的驱动接口,驱动最终操作硬件寄存器完成数据读写。字符设备驱动位于内核层,是嵌入式开发中最常见、最基础的驱动类型。

2. 设备号相关知识

2.1 设备号定义

在Linux中,每个设备在内核中都通过一个唯一的设备号来标识。设备号是一个 dev_t 类型的无符号32位整数,由两部分组成:主设备号和次设备号。

  • 主设备号(major):高12位,用于标识设备对应的驱动程序,即区分设备类型。
  • 次设备号(minor):低20位,用于标识同一个驱动程序管理下的不同设备实例。

例如,一个系统中有两个串口设备,它们共用同一个主设备号(对应同一个串口驱动),但次设备号不同(分别为0和1),从而区分不同的串口。

2.2 主设备号与次设备号

主设备号决定了设备由哪个驱动程序处理,次设备号则用于区分同一驱动下的多个设备。内核通过 dev_t 类型统一管理设备号,并提供一组宏来拆分和组合主次设备号。

设备号在内核中具有全局唯一性,申请设备号时需要避免与已有设备号冲突。常见的设备号分配方式有两种:静态分配(手动指定)和动态分配(由内核自动分配)。

2.3 设备号的数据结构与操作

设备号在内核中使用 dev_t 类型表示,定义在 <linux/types.h> 中。它是一个32位无符号整数,其中高12位为主设备号,低20位为次设备号。

c 复制代码
typedef __u32 __kernel_dev_t;
typedef __kernel_dev_t dev_t;

内核在 <linux/kdev_t.h> 提供了一组宏用于操作设备号,开发者无需关心具体的位运算细节。

  • MAJOR(dev):从 dev_t 中提取主设备号。
  • MINOR(dev):从 dev_t 中提取次设备号。
  • MKDEV(major, minor):由主设备号和次设备号组合生成 dev_t 设备号。
c 复制代码
#define MINORBITS 20
#define MINORMASK ((1U << MINORBITS) - 1)
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma, mi) (((ma) << MINORBITS) | (mi))

使用示例:

c 复制代码
dev_t devno = MKDEV(200, 0);   // 主设备号200,次设备号0
int major = MAJOR(devno);      // major = 200
int minor = MINOR(devno);      // minor = 0

3. 设备号相关函数

3.1 静态申请

静态申请是指开发者手动指定主设备号,调用 register_chrdev_region 函数向内核申请设备号。这种方式适用于已知设备号未被占用的情况,常用于教学或固定设备号的场景。

c 复制代码
int register_chrdev_region(dev_t first, unsigned int count, const char *name);
  • first:要申请的设备号起始值,通常使用 MKDEV(major, minor) 生成。
  • count:连续申请的设备号个数。
  • name:设备名称,显示在 /proc/devices 中。
  • 返回值:成功返回0,失败返回负的错误码。

示例:

c 复制代码
dev_t devno = MKDEV(200, 0);
int ret = register_chrdev_region(devno, 1, "my_char_dev");
if (ret < 0) {
    printk("register_chrdev_region failed\n");
    return ret;
}

3.2 动态申请

动态申请由内核自动分配一个可用的主设备号,开发者无需关心设备号是否冲突。使用 alloc_chrdev_region 函数,适用于大多数实际项目。

c 复制代码
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name);
  • dev:输出参数,内核分配的设备号。
  • firstminor:起始次设备号,通常为0。
  • count:申请的设备号个数。
  • name:设备名称。
  • 返回值:成功返回0,失败返回负的错误码。

示例:

c 复制代码
dev_t devno;
int ret = alloc_chrdev_region(&devno, 0, 1, "my_char_dev");
if (ret < 0) {
    printk("alloc_chrdev_region failed\n");
    return ret;
}
int major = MAJOR(devno);   // 获取自动分配的主设备号

3.3 设备号释放

设备卸载时,必须释放已申请的设备号,使用 unregister_chrdev_region 函数。

c 复制代码
void unregister_chrdev_region(dev_t first, unsigned int count);
  • first:要释放的设备号起始值,通常使用 MKDEV(major, minor) 生成,与申请时的起始设备号保持一致。
  • count:要释放的设备号个数,必须与申请时的个数一致。
  • 返回值:无返回值(void)。

示例:

c 复制代码
unregister_chrdev_region(devno, 1);

设备号释放通常在驱动卸载函数 exit 中调用,与申请函数成对出现,避免资源泄漏。

4. struct cdev 字符设备

在Linux内核中,字符设备使用 struct cdev 结构体表示,定义在 <linux/cdev.h> 中。它描述了字符设备的核心信息,包括设备号、操作函数集等。

c 复制代码
struct cdev {
    struct kobject kobj;
    struct module *owner;
    const struct file_operations *ops;
    struct list_head list;
    dev_t dev;
    unsigned int count;
};
  • kobj:内核对象,用于设备模型管理,一般由内核自动维护,开发者无需直接操作。
  • owner:指向所属模块,通常设置为 THIS_MODULE,用于模块引用计数管理。
  • ops:指向 struct file_operations,即设备操作函数集,用户空间的系统调用最终映射到这些函数。
  • list:链表节点,用于将 cdev 挂接到内核的设备链表中,由内核维护。
  • dev:设备号,类型为 dev_t,标识该字符设备在内核中的唯一编号。
  • count:设备号个数,表示该 cdev 管理的连续设备号数量,通常为1。

开发者通常将 struct cdev 嵌入到自定义的设备结构体中,便于管理设备私有数据。

5. 字符设备操作函数

内核提供了一组函数用于初始化和注册 struct cdev,下面逐一说明每个函数的原型、参数含义和返回值。

cdev_init ------ 初始化 cdev 结构体

c 复制代码
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
  • cdev:指向要初始化的 struct cdev 结构体指针。
  • fops:指向 struct file_operations 操作函数集的指针,将设备操作函数绑定到该 cdev。
  • 返回值:无返回值(void)。

cdev_init 用于初始化一个静态分配的 cdev 结构体,主要完成两件事:将 ops 字段指向传入的 fops,并将 owner 字段设置为 THIS_MODULE。初始化后仍需手动设置 owner 字段(或依赖默认值)。

cdev_add ------ 注册字符设备

c 复制代码
int cdev_add(struct cdev *cdev, dev_t dev, unsigned count);
  • cdev:指向已初始化(或动态分配)的 struct cdev 结构体指针。
  • dev:设备号,类型为 dev_t,通常由 MKDEV(major, minor) 生成或由 alloc_chrdev_region 动态分配。
  • count:该设备管理的连续设备号个数,通常为1。
  • 返回值:成功返回0,失败返回负的错误码。

cdev_add 将 cdev 注册到内核,并把设备号 dev 与 cdev 关联起来。调用成功后,用户空间即可通过设备节点访问该设备。若注册失败,需要释放已申请的设备号。

cdev_del ------ 注销字符设备

c 复制代码
void cdev_del(struct cdev *cdev);
  • cdev:指向要注销的 struct cdev 结构体指针。
  • 返回值:无返回值(void)。

cdev_del 从内核中移除该字符设备,通常在驱动卸载函数 exit 中调用。调用后设备节点将无法再访问,但设备号仍需通过 unregister_chrdev_region 单独释放。

cdev_alloc ------ 动态分配 cdev 结构体

c 复制代码
struct cdev *cdev_alloc(void);
  • 参数:无。
  • 返回值:成功返回指向新分配的 struct cdev 指针,失败返回 NULL。

cdev_alloc 在堆上动态分配一个 struct cdev 结构体并返回指针,适用于需要动态创建设备的场景。与 cdev_init 不同,cdev_alloc 不需要预先定义静态的 cdev 变量,而是由内核通过 kzalloc 在堆上分配内存,因此更适合在设备数量不确定或需要运行时动态创建多个字符设备的场景。

使用 cdev_alloc 时需要注意以下几点:

  • 分配成功后,仍需调用 cdev_init 或手动设置 ops 和 owner 字段,将操作函数集绑定到该 cdev。
  • 注册设备时调用 cdev_add,将设备号与动态分配的 cdev 关联。
  • 卸载设备时,先调用 cdev_del 注销设备,再调用 kfree 释放动态分配的内存,避免内存泄漏。

示例:

c 复制代码
struct cdev *p_cdev;

/* 动态分配 cdev 结构体 */
p_cdev = cdev_alloc();
if (p_cdev == NULL) {
    printk("cdev_alloc failed\n");
    return -ENOMEM;
}

/* 绑定操作函数集 */
p_cdev->ops = &my_fops;
p_cdev->owner = THIS_MODULE;

/* 注册字符设备 */
ret = cdev_add(p_cdev, devno, 1);
if (ret < 0) {
    printk("cdev_add failed\n");
    kfree(p_cdev);
    unregister_chrdev_region(devno, 1);
    return ret;
}

在模块卸载时,需要成对调用注销与释放函数:

c 复制代码
cdev_del(p_cdev);
kfree(p_cdev);
unregister_chrdev_region(devno, 1);

cdev_put ------ 释放 cdev 结构体引用

c 复制代码
void cdev_put(struct cdev *p);
  • p:指向要释放引用的 struct cdev 结构体指针。
  • 返回值:无返回值(void)。

cdev_put 用于释放对 struct cdev 的引用计数,通常在动态分配 cdev 且不再需要该设备时调用。与 cdev_del 不同,cdev_del 负责从内核中注销设备,而 cdev_put 负责释放引用计数,二者需要配合使用。

使用 cdev_put 时需要注意以下几点:

  • 只有通过 cdev_alloc 动态分配的 cdev 才需要调用 cdev_put 释放引用,静态分配的 cdev 无需调用。
  • 调用 cdev_put 后,不能再访问该 cdev 结构体,否则可能导致悬空指针问题。
  • 在模块卸载时,应先调用 cdev_del 注销设备,再调用 cdev_put 释放引用,最后调用 kfree 释放内存。

示例:

c 复制代码
/* 注销设备 */
cdev_del(p_cdev);
/* 释放 cdev 引用 */
cdev_put(p_cdev);
/* 释放动态分配的内存 */
kfree(p_cdev);
unregister_chrdev_region(devno, 1);

下面给出一个完整的初始化与注册示例:

c 复制代码
struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
int ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) {
    printk("cdev_add failed\n");
    unregister_chrdev_region(devno, 1);
    return ret;
}

在模块卸载时,需要成对调用注销函数:

c 复制代码
cdev_del(&my_cdev);
unregister_chrdev_region(devno, 1);

6. 字符设备驱动核心接口

struct file_operations 是字符设备驱动的核心接口,定义在 <linux/fs.h> 中。它是一组函数指针,用户空间的系统调用最终会映射到这些函数。

c 复制代码
struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
	ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
	int (*readdir) (struct file *, void *, filldir_t);
	unsigned int (*poll) (struct file *, struct poll_table_struct *);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, int datasync);
	int (*aio_fsync) (struct kiocb *, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*flock) (struct file *, int, struct file_lock *);
	ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
	ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
	int (*setlease)(struct file *, long, struct file_lock **);
};

常用成员说明:

  • open:打开设备时调用,通常用于初始化硬件或分配资源。
  • release:关闭设备时调用,用于释放资源。
  • read:从设备读取数据。
  • write:向设备写入数据。
  • unlocked_ioctl:设备控制命令处理函数。
  • llseek:文件读写位置偏移。

用户空间的 open、read、write、close、ioctl 系统调用,会通过 VFS 最终调用到 file_operations 中对应的函数。

7. 字符设备驱动实现

编写一个完整的字符设备驱动,通常遵循以下流程:

  1. 定义设备结构体,包含 struct cdev 和私有数据。
  2. 实现 file_operations 中的操作函数(open、read、write、release、ioctl 等)。
  3. 在模块入口函数中:申请设备号、初始化 cdev、注册设备。
  4. 在模块出口函数中:注销设备、删除 cdev、释放设备号。
  5. 编写 Makefile,编译并加载模块,创建设备节点。

创建设备节点是驱动加载后、用户空间访问设备前的必要步骤。常用的创建设备节点方式有两种:手动创建(mknod)和自动创建(借助设备模型)。

方式一:手动创建(mknod)

手动创建是最直接的方式,通过 mknod 命令在 /dev 目录下建立设备节点,需要指定设备类型、主设备号和次设备号。

bash 复制代码
sudo mknod /dev/my_char_dev c major 0
  • c:表示字符设备(character device)。
  • major:主设备号,可通过 cat /proc/devices | grep my_char_dev 查询。
  • 0:次设备号,通常从 0 开始。

手动创建方式简单直接,但需要开发者手动查询设备号并执行命令,且设备节点不会随驱动卸载而自动删除,需要手动清理。

方式二:自动创建(借助设备模型)

自动创建方式利用 Linux 设备模型,在驱动中通过 class_create 和 device_create 接口,让内核在驱动加载时自动在 /dev 目录下创建设备节点,驱动卸载时自动删除,无需手动干预。

class_create ------ 创建设备类

c 复制代码
struct class *class_create(struct module *owner, const char *name);
  • owner:指向所属模块,通常设置为 THIS_MODULE,用于模块引用计数管理。
  • name:设备类的名称,会在 /sys/class 目录下创建对应的类目录。
  • 返回值:成功返回指向 struct class 的指针,失败返回错误指针,需用 IS_ERR 判断。

class_create 用于在内核中创建一个设备类,该类是设备模型中的一个节点,用于组织和管理同类型的设备。创建成功后,会在 /sys/class 下生成一个以 name 命名的目录,后续通过 device_create 创建的设备节点都会挂载到该类下。

device_create ------ 创建设备节点

c 复制代码
struct device *device_create(struct class *cls, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...);
  • cls:指向 class_create 创建的设备类指针。
  • parent:父设备指针,通常设置为 NULL,表示无父设备。
  • devt:设备号,类型为 dev_t,由 MKDEV 生成或 alloc_chrdev_region 动态分配。
  • drvdata:设备私有数据指针,通常设置为 NULL。
  • fmt:设备节点名称的格式化字符串,支持可变参数,如 "my_char_dev" 或 "my_char_dev%d"。
  • 返回值:成功返回指向 struct device 的指针,失败返回错误指针,需用 IS_ERR 判断。

device_create 会在 /dev 目录下自动创建设备节点,并将该设备挂载到 cls 指定的类下。内核会根据 devt 中的主设备号和次设备号自动生成对应的设备节点,无需手动执行 mknod 命令。

使用 class_create 和 device_create 时需要注意以下几点:

  • 调用 class_create 后必须检查返回值,使用 IS_ERR 和 PTR_ERR 处理错误,避免空指针访问。
  • device_create 的 fmt 参数支持格式化字符串,可以动态生成设备节点名称,便于管理多个同类型设备。
  • 在模块卸载时,必须先调用 device_destroy 销毁设备节点,再调用 class_destroy 销毁设备类,顺序不能颠倒。
  • 设备节点名称不能与 /dev 下已有节点冲突,否则创建会失败。
c 复制代码
static struct class *my_class;

/* 在模块入口函数中 */
my_class = class_create(THIS_MODULE, "my_char_class");
if (IS_ERR(my_class)) {
    printk("class_create failed\n");
    return PTR_ERR(my_class);
}

device_create(my_class, NULL, devno, NULL, "my_char_dev");

/* 在模块出口函数中 */
device_destroy(my_class, devno);
class_destroy(my_class);

自动创建方式需要包含 <linux/device.h> 头文件,并在驱动中维护 class 和 device 的生命周期。这种方式更适合实际项目,设备节点随驱动加载自动出现、卸载自动消失,用户体验更好。

两种方式的对比总结如下:

  • 手动创建:操作简单,适合调试和教学场景;需要手动查询设备号,节点不会自动清理。
  • 自动创建 :驱动加载自动生成节点、卸载自动删除,适合实际项目;需要在驱动中额外实现 class_create 和 device_create 逻辑。

7.1 完整示例

下面给出一个完整的字符设备驱动示例,实现简单的数据读写功能。

c 复制代码
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_char_dev"
#define BUF_SIZE 128
static int major = 0;
static dev_t devno;
static struct cdev my_cdev;
static char kernel_buf[BUF_SIZE];
static int my_open(struct inode *inode, struct file *filp)
{
printk("my_open called\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
printk("my_release called\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
int ret;
size_t len = count < BUF_SIZE ? count : BUF_SIZE;
ret = copy_to_user(buf, kernel_buf, len);
if (ret < 0) {
printk("copy_to_user failed\n");
return ret;
}
return len;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *ppos)
{
int ret;
size_t len = count < BUF_SIZE ? count : BUF_SIZE;
ret = copy_from_user(kernel_buf, buf, len);
if (ret < 0) {
printk("copy_from_user failed\n");
return ret;
}
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init my_init(void)
{
int ret;
/* 1. 动态申请设备号 */
ret = alloc_chrdev_region(devno, 0, 1, DEVICE_NAME);
if (ret < 0) {
    printk("alloc_chrdev_region failed\n");
    return ret;
}
major = MAJOR(devno);
printk("alloc device number: major=%d minor=%d\n", major, MINOR(devno));

/* 2. 初始化 cdev 并注册 */
cdev_init(my_cdev, my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(my_cdev, devno, 1);
if (ret < 0) {
    printk("cdev_add failed\n");
    unregister_chrdev_region(devno, 1);
    return ret;
}

printk("my_char_dev driver init success\n");
return 0;
}
static void __exit my_exit(void)
{
/* 1. 注销设备 /
cdev_del(&my_cdev);
/ 2. 释放设备号 */
unregister_chrdev_region(devno, 1);
printk("my_char_dev driver exit\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");

7.2 Makefile 示例

makefile 复制代码
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

7.3 加载与测试

bash 复制代码
# 编译模块
make
加载模块
sudo insmod my_char_dev.ko
查看设备号
cat /proc/devices | grep my_char_dev
创建设备节点(major 替换为实际主设备号)
sudo mknod /dev/my_char_dev c major 0
测试读写
echo "hello" > /dev/my_char_dev
cat /dev/my_char_dev
卸载模块
sudo rmmod my_char_dev

8. 元数据结构体说明

8.1 struct inode

struct inode 是内核中描述文件元数据的结构体,定义在 <linux/fs.h> 中。每个文件(包括设备文件)在内核中都有一个对应的 inode,用于保存文件的属性信息,如文件类型、权限、大小、时间戳等。

对于设备文件,inode 中保存了设备号信息,通过 i_rdev 字段可以获取设备号。当用户空间打开设备节点时,VFS 会找到对应的 inode,并从中取出设备号,进而定位到对应的字符设备驱动。

struct inode 中与设备驱动开发密切相关的常用字段如下:

  • i_rdev:设备号,类型为 dev_t,用于标识该文件对应的设备。
  • i_cdev:指向 struct cdev 的指针,用于字符设备驱动中建立 inode 与 cdev 的关联。
  • i_mode:文件类型与权限位,用于区分普通文件、目录、设备文件等。
  • i_uid / i_gid:文件所有者的用户 ID 和组 ID。
  • i_size:文件大小,对于设备文件通常为 0。
  • i_atime / i_mtime / i_ctime:访问时间、修改时间和状态变更时间。

在字符设备驱动的 open 函数中,通常通过 container_of 宏从 inode->i_cdev 获取自定义设备结构体,从而访问设备私有数据。示例代码如下:

c 复制代码
static int my_open(struct inode *inode, struct file *filp)
{
    struct my_dev *dev;

    /* 通过 inode 获取 cdev,再通过 container_of 获取自定义设备结构体 */
    dev = container_of(inode->i_cdev, struct my_dev, cdev);
    filp->private_data = dev;
    return 0;
}

8.2 struct file

struct file 是内核中描述一个已打开文件实例的结构体,定义在 <linux/fs.h> 中。每当用户空间调用 open 打开一个文件(或设备节点)时,内核就会创建一个新的 struct file 实例,用于保存该次打开的状态信息。同一个文件可以被多次打开,每次打开都会对应一个独立的 struct file,但它们共享同一个 inode。

struct file 中与设备驱动开发密切相关的常用字段如下:

  • f_mode:文件打开模式,包含读、写、追加等标志,用于判断设备是否以只读或只写方式打开。
  • f_flags:文件状态标志,如 O_NONBLOCK、O_APPEND 等,驱动可通过该字段判断是否支持非阻塞操作。
  • f_pos:当前读写位置偏移量,类型为 loff_t,用于记录文件读写的当前位置。
  • f_op:指向 struct file_operations 的指针,即该文件对应的操作函数集。
  • private_data:私有数据指针,驱动可在 open 函数中将其指向自定义设备结构体,供后续 read、write 等函数使用。
  • f_count:引用计数,表示该文件实例被引用的次数,内核通过它管理文件对象的生命周期。

在驱动开发中,private_data 是最常用的字段之一。通常在 open 函数中将自定义设备结构体赋值给 filp->private_data,在 read、write、release 等函数中再取出使用,从而避免使用全局变量,提高代码的可维护性。示例代码如下:

c 复制代码
static int my_open(struct inode *inode, struct file *filp)
{
    struct my_dev *dev;

    dev = container_of(inode->i_cdev, struct my_dev, cdev);
    filp->private_data = dev;   /* 保存设备私有数据 */
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *ppos)
{
    struct my_dev *dev = filp->private_data;   /* 取出设备私有数据 */
    /* 使用 dev 进行数据读取操作 */
    return 0;
}

8.3 联系与区别

struct inode 与 struct file 是内核文件系统中两个既相互关联又职责不同的核心结构体,理解它们的区别对驱动开发至关重要。

  • 生命周期不同 :inode 在文件创建时产生,在文件被删除时销毁,生命周期与文件本身一致;file 在每次 open 时创建,在 close 时销毁,生命周期与一次打开操作一致。
  • 对应关系不同 :一个 inode 可以对应多个 file(同一文件被多次打开);一个 file 只能对应一个 inode。
  • 保存内容不同 :inode 保存文件的静态属性(类型、权限、设备号等);file 保存一次打开操作的动态状态(读写位置、打开标志、私有数据等)。
  • 驱动中的角色不同 :inode 主要用于在 open 时获取设备号和 cdev;file 则贯穿 read、write、release 等整个操作过程,用于传递设备私有数据。

在字符设备驱动中,open 函数同时接收 inode 和 file 两个参数,正是为了完成从 inode 获取设备信息、再通过 file 保存设备私有数据的衔接过程。理解这两个结构体的分工,是编写高质量字符设备驱动的基础。

9. 用户/内核空间数据交换

在字符设备驱动中,用户空间的应用程序与内核空间的驱动之间经常需要交换数据。由于用户空间和内核空间使用不同的地址空间,内核不能直接访问用户空间的指针,必须通过内核提供的专用函数来完成数据的拷贝。常用的数据交换函数有四个:copy_to_user、copy_from_user、get_user 和 put_user。

copy_to_user ------ 从内核空间拷贝数据到用户空间

c 复制代码
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
  • to:目标地址,即用户空间的缓冲区指针,类型为 void __user *。
  • from:源地址,即内核空间的缓冲区指针,类型为 const void *。
  • n:要拷贝的字节数。
  • 返回值:成功返回0;失败返回未拷贝完成的字节数,即剩余未拷贝的字节数。

copy_to_user 用于将内核空间的数据拷贝到用户空间,通常在驱动的 read 函数中调用,把设备读取到的数据返回给用户程序。该函数内部会检查用户空间地址的合法性,并在拷贝前进行必要的安全检查,避免内核因非法地址而崩溃。

示例:

c 复制代码
static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *ppos)
{
    int ret;
    size_t len = count < BUF_SIZE ? count : BUF_SIZE;

    /* 将内核缓冲区 kernel_buf 中的数据拷贝到用户空间 buf */
    ret = copy_to_user(buf, kernel_buf, len);
    if (ret != 0) {
        printk("copy_to_user failed, %d bytes not copied\n", ret);
        return -EFAULT;
    }
    return len;
}

copy_from_user ------ 从用户空间拷贝数据到内核空间

c 复制代码
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
  • to:目标地址,即内核空间的缓冲区指针,类型为 void *。
  • from:源地址,即用户空间的缓冲区指针,类型为 const void __user *。
  • n:要拷贝的字节数。
  • 返回值:成功返回0;失败返回未拷贝完成的字节数。

copy_from_user 用于将用户空间的数据拷贝到内核空间,通常在驱动的 write 函数中调用,把用户程序写入的数据保存到内核缓冲区。与 copy_to_user 一样,该函数也会对用户空间地址进行合法性检查。

示例:

c 复制代码
static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *ppos)
{
    int ret;
    size_t len = count < BUF_SIZE ? count : BUF_SIZE;

    /* 将用户空间 buf 中的数据拷贝到内核缓冲区 kernel_buf */
    ret = copy_from_user(kernel_buf, buf, len);
    if (ret != 0) {
        printk("copy_from_user failed, %d bytes not copied\n", ret);
        return -EFAULT;
    }
    return len;
}

get_user ------ 从用户空间读取单个数据

c 复制代码
int get_user(type *val, const void __user *addr);
  • val:指向内核空间变量的指针,用于保存从用户空间读取到的数据。
  • addr:用户空间的源地址指针。
  • 返回值:成功返回0;失败返回负的错误码(如 -EFAULT)。

get_user 用于从用户空间读取一个简单的数据类型(如 int、char、long 等),适用于读取单个变量或少量数据的场景。与 copy_from_user 相比,get_user 更轻量,适合读取固定大小的标量数据,常用于 ioctl 命令中读取用户传入的参数。

示例:

c 复制代码
int value;
int ret;

/* 从用户空间读取一个 int 类型的数据 */
ret = get_user(value, (int __user *)arg);
if (ret != 0) {
    printk("get_user failed\n");
    return ret;
}
printk("read value from user: %d\n", value);

put_user ------ 向用户空间写入单个数据

c 复制代码
int put_user(type val, void __user *addr);
  • val:要写入用户空间的数据值,类型为标量类型(如 int、char、long 等)。
  • addr:用户空间的目标地址指针。
  • 返回值:成功返回0;失败返回负的错误码(如 -EFAULT)。

put_user 用于向用户空间写入一个简单的数据类型,适用于返回单个变量或少量数据的场景。与 copy_to_user 相比,put_user 更轻量,适合返回固定大小的标量数据,常用于 ioctl 命令中向用户返回结果。

示例:

c 复制代码
int result = 100;
int ret;

/* 将内核中的 result 值写入用户空间 */
ret = put_user(result, (int __user *)arg);
if (ret != 0) {
    printk("put_user failed\n");
    return ret;
}

四个函数的对比总结如下:

  • copy_to_user :批量拷贝,从内核到用户,适合传输大量数据,用于 read 函数。
  • copy_from_user :批量拷贝,从用户到内核,适合传输大量数据,用于 write 函数。
  • get_user :单值读取,从用户到内核,适合读取单个标量,常用于 ioctl。
  • put_user :单值写入,从内核到用户,适合返回单个标量,常用于 ioctl。

在使用这四个函数时,需要注意以下几点:

  • 所有涉及用户空间地址的指针都必须使用 __user 标记,便于内核工具检查地址空间的正确性。
  • 拷贝前应检查用户传入的缓冲区大小,避免越界访问导致内核崩溃。
  • 批量拷贝函数返回的是未拷贝的字节数,判断失败时应检查返回值是否为0,而不是判断返回值是否小于0。
  • 单值读写函数返回的是错误码,判断失败时应检查返回值是否为0。
  • 在 ioctl 中传递结构体等复杂数据时,应使用 copy_to_user 和 copy_from_user,而不是 get_user 和 put_user。

10. 总结

本文系统梳理了Linux平台下Linux 字符设备驱动开发的核心知识,从驱动层次、设备号管理、struct cdev 结构体,到 file_operations 接口、驱动实现流程、元数据结构体以及用户/内核空间数据交换,形成了完整的知识闭环。

回顾全文,需要重点掌握以下几个关键点:

  • 驱动层次:字符设备驱动位于内核层,通过 VFS 承接用户空间系统调用,最终操作硬件寄存器。
  • 设备号管理 :dev_t 由主设备号(高12位)和次设备号(低20位)组成;静态申请用 register_chrdev_region,动态申请用 alloc_chrdev_region,卸载时必须用 unregister_chrdev_region 成对释放。
  • cdev 生命周期 :cdev_init 初始化、cdev_add 注册、cdev_del 注销;动态分配的 cdev 还需配合 cdev_put 和 kfree 释放资源。
  • 核心接口 :struct file_operations 是驱动与用户空间交互的桥梁,open、read、write、release、unlocked_ioctl 是最常用的成员。
  • 设备节点 :手动 mknod 适合调试,借助 class_create 和 device_create 自动创建更适合实际项目。
  • 数据交换 :批量数据用 copy_to_user / copy_from_user,单值数据用 get_user / put_user,务必检查返回值并正确使用 __user 标记。

在实际开发中,建议优先采用动态申请设备号 + 自动创建设备节点的方式,并严格遵循「申请与释放成对、注册与注销成对」的原则,避免资源泄漏。理解 inode 与 file 的分工,以及 private_data 的传递机制,是写出结构清晰、可维护性强的字符设备驱动的关键。掌握以上内容后,即可在此基础上进一步学习阻塞与非阻塞 IO、中断处理、并发与同步等进阶主题。

相关推荐
碧海银沙音频科技研究院2 小时前
基于炬芯ATS3089X的usb U盘/usb audio声卡/usb cdc虚拟串口/录音dump到本地功能实现
stm32·单片机·嵌入式硬件
@松2 小时前
学习stm32cudemx +vs 创建hex文件,刷机
stm32
CC180253944862 小时前
具有强大的图像和语音处理能力的高性能 MCU芯片ESP32-P4NRW32X
单片机·嵌入式硬件
小范的技术工坊3 小时前
Linux应用开发(文件IO)
linux·stm32·单片机
科芯创展5 小时前
XZ1824 宽电压150V 10A开关降压DC-DC控制器技术解析
单片机
恒锐丰科技林技术员5 小时前
EG1416 低侧栅极驱动芯片:小封装下的功率驱动利器
经验分享·嵌入式硬件·硬件工程
Zwawa6 小时前
让电机走到指定位置:编码器位置环 PID
c语言·单片机·嵌入式
桀人6 小时前
I2C概念引入
stm32·单片机·嵌入式硬件
神一样的老师7 小时前
RA4M2 + AHT20 温湿度传感器实战
单片机·嵌入式硬件