【Linux驱动】字符设备驱动内核源码深度解析

Linux内核主要包括三种驱动模型:字符设备驱动块设备驱动 以及网络设备驱动。其中,字符设备驱动是Linux驱动开发中最常见、最基础的驱动模型。
本文将从内核源码角度出发,拆解字符设备驱动的机制,涵盖:

  • 字符设备号管理 :内核如何分配和追踪设备号

  • 字符设备对象(cdev) :内核如何抽象和管理字符设备

  • kobj_map 哈希映射机制 :设备号到 cdev 的快速查找

  • mknod 与 open 系统调用全链路 :从用户态到内核驱动的完整路径

  • 共享内存字符设备驱动案例 :简单的字符设备驱动使用方法代码

📌 提示: 本文基于 Linux 内核 5.x 版本源码进行分析,主要源码文件位于

fs/char_dev.c

include/linux/cdev.h

drivers/base/map.c


一、字符设备号管理

本小节的主线是内核如何管理哪些设备号已经分配,哪些设备号可用

1.1 dev_t 设备号概述

在Linux内核中,每个字符设备都由一个 32 位的设备号(dev_t) 唯一标识。这个 32 位数值被划分为两部分:

  • 主设备号(Major Number) :占用高 12 位(bit 12-31),用于标识设备驱动类型

  • 次设备号(Minor Number) :占用低 20 位(bit 0-19),用于区分同一驱动下的不同设备实例

内核提供了三个关键宏来操作设备号:

复制代码
#define MINORBITS 20
#define MINORMASK ((1U << MINORBITS) - 1)

#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS)) /* 提取主设备号 */
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))  /* 提取次设备号 */
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))        /* 组合生成设备号 */

💡 设计思想: 主设备号相当于"设备类型标识",例如所有I2C设备共享一个主设备号;次设备号则用于区分具体是哪个I2C设备(如 MPU6050 传感器还是 EEPROM 存储器)。这种分层设计使得内核可以在保持设备号空间紧凑的同时,支持大量设备实例

1.2 char_device_struct 结构体

内核通过char_device_struct结构体来记录系统中已分配的设备号范围 (主设备号 + 次设备号区间),形成一个资源管理表,防止设备号冲突。内核维护了chrdevs哈希表来记录设备号的使用情况:

复制代码
/* 定义在 fs/char_dev.c */
#define CHRDEV_MAJOR_HASH_SIZE 255
static struct char_device_struct {
    struct char_device_struct *next;/* 将相同哈希值的节点链接成链表 */
    unsigned int major;        /* 主设备号 */
    unsigned int baseminor;    /* 次设备号起始值 */
    int minorct;               /* 次设备号数量 */
    char name[64];             /* 设备名称 */
    struct cdev *cdev;         /* 内核字符对象(已废弃) */
} *chrdevs[CHRDEV_MAJOR_HASH_SIZE];

🔑 关键设计:内核使用chrdevs维护设备号的分配信息

chrdevs数组大小仅为 255,但主设备号范围是 0~511(甚至理论上可以更大)。内核通过取模操作major % 255将主设备号映射到哈希桶。具体来说:

  • 主设备号 0、255、510... 都落在桶 0

  • 主设备号 1、256、511... 都落在桶 1

  • 桶内链表按主设备号从小到大排序

这种设计下:在不增加数组规模的前提下,理论上支持无限的主设备号范围,且查找效率不受影响。

1.3 __register_chrdev_region 函数

__register_chrdev_region是设备号分配的核心函数,负责在chrdevs哈希表中查找或插入 一个空闲的设备号区间,并返回对应的char_device_struct指针

复制代码
/* 定义在 fs/char_dev.c */
static struct char_device_struct *__register_chrdev_region(unsigned int major, unsigned int baseminor,  int minorct, const char *name) {
    struct char_device_struct *cd, **cp;
    intret=0;
    inti;
    /* 1. 分配新的 char_device_struct 节点 */  
    cd = kzalloc(sizeof(struct char_device_struct), GFP_KERNEL);
    if (cd == NULL)  
        return ERR_PTR(-ENOMEM);  
    mutex_lock(&chrdevs_lock);

    /* 2. 如果 major == 0,动态分配主设备号 */ 
    if (major == 0) { 
        ret = find_dynamic_major();
            if (ret < 0) {
                pr_err("CHRDEV \"%s\" dynamic allocation region is full\n", name);
                goto out;
            }  
            major = ret; 
    }

    /* 3. 主设备号范围校验 */

    if (major >= CHRDEV_MAJOR_MAX) {
        pr_err("CHRDEV \"%s\" major requested (%u) greater than max (%u)\n",  name, major, CHRDEV_MAJOR_MAX - 1);
        ret = -EINVAL;
        goto out;
    }

    cd->major = major;
    cd->baseminor = baseminor;
    cd->minorct = minorct;
    strlcpy(cd->name, name, sizeof(cd->name));
 
    /* 4. 计算哈希桶位置 */
    i = major_to_index(major);
 
    /* 5. 在链表中找到合适的插入位置(按主设备号、次设备号排序) */ 

    for (cp = &chrdevs[i]; *cp; cp = &(*cp)->next) {
        if ((*cp)->major > major ||
           ((*cp)->major == major && 
           ((*cp)->baseminor >= baseminor || 
           (*cp)->baseminor + (*cp)->minorct > baseminor))) 
             break; 
        }
 
    /* 6. 检查次设备号是否冲突(三种重叠检测) */ 
    if (*cp && (*cp)->major == major) {
        int old_min = (*cp)->baseminor;
        intold_max= (*cp)->baseminor+ (*cp)->minorct-1;
        intnew_min=baseminor;
        intnew_max=baseminor+minorct-1;

        /* 新范围与已有范围重叠 → 冲突 */ 
        if (new_max >= old_min && new_max <= old_max) {
            ret = -EBUSY;
            goto out;
        }
        if (new_min <= old_max && new_min >= old_min) {
            ret = -EBUSY;
            goto out;
        }

        /* 新范围完全覆盖已有范围 */ 
        if (new_min < old_min && new_max > old_max) {
            ret = -EBUSY;
            goto out;
        }
    }

    /* 7. 插入链表 */
    cd->next = *cp;
    *cp = cd;
    mutex_unlock(&chrdevs_lock);
    return cd;
out:  
    mutex_unlock(&chrdevs_lock);
    kfree(cd);
    return ERR_PTR(ret);
}

🔑 冲突检测逻辑解析: 内核在插入新设备号时,会检查三种可能的冲突场景:

  1. 新范围尾部与已有范围重叠: new_max落在 [old_min, old_max]区间内

  2. 新范围头部与已有范围重叠: new_min落在 [old_min, old_max]区间内

  3. 新范围完全覆盖已有范围: new_min < old_min且 new_max > old_max

只有三种情况都不满足时,才认为设备号范围无冲突,可以安全注册。

1.4 find_dynamic_major 函数

当驱动调用alloc_chrdev_region(传入 major=0)请求动态分配设备号时,内核会调用find_dynamic_major从空闲范围中查找可用的主设备号:

复制代码
static int find_dynamic_major(void){  
    int i;
    struct char_device_struct *cd;
    /* 高优先级:在 234~254 范围内查找 */ 
    for (i = ARRAY_SIZE(chrdevs) - 1; i >= CHRDEV_MAJOR_DYN_END; i--) {
        if (chrdevs[i] == NULL)  
        return i;
    }
    /* 低优先级:在 511~384 范围内查找 */
    for (i = CHRDEV_MAJOR_DYN_EXT_START;  i >= CHRDEV_MAJOR_DYN_EXT_END; i--) {
        for (cd = chrdevs[major_to_index(i)]; cd; cd = cd->next) {
            if (cd->major == i)  
                break;
        }  
        if (cd == NULL)
            return i; 
        }  
    return -EBUSY;
}

📌 两级查找策略:

优先级 查找范围 说明
234 ~ 254 传统动态分配区域,"向后兼容"的保留区间
511 ~ 384 扩展动态分配区域,满足更多设备注册需求

相关宏定义:

  • CHRDEV_MAJOR_MAX 512

  • CHRDEV_MAJOR_DYN_END 234

  • CHRDEV_MAJOR_DYN_EXT_START 511

  • CHRDEV_MAJOR_DYN_EXT_END 384

1.5 设备号分配的两大对外接口

内核提供了两种设备号分配方式,对应不同的使用场景:

接口 方式
register_chrdev_region() 静态分配
alloc_chrdev_region() 动态分配

两者最终都调用__register_chrdev_region()完成实际注册操作。在Linux系统中,可以通过cat /proc/devices查看所有已注册的设备号列表。设备号注销时,无论静态还是动态分配,统一调用unregister_chrdev_region()归还资源


二、字符设备对象(cdev)

本小节的主线是了解两个结构体的设计,分别是内核字符设备结构体cdev,以及管理字符设备的结构体 kobj_map。

2.1 cdev 结构体

struct cdev是内核中表示字符设备 的核心数据结构。每个字符设备驱动都需要创建一个cdev实例,并将其注册到内核中:

复制代码
/* 摘自 include/linux/cdev.h */
struct cdev {  
    struct kobject kobj;     /* 内嵌的kobject,用于设备模型管理 */
    struct module *owner;    /* 指向所属模块,通常为THIS_MODULE */
    const struct file_operations *ops; /* 设备操作函数集 */
    struct list_head list;   /* 用于将cdev链接到对应设备号的cdev列表 */
    dev_t dev;               /* 记录该字符设备关联的起始设备号 */
    unsigned int count;      /* 从dev开始连续占用的次设备号数量 */
} __randomize_layout;
成员变量 作用
kobj 嵌入的内核对象,使 cdev 能被 sysfs 设备模型管理
owner 指向拥有该设备的模块,用于引用计数管理
ops 指向设备操作函数集(open/read/write/ioctl 等)
list 链表节点,用于将使用该 cdev 的 inode 链接起来
dev 起始设备号(主设备号 + 次设备号)
count 该 cdev 管理的连续次设备号数量

2.2 kobj_map 结构体(哈希表)

struct kobj_map是一个内核内部结构体,定义在drivers/base/map.c中,用于建立设备号(dev_t)到 struct cdev 的快速查找映射

  • kobj_map负责将设备号范围映射到对应的 struct cdev结构体。当内核通过设备号访问字符设备时(如打开设备文件时),会根据设备号在 kobj_map中查找对应的 struct cdev,从而获得其操作函数集

  • void *data指针中保存了cdev结构体指针

  • probes数组是一个哈希桶,每个桶是一个链表,链表节点记录了设备号范围及对应的data(通常指向struct cdev)和获取kobject的函数

  • 通过cdev_add将一个cdev添加到系统中时,实际上就是在kobj_map中插入一个节点

复制代码
/* 摘自 drivers/base/map.c */
struct kobj_map {  
    struct probe {  
        struct probe *next;    /* 链表下一个节点 */
        dev_t dev;             /* 起始设备号 */ 
        unsigned long range;   /* 次设备号范围 */
        struct module *owner;  /* 模块所有者 */ 
        kobj_probe_t *get;     /* 获取kobject的函数(用于查找) */
        int (*lock)(dev_t, void *); /* 锁定函数 */ 
        void *data;            /* 私有数据,通常指向cdev */ 
    } *probes[255];            /* 255个桶的哈希表 */ 
    struct mutex *lock;        /* 保护映射表的锁 */
};

🔑 chrdevs 和 cdev_map ------ 两套哈希表的职责分工:

哈希表 存储内容 职责
chrdevs 已分配的设备号范围 设备号资源管理,防止冲突
cdev_map 设备号到 cdev 映射 运行时快速查找设备驱动

三、cdev 注册函数

本小节的主线是内核如何将用户定义的cdev 结构体注册到内核cdev_map

3.1 cdev_init 函数

cdev_init用于初始化一个已经分配的cdev结构体,将其与文件操作集关联,并设置内嵌 kobject的类型:

复制代码
void cdev_init(struct cdev *cdev, const struct file_operations *fops) {
    memset(cdev, 0, sizeof *cdev); /* 清零结构体 */
    INIT_LIST_HEAD(&cdev->list);   /* 初始化链表头 */
    kobject_init(&cdev->kobj, &ktype_cdev_default); /* 初始化内嵌kobject */ 
    cdev->ops = fops;              /* 绑定文件操作集 */
}
EXPORT_SYMBOL(cdev_init);

EXPORT_SYMBOL宏将该函数导出到内核符号表,使内核模块(.ko 文件)可以调用它。

3.2 cdev_add 函数

完成设备号,次设备数量等参数记录,并将字符设备注册到内核

复制代码
int cdev_add(struct cdev *p, dev_t dev, unsigned count) {
    int error;
    p->dev = dev; /* 记录起始设备号 */
    p->count = count; /* 记录次设备号数量 */

    /* 调用kobj_map注册,将设备号范围映射到该cdev */
    error = kobj_map(cdev_map, dev, count, NULL,  exact_match, exact_lock, p);
    if (error)
        return error;

    /* 增加模块引用计数,防止模块被卸载 */
    kobject_get(p->kobj.parent);
    return 0;
}

EXPORT_SYMBOL(cdev_add);

3.3 kobj_map 函数

这是将 cdev 插入到全局哈希表cdev_map的核心函数,接下来才可以通过设备号查找对应的字符设备,定义在drivers/base/map.c中:

复制代码
int kobj_map(struct kobj_map *domain, dev_t dev, unsigned long range,  
             struct module *module, kobj_probe_t *probe,  
             int (*lock)(dev_t, void *), void *data) {
    /* 1. 计算跨越了几个不同的主设备号 */  
    unsigned n = MAJOR(dev + range - 1) - MAJOR(dev) + 1;
    unsigned index = MAJOR(dev);
    unsigned i;
    struct probe *p;
    if (n > 255) n = 255;

    /* 安全限制 */
    /* 2. 分配 n 个连续的 probe 结构体 */  
    p = kmalloc_array(n, sizeof(struct probe), GFP_KERNEL);
    if (p == NULL) return-ENOMEM;

    /* 3. 初始化 n 个 probe(每个的 data 都指向同一个 cdev) */  
    for (i = 0; i < n; i++, p++) {  
        p->owner = module;  
        p->get = probe;  
        p->lock = lock;  
        p->dev = dev;  
        p->range = range;  
        p->data = data; 
    }

    /* 4. 将 probe 节点插入哈希表,按 range 排序 */  
    mutex_lock(domain->lock);
 
    for (i = 0, p -= n; i < n; i++, p++, index++) {  
        struct probe **s = &domain->probes[index % 255];
        /* 按 range 大小排序,range 小的在前 */  
        while (*s && (*s)->range < range)      
            s = &(*s)->next;  
        p->next = *s; 
        *s = p; 
    }  
    mutex_unlock(domain->lock);
    return 0;
}

💡 为什么按 range 排序? 当查找设备号对应的 cdev 时,kobj_lookup函数会遍历链表,选择range最小且包含该设备号的 probe。这种"最佳匹配"策略确保当多个 cdev 的设备号范围有重叠时,能精确匹配到最具体的那一个。


四、字符设备号与字符设备对象的关系

  • chrdevs------ 设备号资源管理
    记录主设备号的使用区间,防止冲突。在驱动加载时调用register_chrdev_region或alloc_chrdev_region分配设备号时,进行冲突检测。

  • cdev_map------ 运行时查找设备
    根据打开设备文件的设备号,快速找到cdev结构体,获得file_operations。在cdev_add函数中调用kobj_map函数构建哈希表。


五、次设备号的解析与多实例管理

本小节用一个非常简单的例子解析为什么需要区分主次设备号,一个主设备号是如何与多个次设备号关联起来的

在实际应用中,多个功能相同的子设备可以共用同一套驱动程序

  • 不同子设备功能逻辑一样,操作函数集(open、read、write等)完全相同

  • 没必要为每个次设备号都分配一个 cdev,只需要一个cdev就能管理多个实例

  • 用户空间看到三个独立的设备文件(/dev/device1对应次设备号 0,device2对应 1,device3对应 2),但内核中都导向同一个 file_operations

5.1 注册 cdev

复制代码
dev_t devno = MKDEV(major, 0); /* 起始次设备号 0 */
cdev_init(&my_cdev, &fops);
cdev_add(&my_cdev, devno, 3);  /* 占用次设备号 0, 1, 2 */

5.2 定义设备私有结构体和数组

复制代码
struct my_device {  
    void __iomem *regs;   /* 该设备的寄存器映射地址 */  
    struct mutex lock;    /* 互斥锁 */
    /* ... 其他私有数据 */
};
static struct my_device devs[3];  /* 三个实例 */

5.3 在 open 中用次设备号绑定私有数据

复制代码
static int my_open(struct inode *inode, struct file *filp) {
    int minor = iminor(inode);   /* 获取次设备号 */  
    if (minor < 0 || minor >= 3)  
        return -ENODEV;
 
    struct my_device *dev = &devs[minor]; /* 根据次设备号选择设备 */  
    filp->private_data = dev;             /* 保存到私有数据中 */  
    return 0;
}

📌 次设备号使用: 这种"一个 cdev → 多个次设备号 → 多个设备实例"的模式是 Linux 驱动开发中的标准做法。通过 iminor(inode)获取次设备号,再通过 filp->private_data保存设备私有数据,后续的 read/write/ioctl 操作都能通过 filp->private_data获取到正确的设备实例


六、mknod与 open系统调用的完整流程

本小节介绍从用户态打开一个字符设备驱动的完整流程,关注内核如何通过字符设备号找到对应的字符设备结构体,以及用户定义的操作函数如何被替换

6.1 系统调用的整体流程

让我们从用户态的一条命令开始

复制代码
mknod /dev/mydevice c 250 0

这个命令触发的过程被拆解为四个关键步骤:

  1. 使用mknod创建一个字符设备 inode 节点,该节点中保存了默认 open 函数chrdev_open。节点定义在/dev/mydevice

  2. 在用户空间使用open函数,传入"/dev/mydevice"

  3. 通过一系列系统调用,最后到达 do_dentry_open函数,该函数会调用inode节点对应的默认 open 函数chrdev_open

  4. chrdev_open函数在全局哈希表cdev_map中,根据设备号找到用户定义的f_ops操作函数,并替换inode和file中的操作函数。至此,用户自定义的操作函数正式被调用

6.2 mknod创建 node节点

在控制台调用 mknod函数时,经过一系列系统调用,最后会调用 init_special_inode函数。该函数是 VFS 层处理特殊文件的基石,根据文件类型为 inode挂载合适的操作函数表,并保存必要的设备号信息。

复制代码
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev) {
    inode->i_mode = mode;             /* 保存文件类型和权限 */
    if (S_ISCHR(mode)) {
        /* 字符设备文件 */  
        inode->i_fop = &def_chr_fops; /* 默认字符设备操作表 */
        inode->i_rdev = rdev;         /* 保存设备号 */ 
    } else if (S_ISBLK(mode)) {
        /* 块设备文件 */  
        inode->i_fop = &def_blk_fops;  
        inode->i_rdev = rdev; 
    } else if (S_ISFIFO(mode))
        /* 命名管道 */  
        inode->i_fop = &pipefifo_fops;  
    else if (S_ISSOCK(mode)) ;
        /* 套接字不设置操作表 */  
    else
        printk(KERN_DEBUG "bogus i_mode for inode %s:%lu\n",  inode->i_sb->s_id, inode->i_ino);
}

🔑 理解: 此时 inode 中的 i_fop指向的是 def_chr_fops,一个仅包含默认 open 方法 的操作表。真正的驱动专用操作函数表要等到用户调用 open()时,才会通过 chrdev_open函数动态替换。这是一种**"延迟绑定"** 的设计模式

6.3 def_chr_fops ------ 字符设备的默认操作函数

复制代码
/* 来源: fs/char_dev.c */
const struct file_operations def_chr_fops = {
    .open = chrdev_open, /* 只定义了一个 open 方法 */
    .llseek = noop_llseek, /* 一个什么都不做的寻址函数 */
};

6.4 chrdev_open ------ 字符设备 open 默认函数

chrdev_open是整个字符设备驱动机制中最关键的桥接函数:

复制代码
/* 来源: fs/char_dev.c */
static int chrdev_open(struct inode *inode, struct file *filp) {
    struct cdev *p;
    struct cdev *new = NULL;
    int ret=0;
    /* 第一步:获取或查找 cdev */
    p = inode->i_cdev;
    if (!p) {
        struct kobject *kobj;
        int idx;

        /* 根据设备号在全局哈希表 cdev_map 中查找对应的 cdev */
        kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
        if (!kobj)  
            return -ENXIO;

        /* 通过 kobject 反推出包含它的 cdev */
        new = container_of(kobj, struct cdev, kobj);

        /* 并发安全:再次检查并设置 inode->i_cdev(缓存优化) */
        p = inode->i_cdev;
        if (!p) {
            inode->i_cdev = p = new;
            inode->i_cindex = idx;
            list_add(&inode->i_devices, &p->list);
            new = NULL; 
        }
    }

    /* 第二步 ★核心★ 替换文件操作表 */
    /* 将 file 的 f_op 从 def_chr_fops 替换为驱动自己的 fops */
    filp->f_op = fops_get(p->ops);
    if (!filp->f_op) 
        return -ENXIO;
 
    /* 第三步:调用驱动自身的 open 函数 */
    if (filp->f_op->open) {
        ret = filp->f_op->open(inode, filp);
    }
    return ret;
}

🔑 chrdev_open 三步走核心逻辑:

  1. 查找 cdev: 通过kobj_lookup()在全局哈希表中查找,用container_of反推出 cdev。首次查找后缓存到inode->i_cdev

  2. 替换 f_op: filp->f_op = fops_get(p->ops)------最关键的一步,将 file 的文件操作表从def_chr_fops替换为驱动自己的file_operations

  3. 调用驱动 open: 如果驱动实现了自己的 open 方法,则调用它。至此,后续对该文件的 read/write 等操作都会直接调用驱动的对应函数。

6.5 open 函数的完整调用链

6.5.1 用户态 open() 到系统调用入口

用户态的open()是 glibc 等 C 库提供的封装函数,它最终会触发系统调用__NR_open。在 x86-64 架构上,通过syscall指令陷入内核。

6.5.2 内核通用打开流程:do_sys_open → do_filp_open

do_sys_open 将用户态的文件路径名拷贝到内核空间,构造open_how结构(包含打开标志和模式),然后调用do_sys_openat2:

复制代码
SYSCALL_DEFINE3(open, const char __user *, filename, 
                int, flags, umode_t, mode) {
    return do_sys_open(AT_FDCWD, filename, flags, mode);
}
 

long do_sys_open(int dfd, const char __user *filename, 
                 int flags, umode_t mode) {
    struct open_how how = build_open_how(flags, mode);
    return do_sys_openat2(dfd, filename, &how);
}

do_sys_openat2 执行三个关键动作:

复制代码
static long do_sys_openat2(int dfd, const char __user *filename, 
                           struct open_how *how) {
    struct filename *tmp = getname(filename);     /* 拷贝路径到内核空间 */
    int fd = get_unused_fd_flags(how->flags);     /* 分配空闲fd */
    struct file *f = do_filp_open(dfd, tmp, how); /* ★路径查找并打开★ */
    d_install(fd, f);                             /* 关联 fd 与 file */
    return fd;
}

6.5.3 路径查找与打开:path_openat → vfs_open

do_filp_open最终调path_openat(定义在 fs/namei.c),它完成路径的遍历(逐级查找目录项),找到 inode 节点 ,最后调用vfs_open来实际打开文件。

6.5.4 do_dentry_open ------ 核心函数

do_dentry_open负责初始化struct file,并根据 inode 类型设置文件操作表,最终调用open方法。对于字符设备文件,inode->i_fop指向的是def_chr_fops。所以f->f_op->open实际上就是chrdev_open:

复制代码
static int do_dentry_open(struct file *f, struct inode *inode,  
                          int (*open)(struct inode *, struct file *)) {
    f->f_mode = OPEN_FMODE(f->f_flags) | FMODE_LSEEK |  FMODE_PREAD | FMODE_PWRITE;
    f->f_inode = inode;
    f->f_mapping = inode->i_mapping;

    /* ★获取文件操作表:优先使用 inode->i_fop★ */
    f->f_op = fops_get(inode->i_fop);
    if (!f->f_op)  
        return -ENXIO;
    if (open)  
        error = open(inode, f);

    /* 否则调用文件操作表中的 open 方法 */
    /* 对于字符设备 = chrdev_open! */
    if (!error && (f->f_mode & FMODE_OPENED) && f->f_op->open)
        error = f->f_op->open(inode, f);
    return error;
}

📌 完整调用链路总结:

用户态 open()→ sys_open()→

do_sys_open()→do_sys_openat2()→

do_filp_open()→ path_openat()→

vfs_open()→do_dentry_open()→

chrdev_open()(替换 fops)→ 驱动的 open()


七、字符设备驱动开发中的函数详解

7.1 container_of 宏

container_of是 Linux 内核中常用的宏之一,它通过一个结构体成员的指针,反向获取包含该成员的结构体的起始地址:

复制代码
/**
* container_of - 通过结构体成员指针获取父结构体指针
* @ptr: 成员变量的指针
* @type: 包含该成员的结构体类型
* @member: 成员变量在结构体中的名称
*/
#define container_of(ptr, type, member) ({ \
    void *__mptr = (void *)(ptr); \
    BUILD_BUG_ON_MSG(!__same_type(*(ptr), ((type *)0)->member) && \
    !__same_type(*(ptr), void), \
    "pointer type mismatch in container_of"); \
    ((type *)(__mptr - offsetof(type, member))); })

/* 简化理解版本: */
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

该函数根据结构体成员变量的指针,通过该变量相对于结构体的偏移,得到了该变量对应的结构体的指针。这是一个非常灵活的用法,通过保存某个结构体变量的指针,可以通过该指针反向推出该结构体的指针。

💡 container_of 的应用场景: 在 Linux 内核中,这个宏无处不在。比如在chrdev_open中,内核通过kobj_lookup拿到了 cdev 内嵌的kobject的指针,然后通过container_of(kobj, struct cdev, kobj)反推出完整的cdev结构体指针。这种"内嵌 + 反推"的设计是内核面向对象编程思想的经典体现

7.2 register_chrdev_region

register_chrdev_region是 Linux 内核中用于注册字符设备编号范围的函数。该函数为驱动程序预留一段连续的设备号(主设备号 + 起始次设备号),后续将字符设备(通过cdev_add)绑定到这些设备号上

复制代码
/**
* register_chrdev_region - 注册字符设备编号范围
* @first: dev_t 类型,指定要注册的起始设备号(使用 MKDEV(major, minor) 宏生成)
* @count: 需要注册的连续设备号数量(次设备号的范围)
* @name: 设备名称,会出现在 /proc/devices 文件中
* 返回值: 成功返回 0
* 参数无效返回 -EINVAL
* 设备号被占用返回 -EBUSY
*/
int register_chrdev_region(dev_t first, unsigned int count, const char *name);

7.3 THIS_MODULE

THIS_MODULE是一个宏,定义在<linux/module.h>头文件中。它本质上是一个指向当前模块的struct module结构体的指针:

  • 当代码被编译为可加载内核模块(.ko)时,THIS_MODULE指向该模块的struct module实例

  • 当代码被静态编译进内核(built-in)时,THIS_MODULE通常被定义为NULL或一个无实际作用的占位符

复制代码
static struct file_operations my_fops = {
    .owner = THIS_MODULE,  /* 防止模块在使用中被卸载 */
    .open = my_open, .read = my_read,
    .write = my_write,
    .release = my_release,
};

 
struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
cdev_add(&my_cdev, devno, count);

内核通过owner字段知道哪个模块拥有这个字符设备。当用户空间程序通过系统调用(如 open)打开设备文件时,内核会自动调用try_module_get(THIS_MODULE)增加模块的引用计数;当设备被关闭时,内核会调用module_put(THIS_MODULE)减少引用计数。

🔑 这样做的目的是确保模块在设备被打开期间不会被卸载。 如果用户正在使用设备,而管理员执行 rmmod试图卸载驱动,内核会检查模块的引用计数是否为零。若不为零(表示设备正在被使用),则卸载操作会被拒绝,防止因模块代码突然消失而导致系统崩溃。这是一个经典的"资源使用计数"保护模式。

7.4 file_operations 结构体

file_operations是把系统调用和驱动程序关联起来的关键数据结构:

函数指针 对应系统调用 说明
owner ------ 指向拥有该结构的模块(THIS_MODULE)
llseek lseek 修改文件读写位置
read read 从设备读取数据
write write 向设备写入数据
open open 打开设备文件
release close 关闭设备文件(引用计数归零时)
unlocked_ioctl ioctl 设备控制操作(无 BKL 版本)
mmap mmap 将设备内存映射到用户空间
poll poll/select 询问设备是否可非阻塞读写

🎯 总结

本文从内核源码角度,完整梳理了 Linux 字符设备驱动的核心机制:

  1. 设备号管理(chrdevs) :通过 255 大小的哈希数组,利用取模操作管理 0~511 乃至更大的主设备号空间,配合链表按顺序组织,提供了高效的设备号冲突检测机制。

  2. cdev 对象与 kobj_map :cdev 是字符设备的抽象表示,cdev_map 是设备号到 cdev 的映射哈希表。两套哈希表各司其职------chrdevs 管理资源分配,cdev_map 支持运行时查找。

  3. 延迟绑定机制 :inode 创建时只挂载默认的def_chr_fops(仅含 chrdev_open),真正驱动专用的 fops 要等到用户 open 时才通过 chrdev_open 动态替换。

  4. 完整调用链路 :open → sys_open → do_sys_open → do_filp_open → path_openat → do_dentry_open → chrdev_open(替换fops) → 驱动open()

  5. container_of 设计模式 :通过内嵌结构体成员指针反推外围结构体,是 Linux 内核"面向对象"编程思想的集中体现。

  6. 引用计数保护 :通过THIS_MODULE和owner字段,防止设备在使用中被意外卸载。


文章内容为作者过往学习的笔记,接下来会按期更新,预计下一期更新内核结构体kobject解析。如果本文对你有帮助,欢迎点赞、在看、转发,让更多 Linux 驱动开发者受益 🚀