4. 字符设备驱动框架

字符设备是Linux驱动开发中最基础、最常见的一类设备。它按字节流方式访问,不经过块层缓存,典型代表有串口、按键、LED、ADC、GPIO扩展芯片等。理解字符设备驱动框架,核心是抓住四条主线:设备号标识“我是谁”,cdev把驱动注册进内核,file_operations定义“我能做什么”,用户空间系统调用决定“怎么找到我”。这四者串起来,才构成一个可被应用程序打开的完整字符设备。

一、设备号:dev_t与主次设备号

内核用dev_t(32位)表示一个设备号,其中高12位是主设备号(major),低20位是次设备号(minor)。主设备号对应“哪一类驱动”,次设备号对应“这一类里的第几个设备实例”。例如两个LED共用主设备号,但次设备号分别为0和1。

# 查看已注册字符设备主设备号
cat /proc/devices | grep -i mychar

# 手动创建设备节点:mknod 名称 类型 主设备号 次设备号
sudo mknod /dev/mychar0 c 240 0
sudo chmod 666 /dev/mychar0

设备号分配有两种方式:静态指定使用register_chrdev_region(),需要开发者自己保证主设备号不冲突;动态分配使用alloc_chrdev_region(),由内核返回可用主设备号,是推荐做法。释放时统一用unregister_chrdev_region()。

动态分配后,主设备号在编译期未知,因此通常配合class_create()与device_create()自动生成/dev节点,避免手工mknod。

二、cdev注册:把驱动挂到内核字符设备表

拿到设备号后,需要把驱动与设备号绑定。内核用struct cdev描述一个字符设备,关键步骤是:定义cdev、用cdev_init()绑定file_operations、用cdev_add()加入内核。卸载时先cdev_del(),再释放设备号。

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

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(THIS_MODULE, "mychar");
    device_create(my_class, NULL, devno, NULL, "mychar0");
    return 0;
}

这里cdev_add()的第三个参数是设备数量。若一次注册多个次设备号,内核会把它们都关联到同一个cdev,此时需要在open中通过inode->i_rdev区分具体是哪一个次设备。

三、file_operations:驱动能力的函数表

struct file_operations是字符设备驱动的“能力清单”。用户空间每调用一次open/read/write/ioctl,内核就通过它找到对应函数。常用成员如下表:

成员触发时机典型用途
open应用调用open()初始化硬件、申请资源、保存私有数据
release应用调用close()释放资源、关闭时钟、复位设备
read应用调用read()从设备读取数据到用户缓冲区
write应用调用write()把用户数据写入设备
unlocked_ioctl应用调用ioctl()执行非读写类控制命令
llseek应用调用lseek()调整文件偏移,字符设备常可省略

实现时要注意:read/write的返回值是实际传输字节数,返回负值表示错误;copy_to_user()和copy_from_user()是用户空间与内核空间之间安全拷贝数据的唯一正确方式,不能直接解引用用户指针。

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *ppos)
{
    char kbuf[64] = "hello from kernel";
    if (count > sizeof(kbuf))
        count = sizeof(kbuf);
    if (copy_to_user(buf, kbuf, count))
        return -EFAULT;
    return count;
}

static long my_ioctl(struct file *filp, unsigned int cmd,
                     unsigned long arg)
{
    switch (cmd) {
    case MYCHAR_RESET:
        /* 执行复位 */
        break;
    default:
        return -ENOTTY;
    }
    return 0;
}

四、用户空间调用流程与联调要点

应用程序执行open("/dev/mychar0", O_RDWR)时,内核经历:VFS查找inode → 根据设备号找到cdev → 取出file_operations → 调用驱动的open。后续read/write/ioctl都复用这条路径。可以用下面这张图理解整个调用链:

字符设备驱动调用链:从用户空间到硬件 用户空间应用 open / read / write ioctl / close VFS 虚拟文件系统 查找 inode 解析 dev_t 字符设备层 cdev dev_t → cdev 映射 取出 file_operations 驱动函数 my_open my_read ... struct file_operations .open .release .read .write .unlocked_ioctl copy_to_user / copy_from_user 安全拷贝 返回实际字节数,错误返回负 errno 硬件寄存器 / GPIO / 中断 ioremap 映射后读写,或通过子系统接口访问 设备节点 /dev/mychar0 c 240 0

联调时最常见的现象是:insmod成功,但open返回No such device。这通常不是驱动逻辑错,而是设备节点没建、主次设备号不匹配,或cdev_add失败未检查返回值。另一个高频问题是read返回-EFAULT,原因多为直接对用户指针赋值,而没有使用copy_to_user()。

不要在open里做耗时初始化,也不要在中断上下文调用可能睡眠的copy_to_user()。字符设备驱动的open/read/write通常运行在进程上下文,可以睡眠,但ioctl中的命令语义要清晰,未知命令应返回-ENOTTY。

五、复盘:一个最小可用的字符设备驱动骨架

  1. 用alloc_chrdev_region()动态申请设备号,记录返回的dev_t。
  2. 定义file_operations,至少实现open、release、read、write、unlocked_ioctl。
  3. 用cdev_init()绑定fops,用cdev_add()注册,并检查返回值。
  4. 用class_create()和device_create()自动生成/dev节点。
  5. 卸载时逆序执行:device_destroy → class_destroy → cdev_del → unregister_chrdev_region。
  6. 用dmesg观察printk日志,用cat /proc/devices确认主设备号,用ls -l /dev/mychar0确认节点类型。

掌握这套框架后,后续的platform驱动、misc设备、sysfs属性、中断处理,都是在字符设备骨架上叠加总线匹配、资源管理和并发控制。把设备号、cdev、file_operations、系统调用这四层关系理清,字符设备驱动就不再是“背模板”,而是可以按需裁剪和扩展的工程结构。