[TOC]

drivers/char 字符设备(Character Devices) 面向字节流的设备驱动模型

历史与背景

这项技术是为了解决什么特定问题而诞生的?

drivers/char 目录及其实现的字符设备(Character Device)模型是Linux乃至整个UNIX家族中最古老、最基础的驱动模型之一。它的诞生源于UNIX的设计哲学——“一切皆文件”(Everything is a file)。

这项技术的核心目标是为那些不以固定大小的数据块(block)进行I/O,而是以连续的字节流(byte stream)方式进行通信的硬件设备提供一个统一、标准的抽象接口。在早期,这主要包括:

  • 终端和串口(TTYs and Serial Ports):用户通过键盘输入字符,屏幕显示字符,这些都是典型的字节流操作。
  • 打印机:向打印机发送要打印的数据流。
  • 磁带机:顺序地读取或写入数据。

通过将这些硬件抽象成文件系统中的一个节点(如 /dev/ttyS0),用户空间的应用程序就可以使用标准的文件I/O系统调用(open, read, write, close)来与硬件交互,而无需了解底层硬件的具体细节。这极大地简化了应用程序的开发,并提供了一致的硬件访问模型。

它的发展经历了哪些重要的里程碑或版本迭代?

字符设备模型虽然古老,但在Linux的发展过程中也经历了重要的演进:

  • 继承UNIX模型:Linux最初完整地继承了UNIX的字符设备模型,使用静态的主设备号(Major Number)和次设备号(Minor Number)来唯一标识一个设备。
  • register_chrdev 时代:在Linux 2.4及更早的内核中,驱动程序通过 register_chrdev() 函数来注册。这个函数会为驱动程序保留一个完整的主设备号(0-255),并将一个 struct file_operations 结构体与之关联。这种方式的主要缺点是:
    • 浪费设备号:即使一个驱动只管理一个设备,它也会占用全部256个次设备号。
    • 主设备号冲突:主设备号是稀缺资源,开发者需要手动选择一个未被使用的主设备号,容易产生冲突。
  • cdev 框架的引入(Linux 2.6):这是字符设备模型最重大的里程碑。为了解决上述问题,内核引入了一个更灵活、更健壮的cdev(character device)框架。
    1. 动态设备号分配:引入 alloc_chrdev_region() 函数,允许驱动程序动态地申请一段设备号(主/次设备号范围),避免了硬编码和冲突。
    2. cdev 结构体:引入 struct cdev,它将 file_operations 与一个或多个设备号关联起来。这使得设备号的分配与字符设备的注册解耦。驱动程序通过 cdev_init()cdev_add() 来激活字符设备。
  • sysfsudev的集成:随着统一设备模型的引入,cdev_add() 会触发内核向用户空间发送uevent。用户空间的udev守护进程接收到事件后,可以自动在 /dev 目录下创建对应的设备文件节点,并设置正确的权限。这取代了过去需要手动执行 mknod 命令的繁琐操作。

目前该技术的社区活跃度和主流应用情况如何?

字符设备模型是Linux内核的基石,极其稳定和成熟。虽然其核心机制不常变动,但它仍然是内核中最活跃、应用最广泛的驱动模型之一。几乎所有不属于块设备和网络设备的驱动,都会通过字符设备接口暴露给用户空间。其应用包括:

  • TTY子系统:管理所有的终端、串口和伪终端。
  • 输入子系统:鼠标、键盘等设备通过 /dev/input/eventX 字符设备节点提供事件流。
  • GPU驱动:通过DRM(Direct Rendering Manager)在 /dev/dri/cardX 提供 ioctl 接口,用于复杂的图形命令提交。
  • RTC(实时时钟):通过 /dev/rtc0 提供时间访问。
  • FPGA、I/O卡等专用硬件:通常会提供一个字符设备接口用于配置和数据交换。
  • 内核虚拟设备:如 /dev/null, /dev/zero, /dev/random 等。

核心原理与设计

它的核心工作原理是什么?

字符设备的核心工作原理是通过主/次设备号,将文件系统中的设备节点与内核中的驱动程序关联起来

  1. 注册:驱动程序在初始化时,首先向内核申请一段设备号(alloc_chrdev_region),然后初始化一个 cdev 结构体,该结构体中包含了指向 file_operations 结构体的指针。最后,通过 cdev_addcdev 和申请到的设备号注册到内核的一个全局表中。
  2. 用户空间访问:一个用户进程调用 open("/dev/mydevice", ...)
  3. VFS(虚拟文件系统)层:VFS根据路径找到对应的inode。它发现这是一个字符特殊文件,并从中读取到主设备号和次设备号。
  4. 分派:VFS使用主设备号在内核的字符设备表中查找对应的cdev结构。
  5. 调用驱动:找到cdev后,VFS就获取了其内部的 file_operations 指针。VFS会调用这个结构体中的 .open 函数,并将 inodefile 结构体作为参数传递给驱动。
  6. 后续操作:一旦 open 成功,用户进程后续的 read, write, ioctl, close 等系统调用都会通过VFS被精确地分派到该驱动 file_operations 结构中对应的函数指针上。

struct file_operations 是这个模型的核心,它定义了驱动能对设备文件执行的所有操作。

它的主要优势体现在哪些方面?

  • 标准统一的API:应用程序开发者可以使用熟知的文件I/O API来操作各种不同的硬件,学习成本低,可移植性好。
  • 简单:对于流式设备,这个模型非常直观和简单。
  • 灵活性(ioctlioctl(I/O Control)系统调用为模型提供了一个强大的“后门”。对于那些无法通过简单的read/write表达的复杂、设备专属的操作,可以通过ioctl来实现,这使得该模型几乎可以适配任何类型的设备。
  • 内核集成:与VFS、权限管理、文件描述符等标准内核机制无缝集成。

它存在哪些已知的劣劣势、局限性或在特定场景下的不适用性?

  • 不适合随机访问:字符设备被设计为顺序的数据流。它没有内置的寻址或随机访问(seeking)能力。虽然可以通过 llseek 回调实现,但这并非其典型用法,且效率不高。
  • ioctl的滥用ioctl虽然灵活,但它也是一把双刃剑。它的参数是无类型的整数和指针,缺乏标准化,容易导致API混乱、难以维护,并且可能引入安全漏洞(如信息泄露、内核地址传递等)。
  • 无通用缓冲层:与块设备不同,字符设备模型本身不提供通用的缓冲或缓存机制(如页缓存)。所有缓冲策略都需要驱动程序自行实现。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?请举例说明。

字符设备是处理任何面向流、非结构化或基于命令的硬件交互的首选解决方案。

  • 串行通信:如UART、RS-232/485设备。数据是一个接一个字节地发送和接收的,完美契合字节流模型。
  • 伪设备(Pseudo-devices)
    • /dev/null:一个数据“黑洞”,写入它的任何数据都被丢弃。
    • /dev/zero:一个数据源,读取它会得到无穷无尽的零字节。
    • /dev/random:一个数据源,提供高质量的随机数流。
  • 硬件控制接口:当一个设备的主要功能不是数据传输,而是配置和控制时,字符设备接口(主要是ioctl)是理想选择。例如,配置一个音频编解码器(Codec)的音量、路由,或者向一个FPGA下发配置命令。
  • 简单的用户态驱动接口:对于一些简单的硬件,可以通过UIO(Userspace I/O)或直接mmap字符设备提供的内存区域,让用户空间程序直接访问硬件寄存器。

是否有不推荐使用该技术的场景?为什么?

  • 存储设备:如硬盘、SSD、SD卡、U盘等。这些设备的基本操作单元是扇区或块,并且需要高效的随机访问能力和复杂的I/O调度(如电梯算法)。这些场景必须使用块设备模型(drivers/block)。
  • 网络设备:如网卡。网络通信是基于数据包(packet)的,有复杂的协议栈和异步处理模型。它们必须使用专门的网络设备模型(struct net_device 和套接字接口)。

对比分析

请将其 与 其他相似技术 进行详细对比。

字符设备最直接的对比对象是Linux内核中的另外两大驱动模型:块设备和网络设备。这里主要对比字符设备和块设备。

特性 字符设备 (Character Device) 块设备 (Block Device)
访问方式 字节流。顺序访问,没有固定的I/O单元大小。 数据块。随机访问,I/O操作的基本单位是固定大小的块(如512字节或4KB)。
缓冲/缓存 无通用缓冲层。驱动程序需自行实现缓冲。 深度集成内核缓冲机制。通过页缓存(Page Cache)进行高效的读写缓存,并由I/O调度器优化请求队列。
内核接口 struct file_operations,直接与VFS交互。 struct block_device_operations 和请求队列(request queue)机制。驱动处理的是封装好的struct biostruct request
I/O调度 read/write请求通常被直接、同步地传递给驱动。 。内核I/O调度器(如BFQ, Kyber)会对请求进行合并、排序,以优化寻道时间或保证公平性。
典型设备 串口、终端、鼠标、键盘、声卡、各种控制接口。 硬盘驱动器(HDD)、固态硬盘(SSD)、SD卡、U盘、RAM disk。
Sysfs路径 通常位于 /sys/class/<class_name>/ 通常位于 /sys/block/
设备节点 /dev/ttyS0, /dev/input/event0, /dev/null /dev/sda, /dev/sdb1, /dev/nvme0n1

drivers/char/random.c

random_init_early

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/*
* 这被称为极早的,在计时功能之前
* 可用,但 Arch Randomness 是。尚未启用中断。
*/
void __init random_init_early(const char *command_line)
{
unsigned long entropy[BLAKE2S_BLOCK_SIZE / sizeof(long)];
size_t i, longs, arch_bits;

#if defined(LATENT_ENTROPY_PLUGIN)
static const u8 compiletime_seed[BLAKE2S_BLOCK_SIZE] __initconst __latent_entropy;
_mix_pool_bytes(compiletime_seed, sizeof(compiletime_seed));
#endif

for (i = 0, arch_bits = sizeof(entropy) * 8; i < ARRAY_SIZE(entropy);) {
longs = arch_get_random_seed_longs(entropy, ARRAY_SIZE(entropy) - i);
if (longs) {
_mix_pool_bytes(entropy, sizeof(*entropy) * longs);
i += longs;
continue;
}
longs = arch_get_random_longs(entropy, ARRAY_SIZE(entropy) - i);
if (longs) {
_mix_pool_bytes(entropy, sizeof(*entropy) * longs);
i += longs;
continue;
}
arch_bits -= sizeof(*entropy) * 8;
++i;
}

_mix_pool_bytes(init_utsname(), sizeof(*(init_utsname())));
_mix_pool_bytes(command_line, strlen(command_line));

/* Reseed if already seeded by earlier phases. */
if (crng_ready())
crng_reseed(NULL);
else if (trust_cpu)
_credit_init_bits(arch_bits);
}

random_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* 这是在前一个函数之后调用的一点,现在可以访问时间戳计数器。尚未启用中断。
*/
void __init random_init(void)
{
unsigned long entropy = random_get_entropy();
ktime_t now = ktime_get_real();

_mix_pool_bytes(&now, sizeof(now));
_mix_pool_bytes(&entropy, sizeof(entropy));
add_latent_entropy();

/*
* If we were initialized by the cpu or bootloader before jump labels
* or workqueues are initialized, then we should enable the static
* branch here, where it's guaranteed that these have been initialized.
*/
if (!static_branch_likely(&crng_is_ready) && crng_init >= CRNG_READY)
crng_set_ready(NULL);

/* Reseed if already seeded by earlier phases. */
if (crng_ready())
crng_reseed(NULL);

WARN_ON(register_pm_notifier(&pm_notifier));

WARN(!entropy, "Missing cycle counter and fallback timer; RNG "
"entropy collection will consequently suffer.");
}

drivers/char/mem.c 特殊内存设备(Special Memory Devices) 提供对物理内存和多种经典伪设备的访问

历史与背景

这项技术是为了解决什么特定问题而诞生的?

这项技术并非为了解决一个单一的问题,而是为了在Linux内核中实现一组基础的、符合Unix“一切皆文件”哲学的特殊字符设备。这些设备为用户空间提供了一些最基本、最核心的抽象概念的访问接口。

它具体解决了以下几类问题:

  • 物理内存访问:为底层调试工具、诊断程序以及一些特殊的驱动程序提供直接读写物理内存(/dev/mem)和内核虚拟内存(/dev/kmem)的能力。在操作系统发展的早期,这是一种至关重要的底层交互方式。
  • 提供数据源与汇:创建标准化的“数据源”(Source)和“数据汇”(Sink)。例如,需要一个无限的、不消耗资源的“垃圾桶”(/dev/null)来丢弃不需要的输出;需要一个无限的零字节流(/dev/zero)来初始化文件或内存区域。
  • 提供随机性:需要一个可靠的、高质量的随机数来源(/dev/random, /dev/urandom),为加密、安全协议和科学模拟等应用提供基础。
  • 错误处理测试:需要一个设备来模拟“磁盘已满”的情况(/dev/full),以方便开发者测试其应用程序的错误处理路径。

它的发展经历了哪些重要的里程碑或版本迭代?

drivers/char/mem.c是内核最古老的文件之一,其内容是逐步演进和增强的。

  1. 经典设备的实现/dev/null, /dev/zero等设备自Unix早期就已存在,Linux在诞生之初就实现了它们。
  2. 随机数生成器的革命:Linux的随机数生成器由Theodore Ts’o实现,是早期操作系统中的一大进步。它引入了基于**熵池(Entropy Pool)**的模型,从键盘、鼠标、磁盘I/O等不可预测的事件中收集“熵”,从而产生高质量的伪随机数,这使其成为一个密码学安全的伪随机数生成器(CSPRNG)。
  3. /dev/mem/dev/kmem 的安全限制:这是最重要的里程碑。随着Linux对安全性的日益重视,直接暴露物理和内核内存被视为巨大的安全风险。内核引入了CONFIG_STRICT_DEVMEM配置选项。在该选项开启时(现在绝大多数发行版的默认设置),对/dev/mem的访问被严格限制在设备内存映射的“白名单”区域,完全禁止了对常规RAM的访问。对/dev/kmem的访问则基本被完全禁止,使其在现代系统上名存实亡。
  4. getrandom(2)系统调用:为了提供一个比打开和读取/dev/urandom更高效、更安全的获取随机数的接口,内核引入了getrandom(2)系统调用。

目前该技术的社区活跃度和主流应用情况如何?

这个文件中的设备可以分为两类,其活跃度和应用情况截然不同:

  • 日常核心组件 (/dev/null, /dev/zero, /dev/urandom, /dev/full):这些设备是所有Linux/Unix-like系统的基石,被无数的shell脚本、系统服务和应用程序频繁使用。它们的代码极其稳定,几乎不需要改动。
  • 受限的底层工具 (/dev/mem, /dev/kmem, /dev/port):这些设备在现代通用桌面和服务器系统上的使用已非常罕见,主要限于嵌入式系统开发、硬件诊断、内核调试等高度专业的领域。它们的使用通常被认为是不安全的,并受到内核的严格限制。

核心原理与设计

它的核心工作原理是什么?

drivers/char/mem.c的本质是一个字符设备驱动程序,它并不驱动任何真实的物理硬件,而是通过实现file_operations结构中的回调函数,在软件层面模拟了这些特殊设备的行为。

  1. 设备注册:在驱动初始化时,它会为这些设备(mem, kmem, null等)注册主设备号(MEM_MAJOR,通常是1)和不同的次设备号。
  2. file_operations 分派:当用户空间open()一个设备文件时(如/dev/null),VFS会根据其次设备号,将后续的read, write等操作分派给不同的file_operations实例。
    • /dev/null (null_fops)
      • .write(): 接收数据,但不做任何事,然后直接返回写入的字节数,表示“成功写入”。
      • .read(): 不返回任何数据,直接返回0,表示文件结束(EOF)。
    • /dev/zero (zero_fops)
      • .read(): 不论读取多少字节,都向用户提供的缓冲区中填充零字节。
      • .mmap(): 允许将这个“无限的零”映射到内存,这是创建匿名内存映射(Anonymous mmap)的一种方式。
    • /dev/mem (mem_fops)
      • .read()/.write(): 将文件偏移量(f_pos)视为物理地址。在进行读写前,会通过devmem_is_allowed()进行严格的安全检查。如果允许,它会使用copy_to_user/copy_from_user在用户缓冲区和该物理地址之间直接拷贝数据。
    • /dev/random/dev/urandom (random_fops)
      • .read(): 调用内核随机数生成器中的函数(get_random_bytes等),从熵池中提取随机数据。/dev/random在熵池不足时会阻塞,而/dev/urandom不会。

它的主要优势体現在哪些方面?

  • 标准化和简单性:将复杂的概念(如物理内存、随机性、空洞)抽象成简单的文件接口,极大地简化了用户空间编程和脚本编写。
  • 效率:对于nullzero等设备,其内核实现极其轻量,性能非常高。
  • 基础性:为整个操作系统提供了不可或缺的基础功能。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 严重的安全风险/dev/mem/dev/kmem是最大的劣势。它们打破了操作系统最核心的内存保护和进程隔离模型,允许任何有权访问它们的进程读取/修改内核和其他进程的内存,是潜在的巨大安全漏洞。
  • /dev/mem的局限性:即使在允许访问的情况下,通过read/write系统调用来访问物理内存,会涉及用户态和内核态之间的上下文切换和数据拷贝,对于需要高性能访问设备内存的场景(如显卡驱动),效率不如mmap

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

  • /dev/null:在shell脚本中丢弃不需要的标准输出和标准错误 (command > /dev/null 2>&1)。
  • /dev/zero:创建指定大小的空文件,用于制作交换文件、磁盘镜像或测试 (dd if=/dev/zero of=disk.img bs=1M count=1024)。
  • /dev/urandom:为应用程序(如SSH, GnuPG, web服务器的TLS)提供高质量的随机数种子。这是绝大多数场景下的首选随机数来源。
  • /dev/random:仅用于对随机性要求极高、且可以接受长时间阻塞以等待足够熵的场景(如生成一次性的、非常关键的加密长密钥)。
  • /dev/full:用于开发和测试,以确保应用程序能优雅地处理“磁盘空间不足”(ENOSPC)的错误。
  • /dev/mem:在嵌入式系统或进行硬件调试时,用于从用户空间直接读写硬件寄存器的物理地址。

是否有不推荐使用该技术的场景?为什么?

  • 在任何生产服务器或桌面系统上使用/dev/mem/dev/kmem进行常规操作:这是绝对不推荐的,因为其巨大的安全风险。
  • 现代设备驱动开发:驱动程序不应该要求用户空间通过/dev/mem来访问其设备寄存器。正确的做法是,驱动在内核空间使用ioremap()映射设备内存,并通过mmap()文件操作,将特定的、受控的内存区域映射给用户空间。
  • 需要非阻塞随机数的应用:不应使用/dev/random,因为它可能导致应用无限期挂起。应使用/dev/urandomgetrandom(2)

对比分析

请将其 与 其他相似技术 进行详细对比。

/dev/mem vs. 驱动 mmap 实现

特性 /dev/mem 驱动mmap实现
访问粒度 粗粒度。暴露所有(被允许的)物理内存。 细粒度。只暴露驱动程序明确指定的、属于其设备的内存区域。
控制权 控制权在用户(选择访问哪个地址)。 控制权在驱动(决定哪个区域可以被映射)。
安全性 极低 。这是现代驱动与用户空间进行高性能内存共享的标准、安全的方式。
性能 涉及read/write系统调用和数据拷贝,性能较低。 mmap后,用户空间直接通过指针访问内存,无系统调用开销,性能极高。

/dev/zero vs. fallocate(2)

特性 dd if=/dev/zero ... fallocate(2)
工作模式 数据流模式。从设备读取零字节流,并将其写入文件。 元数据操作模式。直接告知文件系统:“请为这个文件预留空间”。
效率 较低。需要实际地将零写入页面缓存,并最终可能(取决于文件系统)将零块写入磁盘。 非常高。文件系统可以直接分配和标记“未写入的盘区”(unwritten extents),而无需进行任何数据I/O。
磁盘空间 创建的是一个非稀疏文件。 可以创建非稀疏文件,也可以通过FALLOC_FL_PUNCH_HOLE创建稀疏文件。
适用场景 传统的、兼容性最广的创建大文件的方式。 现代的、高性能的文件空间预分配方式。

文件操作集定义:实现/dev/mem, /dev/null等核心设备的行为

本代码片段是mem字符设备驱动的“后端”实现。它为之前注册的各个次设备(如/dev/mem, /dev/null, /dev/zero等)分别定义了专属的file_operations结构体。这个结构体在VFS(虚拟文件系统)中扮演着核心角色,它本质上是一个函数指针表,精确地定义了当用户空间对一个文件执行read, write, mmap等系统调用时,内核应该执行的具体操作。这段代码是实现这些基础设备各自独特行为的关键。

实现原理分析

代码的原理是为每个逻辑设备提供一个定制化的file_operations实例,该实例在memory_open函数(在之前的代码片段中分析过)中被动态地挂载到打开的文件对象上。

  1. 代码重用: 代码开头使用#define将多个操作重定向到null设备的操作上(如write_zero重定向到write_null)。这是一种高效的代码复用策略,因为/dev/zero/dev/null在写操作上的行为是完全一致的(即接受并丢弃所有数据)。
  2. /dev/mem (mem_fops):
    • 此结构体定义了对物理内存的直接访问行为。read_mem会从文件偏移量指定的物理地址读取数据,write_mem会向指定物理地址写入数据。
    • mmap_mem是其最强大的功能,它允许将一段物理地址空间(例如外设寄存器区域)直接映射到进程的地址空间中,从而实现最高效的访问。
  3. /dev/null (null_fops):
    • 定义了“黑洞”设备的行为。read_null不返回任何数据(立即返回EOF,即读取长度为0)。write_null接受任何数据但立即丢弃,总是成功返回写入的字节数。llseek是无操作的(noop),因为文件没有实际内容或大小。
  4. /dev/zero (zero_fops):
    • 定义了“无限零字节源”的行为。read_zero总是返回填满了零字节的缓冲区。其写操作通过宏定义重用了write_null的实现。
    • 它也支持mmap,这是一种在用户空间获取匿名、零初始化的内存区域的标准方法。
  5. /dev/full (full_fops):
    • 定义了“设备已满”的行为。write_full总是失败并返回错误码ENOSPC(设备上没有剩余空间)。其读操作则重用了read_iter_zero,返回零字节流。
  6. 条件编译: 代码使用#ifdef#ifndef来根据内核配置调整结构体的定义。例如,port_fops仅在CONFIG_DEVPORT启用时编译,而mem_fopszero_fops中与无MMU相关的mmap辅助函数仅在!CONFIG_MMU时编译。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 定义别名,复用null设备的操作函数,以减少代码冗余。
#define zero_lseek null_lseek
#define full_lseek null_lseek
#define write_zero write_null
#define write_iter_zero write_iter_null
#define splice_write_zero splice_write_null
// open_mem 和 open_port 可以复用同一个open函数实现。
#define open_mem open_port

// /dev/mem 的文件操作函数集。
// __maybe_unused 告诉编译器,如果CONFIG_DEVMEM未定义,这个变量未使用是正常的。
static const struct file_operations __maybe_unused mem_fops = {
.llseek = memory_lseek, // 定位文件指针(即物理地址)。
.read = read_mem, // 从物理地址读取。
.write = write_mem, // 向物理地址写入。
.mmap = mmap_mem, // 将物理内存映射到进程地址空间。
.open = open_mem, // 打开/dev/mem的特定处理。
#ifndef CONFIG_MMU // 仅在没有MMU的系统上编译以下部分。
.get_unmapped_area = get_unmapped_area_mem, // 为mmap查找合适的地址范围。
.mmap_capabilities = memory_mmap_capabilities, // 报告mmap的能力。
#endif
.fop_flags = FOP_UNSIGNED_OFFSET, // 标志,表示文件偏移量是无符号的。
};

// /dev/null 的文件操作函数集。
static const struct file_operations null_fops = {
.llseek = null_lseek, // 无操作的定位。
.read = read_null, // 读取立即返回EOF。
.write = write_null, // 写入的数据全部被丢弃。
.read_iter = read_iter_null, // read()的现代迭代器版本。
.write_iter = write_iter_null, // write()的现代迭代器版本。
.splice_write = splice_write_null, // 管道写入操作。
.uring_cmd = uring_cmd_null, // io_uring异步接口。
};

#ifdef CONFIG_DEVPORT // 仅在x86等支持端口I/O的架构上编译。
// /dev/port 的文件操作函数集。
static const struct file_operations port_fops = {
.llseek = memory_lseek, // 定位(端口号)。
.read = read_port, // 从I/O端口读取。
.write = write_port, // 向I/O端口写入。
.open = open_port, // 打开/dev/port的特定处理。
};
#endif

// /dev/zero 的文件操作函数集。
static const struct file_operations zero_fops = {
.llseek = zero_lseek, // 定位(无操作,通过宏复用null_lseek)。
.write = write_zero, // 写入(丢弃数据,通过宏复用write_null)。
.read_iter = read_iter_zero, // 读取无限的零字节流。
.read = read_zero, // 旧版的读取接口。
.write_iter = write_iter_zero, // 写入(丢弃,复用write_iter_null)。
.splice_read = copy_splice_read, // 从/dev/zero向管道高效传输零。
.splice_write = splice_write_zero, // 写入管道(丢弃,复用splice_write_null)。
.mmap = mmap_zero, // 映射一块匿名的、零填充的内存。
.get_unmapped_area = get_unmapped_area_zero,
#ifndef CONFIG_MMU // 无MMU系统的特定mmap能力。
.mmap_capabilities = zero_mmap_capabilities,
#endif
};

// /dev/full 的文件操作函数集。
static const struct file_operations full_fops = {
.llseek = full_lseek, // 定位(无操作)。
.read_iter = read_iter_zero, // 读取(返回零字节,复用zero的实现)。
.write = write_full, // 写入(总是失败,返回ENOSPC)。
.splice_read = copy_splice_read, // 从/dev/full向管道传输零。
};

字符设备 “mem” 注册:创建/dev/null, /dev/zero等基础设备

本代码片段是Linux内核中一个基础且至关重要的字符设备驱动。它的核心功能是注册一个主设备号为MEM_MAJOR(通常是1)的字符设备驱动,名为”mem”。这个驱动本身并不实现单一的功能,而是作为一个多路复用器(multiplexer),根据次设备号(minor number)的不同,将文件操作分发给不同的、功能独立的子驱动。通过这种机制,它在系统启动时创建了一系列众所周知的基础和虚拟设备,如/dev/null, /dev/zero, /dev/random, /dev/kmsg等。这些设备是任何POSIX兼容系统正常运行所必需的。

实现原理分析

该驱动的设计模式是一个典型的“分发器”或“前端”驱动,其原理如下:

  1. 设备列表 (devlist): 驱动内部定义一个静态的配置数组devlist。这个数组的索引就是次设备号 (minor number)。数组的每个元素memdev都描述了一个具体的设备,包含:
    • 设备名称(如 “null”)。
    • 一个指向其专用file_operations结构体的指针(如 &null_fops)。
    • 特定的文件模式标志(fmode)。
    • 设备节点权限(mode)。
  2. 注册单一主设备 (chr_dev_init): 在内核初始化时,chr_dev_init函数被调用。它首先通过register_chrdev向内核注册一个单一的字符设备驱动,占用MEM_MAJOR主设备号,并将其顶层的file_operations结构体设置为memory_fops。这意味着,任何对主设备号为MEM_MAJOR的设备文件的open操作,最初都会被路由到memory_fops.open,即memory_open函数。
  3. 分发操作 (memory_open): memory_open函数是实现多路复用的关键。当一个设备文件(例如/dev/null)被打开时:
    • 它通过iminor(inode)获取该文件的次设备号。
    • 使用该次设备号作为索引在devlist数组中查找对应的memdev条目。
    • 最关键的一步: 它将打开的文件对象filpf_op指针,从&memory_fops替换为devlist条目中指定的专用file_operations指针(例如 &null_fops)。
    • 从此以后,所有对该文件描述符的后续操作(read, write, llseek等)都将直接调用被替换后的操作集(如null_fops中的函数),完全绕过了顶层的memory_fops
  4. 设备节点创建 (chr_dev_init): chr_dev_init在注册了主设备后,会注册一个名为”mem”的设备类(mem_class),然后遍历devlist数组。对数组中每一个有效的条目,它都会调用device_create。这个函数会通知udevdevtmpfs/dev/目录下创建相应的设备文件(例如,为次设备号为3的条目创建名为”null”的设备文件,即/dev/null)。
  5. 权限设定 (mem_devnode): mem_class中注册了一个.devnode回调函数mem_devnode。在device_create创建设备节点时,会调用此回调,它会从devlist中查找并设置该设备文件应有的权限(如/dev/null的0666)。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义一个描述内存字符设备的结构体数组。
// 数组的索引即为次设备号(minor number)。
static const struct memdev {
const char *name; // 设备名,用于创建/dev/下的文件名。
const struct file_operations *fops; // 指向该设备专用的文件操作函数集。
fmode_t fmode; // 需要添加到文件对象上的特殊模式标志。
umode_t mode; // 设备文件在/dev/下被创建时的权限。
} devlist[] = {
#ifdef CONFIG_DEVMEM // 如果内核配置了/dev/mem支持
[1] = { "mem", &mem_fops, 0, 0 }, // /dev/mem,次设备号1
#endif
[3] = { "null", &null_fops, FMODE_NOWAIT, 0666 }, // /dev/null,次设备号3
#ifdef CONFIG_DEVPORT // 如果内核配置了/dev/port支持 (主要用于x86)
[4] = { "port", &port_fops, 0, 0 }, // /dev/port,次设备号4
#endif
[5] = { "zero", &zero_fops, FMODE_NOWAIT, 0666 }, // /dev/zero,次设备号5
[7] = { "full", &full_fops, 0, 0666 }, // /dev/full,次设备号7
[8] = { "random", &random_fops, FMODE_NOWAIT, 0666 }, // /dev/random,次设备号8
[9] = { "urandom", &urandom_fops, FMODE_NOWAIT, 0666 },// /dev/urandom,次设备号9
#ifdef CONFIG_PRINTK // 如果内核配置了printk支持
[11] = { "kmsg", &kmsg_fops, 0, 0644 }, // /dev/kmsg,次设备号11
#endif
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// memory_open: "mem"主设备号下所有设备的通用open函数,扮演分发角色。
static int memory_open(struct inode *inode, struct file *filp)
{
int minor;
const struct memdev *dev;

minor = iminor(inode); // 获取被打开文件的次设备号。
if (minor >= ARRAY_SIZE(devlist))
return -ENXIO; // 次设备号无效,设备不存在。

dev = &devlist[minor]; // 根据次设备号查找对应的设备定义。
if (!dev->fops)
return -ENXIO; // 如果该次设备号未定义,则设备不存在。

// 核心步骤:将文件操作指针从通用的memory_fops替换为设备专用的fops。
filp->f_op = dev->fops;
filp->f_mode |= dev->fmode; // 添加设备特定的文件模式。

// 如果专用的fops有自己的open函数,则调用它进行进一步的初始化。
if (dev->fops->open)
return dev->fops->open(inode, filp);

return 0;
}

// memory_fops: 顶层的、通用的文件操作集,仅用于捕获open操作。
static const struct file_operations memory_fops = {
.open = memory_open,
.llseek = noop_llseek, // 提供一个默认的、无操作的llseek。
};

// mem_devnode: devtmpfs/udev创建设备节点时的回调,用于设置文件权限。
static char *mem_devnode(const struct device *dev, umode_t *mode)
{
// 如果devlist中为此设备定义了权限模式。
if (mode && devlist[MINOR(dev->devt)].mode)
// 则将该模式赋值给*mode,devtmpfs将使用此权限创建文件。
*mode = devlist[MINOR(dev->devt)].mode;
return NULL;
}

// 定义名为"mem"的设备类。/sys/class/mem将因此被创建。
static const struct class mem_class = {
.name = "mem",
.devnode = mem_devnode, // 关联权限设置回调。
};

// chr_dev_init: 整个驱动的初始化函数。
static int __init chr_dev_init(void)
{
int retval;
int minor;

// 注册主设备号为MEM_MAJOR(1),名为"mem"的字符设备驱动。
if (register_chrdev(MEM_MAJOR, "mem", &memory_fops))
printk("unable to get major %d for memory devs\n", MEM_MAJOR);

// 注册"mem"设备类。
retval = class_register(&mem_class);
if (retval)
return retval;

// 遍历devlist,为每个定义的次设备创建设备节点。
for (minor = 1; minor < ARRAY_SIZE(devlist); minor++) {
if (!devlist[minor].name)
continue; // 跳过未定义的次设备号。

// 架构相关的特殊处理,ARM平台此条件通常为假。
if ((minor == DEVPORT_MINOR) && !arch_has_dev_port())
continue;

// 创建设备。这将触发udev/devtmpfs在/dev下创建对应的文件。
// 例如,当minor=3时,会创建一个名为"null"的设备文件。
device_create(&mem_class, NULL, MKDEV(MEM_MAJOR, minor),
NULL, devlist[minor].name);
}

// 调用tty子系统的初始化,表明存在依赖关系或初始化顺序要求。
return tty_init();
}

// 注册为文件系统初始化调用,确保在可以创建设备时执行。
fs_initcall(chr_dev_init);

drivers/char/misc.c

misc.c: Linux 杂项字符设备驱动核心

此文件是Linux内核中一个历史悠久且至关重要的部分, 它实现了杂项字符设备子系统。其核心原理是提供一个共享的、固定的主设备号(MISC_MAJOR, 通常是10), 并为大量功能相对简单、不值得拥有独立主设备号的字符设备驱动程序, 提供一个动态或静态分配次设备号(minor number)的统一注册和管理框架

这极大地节省了宝贵的主设备号资源(在传统上, 这是一个有限的256个号码池), 同时为驱动开发者提供了一个极其简洁的API (misc_register/misc_deregister)来创建字符设备节点。


核心机制与数据结构

驱动的核心围绕着一个受互斥锁(misc_mtx)保护的全局链表(misc_list)。每个通过misc_register注册的设备, 都会作为一个struct miscdevice节点被添加到这个链表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* 全局链表的头节点. 所有已注册的 miscdevice 都会被链接到这里.
*/
static LIST_HEAD(misc_list);
/*
* 一个互斥锁 (Mutex), 用于保护对 misc_list 和 misc_minors_ida 的并发访问.
* 使用互斥锁而不是自旋锁, 是因为在锁保护的区域内会调用可能休眠的函数(如 device_create).
*/
static DEFINE_MUTEX(misc_mtx);

/*
* 一个 IDA (ID Allocator) 实例.
* IDA 是内核中用于高效分配和释放整数ID的机制, 非常适合管理稀疏的次设备号.
*/
static DEFINE_IDA(misc_minors_ida);

次设备号管理

misc驱动可以使用ida机制来分配次设备号, 支持动态分配和静态请求两种模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*
* misc_minor_alloc: 分配一个次设备号.
*/
static int misc_minor_alloc(int minor)
{
if (minor == MISC_DYNAMIC_MINOR) {
/*
* 如果请求的是动态次设备号, 使用 ida_alloc_range 寻找一个可用的号码.
* 范围从 MISC_DYNAMIC_MINOR + 1 (通常是1) 到 MINORMASK (次设备号上限).
*/
ret = ida_alloc_range(&misc_minors_ida, MISC_DYNAMIC_MINOR + 1,
MINORMASK, GFP_KERNEL);
} else {
/*
* 如果请求的是一个静态的、指定的次设备号, 使用 ida_alloc_range
* 尝试精确地分配那一个号码 (范围的起始和结束都是 minor).
*/
ret = ida_alloc_range(&misc_minors_ida, minor, minor, GFP_KERNEL);
}
return ret;
}

/*
* misc_minor_free: 释放一个次设备号.
*/
static void misc_minor_free(int minor)
{
/* 调用 ida_free 将号码返还给 ida 池, 使其可被再次分配. */
ida_free(&misc_minors_ida, minor);
}

核心分发器: misc_open

这是整个misc子系统的魔法所在。所有对主设备号为10的设备的open()系统调用, 都会被路由到这个函数。misc_open的核心原理是充当一个中央分发器(dispatcher):

  1. 它根据用户尝试打开的次设备号, 在全局的misc_list中进行搜索。
  2. 如果找不到对应的驱动, 它会尝试按需加载内核模块(request_module), 然后再次搜索。
  3. 一旦找到匹配的miscdevice结构体, 它就执行最关键的一步: 调用replace_fops, 将文件对象(struct file)中的通用misc_fops替换为该设备驱动程序自己定义的、真正的file_operations
  4. 此后, 对该文件的所有后续操作(如read, write, ioctl, release)都将直接调用到真正的驱动程序函数中, misc核心的角色宣告完成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/*
* misc_open: 对主设备号为 MISC_MAJOR 的所有设备的通用 open 函数.
*/
static int misc_open(struct inode *inode, struct file *file)
{
/* 获取用户尝试打开的次设备号. */
int minor = iminor(inode);
struct miscdevice *c = NULL, *iter;
int err = -ENODEV;
const struct file_operations *new_fops = NULL;

mutex_lock(&misc_mtx);

/* 第一次遍历 misc_list, 查找匹配的次设备号. */
list_for_each_entry(iter, &misc_list, list) {
if (iter->minor != minor)
continue;
c = iter;
/* 获取驱动程序的 file_operations, 这会增加模块的引用计数. */
new_fops = fops_get(iter->fops);
break;
}

/* 如果第一次没有找到... */
if (!new_fops) {
mutex_unlock(&misc_mtx);
/*
* ...请求内核加载可能提供此设备的模块.
* 例如, 如果 minor=58, 内核会尝试加载别名为 "char-major-10-58" 的模块.
*/
request_module("char-major-%d-%d", MISC_MAJOR, minor);
mutex_lock(&misc_mtx);

/* 再次遍历, 检查模块加载后是否出现了我们需要的设备. */
list_for_each_entry(iter, &misc_list, list) {
if (iter->minor != minor)
continue;
c = iter;
new_fops = fops_get(iter->fops);
break;
}
/* 如果仍然找不到, 则跳转到 fail 标签, 返回错误. */
if (!new_fops)
goto fail;
}

/*
* 将 miscdevice 结构体指针存入 file->private_data.
* 这是一个非常方便的特性, 使得驱动的 fops 函数可以轻松地
* 通过 file->private_data 访问到自己的设备状态结构体.
*/
file->private_data = c;

err = 0;
/*
* *** 核心操作 ***
* 将文件对象 file 的 file_operations 指针替换为驱动自己的 new_fops.
*/
replace_fops(file, new_fops);
/*
* 如果驱动自己的 fops 中定义了 open 方法, 现在就调用它,
* 让驱动有机会执行自己的初始化.
*/
if (file->f_op->open)
err = file->f_op->open(inode, file);
fail:
mutex_unlock(&misc_mtx);
return err;
}

公共 API: misc_registermisc_deregister

这两个函数是暴露给其他驱动程序使用的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*
* misc_register: 注册一个新的杂项设备.
*/
int misc_register(struct miscdevice *misc)
{
/* ... */
mutex_lock(&misc_mtx);

/* 1. 分配次设备号 (动态或静态). */
if (is_dynamic) {
/* ... */
misc->minor = i;
} else {
/* ... 检查冲突并分配 ... */
}

/* 2. 创建设备节点. */
/*
* 调用 device_create_with_groups, 它会:
* a. 在 sysfs 中创建设备条目 (例如, /sys/class/misc/watchdog).
* b. 触发 udevd, 在 /dev 目录下创建设备文件 (例如, /dev/watchdog).
* c. 调用 misc_devnode 回调函数, 允许驱动自定义设备文件名和权限.
*/
misc->this_device =
device_create_with_groups(&misc_class, misc->parent, dev,
misc, misc->groups, "%s", misc->name);
if (IS_ERR(misc->this_device)) {
/* 错误处理: 释放已分配的次设备号. */
misc_minor_free(misc->minor);
/* ... */
goto out;
}

/* 3. 将设备添加到全局链表的头部. */
list_add(&misc->list, &misc_list);
out:
mutex_unlock(&misc_mtx);
return err;
}
EXPORT_SYMBOL(misc_register);

/*
* misc_deregister: 注销一个杂项设备.
*/
void misc_deregister(struct miscdevice *misc)
{
/* ... */
mutex_lock(&misc_mtx);
/* 1. 从全局链表中移除. */
list_del(&misc->list);
/* 2. 销毁设备节点 (从 /dev 和 /sys 中移除). */
device_destroy(&misc_class, MKDEV(MISC_MAJOR, misc->minor));
/* 3. 释放次设备号. */
misc_minor_free(misc->minor);
mutex_unlock(&misc_mtx);
}
EXPORT_SYMBOL(misc_deregister);

初始化: misc_init

此函数在内核启动时被调用, 用于建立整个misc子系统的基础。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* misc_init: misc 子系统初始化函数.
*/
static int __init misc_init(void)
{
/* 1. 在 /proc 目录下创建 "misc" 文件, 用于显示已注册的设备列表. */
misc_proc_file = proc_create_seq("misc", 0, NULL, &misc_seq_ops);

/* 2. 注册 "misc" 设备类, 在 sysfs 中创建 /sys/class/misc/. */
err = class_register(&misc_class);
/* ... */

/*
* 3. *** 最终注册 ***
* 向内核注册一个主设备号为 MISC_MAJOR 的字符设备驱动.
* 关键在于, 将其 file_operations 设置为通用的 misc_fops,
* 这样所有对该主设备号的 open() 调用都会进入我们的 misc_open 分发器.
*/
err = __register_chrdev(MISC_MAJOR, 0, MINORMASK + 1, "misc", &misc_fops);
/* ... */
return 0;
/* ... 错误处理 ... */
}
/* subsys_initcall 确保 misc 子系统在其他普通驱动之前被初始化. */
subsys_initcall(misc_init);

fs/char_dev.c

chrdev_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static struct kobject *base_probe(dev_t dev, int *part, void *data)
{
if (request_module("char-major-%d-%d", MAJOR(dev), MINOR(dev)) > 0)
/* Make old-style 2.4 aliases work */
request_module("char-major-%d", MAJOR(dev));
return NULL;
}

void __init chrdev_init(void)
{
/* 初始化字符设备的映射表(cdev_map)。
kobj_map 是一种内核数据结构,用于管理设备编号与设备对象之间的映射关系。
base_probe 是一个回调函数,用于在设备被访问时执行特定的探测逻辑。 */
cdev_map = kobj_map_init(base_probe, &chrdevs_lock);
}

字符设备(cdev)的初始化与注册核心

此代码片段展示了Linux内核中用于管理**字符设备(character device)**的核心数据结构和函数。字符设备是Linux中三类主要设备之一(另两类是块设备和网络设备), 它们提供了一个基于字节流的、无缓冲的I/O模型。我们之前分析的gpiolib最终就是通过这套机制, 将一个GPIO控制器暴露为用户空间可以访问的/dev/gpiochipN设备文件。


cdev_init: 初始化一个cdev结构体

此函数是创建任何字符设备的第一步, 其作用是准备一个struct cdev实例, 为其填充必要的初始值, 并将其与具体的功能实现(即文件操作集)关联起来

原理与工作流程:
它本质上是一个结构化的构造函数。

  1. 清零: memset将整个结构体清零, 这是一个标准的、安全的初始化实践。
  2. 链表初始化: INIT_LIST_HEAD初始化list成员, 这个链表用于将内核中所有的cdev链接在一起。
  3. Kobject初始化: kobject_init是关键。struct cdev内部包含一个struct kobject, 这是它在Linux设备模型中的核心表示。此函数会初始化kobject, 最重要的是, 将它与一个”类型”(ktype_cdev_default)关联起来。这个类型定义了当此kobject的最后一个引用被释放时, 应该如何进行清理。
  4. 关联操作集: cdev->ops = fops是功能性的核心。它将这个cdev实例与一个file_operations结构体(由驱动程序提供, 如我们之前看到的gpio_fileops)连接起来。这张”功能表”告诉内核, 当用户空间对这个设备文件进行open, read, ioctl等操作时, 应该调用哪个具体的驱动函数来执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* cdev_init() - 初始化一个cdev结构体
* @cdev: 要被初始化的结构体
* @fops: 这个设备的文件操作集
*
* 初始化@cdev, 记住@fops, 使其准备好通过cdev_add()添加到系统中.
*/
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
{
/* 使用memset将cdev结构体的所有字节清零. */
memset(cdev, 0, sizeof *cdev);
/* 初始化内嵌的list_head成员, 使其成为一个空的链表头. */
INIT_LIST_HEAD(&cdev->list);
/*
* 初始化内嵌的kobject, 并将其类型设置为ktype_cdev_default.
* 这为cdev提供了引用计数和在sysfs中的表示能力, 并指定了其销毁时的清理函数.
*/
kobject_init(&cdev->kobj, &ktype_cdev_default);
/* 将驱动程序提供的文件操作函数集(fops)与这个cdev关联起来. */
cdev->ops = fops;
}

ktype_cdev_default & cdev_default_release: cdev的类型与生命周期管理

这对组合定义了cdevkobject的”类型”, 其核心是release函数, 它实现了自动的资源清理机制

原理与工作流程:
当一个kobject的引用计数因为最后一个使用者调用kobject_put()而降为零时, kobject核心会自动调用其ktype中指定的release函数。

  • cdev_default_release就是这个清理函数。它会调用cdev_purge来断开此cdev与VFS的所有连接, 并清理任何挂起的操作。然后, 它会调用kobject_put(parent)来释放对父设备kobject的引用, 这是正确管理设备层次结构生命周期的关键。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 当cdev的kobject引用计数降为零时, 此函数被自动调用. */
static void cdev_default_release(struct kobject *kobj)
{
/* 使用container_of宏从内嵌的kobj成员找到其容器cdev结构体的地址. */
struct cdev *p = container_of(kobj, struct cdev, kobj);
/* 获取父kobject的指针. */
struct kobject *parent = kobj->parent;

/* 清理cdev, 使其失效并断开与文件系统的所有连接. */
cdev_purge(p);
/* 释放对父kobject的引用. 如果这是最后一个引用, 将会触发父kobject的release函数. */
kobject_put(parent);
}

/* 定义一个kobj_type实例, 将其release函数指向cdev_default_release. */
static struct kobj_type ktype_cdev_default = {
.release = cdev_default_release,
};

cdev_device_add: 添加字符设备及其对应的sysfs设备

这是一个高度封装的辅助函数, 它将两个独立但紧密相关的操作合并为一个原子步骤: 将一个字符设备注册到VFS, 并将其对应的struct device注册到sysfs。这是在现代Linux内核中创建功能完整的字符设备节点的标准方法。

原理与工作流程:

  1. 建立父子关系: (在内部)将cdevkobject设置为devkobject的子对象, 建立设备模型中的层次结构。
  2. 注册到VFS (cdev_add): 调用cdev_addcdev和它的设备号(dev->devt)添加到内核的字符设备表中。从这一刻起, 内核就知道如何将对该设备号的操作路由到这个cdevfile_operations
  3. 注册到sysfs (device_add): 调用device_addstruct device实例注册到设备模型核心。这会在/sys/devices/sys/class下创建相应的目录和属性文件。这个操作会产生一个uevent事件。
  4. 自动创建设备节点: 用户空间的mdev(在STM32等嵌入式系统上)或udev(在桌面系统上)会监听到这个uevent事件, 并根据事件中包含的设备号和名称, 自动在/dev目录下创建对应的设备文件(例如/dev/gpiochip0)。
  5. 错误回滚: 函数包含了健壮的错误处理。如果cdev_add成功了, 但后续的device_add失败了, 它会立即调用cdev_del来撤销cdev_add的操作, 保证系统状态的一致性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* cdev_device_add() - 添加一个字符设备及其对应的struct device, 并将它们链接起来
* @cdev: cdev结构体
* @dev: device结构体
* ... (其余注释见原文)
*/
int cdev_device_add(struct cdev *cdev, struct device *dev)
{
int rc = 0;

/* 检查dev结构体是否已分配一个有效的设备号(dev_t). */
if (dev->devt) {
/* (内部)将dev->kobj设置为cdev->kobj的父对象. */
cdev_set_parent(cdev, &dev->kobj);

/* 步骤2: 调用cdev_add将字符设备注册到VFS, 关联设备号和文件操作. */
rc = cdev_add(cdev, dev->devt, 1);
if (rc)
return rc;
}

/* 步骤3: 调用device_add将设备注册到sysfs, 触发uevent. */
rc = device_add(dev);
/* 步骤5: 如果device_add失败, 并且cdev之前已成功添加, 则必须撤销cdev的添加. */
if (rc && dev->devt)
cdev_del(cdev);

return rc;
}

fs/char_dev.c: Linux字符设备号核心注册机制

此代码片段来自于Linux内核文件系统层中的char_dev.c, 它实现了所有字符设备驱动程序赖以生存的基础——设备号的分配、注册与管理。它并非一个具体的设备驱动, 而是为所有字符设备驱动提供服务的核心”注册表”和API。

其核心原理是维护一个全局的、受锁保护的注册表, 用于记录所有已被声明占用的主/次设备号范围。它提供了一套API, 允许驱动程序以多种方式(请求特定号码、请求动态分配等)来安全地、无冲突地”预定”它们需要的设备号范围, 并将这些号码与驱动程序的操作函数集(cdev)最终关联起来。


核心数据结构与并发控制

内核使用一个哈希表(chrdevs)来高效地存储已注册的设备号范围。主设备号被用作哈希表的索引, 每个哈希桶都是一个char_device_struct的链表, 用于解决哈希冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// (在别处定义, 但这是其概念)
// static struct char_device_struct *chrdevs[CHRDEV_MAJOR_HASH_SIZE];
//
// struct char_device_struct {
// struct char_device_struct *next; // 链表指针
// unsigned int major; // 主设备号
// unsigned int baseminor; // 起始次设备号
// int minorct; // 次设备号数量
// char name[64]; // 驱动名称
// struct cdev *cdev; // 指向关联的cdev结构
// };

// (在别处定义)
// static DEFINE_MUTEX(chrdevs_lock);

所有对这个哈希表的操作都必须持有chrdevs_lock互斥锁, 这确保了即使在多核或单核抢占式系统(如STM32H750)上, 设备号的分配和释放也是原子的, 不会产生竞争条件。


底层核心引擎: __register_chrdev_region

这是所有注册API最终都会调用的核心工作函数。它负责在注册表中原子地保留一个指定的设备号范围。

  1. 动态主设备号分配: 如果调用者请求动态分配主设备号(major == 0), 它会调用find_dynamic_majorfind_dynamic_major会扫描预留的动态主设备号范围, 在chrdevs哈希表中寻找一个完全未被使用的条目。
  2. 冲突检测: 这是最关键的步骤。函数会遍历chrdevs哈希表中对应主设备号的链表, 仔细检查请求的次设备号范围(baseminorbaseminor + minorct - 1)是否与任何已注册的范围有重叠。如果发现任何重叠, 它会立即返回-EBUSY错误。
  3. 插入新条目: 如果没有冲突, 它会分配一个新的char_device_struct, 填充信息(主/次设备号, 数量, 名称), 并将其插入到链表的正确位置, 以保持链表有序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/*
* __register_chrdev_region: 在注册表中预定一个主/次设备号范围.
* 这是所有注册API的底层实现.
*/
static struct char_device_struct *
__register_chrdev_region(unsigned int major, unsigned int baseminor,
int minorct, const char *name)
{
struct char_device_struct *cd;
/* ... 其他变量 ... */

/* ... 输入参数合法性检查 ... */

cd = kzalloc(sizeof(struct char_device_struct), GFP_KERNEL);
if (cd == NULL)
return ERR_PTR(-ENOMEM);

mutex_lock(&chrdevs_lock); // 获取锁, 保证操作原子性

if (major == 0) { // 如果请求动态分配
major = find_dynamic_major();
if (major < 0) {
/* ... 错误处理 ... */
}
}

/* ... 遍历链表, 检查请求的范围是否与已存在的范围重叠 ... */
/* for (curr = chrdevs[i]; curr; ... ) { */
/* if (重叠) goto out; */
/* } */

/* ... 填充cd结构体, 并将其插入到链表的正确位置 ... */

mutex_unlock(&chrdevs_lock); // 释放锁
return cd;
out:
mutex_unlock(&chrdevs_lock);
kfree(cd);
return ERR_PTR(ret);
}

高层API封装

内核基于__register_chrdev_region提供了多个更方便的API。

  • register_chrdev_region(dev_t from, ...): 用于注册一个已知的主/次设备号范围。它的一个巧妙之处在于, 它可以处理跨越主设备号边界的请求, 它会将这种请求分解为多个对__register_chrdev_region的调用。
  • alloc_chrdev_region(dev_t *dev, ...): 这是最常用的API之一。驱动程序只关心需要多少个次设备号, 而不关心具体的主设备号是什么。此函数会调用__register_chrdev_region时将major设为0, 让内核动态地为其寻找一个可用的主设备号, 并通过dev_t *dev指针返回分配到的第一个设备号。
  • __register_chrdev(...): 这是一个更高层次的”一体化”函数。它不仅完成了设备号的注册(通过调用__register_chrdev_region), 还进一步创建了一个cdev结构体, 并调用cdev_add将其与刚刚分配的设备号范围关联起来cdev结构体中包含了指向驱动程序file_operations的指针, cdev_add的作用就是将设备号与驱动的实际操作函数(open, read, write等)正式”链接”起来, 使设备”激活”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
* __register_chrdev: 注册设备号范围, 并将其与文件操作关联.
*/
int __register_chrdev(unsigned int major, unsigned int baseminor,
unsigned int count, const char *name,
const struct file_operations *fops)
{
struct char_device_struct *cd;
struct cdev *cdev;
int err = -ENOMEM;

/* 步骤1: 调用底层函数, 在注册表中预定设备号范围. */
cd = __register_chrdev_region(major, baseminor, count, name);
if (IS_ERR(cd))
return PTR_ERR(cd);

/* 步骤2: 分配一个 cdev 结构体, 这是字符设备的内核抽象. */
cdev = cdev_alloc();
if (!cdev)
goto out2;

/* 步骤3: 初始化 cdev, 将其指向驱动程序的 file_operations. */
cdev->owner = fops->owner;
cdev->ops = fops;
kobject_set_name(&cdev->kobj, "%s", name);

/*
* 步骤4: 调用 cdev_add, 将 cdev (操作) 与 设备号 (地址) 正式链接.
* 从这一刻起, 对这些设备号的 VFS 操作就会被路由到 fops 中.
*/
err = cdev_add(cdev, MKDEV(cd->major, baseminor), count);
if (err)
goto out;

/* 保存 cdev 指针, 用于未来的注销. */
cd->cdev = cdev;

/* 如果是动态分配, 返回分配到的主设备号; 否则返回0表示成功. */
return major ? 0 : cd->major;
out:
kobject_put(&cdev->kobj);
out2:
/* 关键的错误处理: 如果 cdev_add 失败, 必须注销之前已预定的设备号范围. */
kfree(__unregister_chrdev_region(cd->major, baseminor, count));
return err;
}