3. 字符设备驱动基础(一):字符设备的概念与主次设备号、设备号分配(静态与动态)、cdev结构体与file_operations接口、实现简单的open/read/write/close

3.1 字符设备的概念与本质

在 Linux 内核中,设备驱动被划分为三大类:字符设备、块设备和网络设备。字符设备是其中最基础、最常用的一类,也是嵌入式驱动开发入门的首选。

字符设备(Character Device) 是指以字节流(Byte Stream)形式进行数据传输的设备。应用程序对字符设备的访问,通常通过 open()、read()、write()、close() 等标准系统调用完成,数据按顺序、无缓冲地逐字节或逐块传输。

典型字符设备举例: 串口(UART)、LED 灯、按键、GPIO、I2C/SPI 从设备、温度传感器、蜂鸣器、LCD 屏(帧缓冲设备除外)。几乎所有“非块、非网络”的设备都可以归为字符设备。
核心特征: 字符设备不经过内核的页缓存(Page Cache),数据直接在内核空间与用户空间之间拷贝。这使得字符设备驱动天然适合实时性要求高、数据量小或控制类场景。
字符设备在 Linux 系统中的位置与数据流 用户空间(User Space) 应用程序(App)调用 open / read / write / close 系统调用层(VFS) 内核空间(Kernel Space) 字符设备驱动:cdev + file_operations 回调 硬件设备(UART / GPIO / I2C) 数据流:App → VFS → 字符设备驱动 → 硬件

3.2 主设备号与次设备号

Linux 内核通过 设备号(Device Number) 来唯一标识一个设备。设备号是一个 dev_t 类型的 32 位无符号整数,内部划分为两部分:

组成部分 位宽 作用 示例
主设备号(Major Number) 高 12 位 标识设备对应的驱动程序。内核通过主设备号找到对应的 cdev 结构体。 89(I2C 设备)
次设备号(Minor Number) 低 20 位 标识由同一驱动管理的多个物理或逻辑设备实例。 0、1、2
注意: 主设备号 0 和 255 为系统保留。主设备号 1 ~ 255 中,部分已被内核预定义(如 1 为内存设备,4 为 tty 设备)。自定义驱动应避免与已有主设备号冲突。

内核提供了以下宏来操作 dev_t:

#include <linux/types.h>

dev_t dev = MKDEV(major, minor);   // 由主次设备号合成 dev_t
int major = MAJOR(dev);            // 从 dev_t 提取主设备号
int minor = MINOR(dev);            // 从 dev_t 提取次设备号

3.3 设备号分配:静态分配 vs 动态分配

在注册字符设备之前,驱动必须获得一个可用的设备号。Linux 内核提供两种分配策略:

分配方式 函数 优点 缺点 适用场景
静态分配 register_chrdev_region() 设备号固定,便于用户空间创建设备节点。 需要手动查询 /proc/devices 确保不冲突;维护成本高。 驱动已广泛部署,设备号已标准化。
动态分配 alloc_chrdev_region() 自动分配未使用的主设备号,避免冲突。 设备号不固定,需通过 /proc/devices 或 udev 动态创建设备节点。 绝大多数嵌入式驱动开发的首选。

3.3.1 静态分配示例

#include <linux/fs.h>

#define MY_MAJOR  200
#define MY_MINOR  0
#define MY_COUNT  1

dev_t dev;
int ret;

dev = MKDEV(MY_MAJOR, MY_MINOR);
ret = register_chrdev_region(dev, MY_COUNT, "my_device");
if (ret < 0) {
    pr_err("Failed to register chrdev region\n");
    return ret;
}

3.3.2 动态分配示例

#include <linux/fs.h>

dev_t dev;
int ret;

ret = alloc_chrdev_region(&dev, 0, 1, "my_device");
if (ret < 0) {
    pr_err("Failed to alloc chrdev region\n");
    return ret;
}
pr_info("Allocated major=%d minor=%d\n", MAJOR(dev), MINOR(dev));
推荐做法: 在模块初始化时使用 alloc_chrdev_region() 动态分配设备号,并在 /proc/devices 中查看分配结果。用户空间可通过 mknod 或 udev 自动创建设备节点。
设备号分配流程:静态 vs 动态 静态分配 register_chrdev_region() 手动指定主设备号(如 200) 需检查 /proc/devices 避免冲突 设备节点固定:mknod /dev/mydev c 200 0 动态分配 alloc_chrdev_region() 内核自动分配未使用的主设备号 通过 /proc/devices 查看分配结果 设备节点动态:udev / mknod 按需创建 对比 推荐:动态分配(alloc_chrdev_region)是嵌入式驱动开发的标准做法

3.4 cdev 结构体与 file_operations 接口

在 Linux 内核中,每个字符设备都由一个 struct cdev 结构体表示。驱动开发者需要完成以下核心工作:

  1. 定义并初始化 cdev 结构体
  2. 填充 file_operations 结构体,实现 open、read、write、release 等回调函数
  3. 将 cdev 注册到内核,使其与设备号关联

3.4.1 cdev 结构体

#include <linux/cdev.h>

struct cdev {
    struct kobject kobj;          // 内核对象基类
    struct module *owner;         // 所属模块(通常设为 THIS_MODULE)
    const struct file_operations *ops;  // 操作函数集
    struct list_head list;        // 内核链表节点
    dev_t dev;                    // 设备号
    unsigned int count;           // 次设备号数量
};

cdev 初始化与注册步骤:

struct cdev my_cdev;
dev_t dev;

// 1. 分配设备号(以动态分配为例)
alloc_chrdev_region(&dev, 0, 1, "my_device");

// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

// 3. 将 cdev 添加到内核
cdev_add(&my_cdev, dev, 1);

3.4.2 file_operations 结构体

file_operations 是字符设备驱动的核心接口表。内核在用户空间发起系统调用时,通过 VFS 层查找该结构体中的对应函数指针并调用。

成员 函数原型 说明
open int (*open)(struct inode *, struct file *) 打开设备,进行初始化、权限检查、硬件上电等操作。
read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据到用户空间缓冲区。
write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 从用户空间缓冲区写入数据到设备。
release int (*release)(struct inode *, struct file *) 关闭设备,释放资源、硬件下电等(对应 close 系统调用)。
llseek loff_t (*llseek)(struct file *, loff_t, int) 修改文件读写位置(可选)。
关键点: read 和 write 回调中的缓冲区指针是 __user 标记的,表示指向用户空间。驱动必须使用 copy_to_user() 和 copy_from_user() 进行安全的数据拷贝,不能直接解引用。
cdev 与 file_operations 结构关系 struct cdev owner: THIS_MODULE ops → file_operations dev: 主次设备号 count: 次设备数量 list: 内核链表 struct file_operations .open = my_open .read = my_read .write = my_write .release = my_release .llseek = my_llseek(可选) ops 指针 cdev_add() 将 cdev 注册到内核,VFS 通过设备号找到 cdev,再通过 ops 找到具体回调函数

3.5 实现简单的 open / read / write / close

下面我们实现一个完整的虚拟字符设备驱动。该驱动不操作真实硬件,而是在内核中维护一个 1KB 的缓冲区,演示完整的 open、read、write、release 流程。

3.5.1 驱动代码框架

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>   // copy_to_user / copy_from_user

#define DEVICE_NAME "my_char_dev"
#define BUFFER_SIZE 1024

static dev_t dev_num;
static struct cdev my_cdev;
static char device_buffer[BUFFER_SIZE];
static int buffer_len = 0;

// open 回调
static int my_open(struct inode *inode, struct file *filp)
{
    pr_info("Device opened\n");
    return 0;
}

// read 回调
static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    size_t available = buffer_len - *f_pos;
    size_t to_read = min(count, available);

    if (to_read == 0)
        return 0;

    if (copy_to_user(buf, device_buffer + *f_pos, to_read)) {
        pr_err("Failed to copy data to user\n");
        return -EFAULT;
    }

    *f_pos += to_read;
    pr_info("Read %zu bytes from device\n", to_read);
    return to_read;
}

// write 回调
static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    size_t available = BUFFER_SIZE - *f_pos;
    size_t to_write = min(count, available);

    if (to_write == 0)
        return -ENOSPC;

    if (copy_from_user(device_buffer + *f_pos, buf, to_write)) {
        pr_err("Failed to copy data from user\n");
        return -EFAULT;
    }

    *f_pos += to_write;
    if (*f_pos > buffer_len)
        buffer_len = *f_pos;

    pr_info("Wrote %zu bytes to device\n", to_write);
    return to_write;
}

// release 回调(对应 close)
static int my_release(struct inode *inode, struct file *filp)
{
    pr_info("Device closed\n");
    return 0;
}

// 定义 file_operations
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .read    = my_read,
    .write   = my_write,
    .release = my_release,
};

// 模块初始化
static int __init my_init(void)
{
    int ret;

    // 1. 动态分配设备号
    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("Allocated major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num));

    // 2. 初始化 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    // 3. 注册 cdev
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    pr_info("Character device driver loaded\n");
    return 0;
}

// 模块退出
static void __exit my_exit(void)
{
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("Character device driver unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Linux Driver Developer");
MODULE_DESCRIPTION("Simple character device driver example");

3.5.2 用户空间测试程序

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
    int fd;
    char write_buf[] = "Hello from user space!";
    char read_buf[64] = {0};

    // 打开设备(需要先 mknod /dev/my_char_dev c major 0)
    fd = open("/dev/my_char_dev", O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    // 写入数据
    write(fd, write_buf, strlen(write_buf));

    // 重置文件偏移到起始位置
    lseek(fd, 0, SEEK_SET);

    // 读取数据
    read(fd, read_buf, sizeof(read_buf));
    printf("Read from device: %s\n", read_buf);

    // 关闭设备
    close(fd);
    return 0;
}
测试步骤:
1. 编译驱动模块:make
2. 加载模块:sudo insmod my_char_dev.ko
3. 查看设备号:cat /proc/devices | grep my_char_dev
4. 创建设备节点:sudo mknod /dev/my_char_dev c [major] 0
5. 编译并运行测试程序:gcc test.c -o test && sudo ./test
6. 卸载模块:sudo rmmod my_char_dev

3.6 关键注意事项与常见错误

常见错误 后果 正确做法
直接解引用用户空间指针 内核崩溃(Oops) 使用 copy_to_user() / copy_from_user()
未检查 copy_to_user 返回值 部分数据未拷贝,用户读到脏数据 检查返回值,非零时返回 -EFAULT
未处理文件偏移 f_pos 多次读写位置错乱 在 read/write 中正确更新 *f_pos
cdev_add 失败后未释放设备号 设备号泄漏 在错误路径中调用 unregister_chrdev_region()
模块退出时未调用 cdev_del 内核 panic 或设备残留 在 __exit 函数中先 cdev_del 再 unregister_chrdev_region
安全红线: 永远不要在内核空间直接使用 memcpy() 操作来自用户空间的指针。必须使用 copy_from_user() 和 copy_to_user(),这两个函数会进行地址合法性检查,并在访问失败时返回非零值。

3.7 本章小结

  • 字符设备 是 Linux 驱动开发的基础,以字节流方式与用户空间交互。
  • 设备号 由主设备号(驱动标识)和次设备号(设备实例标识)组成,通过 dev_t 管理。
  • 设备号分配 推荐使用 alloc_chrdev_region() 动态分配,避免冲突。
  • cdev 结构体 是字符设备在内核中的核心表示,通过 cdev_init() + cdev_add() 注册。
  • file_operations 是驱动与 VFS 的接口表,必须实现 open、read、write、release 等回调。
  • 用户空间数据拷贝 必须使用 copy_to_user() / copy_from_user(),禁止直接解引用。