字符设备驱动基础:从模型到实战

字符设备驱动,说白了就是 Linux 驱动里最基础、最常用的一类。你想想看,键盘、鼠标、串口、LED 灯——这些设备本质上都是按字节流来读写数据的。我刚开始学驱动时,第一个练手的项目就是字符设备驱动。嗯,今天我们就把它彻底讲透。

字符设备驱动模型

Linux 把设备抽象成三类:字符设备、块设备和网络设备。字符设备的特点是:数据按顺序读写,没有缓冲区,不支持随机访问。比如你按下一个按键,驱动就上报一个键值,就这么简单。

整个模型的核心就三个角色:

  • 应用程序:通过 open/read/write/close 等系统调用操作设备
  • VFS(虚拟文件系统):统一接口层,屏蔽底层差异
  • 设备驱动:真正和硬件打交道的代码

我画了一张图,帮你理清它们之间的关系:

字符设备驱动模型架构 应用程序 (App) open/read/write/close VFS (虚拟文件系统) file_operations 结构体 字符设备驱动 (Driver) 硬件设备 数据流向 App → VFS → file_ops → Driver → Hardware

核心要点:应用程序不直接调用驱动函数,而是通过 VFS 这个中间层。驱动只需要注册好 file_operations,剩下的交给内核去串联。

file_operations 结构体

这个结构体,是驱动和 VFS 之间的契约。你实现了哪些函数,VFS 就能调用哪些。说白了,它就是一张函数指针表。

我挑几个最常用的成员讲讲:

成员 函数原型 作用
open int (*open)(struct inode *, struct file *) 打开设备,做初始化
release int (*release)(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 *) 向设备写入数据
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令
llseek loff_t (*llseek)(struct file *, loff_t, int) 修改文件读写位置

我的习惯:写驱动时,我一般先定义一个 file_operations 变量,把需要的函数指针填进去。没实现的成员直接设为 NULL,内核会帮你处理默认行为。

来看一个实际例子:

// 定义一个简单的字符设备驱动操作集
static int my_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "my_device: open() called\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "my_device: release() called\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    // 这里实现从设备读取数据的逻辑
    printk(KERN_INFO "my_device: read() called, count=%zu\n", count);
    return 0;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    // 这里实现向设备写入数据的逻辑
    printk(KERN_INFO "my_device: write() called, count=%zu\n", count);
    return count;
}

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

注意:read 和 write 函数的 buf 参数带有 __user 标记,说明它指向用户空间。千万不要在内核空间直接解引用!必须用 copy_to_user 和 copy_from_user 来安全传输数据。我曾经见过新手直接 memcpy,结果内核直接 oops。

设备号注册与注销

每个字符设备在内核里都有一个唯一的编号——设备号。它由主设备号和次设备号组成:

  • 主设备号:标识设备类型,比如 1 代表内存设备,4 代表 tty 设备
  • 次设备号:标识同类设备中的不同实例

设备号用 dev_t 类型表示,内核提供了宏来操作它:

// 从主次设备号合成 dev_t
dev_t devno = MKDEV(major, minor);

// 从 dev_t 提取主设备号
int major = MAJOR(devno);

// 从 dev_t 提取次设备号
int minor = MINOR(devno);

注册设备号有两种方式:

方式 函数 适用场景
静态分配 register_chrdev_region(dev_t from, unsigned count, const char *name) 你知道要用的主设备号,且确保没被占用
动态分配 alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name) 让内核自动分配一个可用的主设备号

我的建议:除非你有特殊需求(比如要兼容旧版驱动),否则一律用动态分配。内核比你更清楚哪些设备号是空闲的。我早期做项目时硬编码了主设备号,结果在另一台机器上冲突了,排查了半天才发现是设备号被占用了。

注销设备号很简单,用这一个函数:

void unregister_chrdev_region(dev_t from, unsigned count);

完整的注册流程是这样的:

dev_t devno;
int major;

// 第一步:动态分配设备号
if (alloc_chrdev_region(&devno, 0, 1, "my_device") < 0) {
    printk(KERN_ERR "Failed to allocate device number\n");
    return -EIO;
}

major = MAJOR(devno);
printk(KERN_INFO "Allocated major number: %d\n", major);

// ... 后续还要做 cdev 初始化、添加等操作 ...

// 模块卸载时注销
unregister_chrdev_region(devno, 1);

cdev 结构体

cdev 是内核用来管理字符设备的核心结构体。它把设备号和 file_operations 绑定在一起。说白了,cdev 就是驱动在内核里的"身份证"。

cdev 结构体定义如下:

struct cdev {
    struct kobject kobj;          // 内核对象,用于引用计数
    struct module *owner;         // 所属模块
    const struct file_operations *ops;  // 操作函数集
    struct list_head list;        // 链表节点
    dev_t dev;                    // 设备号
    unsigned int count;           // 次设备号数量
};

操作 cdev 的步骤就三步:

  1. 分配 cdev:用 cdev_alloc() 或静态定义
  2. 初始化 cdev:用 cdev_init() 绑定 file_operations
  3. 添加 cdev:用 cdev_add() 注册到内核

来看完整代码:

static struct cdev my_cdev;

static int __init my_init(void)
{
    dev_t devno;
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&devno, 0, 1, "my_device");
    if (ret < 0) {
        printk(KERN_ERR "alloc_chrdev_region failed\n");
        return ret;
    }

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

    // 3. 添加 cdev 到内核
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret < 0) {
        printk(KERN_ERR "cdev_add failed\n");
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    printk(KERN_INFO "my_device initialized, major=%d\n", MAJOR(devno));
    return 0;
}

static void __exit my_exit(void)
{
    dev_t devno = my_cdev.dev;

    // 1. 删除 cdev
    cdev_del(&my_cdev);

    // 2. 注销设备号
    unregister_chrdev_region(devno, 1);

    printk(KERN_INFO "my_device unloaded\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

避坑指南:我曾经在项目里犯过一个低级错误——先调了 cdev_add,然后才去创建设备文件。结果用户空间程序在设备文件还没创建时就打开了设备,导致内核 panic。正确的顺序是:先注册设备号 → 添加 cdev → 创建设备文件。卸载时顺序反过来。

完整驱动框架一览

把上面所有知识点串起来,一个完整的字符设备驱动框架就是这样的:

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

// 2. 模块初始化
static int __init my_init(void)
{
    // 分配设备号 → 初始化 cdev → 添加 cdev
}

// 3. 模块卸载
static void __exit my_exit(void)
{
    // 删除 cdev → 注销设备号
}

module_init(my_init);
module_exit(my_exit);

我的经验:刚开始写驱动时,建议先用动态设备号 + 简单 printk 调试。等把整个框架跑通了,再往里填真正的硬件操作代码。这样出问题时,你能快速定位是框架问题还是硬件问题。

字符设备驱动是 Linux 驱动开发的基石。你把这个模型吃透了,后面学平台驱动、I2C 驱动、SPI 驱动都会轻松很多。嗯,今天就到这里,动手写一个你自己的字符设备驱动试试吧。