字符设备驱动基础:从框架到实战

大家好,我是你们的嵌入式Linux驱动讲师。今天我们来聊聊字符设备驱动的基础。说实话,字符设备驱动是Linux驱动开发里最基础、也是最核心的一块。我刚开始学驱动时,就是从这里入门的。你想想看,键盘、鼠标、串口,这些统统都是字符设备。搞懂了它,后面学块设备、网络设备就会轻松很多。

字符设备驱动框架:一个简单的模型

字符设备驱动,说白了就是让应用程序能通过文件操作的方式来访问硬件。比如你在用户空间写open("/dev/mydev", O_RDWR),内核就能找到对应的驱动,然后执行你预先写好的打开函数。

这个框架其实不复杂。我画个简单的图给你看:

应用程序 (open/read/write/close)
        ↓
  系统调用接口
        ↓
  VFS (虚拟文件系统)
        ↓
  字符设备驱动 (file_operations)
        ↓
  硬件设备

嗯,这里要注意一点:VFS是Linux的虚拟文件系统层。它把各种设备都抽象成了文件。所以你在用户空间操作设备,感觉就像在读写一个普通文件。这个设计真的很巧妙,我个人非常佩服。

file_operations结构体:驱动的灵魂

每个字符设备驱动都必须实现一个file_operations结构体。这个结构体定义了驱动能提供的所有操作。我把它叫做驱动的「菜单」——应用程序点哪个菜,驱动就执行哪个函数。

来看看这个结构体的核心成员:

成员 说明 我常用的场景
.owner 模块所有者,一般设为THIS_MODULE 防止模块被卸载时还在使用
.open 打开设备时调用 初始化硬件、分配资源
.release 关闭设备时调用 释放资源、关闭硬件
.read 从设备读取数据 从硬件寄存器读数据
.write 向设备写入数据 向硬件寄存器写数据
.unlocked_ioctl 设备控制命令 设置波特率、切换模式等

我在项目中遇到过一个问题:有个同事忘了设置.owner字段,结果模块被卸载后,应用程序还在调用驱动,直接导致内核崩溃。所以这个字段虽然小,但千万别漏掉。

设备号:驱动的身份证

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

  • 主设备号:标识设备类型。比如串口设备通常是4,tty设备通常是5。
  • 次设备号:标识同类设备中的不同实例。比如tty0、tty1。

设备号在内核里用dev_t类型表示。你可以用MAJOR()MINOR()宏来提取主次设备号。

重要提示:主设备号可以静态申请,也可以动态分配。我个人习惯用动态分配,省得跟别人冲突。你想想看,要是你硬编码了一个主设备号,结果别人的驱动也用了这个号,那不就打架了吗?

设备号注册与注销

注册设备号有两种方式:

// 方式一:静态申请(不推荐)
int register_chrdev_region(dev_t from, unsigned count, const char *name);

// 方式二:动态分配(推荐)
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);

注销就更简单了:

void unregister_chrdev_region(dev_t from, unsigned count);

我曾经犯过一个低级错误:在模块卸载时忘了注销设备号。结果下次加载模块时,设备号已经被占用了。嗯,这种错误犯一次就够了,后来我每次写驱动都会在退出函数里仔细检查。

cdev结构体:内核里的设备对象

cdev是内核用来描述字符设备的结构体。它把设备号和file_operations绑定在一起。你可以把它理解成驱动的「户口本」。

使用cdev的典型流程:

// 1. 分配cdev结构体
struct cdev *my_cdev = cdev_alloc();

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

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

// 4. 卸载时删除cdev
cdev_del(my_cdev);

小技巧:我习惯把cdev结构体嵌入到自己的设备结构体中,而不是单独分配。这样管理起来更方便,也减少了内存泄漏的风险。

简单的字符设备驱动实现

好了,理论说完了,我们来写一个最简单的字符设备驱动。这个驱动只做一件事:记录应用程序写入的数据,然后读出来。

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

#define DEVICE_NAME "mydemo"
#define BUFFER_SIZE 1024

static dev_t devno;
static struct cdev *my_cdev;
static char buffer[BUFFER_SIZE];
static int buffer_len = 0;

static int my_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "mydemo: device opened\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "mydemo: device closed\n");
    return 0;
}

static ssize_t my_read(struct file *file, char __user *buf, 
                       size_t count, loff_t *ppos)
{
    int ret;
    
    if (*ppos >= buffer_len)
        return 0;
    
    if (count > buffer_len - *ppos)
        count = buffer_len - *ppos;
    
    ret = copy_to_user(buf, buffer + *ppos, count);
    if (ret)
        return -EFAULT;
    
    *ppos += count;
    return count;
}

static ssize_t my_write(struct file *file, const char __user *buf,
                        size_t count, loff_t *ppos)
{
    if (count > BUFFER_SIZE)
        count = BUFFER_SIZE;
    
    if (copy_from_user(buffer, buf, count))
        return -EFAULT;
    
    buffer_len = count;
    *ppos = count;
    
    printk(KERN_INFO "mydemo: wrote %zu bytes\n", count);
    return count;
}

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)
{
    // 动态分配设备号
    if (alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME) < 0) {
        printk(KERN_ERR "mydemo: failed to alloc devno\n");
        return -1;
    }
    
    // 分配并初始化cdev
    my_cdev = cdev_alloc();
    if (!my_cdev) {
        unregister_chrdev_region(devno, 1);
        return -ENOMEM;
    }
    
    cdev_init(my_cdev, &my_fops);
    my_cdev->owner = THIS_MODULE;
    
    // 添加cdev到内核
    if (cdev_add(my_cdev, devno, 1) < 0) {
        cdev_del(my_cdev);
        unregister_chrdev_region(devno, 1);
        return -1;
    }
    
    printk(KERN_INFO "mydemo: loaded, major=%d, minor=%d\n",
           MAJOR(devno), MINOR(devno));
    return 0;
}

static void __exit my_exit(void)
{
    cdev_del(my_cdev);
    unregister_chrdev_region(devno, 1);
    printk(KERN_INFO "mydemo: unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

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

避坑指南:我曾经在copy_to_usercopy_from_user上栽过跟头。记住,内核空间不能直接访问用户空间的内存,必须用这两个函数。直接使用memcpy会导致内核崩溃,别问我怎么知道的...

测试驱动

驱动编译加载后,你需要手动创建设备节点:

# 查看设备号(从dmesg获取)
cat /proc/devices | grep mydemo

# 创建设备节点
mknod /dev/mydemo c 主设备号 0

# 测试读写
echo "Hello Driver" > /dev/mydemo
cat /dev/mydemo

如果一切正常,你应该能看到输出"Hello Driver"。嗯,这就是你的第一个字符设备驱动了。

总结

字符设备驱动的核心就这几步:

  1. 实现file_operations结构体
  2. 申请设备号(推荐动态分配)
  3. 初始化并添加cdev结构体
  4. 在模块退出时做好清理工作

说实话,这个框架我写了不下上百次。每次写新驱动,都是从这个模板开始改。你把这个基础打牢了,后面学复杂驱动就会轻松很多。下一章我们会讲如何用udev自动创建设备节点,以及如何实现更复杂的ioctl控制命令。到时候见!