3. 字符设备驱动入门

字符设备是 Linux 驱动开发中最基础、也最容易建立完整认知的一类设备。它按字节流方式访问,典型代表包括串口、按键、LED、ADC、RTC 等。要写出第一个可加载、可访问的字符设备驱动,必须掌握四个核心对象:设备号、cdev、file_operations 以及注册与注销流程。本节围绕这四者展开,帮助你把“驱动如何被内核识别、如何被用户态打开、如何最终调用到自己的函数”这条链路彻底打通。

一、核心原理:从用户态 open 到驱动回调

用户态执行 open("/dev/mychar", O_RDWR) 时,内核并不会直接跳到你的驱动函数。它先通过 VFS 找到该路径对应的 inode,再根据 inode 中的设备号(i_rdev)在字符设备映射表中查找已注册的 struct cdev,然后从 cdev 中取出 file_operations 指针,最终调用其中的 .open 回调。因此,设备号是“钥匙”,cdev 是“挂载点”,file_operations 是“函数入口表”。

设备号(dev_t) = 主设备号(major,12 位) + 次设备号(minor,20 位)。主设备号标识“哪一类驱动”,次设备号标识“该类驱动下的第几个设备实例”。

内核提供 MKDEV(major, minor) 合成设备号,MAJOR(dev) 与 MINOR(dev) 拆分设备号。设备号有两种分配方式:静态指定(register_chrdev_region)和动态申请(alloc_chrdev_region)。现代驱动推荐动态申请,避免主设备号冲突。

二、方法流程:字符设备驱动的标准骨架

一个规范的字符设备驱动通常按以下顺序组织:定义 file_operations → 申请设备号 → 初始化并添加 cdev → 创建类与设备节点 → 实现回调 → 模块退出时逆序释放。下面给出最小可用骨架:

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

static dev_t devno;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;

static int my_open(struct inode *inode, struct file *filp)
{
    pr_info("mychar: open, minor=%d\n", iminor(inode));
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off)
{
    return 0;   /* 示例:无数据可读 */
}

static const struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open  = my_open,
    .read  = my_read,
};

static int __init mychar_init(void)
{
    int ret;

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

    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

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

    my_class = class_create("mychar");
    my_device = device_create(my_class, NULL, devno, NULL, "mychar%d", 0);

    pr_info("mychar: major=%d minor=%d\n", MAJOR(devno), MINOR(devno));
    return 0;
}

static void __exit mychar_exit(void)
{
    device_destroy(my_class, devno);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
}

module_init(mychar_init);
module_exit(mychar_exit);
MODULE_LICENSE("GPL");

加载后可用 dmesg 查看主次设备号,用 ls -l /dev/mychar0 确认设备节点,用 cat /proc/devices 验证字符设备注册结果。

三、关键对象对照清单

对象 作用 关键 API 释放方式
dev_t 设备号 标识驱动与设备实例 alloc_chrdev_region / register_chrdev_region unregister_chrdev_region
struct cdev 把设备号与 fops 绑定 cdev_init / cdev_add cdev_del
file_operations 提供 open/read/write/ioctl 等回调 静态定义并赋值 .owner 随模块卸载自动失效
class / device 自动创建 /dev 节点 class_create / device_create device_destroy / class_destroy

四、注册与注销流程可视化

字符设备驱动:注册(蓝)与注销(红)流程 1. alloc_chrdev_region 动态申请 dev_t 2. cdev_init 绑定 file_operations 3. cdev_add 加入字符设备映射表 4. class_create device_create 内核中的关键数据结构 dev_t major + minor struct cdev ops → file_operations file_operations open / read / write / ioctl 用户态 open("/dev/mychar0") VFS 按 i_rdev 查找 cdev → 调用 .open device_destroy class_destroy cdev_del 从映射表移除 unregister_chrdev_region 释放 dev_t

五、案例场景:一个可读的“版本号”字符设备

假设我们要实现一个只读设备,用户 cat /dev/ver0 时返回驱动版本字符串。核心只需在 my_read 中把字符串拷贝到用户缓冲区,并正确维护 *off 偏移:

static char ver_str[] = "mychar v1.0\n";

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off)
{
    if (*off >= sizeof(ver_str))
        return 0;                       /* EOF */
    if (len > sizeof(ver_str) - *off)
        len = sizeof(ver_str) - *off;
    if (copy_to_user(buf, ver_str + *off, len))
        return -EFAULT;
    *off += len;
    return len;
}

加载模块后执行 cat /dev/ver0,应输出 mychar v1.0。这个例子虽小,却完整覆盖了设备号申请、cdev 注册、fops 回调、用户态访问四条主线。

六、常见误区与复盘

误区一:只调用 register_chrdev 就以为万事大吉。该接口会一次性占用 0~255 全部次设备号,现代内核已不推荐,应改用 alloc_chrdev_region + cdev_add。
误区二:忘记设置 my_cdev.owner = THIS_MODULE,导致模块在使用中被卸载,引发内核崩溃。
误区三:注销顺序写反。正确顺序是先 device_destroy/class_destroy,再 cdev_del,最后 unregister_chrdev_region,与注册顺序严格逆序。
误区四:在 read/write 中直接访问用户指针。必须使用 copy_to_user / copy_from_user,否则会触发 oops。
调试建议:加载后用 cat /proc/devices | grep mychar 确认主设备号;用 ls -l /dev/mychar0 检查节点;用 dmesg | tail 观察 open 回调是否被触发。若 open 返回 ENODEV,优先排查 cdev 是否成功 add、设备号是否一致。

掌握本节内容后,你已经具备编写一个最小字符设备驱动的完整能力。后续更复杂的 ioctl、阻塞、poll、并发控制,都是在这套“设备号 + cdev + fops + 注册注销”骨架上逐步扩展出来的。