3. 字符设备驱动入门

字符设备是Linux驱动开发中最基础、也最容易建立完整认知的一类设备。它按字节流方式访问,不经过块层缓存,典型代表有串口、按键、LED、GPIO扩展芯片以及各类传感器。要写出一个可加载、可访问、可卸载的字符设备驱动,必须掌握四个核心要素:设备号、file_operations、cdev以及文件操作接口的实现。

一、主次设备号:内核识别设备的“身份证”

在Linux中,每个字符设备都由一个设备号唯一标识。设备号是32位无符号整数,由主设备号和次设备号两部分组成。主设备号用于匹配对应的驱动程序,次设备号用于区分同一驱动下的不同设备实例。

# 查看当前系统中已注册的字符设备
cat /proc/devices

# 查看设备节点的主次设备号
ls -l /dev/ttyS0
crw-rw---- 1 root dialout 4, 64 ... /dev/ttyS0
# 其中 4 是主设备号,64 是次设备号

内核提供了一组宏来操作设备号,避免手工移位出错:

  • MKDEV(major, minor):由主、次设备号合成完整dev_t。
  • MAJOR(dev):从dev_t中提取主设备号。
  • MINOR(dev):从dev_t中提取次设备号。

设备号的分配有两种方式:一是静态指定,使用register_chrdev_region(),需要开发者自己选定一个未被占用的主设备号;二是动态分配,使用alloc_chrdev_region(),由内核自动分配可用主设备号,这是推荐做法,可避免冲突。释放时统一使用unregister_chrdev_region()。

动态分配后,主设备号不会自动出现在/dev下,通常需要结合udev规则或mknod命令手动创建设备节点,否则应用程序无法通过文件路径访问设备。

二、file_operations:驱动与VFS之间的契约

用户空间对设备文件调用open/read/write/close时,VFS会把这些系统调用转发到驱动注册的file_operations结构体中对应的函数指针。因此,file_operations是字符设备驱动的核心接口表,它决定了驱动“能做什么”。

成员函数对应系统调用作用
openopen()打开设备,完成初始化、引用计数递增
releaseclose()关闭设备,释放资源、引用计数递减
readread()从设备读取数据到用户缓冲区
writewrite()将用户缓冲区数据写入设备
unlocked_ioctlioctl()执行设备专属控制命令
llseeklseek()调整文件读写位置
pollpoll()/select()支持多路复用与阻塞等待

未实现的成员应保持为NULL,VFS会返回-EINVAL或-ENOTTY等错误,而不是让内核崩溃。结构体通常用指定初始化器赋值,清晰且不易受字段顺序变化影响:

static const struct file_operations demo_fops = {
    .owner   = THIS_MODULE,
    .open    = demo_open,
    .release = demo_release,
    .read    = demo_read,
    .write   = demo_write,
};

三、cdev的注册与注销:把驱动挂到内核

struct cdev是内核对字符设备的抽象,它把设备号与file_operations绑定在一起。注册流程通常分为三步:分配设备号、初始化并添加cdev、创建设备节点。

  1. 使用alloc_chrdev_region()申请设备号范围。
  2. 用cdev_init()初始化cdev并绑定fops,再调用cdev_add()将其加入内核。
  3. 通过class_create()与device_create()自动生成/dev节点。
static int __init demo_init(void)
{
    int ret;
    dev_t devno;

    ret = alloc_chrdev_region(&devno, 0, 1, "demo_chrdev");
    if (ret < 0)
        return ret;

    cdev_init(&demo_cdev, &demo_fops);
    demo_cdev.owner = THIS_MODULE;

    ret = cdev_add(&demo_cdev, devno, 1);
    if (ret < 0) {
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    demo_class = class_create(THIS_MODULE, "demo");
    device_create(demo_class, NULL, devno, NULL, "demo0");
    return 0;
}

注销顺序与注册严格相反:先device_destroy()、class_destroy(),再cdev_del(),最后unregister_chrdev_region()。顺序颠倒会导致内核引用已释放对象,引发Oops。

四、open/read/write/release的实现要点

这四个接口是驱动与用户交互的主通道,实现时需注意参数语义与数据拷贝安全。

  • open:参数为struct inode *和struct file *。可通过container_of()从inode的cdev反查自定义设备结构体,完成硬件初始化或引用计数递增。
  • read:原型为ssize_t (*read)(struct file *, char __user *, size_t, loff_t *)。必须使用copy_to_user(),不能直接解引用用户指针;返回实际读取字节数。
  • write:使用copy_from_user()把用户数据搬到内核缓冲区,注意校验返回值,失败时返回-EFAULT。
  • release:与open配对,释放缓冲区、关闭硬件、递减引用计数,返回值通常为0。
static ssize_t demo_read(struct file *filp, char __user *buf,
                         size_t count, loff_t *ppos)
{
    if (*ppos >= sizeof(kbuf))
        return 0;
    if (count > sizeof(kbuf) - *ppos)
        count = sizeof(kbuf) - *ppos;

    if (copy_to_user(buf, kbuf + *ppos, count))
        return -EFAULT;

    *ppos += count;
    return count;
}
常见误区:一是忘记在release中释放open时申请的内存,造成每次打开都泄漏;二是read/write中直接使用用户指针,导致内核态访问用户地址而崩溃;三是cdev_add成功后未检查返回值,设备号已占用却继续注册,最终在卸载时出现资源残留。

五、完整数据流与状态关系

下图展示了从用户空间系统调用到驱动回调的完整路径,以及设备号、cdev、file_operations三者之间的绑定关系。

字符设备驱动核心对象与调用路径 用户空间 open/read/write/close /dev/demo0 VFS 虚拟文件系统 sys_call_table 分发 inode → cdev 映射 字符设备驱动 file_operations 回调 demo_open/read/write dev_t 设备号 主设备号 major 次设备号 minor MKDEV / MAJOR / MINOR struct cdev cdev_init() 绑定 fops cdev_add() 加入内核 cdev_del() 注销 file_operations .open / .release .read / .write .unlocked_ioctl / .poll 注册与注销顺序 注册:alloc_chrdev_region → cdev_init → cdev_add → class_create → device_create 注销:device_destroy → class_destroy → cdev_del → unregister_chrdev_region

理解这张关系图后,编写字符设备驱动的思路就非常清晰:先确定设备号分配策略,再定义file_operations并实现四个基本回调,接着用cdev把设备号与fops绑定注册,最后在模块退出时按相反顺序注销。掌握这一套骨架,后续的ioctl、poll、并发控制与设备树匹配,都是在这个骨架上逐步扩展。