第3章 字符设备驱动框架

字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种模型。我刚开始学驱动时,就是从字符设备入手的。你想想看,键盘、鼠标、串口,这些设备本质上都是字符设备——数据按字节流传输,顺序读写。这一章,我们就来彻底搞懂它的框架。

3.1 字符设备驱动模型

Linux把设备分成了三大类:字符设备、块设备和网络设备。字符设备的特点是啥?数据像流水一样,一个一个字节地传。没有缓冲区,不能随机访问。嗯,这里要注意,字符设备驱动其实就是一个软件层,它把硬件操作封装成标准的文件操作接口。

我在项目中遇到过一个问题:一个客户说他的POS机刷卡器不工作了。我排查了半天,发现是驱动没有正确注册到内核。说白了,驱动没挂上去,应用层根本找不到这个设备。所以理解驱动模型,是第一步。

字符设备驱动的核心结构是这样的:

应用层 (open/read/write/close)
    ↓
系统调用 (VFS层)
    ↓
字符设备驱动 (file_operations)
    ↓
硬件操作 (寄存器读写)

这个模型的好处是啥?应用开发者不需要懂硬件。他只需要用open、read、write这些标准函数,就能操作设备。这就是Linux的设计哲学——一切皆文件。

3.2 设备号:主设备号与次设备号

每个字符设备在内核里都有一个唯一的编号,叫设备号。设备号由两部分组成:主设备号和次设备号。

类型 位数 作用
主设备号 12位 (0~4095) 标识设备对应的驱动程序
次设备号 20位 (0~1048575) 标识同一个驱动下的不同设备实例

举个例子:你电脑上可能插了两个USB转串口设备。它们的主设备号相同(都是同一个驱动),但次设备号不同(ttyUSB0和ttyUSB1)。

我个人习惯用动态分配设备号,而不是静态指定。为什么?因为静态分配容易冲突。我曾经在调试时,手动指定了一个主设备号,结果跟系统里已有的驱动冲突了,内核直接报错。从那以后,我都是用alloc_chrdev_region()来动态分配。

设备号操作宏:

  • MAJOR(dev_t dev) — 从设备号中提取主设备号
  • MINOR(dev_t dev) — 从设备号中提取次设备号
  • MKDEV(int major, int minor) — 将主次设备号组合成dev_t

3.3 file_operations结构体

这个结构体,是字符设备驱动的灵魂。它定义了驱动能提供的所有操作函数。说白了,它就是驱动和应用之间的接口协议。

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 *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    // ... 还有很多其他操作
};

你想想看,应用层调用read(fd, buf, len)时,内核VFS层就会找到这个设备对应的file_operations,然后调用你实现的read函数。这就是整个驱动工作的核心机制。

我建议初学者先实现这四个基本操作:

  • open — 打开设备,做初始化工作
  • release — 关闭设备,释放资源
  • read — 从设备读取数据
  • write — 向设备写入数据

避坑指南:我曾经在写read函数时,忘了检查用户空间指针的有效性。结果应用层传了个空指针,内核直接Oops。记住,一定要用copy_to_user()copy_from_user()来安全地传输数据。

3.4 设备节点的创建

设备节点是应用层访问驱动的入口。它位于/dev目录下,本质上就是一个文件。创建设备节点有两种方式:手动和自动。

3.4.1 手动创建

mknod命令:

mknod /dev/mydevice c 240 0

参数说明:c表示字符设备,240是主设备号,0是次设备号。

手动创建有个问题:每次加载驱动都要手动敲命令,而且设备号变了还得改。所以我不推荐在生产环境用这种方式。

3.4.2 自动创建

自动创建设备节点,需要用到Linux的设备模型。核心是这两个步骤:

  1. 创建设备类(class)
  2. 在类下创建设备
// 创建类
struct class *my_class = class_create(THIS_MODULE, "my_device_class");

// 创建设备
device_create(my_class, NULL, devno, NULL, "mydevice");

这样,当驱动加载时,udevmdev会自动在/dev下创建设备节点。卸载驱动时,节点也会自动删除。省心多了。

注意:自动创建设备节点需要内核配置了CONFIG_DEVTMPFSCONFIG_UEVENT_HELPER。我在一个嵌入式板子上就遇到过,内核没开这些选项,结果设备节点死活不出来。查了半天才发现是内核配置的问题。

3.5 简单的字符设备驱动实现

好了,理论说完了,我们来写一个完整的字符设备驱动。这个驱动很简单:它维护一个缓冲区,应用层可以读写这个缓冲区。

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

#define DEVICE_NAME "mychardev"
#define BUFFER_SIZE 1024

static int major;
static struct cdev my_cdev;
static struct class *my_class;
static char device_buffer[BUFFER_SIZE];
static int buffer_len = 0;

// open 函数
static int my_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "Device opened\n");
    return 0;
}

// release 函数
static int my_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "Device closed\n");
    return 0;
}

// read 函数
static ssize_t my_read(struct file *file, char __user *buf, 
                       size_t len, loff_t *offset)
{
    int bytes_to_read;
    
    if (*offset >= buffer_len)
        return 0;
    
    bytes_to_read = min(len, (size_t)(buffer_len - *offset));
    
    if (copy_to_user(buf, device_buffer + *offset, bytes_to_read))
        return -EFAULT;
    
    *offset += bytes_to_read;
    return bytes_to_read;
}

// write 函数
static ssize_t my_write(struct file *file, const char __user *buf,
                        size_t len, loff_t *offset)
{
    int bytes_to_write;
    
    bytes_to_write = min(len, (size_t)BUFFER_SIZE);
    
    if (copy_from_user(device_buffer, buf, bytes_to_write))
        return -EFAULT;
    
    buffer_len = bytes_to_write;
    *offset = 0;
    
    return bytes_to_write;
}

// file_operations 结构体
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)
{
    dev_t devno;
    
    // 动态分配设备号
    alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME);
    major = MAJOR(devno);
    
    // 初始化cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    
    // 添加cdev到内核
    cdev_add(&my_cdev, devno, 1);
    
    // 自动创建设备节点
    my_class = class_create(THIS_MODULE, "myclass");
    device_create(my_class, NULL, devno, NULL, DEVICE_NAME);
    
    printk(KERN_INFO "My char device loaded, major=%d\n", major);
    return 0;
}

// 模块卸载
static void __exit my_exit(void)
{
    dev_t devno = MKDEV(major, 0);
    
    device_destroy(my_class, devno);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
    
    printk(KERN_INFO "My char device unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");

这个驱动虽然简单,但五脏俱全。它包含了:

  • 设备号的动态分配
  • file_operations的实现
  • 设备节点的自动创建
  • 安全的用户空间数据拷贝

测试方法:加载驱动后,用echo "hello" > /dev/mychardev写入数据,再用cat /dev/mychardev读取。如果能看到"hello",说明驱动工作正常。

嗯,这里要注意一点:这个驱动没有考虑并发访问的问题。如果多个进程同时读写,会出现数据混乱。不过作为入门示例,先理解框架更重要。并发控制我们后面章节会专门讲。

我记得刚开始写驱动时,总是忘记调用cdev_add,结果设备注册不上去。或者忘了copy_to_user,直接在内核空间操作用户指针。这些坑,踩过一次就记住了。

总结一下这一章的核心:字符设备驱动就是通过file_operations把硬件操作映射成文件操作。设备号是驱动的身份证,设备节点是应用层的入口。掌握了这些,你就迈出了Linux驱动开发的第一步。