[toc]
/drivers/block/brd.c: Linux 内核 RAM 块设备驱动
介绍
/drivers/block/brd.c 是 Linux 内核中的一个驱动程序,它实现了一个基于 RAM 的块设备,通常被称为 RAM disk。这个驱动程序会分配一块系统内存,并将其模拟成一个标准的块设备(如 /dev/ram0),使其可以像硬盘或 U 盘一样被格式化、挂载文件系统和进行读写操作。由于所有操作都在内存中完成,其 I/O 速度极快,但存储的数据是易失的,会在系统重启后丢失。
历史与背景
这项技术是为了解决什么特定问题而诞生的?
brd.c 的诞生主要是为了解决在系统引导早期阶段访问文件系统以及提供一个高速临时存储的需求。
- 引导过程中的根文件系统:在内核启动初期,可能需要加载特定的驱动模块(例如磁盘控制器或网络驱动)才能访问真正的根文件系统。
initrd(initial ramdisk) 机制应运而生,它将一个临时的、包含必要工具和驱动的小型文件系统加载到 RAM disk 中。内核启动后,首先挂载这个 RAM disk 作为临时根目录,执行其中的脚本加载所需模块,然后再切换到真正的根文件系统。brd.c是实现initrd的关键技术之一。 - 统一和简化实现:在
brd.c成为标准实现之前,存在一个较老的驱动rd.c。brd.c借鉴了rd.c和loop.c的设计,提供了一个更现代化、更简洁的实现方式,并整合到 Linux 块设备驱动框架中。
它的发展经历了哪些重要的里程碑或版本迭代?
brd.c 的发展相对稳定,其演进主要体现在与内核块设备子系统的集成和优化上。
- 早期实现 (
rd.c):最初的 RAM disk 功能由rd.c提供,功能相对简单。 brd.c的引入:brd.c在 2007 年左右被引入,作为一个更清晰、更通用的 RAM disk 驱动实现。它使用了现代内核的 API,如request_queue和gendisk。- 动态化和模块化:
brd.c被设计为可加载模块,允许系统管理员在运行时通过modprobe命令动态地创建和配置 RAM disk,而无需重新编译内核。可以指定 RAM disk 的数量 (rd_nr) 和大小 (rd_size)。 - 数据结构优化:在其内部实现中,
brd.c使用了基数树(radix tree)来管理和存储构成 RAM disk 内容的内存页面,这是一种高效管理稀疏大空间的方式。
目前该技术的社区活跃度和主流应用情况如何?
brd.c 是一个非常成熟和稳定的内核模块,其核心代码变动不大。社区的活跃度主要体现在维护其与新内核版本的兼容性上。
它的主流应用依然是作为 initrd 或 initramfs 的基础,并且在某些特定的测试和开发场景中被广泛使用,例如:
- I/O 性能基准测试:由于其极高的读写速度,常被用来测试文件系统或应用的 I/O 性能上限,排除物理磁盘的瓶颈。
- 教学与实验:对于学习 Linux 内核块设备驱动的开发者来说,
brd.c是一个代码结构清晰、功能明确的绝佳范例。
核心原理与设计
它的核心工作原理是什么?
brd.c 的核心原理是创建一个虚拟的块设备,并将所有对该设备的读写请求重定向到一块预先分配或动态增长的系统内存上。
- 设备创建:当加载
brd模块时,它会在内核中注册一个新的块设备驱动。用户可以通过模块参数指定创建的设备数量和大小。每个设备(如/dev/ram0,/dev/ram1)都会获得一个主设备号和唯一的次设备号。 - 内存管理:
brd.c使用基数树 (radix_tree) 来管理 backing store(后备存储)的内存页。当有写操作发生时,如果对应的内存页尚未分配,驱动会从系统中申请一个新的内存页,并将其插入到基数树中。这种方式实现了内存的按需分配,只有在数据被写入时才会实际占用内存。 - I/O 请求处理:当上层(如文件系统)发起一个 I/O 请求(封装在
bio结构体中)时,块设备层会将其传递给brd驱动的请求处理函数。该函数不会将请求发送给任何物理硬件,而是直接在内存中完成操作:- 写操作:将
bio中携带的数据拷贝到基数树中对应的内存页。 - 读操作:从基数树中找到对应的内存页,并将数据拷贝到
bio指定的缓冲区。
- 写操作:将
它的主要优势体现在哪些方面?
- 极高的性能:所有 I/O 操作都在内存中进行,速度远超任何物理存储设备,只受限于内存和 CPU 的速度。
- 模拟真实块设备:它表现为一个标准的块设备,支持分区、创建文件系统、LVM 等所有适用于块设备的操作,这使其在测试中非常有用。
- 简单易用:通过
modprobe加载模块并指定参数即可轻松创建和配置,无需复杂的设置。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 数据易失性:存储在
brd.c创建的 RAM disk 上的所有数据在系统断电或重启后会全部丢失。 - 占用系统内存:RAM disk 使用的内存是从系统可用内存中划分出来的,会减少其他应用程序可用的内存量。
- 无压缩:
brd.c直接存储原始数据,不会对数据进行压缩,对于可压缩的数据来说,空间利用率不如zram。 - 无法跨节点共享:它完全是本地节点的资源,无法在分布式系统或集群中作为共享存储使用。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?请举例说明。
- 系统引导:作为
initrd的后端,在系统启动早期加载必要的驱动程序,这是其最经典和核心的用途。 - 高速临时文件存储:对于需要大量读写临时文件且对性能要求极高的应用(如某些编译任务、数据库测试),可以将临时目录挂载到 RAM disk 上以获得巨大加速。
- 安全敏感数据的处理:处理临时性的敏感数据(如解密的密钥、证书文件等),利用其易失性,确保数据在重启后被彻底清除。
- 块设备相关功能的测试:测试文件系统、RAID 配置、LVM 或加密软件(如
dm-crypt)时,使用 RAM disk 可以快速创建和销毁测试用的块设备,而无需操作物理硬盘。
是否有不推荐使用该技术的场景?为什么?
- 持久化数据存储:绝对不能用于存储需要长期保存的关键数据,因为数据会在重启后丢失。
- 内存紧张的系统:在内存资源本就不足的系统上使用大型 RAM disk 会严重影响系统性能,甚至导致系统因内存不足而崩溃。
- 替代缓存:不应将其用作应用层缓存的替代品。Linux 内核自身的文件系统缓存(page cache)已经非常高效,对于大多数场景,直接让内核管理缓存是更好的选择。
对比分析
请将其 与 其他相似技术 进行详细对比。
| 特性 | brd.c (RAM disk) |
tmpfs / ramfs |
zram |
|---|---|---|---|
| 实现方式 | 块设备驱动。在内存中模拟一个完整的块设备,需要格式化文件系统后才能挂载。 | 虚拟文件系统。直接在内核页缓存中创建一个文件系统实例,无需底层块设备,直接挂载即可使用。 | 压缩的块设备驱动。与 brd.c 类似,但在写入内存前会对数据进行压缩。 |
| 性能开销 | 极低。直接内存拷贝,无压缩开销。 | 极低。与 brd.c 性能相似,绕过了块设备层,理论上更直接。 |
较低。读写时需要额外的 CPU 周期进行解压/压缩,因此 I/O 延迟略高于 brd.c 和 tmpfs。 |
| 资源占用 | 直接占用物理内存,大小固定或按需增长,但不会被交换到磁盘。 | 动态调整大小,只占用实际使用的内存。tmpfs 在内存压力下其内容可以被交换到 swap 分区。 |
占用物理内存,但由于压缩,实际占用的内存通常远小于其报告的容量。 |
| 隔离级别 | 块设备级别。可以对其进行分区,或用于裸设备访问。 | 文件系统级别。表现为一个挂载点,无法进行块级别的操作。 | 块设备级别。可以作为 swap 设备或格式化后用作通用存储。 |
| 启动速度 | 需要 mkfs 格式化,比 tmpfs 略慢。 |
极快,mount 命令即可立即使用。 |
需要配置和创建设备,然后根据用途决定是否格式化或 mkswap。 |
| 主要用途 | initrd、块设备功能测试、需要块设备接口的高速临时存储。 |
/tmp、共享内存 (/dev/shm)、无需块设备接口的临时文件存储。 |
内存受限设备上的 swap 空间、压缩的临时文件存储。 |
总结
请为我总结其关键特性,并提供学习该技术的要点建议。
关键特性总结:
- 块设备模拟:
brd.c在内存中创建一个功能完整的块设备,这是其与tmpfs最本质的区别。 - 高性能:基于 RAM 的特性使其读写速度极快。
- 易失性:所有数据在系统重启后都会丢失。
- 模块化与可配置性:可以通过内核模块参数灵活地创建多个不同大小的 RAM disk。
学习该技术的要点建议:
- 动手实践:学习
brd.c最好的方式是亲手操作。尝试使用modprobe brd rd_nr=... rd_size=...命令加载模块,然后用mkfs格式化/dev/ram0,最后mount它并进行文件操作。 - 理解块设备层:要深入理解
brd.c,需要对 Linux 的块设备层有一个基本的认识,了解gendisk、request_queue和bio等核心数据结构的作用。 - 阅读源码:
brd.c的源码相对简短且逻辑清晰,是学习 Linux 设备驱动程序开发的优秀范本。通过阅读源码,可以理解 I/O 请求是如何在没有物理硬件的情况下被处理的。 - 对比学习:将
brd.c与tmpfs和zram进行对比,理解它们各自的设计哲学和适用场景。这能帮助你更深刻地理解 Linux 中不同内存使用技术的权衡。例如,思考“为什么/tmp通常使用tmpfs而不是 RAM disk?”这样的问题。
核心数据结构与按需页面管理 (brd_lookup_page, brd_insert_page, brd_free_pages)
核心功能
该部分代码是 RAM 盘(brd)驱动的基石,负责管理作为块设备后备存储的物理内存页。它定义了核心数据结构 brd_device,其中 xarray 数据结构是实现“稀疏”存储的关键。brd_lookup_page 用于在 xarray 中无锁、安全地查找与给定扇区对应的物理页。brd_insert_page 则在需要写入一个尚未分配的块时,动态地分配一个新的物理页并插入到 xarray 中。brd_free_pages 在设备卸载时,负责遍历 xarray 并释放所有已分配的物理页。
实现原理分析
稀疏存储与
xarray:brd驱动的一个核心特性是“按需分配”(on-demand allocation)或称为“精简配置”(thin provisioning)。一个 1GB 的 RAM 盘在刚创建时并不会立即占用 1GB 内存。只有当数据被首次写入某个块时,内核才会为该块分配一个物理页。这种机制是通过struct xarray brd_pages实现的。xarray是一个高度优化的、支持多线程安全访问的基数树(radix tree),非常适合用于管理稀疏的、以整数(这里是页帧号)为索引的数据集合。它仅为实际存在的条目(物理页指针)分配内部节点,从而极大地节省了元数据存储开销。无锁查找与 RCU:
brd_lookup_page函数展示了在Linux内核中进行高性能、无锁数据查找的典型范式。它使用 RCU(Read-Copy-Update)来保护对xarray的并发读取。rcu_read_lock(): 建立一个RCU读端临界区。在此区域内,可以保证被 RCU 保护的数据结构(xarray的节点)不会被释放。xas_load(): 从xarray中加载指针。xas_retry(): 检查在加载过程中,xarray是否发生了修改。如果返回true,则意味着查找操作与一个写操作(插入/删除)发生了竞争,当前结果可能无效,需要重试。get_page_unless_zero(): 这是一个关键的原子操作,用于安全地增加物理页的引用计数。它解决了lookup -> get_page的经典竞态问题:在查找到一个页但在增加其引用计数之前,另一个线程可能已经释放了这个页(引用计数变为0)。此函数能原子地检查引用计数是否为0,如果不为0则加一并返回true,否则返回false。xas_reload(): 再次加载指针,并与之前加载的进行比较。这是为了确认在我们成功增加页引用计数之后,该xarray条目没有被其他写者修改成指向另一个不同的页。如果变了,我们必须放弃当前获取的页 (put_page) 并重试整个过程。
特定场景分析 (单核、无MMU的 STM32H750)
并发与锁: 在单核处理器上,由多核并行执行导致的竞态条件不复存在。然而,锁和 RCU 机制依然至关重要。
RCU: 在单核、可抢占的内核中,RCU 临界区 (rcu_read_lock/unlock) 的主要作用是禁止内核抢占。这确保了在读端临界区内,代码的执行不会被其他任务中断,从而保证了xas_load和xas_reload之间数据的一致性视图。如果内核是不可抢占的,RCU 锁的开销会变得非常小。xa_lock: 在brd_insert_page中使用的xa_lock(自旋锁) 依然是必需的。它可以防止一个进程在执行插入操作的过程中被中断,而中断服务程序(ISR)恰好也尝试访问同一个 RAM 盘,从而破坏xarray结构。在单核上,自旋锁的主要作用是禁止中断(或禁止抢占,取决于具体实现),而不是在多核间进行总线同步。
内存分配与 HIGHMEM:
brd_insert_page中使用了__GFP_HIGHMEM标志。在没有MMU的系统架构上(如uClinux for Cortex-M7),“高地址内存”(HIGHMEM)的概念是不存在的。内存是一个单一、平坦的物理地址空间,所有内存对内核来说都是直接可访问的“低地址内存”(LOWMEM)。因此,__GFP_HIGHMEM标志会被内核的内存分配器忽略。页分配器(如SLOB或SLAB)会从可用的物理内存区域中分配一个标准大小的物理页。页操作:
struct page: 在无MMU系统中,struct page依然是管理物理内存的基本单元,它跟踪每个物理页的引用计数、状态等信息。get_page/put_page: 这些函数的功能不变,它们原子地增减struct page中的引用计数,用于控制物理页的生命周期。brd驱动通过这种方式正确地管理着作为其后备存储的所有物理页的生命周期。
源码及逐行注释
1 | /** |
BIO 请求处理与数据传输 (brd_submit_bio, brd_rw_bvec, brd_do_discard)
核心功能
这组函数是 brd 驱动的 I/O 引擎。brd_submit_bio 是块设备层调用 brd 驱动处理 I/O 请求的唯一入口点。它首先判断 BIO 请求的类型。如果是 DISCARD 请求,它会调用 brd_do_discard 来释放相应的物理页,实现 TRIM/UNMAP 功能;如果是常规的读写请求,它会循环调用 brd_rw_bvec 来处理 BIO 中的每一个数据段(bio_vec)。brd_rw_bvec 是实际执行数据拷贝的函数,它负责查找或创建目标扇区对应的物理页,然后将用户数据从 BIO 的页面拷贝到 RAM 盘的物理页(写操作),或反之(读操作)。
实现原理分析
BIO 迭代模型: Linux 内核的块 I/O 使用 BIO 结构来描述一个 I/O 请求。一个 BIO 可以包含多个段(
bio_vec),这些段可以指向物理上不连续的内存页。brd_submit_bio中的do-while循环和brd_rw_bvec中对bio->bi_iter的使用,共同构成了一个标准的 BIO 迭代处理模式。brd_rw_bvec每次只处理 BIO 中的一个bio_vec,处理完后通过bio_advance_iter_single更新迭代器bi_iter,使其指向下一个待处理的数据位置。外层循环while (bio->bi_iter.bi_size)检查是否还有未处理的数据,从而确保整个 BIO 被完整处理。跨页边界处理:
brd_rw_bvec函数内部通过bv.bv_len = min_t(u32, bv.bv_len, PAGE_SIZE - offset);这行代码,精巧地处理了跨页边界的 I/O 请求。它确保了单次memcpy操作绝对不会跨越 RAM 盘后备存储中的物理页边界。offset计算出请求在目标页内的起始字节偏移。PAGE_SIZE - offset则是从这个偏移到页末尾的剩余空间。通过取bio_vec长度和这个剩余空间的最小值,brd_rw_bvec保证了本次数据传输只在单个目标物理页内进行。如果一个bio_vec本身跨越了两个 RAM 盘的页,那么它会被分两次处理,这由外层的 BIO 迭代循环来驱动。直接内存映射 (
kmap_local): 为了访问 BIO 向量(bio_vec)中可能位于高地址内存(HIGHMEM)的物理页,brd_rw_bvec使用了bvec_kmap_local。这个函数会为bio_vec所指向的物理页创建一个临时的内核虚拟地址映射,使得内核可以通过一个普通的指针kaddr来访问这块内存。操作完成后,kunmap_local会撤销这个映射。这是一个在块设备驱动中处理高地址内存页的标准方法。DISCARD操作的实现:brd_do_discard函数将DISCARD请求(TRIM/UNMAP)翻译为释放物理页的操作。它首先将请求的扇区范围对齐到 RAM 盘的页边界 (PAGE_SECTORS),因为brd的分配粒度是页。然后,它在一个循环中调用__xa_erase从xarray中移除指向这些页的指针。移除成功后,它调用put_page来减少页的引用计数。当引用计数降为零时,这个物理页就会被返还给系统,从而实现了存储空间的“回收”。
特定场景分析 (单核、无MMU的 STM32H750)
kmap的行为: 在没有 MMU 的系统上,内核虚拟地址空间和物理地址空间是直接或简单偏移映射的。不存在“高地址内存”的概念,所有的物理内存页对于内核来说都是直接可访问的。在这种情况下,bvec_kmap_local和kunmap_local通常会退化为空操作(或者仅仅是简单的地址计算),它们的开销非常小。数据拷贝可以直接在物理地址间进行。I/O 调度与并发:
brd驱动是一个非常简单的驱动,它没有自己的请求队列,而是直接处理submit_bio。在单核系统上,BIO 请求是串行提交给brd_submit_bio的。由于brd的所有操作(查找页、分配页、内存拷贝)都是在submit_bio的上下文中同步完成的,并且速度极快(内存到内存拷贝),因此不存在复杂的 I/O 调度问题。请求的处理是即时的,bio_endio(bio)会在submit_bio函数返回前被调用,通知块设备层该 I/O 已完成。REQ_NOWAIT的意义: 在brd_rw_bvec中,当写操作需要分配新页而brd_insert_page失败并返回-ENOMEM时,代码会检查(opf & REQ_NOWAIT)。在内存紧张的嵌入式系统中,这个检查是有意义的。如果请求不允许等待(REQ_NOWAIT),例如来自中断上下文的请求,驱动会通过bio_wouldblock_error(bio)通知上层,表示操作如果继续可能会阻塞,应稍后重试。如果请求允许等待,驱动则通过bio_io_error(bio)简单地报告一个 I/O 错误。
源码及逐行注释
1 | /** |
模块初始化与设备管理 (brd_init, brd_exit, brd_alloc)
核心功能
这组函数负责 brd 驱动的整体生命周期管理。brd_init 是模块加载时的入口点,它的核心任务是:
- 向内核注册一个主设备号为
RAMDISK_MAJOR(通常是 1) 的块设备驱动。 - 设置一个“探测”(probe)回调函数
brd_probe,用于按需创建设备实例。 - 根据模块参数
rd_nr,预先创建指定数量的 RAM 盘设备。
brd_alloc 是创建单个 RAM 盘设备实例的核心函数。它负责分配 brd_device 和 gendisk 结构体,初始化它们,设置块设备参数(如容量),并将 gendisk 对象添加到系统中,使其对用户可见(例如,作为 /dev/ram0)。
brd_exit 是模块卸载时的入口点,它调用 brd_cleanup 来执行与 brd_init 和 brd_alloc 相反的操作:遍历所有已创建的设备,从系统中移除 gendisk 对象,释放所有后备存储页,最后释放设备自身的数据结构。
实现原理分析
动态设备创建 (
probe):brd驱动实现了一个非常灵活的设备实例化模型。在brd_init中,它通过__register_blkdev注册驱动时,并没有创建任何设备,而是提供了一个brd_probe函数。当用户首次访问一个属于该主设备号但尚未被内核实例化的设备节点时(例如,通过fdisk /dev/ramX),块设备层就会调用这个brd_probe回调。回调函数会解析设备号中的次设备号(minor number),并调用brd_alloc来动态地、按需地创建这个新的 RAM 盘实例。这种机制避免了在启动时就创建大量可能永远不会被使用的设备,节省了启动时间和内核内存。通用磁盘层 (
gendisk): Linux 内核通过struct gendisk来表示一个独立的块设备(一个“磁盘”)。brd_alloc中对blk_alloc_disk的调用是创建块设备的关键步骤。这个函数分配并初始化一个gendisk对象。随后,驱动需要填充这个对象的各个字段:major,first_minor,minors: 定义设备号。fops: 指向驱动的block_device_operations结构体,将 I/O 请求(如submit_bio)与驱动的实现关联起来。private_data: 一个私有指针,通常指向驱动自己的设备特定数据结构(这里是brd_device),这是将通用层与驱动私有数据连接起来的标准方法。disk_name: 设备名(如 “ram0”)。set_capacity: 设置磁盘的容量(以512字节扇区为单位)。add_disk: 最后,调用此函数将gendisk注册到内核,使其对系统可见。
模块参数 (
module_param):rd_nr和rd_size被声明为模块参数,这允许用户在加载模块时(例如,通过insmod brd.ko rd_nr=4 rd_size=16384)或在内核启动命令行中指定 RAM 盘的数量和大小。这提供了极大的灵活性,无需重新编译内核即可调整驱动行为。0444的权限意味着这些参数在加载后是只读的。资源清理的安全性:
brd_cleanup中的list_for_each_entry_safe宏的使用是安全遍历并删除链表节点的标准做法。_safe版本的宏会额外保存下一个节点的指针,因此即使当前节点brd在循环体内被删除和释放(通过brd_free_device),循环也能安全地继续进行。del_gendisk会确保在删除磁盘前,所有进行中的 I/O 都已完成,并阻止新的 I/O 被提交,保证了设备的安全移除。
特定场景分析 (单核、无MMU的 STM32H750)
设备创建: 在一个典型的嵌入式 STM32H750 系统中,
rd_nr通常会被配置为1或2,以便在启动时创建一个 RAM 盘用于临时文件系统(tmpfs)或其他用途。brd_init将被调用,它会注册驱动并循环调用brd_alloc(0),brd_alloc(1)等来创建这些设备。动态探测功能虽然存在,但在静态配置的嵌入式系统中可能较少使用。内存分配 (
kzalloc):brd_alloc中的kzalloc调用将在 STM32H750 的 RAM 中分配一小块内存用于brd_device结构体。由于这是内核内存,它会在系统的核心内存区域进行分配。块设备层交互: 即使在没有真实磁盘的嵌入式系统上,完整的块设备层依然存在。文件系统(如 ext2, VFAT)可以被挂载到
/dev/ram0上。当文件系统进行读写时,它会生成 BIO 请求,通过 VFS 和块设备层,最终调用到brd_submit_bio。整个软件栈的功能是完整且一致的。set_capacity的计算:set_capacity(disk, rd_size * 2)这行代码设置了磁盘容量。rd_size参数的单位是千字节(KB)。由于set_capacity的单位是512字节的扇区,所以rd_size (KB) * 1024 / 512 = rd_size * 2。这个计算对于任何平台都是一样的。
源码及逐行注释
1 | /* ... (模块参数定义) ... */ |










