Device Mapper源码深度解析:核心结构、IO映射与性能调优实践

发布时间:2026/10/6 13:44:26
Device Mapper源码深度解析:核心结构、IO映射与性能调优实践 Device Mapper 这套框架我前前后后啃了两个多月的源码。起因是排查一套基于 LVM 的存储环境时遇到一个只在特定 IO 深度下出现的性能拐点追到最后发现瓶颈既不在磁盘也不在文件系统而是卡在 Device Mapper 层的 bio 拆分逻辑上。从那之后我就意识到凡是跟 LVM、dm-crypt、软 RAID、容器存储驱动打过交道的人早晚都得把这层代码吃透。网上讲 Device Mapper 原理的文章非常多但大多停留在“它是什么、它能干什么”的层面真正把源码掰开揉碎讲清楚的文章少得可怜。这篇文章我想换一种写法直接站在代码分析的角度把 Device Mapper 的核心数据结构、IO 映射路径、常见 target 的实现逻辑以及我在实际调试中踩过的一些坑一次性说透。适合正在看内核源码的开发者、做存储中间件的人以及那些被 IO 性能问题折磨过的运维同学参考。1. 先把框架讲透Device Mapper 到底在管什么1.1 它解决的是“块设备的映射问题”Device Mapper 本质上是一个位于块设备层的映射框架。它的核心价值可以用一句话概括把上层发下来的 IO 请求按照某种规则重新计算目标设备上的偏移量然后把请求转发出去。LVM 的逻辑卷、dm-crypt 的全盘加密、软 RAID 的条带化全都是在这个框架上以“target”插件的形式实现的。理解这一点特别重要。很多人第一次接触 Device Mapper 时会被 LVM 那一堆概念绕晕什么 PV、VG、LV、PE其实剥开来看LVM 不过是 Device Mapper 的一个用户态管理工具。你执行lvcreate时它最终通过 ioctl 向内核下发一个 table这个 table 描述的是“逻辑卷的每个区间分别映射到物理卷的哪个位置”。内核拿到这张表之后剩下的就是一个纯粹的地址翻译问题。所以读 Device Mapper 源码本质上是在读一个“地址翻译引擎”。它不关心 IO 的内容是什么只关心 IO 应该往哪儿送。这种关注点分离的设计让我第一次读的时候就觉得非常清爽——整个框架的复杂度被严格限制在了“映射规则”和“bio 转发”这两个核心问题上。1.2 代码分布读源码从哪里下手Device Mapper 的代码主要集中在内核源码树的drivers/md/目录下。这里要提醒一句虽然目录名叫 md但它和传统的软 RAIDmdraid是两套不同的代码只是在同一个目录里维护而已。核心文件可以按功能分成三组。第一组是框架本身包括dm.c设备生命周期与 IO 入口、dm-table.c映射表管理、dm-core.h核心数据结构定义、dm-ioctl.c用户态接口。第二组是公共基础设施比如dm-target.ctarget 注册、dm-stats.cIO 统计、dm-kcopyd.c后台拷贝引擎快照功能依赖它。第三组就是各类 target 实现dm-linear.c、dm-stripe.c、dm-crypt.c、dm-mirror.c、dm-thin.c等等。如果是从零开始读我建议按这个顺序来先读dm-core.h理解数据结构再读dm.c的 IO 入口函数然后精读dm-linear.c和dm-stripe.c这两个最简单的 target最后再挑战dm-crypt.c。上来就啃 dm-thin 或者 dm-era 这种复杂 target很容易被快照、回放之类的细节淹没反而不利于建立整体认知。2. 核心数据结构解析一切映射都建立在它们之上2.1 mapped_device一个 dm 设备的“总装车间”struct mapped_device是理解整个框架的钥匙。它代表一个用户可见的 dm 设备比如/dev/dm-0。这个结构体非常庞大但我认为真正需要重点关注的是四个成员请求队列、gendisk、映射表和线程状态。请求队列request_queue是设备与块层交互的入口任何发往该设备的 bio 都会进入这个队列的处理函数。gendisk 则是设备在内核中的“身份证明”它让这个 dm 设备在/dev下可见、可被分区、可以被文件系统挂载。映射表gmapped或map字段不同版本命名有差异保存的是当前生效的映射规则它的类型是struct dm_table *后面单独讲。线程状态相关的字段主要是为了处理延迟请求和后台任务比如deferred_bios链表以及对应的唤醒机制。从内存布局来看一个 mapped_device 就像是一个“总装车间”bio 进来之后它负责调度映射表去翻译地址翻译完成后再决定是直接转发还是需要克隆一份 bio。所有并发 IO 的协调工作最终都在这个结构体内部的锁和队列中完成。我在读代码时习惯先把结构体打印出来对照着看pahole看一下成员偏移能帮助快速建立空间感。2.2 dm_table 与 dm_target规则表和被执行者struct dm_table是映射规则的容器它内部保存了一个struct dm_target数组。每个dm_target描述一段连续的地址区间包含起始扇区、长度、对应的 target 类型指针以及 target 私有数据。举个例子假设有个逻辑卷由两块物理盘组成第一块盘负责前 100 万扇区第二块盘负责接下来的 200 万扇区那么这个卷的 dm_table 里就有两个 dm_target 数组项。第一个 target 的 begin 是 0len 是 1000000type 是 linearprivate 指向“物理设备 起始偏移”第二个 target 的 begin 是 1000000len 是 2000000type 同样是 linear但 private 指向的是第二块盘。这种“把整个设备切成多段规则”的设计非常灵活。它能实现的不只是线性拼接还可以做到不同区间采用完全不同的映射策略。比如一个逻辑卷的前半段用 SSD 做 linear后半段用三块 HDD 做 striped这在 dm_table 的层面就是一个数组里放两个不同 type 的 target 而已。dm_table在代码分析中有一个需要特别留意的点table 是可以被“热替换”的。用户态通过 ioctl 先加载一张新表再触发 resume 动作内核才会把旧的 table 指针换成新的。这个过程中如果正在处理 IO就必须保证不会出现“读一半老规则、写一半新规则”的状态。这是 Device Mapper 设计上比较精巧也比较绕的地方后面在 IO 路径部分会详细展开。2.3 target_type让第三方扩展成为可能struct target_type是 target 插件需要实现的一组回调函数集合。你可以把它跟 VFS 的file_operations做类比——框架定义好接口具体实现由各模块完成。最重要的回调是map和end_io。map函数负责将传入的 bio 按照自身的映射算法改写 bi_sector 和 bi_disk然后返回一个状态码end_io则是在目标设备完成 IO 之后被调用用于处理错误、统计信息或者触发后续动作。除此之外还有status输出设备状态dmsetup status的输出就是从这儿来的、message处理用户态传来的自定义命令、prepare_ioctl和ioctl处理直接发给 dm 设备的控制命令。这里涉及一个非常重要的设计细节map函数返回的状态码不是简单的是非值而是三态——DM_MAPIO_SUBMITTED表示 bio 已经被接受并异步处理DM_MAPIO_REMAPPED表示 bio 已经被改写地址并需要继续下发DM_MAPIO_REQUEUE表示当前无法处理需要重试。写自定义 target 时状态码搞错的后果非常隐蔽返回 REMAPPED 但实际并没有改写 bio数据就会被写进错误的位置且很难排查。3. 一次 IO 请求的完整旅行提交、拆分、克隆与返回3.1 从 submit_bio 到 dm_submit_bio一次 IO 从文件系统层发起后最终会通过submit_bio_noacct进入块设备层。块设备层根据 bio 的目标设备找到对应的request_queue然后调用队列的make_request_fn回调。对于 Device Mapper 设备来说这个回调最终指向了dm_submit_bio不同内核版本的函数名和封装层级略有差异但思路一致。dm_submit_bio拿到 bio 之后做的第一件事不是翻译地址而是先检查设备状态。如果设备正在 suspend比如用户执行了dmsetup suspend新来的 bio 不能直接处理会被挂到deferred_bios链表上等 resume 之后再重新处理。这个机制的背后是为了配合映射表的原子替换——如果在替换过程中让新 IO 进入硬件可能看到不一致的映射状态。状态检查通过后dm_submit_bio会先尝试一把“快速路径”如果 bio 经过计算后只需要发给一个 target而且长度不超过限制就直接走__map_bio把地址改掉立刻下发。如果条件不满足比如跨了多个 target 的边界就得进入拆分流程。我看过很多性能分析报告把这一层的开销忽略掉实际上 bio 拆分路径上锁竞争和内存分配的成本相当可观。3.2 bio 拆分超过限制就得“分块”__split_and_process_bio是核心的拆分逻辑处理器。它要处理两类情况一类是 bio 的长度超过了 target 或下层设备的最大允许值必须拆小另一类是 bio 跨越了多个 target 的地址区间必须先知道每段区间各有多少。我在分析过程中发现这里最需要关注的是“对齐”问题。内核里很多优化都依赖 bio 与扇区、块大小的对齐关系一旦拆分不对齐轻则性能退化重则触发底层驱动的 BUG_ON。Device Mapper 的做法是自己维护一个max_io_len字段表示当前 target 最多能接收多少长度的 IO然后按这个上限去切 bio。拆分时还有个隐藏的坑bio 是带页表的拆出来的子 bio 不能简单拷贝结构体必须对页表做引用计数处理否则可能导致页被提前释放。bio_clone系列函数就是干这个的。代码里你会在很短的距离内看到bio_alloc_clone、bio_chain、split_bio这几个函数来回调用逻辑密度很高。第一次读的时候建议打开函数调用图我那时候用cscope跳了几十个来回才彻底理顺拆分和克隆之间的细节关系。3.3 两个返回码决定后续命运bio 被 target 的map函数处理后有三种结果需要处理但我实际调试中感觉最需要理解的是DM_MAPIO_REMAPPED和DM_MAPIO_SUBMITTED的区别。DM_MAPIO_REMAPPED意味着 bio 的地址已经被改写框架需要继续把它提交到新设备上。这个过程通常会调用generic_make_request让新的 bio 重新走一遍块设备层的处理流程。如果目标设备还是另一个 dm 设备那么这个流程就会递归下去形成设备栈。这也就解释了为什么 Device Mapper 可以做无限层级的堆叠——每层都只负责自己那一层地址翻译。DM_MAPIO_SUBMITTED则意味着 target 自己接管了 bio 的生命周期。最典型的是 dm-crypt它收到 bio 后不会立刻转发而是要经过加密转换、分配新的 request、提交到底层设备等底层 IO 完成后还要在end_io中做解密或错误处理。整个生命周期已经不属于框架的管辖范围了框架只需要等 target 最终调用 bio 的 endio 回调来回收 bio 即可。这种设计让框架本身保持精简但同时把复杂性转移到了各个 target 中。对想阅读源码的人来说理解map返回值的语义就是理解 Device Mapper 的半壁江山。我见过有同学在写自定义 target 时把这两个值搞混后果是数据路径上出现“双重提交”或 bio 泄漏而且用 ftrace 都很难一眼定位。3.4 完成路径上的 end_io 链bio 被底层设备完成后会沿着 endio 回调链一层层往回走。对 Device Mapper 来说每个被克隆出去的 bio 都携带了一个dm_io结构里面记录了原 bio、target 指针以及克隆关系。框架的end_io回调会调用 target 的end_io函数让 target 有机会做收尾工作。比如 dm-mirror 在检测到写失败时会在这里触发重新同步逻辑dm-crypt 会在这里释放加密上下文并拷贝数据。等所有 clone 都完成后框架才把状态合并回原始 bio调用原 bio 的 endio。这个“最后一个 clone 完成才算完成”的计数逻辑在代码里是通过struct dm_io的io_count原子计数来实现的。每次 clone 完成时递减减到零就收尾。这个机制本身不复杂但调试时很头疼——一旦出现 IO 超时你很难判断是卡在哪个 target 的哪个 clone 上。后面排障章节我会讲怎么用 trace 事件来定位。4. 三个高频 target 源码级拆解linear、striped、crypt4.1 dm-linear最朴素的偏移衬托dm-linear是逻辑上最简单的 target也是最好的入门样例。它的map函数做的事只有一件把 bio 的起始扇区加上一个固定的偏移量。// 简化自 dm-linear.c static void linear_map(struct dm_target *ti, struct bio *bio) { struct linear_c *lc ti-private; bio-bi_bdev lc-dev-bdev; bio-bi_iter.bi_sector lc-start; }这里有个细节值得玩味lc-start是设备上的起始扇区而不是 target 自身的ti-begin。tb 的 begin 表示的是这个 target 在 dm 设备上的逻辑起始位置而 target 内部私有数据里的 start 才是真正的物理映射起点。如果你在读代码时把这两个概念搞混后面理解 dm-stripe 的地址计算就会非常痛苦。linear 之所以重要不仅因为它简单更因为它是 LVM 线性卷的基础。一个逻辑卷如果连续分配在一整块 PV 上生成的 table 就是一堆 linear target。调试 LVM 的 IO 路径时你经常需要在 dm-linear 这层确认偏移量是否与 LVM 元数据描述的 PE 布局一致。我遇到过不少“LV 数据错位”的诡异问题最后查下来就是偏移量计算错误在 hardware 层被掩盖了。4.2 dm-striped条带化的映射数学dm-stripe比 linear 复杂一个量级它的核心是条带化映射算法。假设有 N 块设备条带大小是 chunk_size单位是扇区那么一个逻辑扇区 sector 要经过两步计算// 简化自 dm-stripe.c 的 stripe_map_sector static void stripe_map_sector(sector_t sector, uint32_t stripes, sector_t chunk_size, struct stripe_c *sc) { sector_t chunk sector sc-chunk_shift; sector_t chunk_offset sector (chunk_size - 1); // chunk 在哪个条带设备上 stripe sector_div(chunk, stripes) % stripes; // 该条带设备上的偏移 result (chunk sc-chunk_shift) chunk_offset; }这里最值得学习的是它用位移和位与代替乘除法的思路。因为 chunk_size 被限制为 2 的幂sector chunk_shift就能得到 chunk 编号sector (chunk_size - 1)得到 chunk 内的偏移量。在 IO 密集路径上任何除法都是性能灾难这种“用空间换时间”的做法在内核代码里非常常见。dm-stripe 的拆分逻辑也比 linear 复杂。一个 bio 如果跨越了条带边界就必须被拆成多个子 bio分别映射到不同的底层设备。这导致stripe_map函数需要同时处理三种情况bio 完全落在单个条带内、跨条带但不超过 chunk、跨多个 chunk。每种情况下的地址计算公式不同代码里用 switch case 区分读的时候要对着纸面推演几组数据否则很容易绕晕。我自己常用的验证方法是构造一个 3 设备、chunk_size 为 128 扇区的虚拟设备然后用blktrace观察 IO 到达各个底层设备的分布情况反向验证映射算法是否理解正确。这种“从代码到现象、再从现象反推代码”的方式比单纯读函数有效得多。4.3 dm-crypt热路径上的加密开销dm-crypt应该是所有 target 里最值得精读的一个。它能做到全盘加密但代价是每条 IO 都要经过加解密性能开销非常可观。crypt 的map函数核心逻辑是对 bio 的每个扇区根据它的逻辑扇区号生成 IV初始化向量然后用 IV 加上加密密钥对数据进行转换最后把转换后的数据写到底层设备的对应位置。注意因为加密后的数据长度和原始数据长度一样所以 dm-crypt 可以保持“一对一”的地址映射这和 LUKS 头里保存元数据的逻辑是两回事。IV 生成是 crypt target 里最有意思的部分。最简单的模式是用扇区号直接做 IV但这样会泄露数据模式更安全的模式如 XTS 会用扇区号作为 tweak再配合密钥产生真正的 IV。代码里crypt_convert函数是核心循环它对每个扇区调用一次 cipher之间还要处理 SG 列表的内存映射。这里需要对内核 crypto API 有一定的了解否则会卡在skcipher_walk这类抽象上。从性能角度dm-crypt 的开销主要在三个方面CPU 加密计算耗时、IO 提交时额外的 request 复制、以及因为加密导致无法使用底层设备的某些优化比如合并。这也是为什么在实际生产环境中dm-crypt 通常需要搭配支持 AES-NI 的 CPU否则吞吐量会很难看。我测过一组数据同样一块 NVMe SSD开 dm-crypt 后随机读 IOPS 大约下降 30% 到 50%带宽下降相对小一些具体取决于块大小。5. 并发、锁与性能再稳定的框架也怕热路径放大5.1 不同版本的数据通路差异读源码时一定要先确认内核版本因为 Device Mapper 在 4.x 和 5.x/6.x 之间的 IO 路径有过一次比较大的重构。旧版本分成 request-based 和 bio-based 两套入口前者走的完全是另一套 request 逻辑新版本统一了 bio-based 路径dm_submit_bio成为唯一入口。queue_io和deferred_bios这些旧命名在不同版本里也有变化。我最早参考的资料是 3.x 的源码后来直接上手 5.15发现函数名对不上一度很困惑。建议读者在git log drivers/md/dm.c里先看一眼提交历史理解这几年的演进脉络对读代码非常有帮助。另一个需要注意的变化是submit_bio接口本身。块设备层引入submit_bio_noacct之后Device Mapper 的入口封装也做了相应调整。看不懂这些演进过程时我在网上查资料见过不少针对老版本的讲解但很多在新版本上跑不通了动手前先确认版本几乎成了我的习惯。5.2 性能关键点与观测手段Device Mapper 层的性能瓶颈通常集中在三处。第一是锁竞争。dm_submit_bio在访问设备状态和映射表时需要持锁高并发下锁的争抢会非常明显。观察方法可以通过perf lock或者直接开CONFIG_LOCK_STAT看锁的 contention 统计。第二是 bio 克隆和内存分配。每次拆分和克隆都涉及mempool分配虽然比普通 kmalloc 快但在极端 IOPS 下仍然是不可忽视的开销。这里有个经验值如果dmsetup stats显示的平均 IO 时延比 FIO 直接打底层盘高出 20 微秒以上大概率就是在克隆路径上耗时。第三是 target 内部的处理逻辑尤其是 dm-crypt 的加密操作和 dm-thin 的元数据查询。这类开销无法通过 IO 调度参数优化唯一的办法是升级硬件或者调整 target 的配置参数。观测手段方面我强烈建议用blktracebtt组合来量化每一跳的时间分布。先对 dm 设备做一次 trace再对底层物理设备做一次 trace对比就能定位到每一跳的耗时差异。bpf脚本也可以挂在dm_submit_bio和dm_endio上统计时延分布比 ftrace 更像黑魔法但调试效率确实高。5.3 队列参数与调优方向针对 Device Mapper 设备的队列参数/sys/block/dm-*/queue/下有很多可调项但真正值得把玩的其实不多。max_sectors_kb决定单次 bio 的最大大小。如果底层盘支持大 IO而 dm 层把 max_sectors 设得很小就会导致本可以合并的大块 IO 被拆散性能明显下降。我在一台老内核机器上遇到过这个问题调整后顺序读带宽直接翻倍。scheduler参数对 dm 设备通常没有意义因为它下面还有真实设备真正的调度发生在物理盘那一层。add_random这个参数跟 IO 性能的关系很微妙。它决定是否把块设备的 IO 事件加入内核熵池开启后会在 IO 路径上引入额外的锁操作高并发下可能有几个百分点的性能损失。对不需要熵贡献的纯数据卷关掉它是合理的。这些优化手段单独看都很不起眼但在大规模存储集群里几个百分点的性能差距会被放大得非常明显。6. 实战排障从 deadlock 到 io hang 的排查记录6.1 实践中最常踩的坑读 Device Mapper 源码的过程中我踩过不少坑也看了很多邮件列表里的讨论这里记录几个典型问题。bio 泄漏是写自定义 target 最容易犯的错误。如果 map 函数返回了DM_MAPIO_SUBMITTED但 target 内部投递 bio 后没有正确设置 endio 回调bio 就永远不会完成上层进程会一直处于 D 状态。排查方法是用/proc/下的 io 统计对比发下去的 bio 和完成的 bio 数量。锁顺序不当导致死锁是最隐蔽的坑。Device Mapper 的代码路径里有多把锁比如 mapped_device 的 lock、table 的锁、以及 target 内部的锁。如果 target 的 map 函数里持锁后调用了可能触发递归提交 bio 的函数而递归路径又尝试获取同一把自旋锁直接就会自旋死锁。内核的lockdep对这种场景非常敏感建议开发环境一直开着。table 热替换导致 IO 丢失这个坑主要发生在 suspend/resume 与 IO 交错时。如果你在 suspend 过程中没有等待所有 in-flight IO 完成就 resume 新表那些还停留在旧表路径上的 bio 可能被错误处理或直接丢弃。内核通常用dm_wait_for_completion来处理这个问题但第三方写的 target 如果不尊重这个机制同样会出现数据不一致。6.2 排查方法具体怎么用遇到 IO hang 时我一般按以下顺序排查。第一步先确认是哪个进程卡住。ps -eo pid,stat,wchan:30,cmd看进程状态如果是 D 状态wchan会指到内核函数名。如果卡在wait_on_buffer、submit_bio_wait这类函数上说明问题大概率在块设备层或 dm 层。第二步用dmsetup table --showkeys确认当前映射表的状态用dmsetup status查看设备的内部状态。比如 dm-mirror 会显示同步进度dm-thin 会显示剩余元数据空间这些信息对于判断是否因资源耗尽导致 hang 很有帮助。第三步打开block:和dm:开头的 tracepoint。具体命令是trace-cmd record -e block_rq_issue -e block_rq_complete -e dm_bio_map -e dm_bio_endio。这样能看到每个 bio 从 dm 层进入底层设备的完整时间线哪一跳卡住一目了然。第四步如果还是定位不到就需要开lockdep和hung_task的内核参数。hung_task_timeout_secs设小一点让系统在 hang 出现时第一时间打印堆栈。内核打印出的所有进程堆栈里通常有几个是持有锁的顺着锁的关系就能还原死锁现场。6.3 冷知识速查表我把读代码和排查过程中觉得最有用的冷知识整理成了表格方便大家快速查阅场景关键点建议排查思路LVM 设备 IO 路径逻辑卷由多个 dm_target 组成用dmsetup table查看具体 target 类型和参数性能拐点可能是 bio 拆分的锁竞争perf lock record观察锁 contentionio hang卡在 dm 层还是底层设备blktrace对比 dm 设备与底层设备的请求完成情况自定义 target 数据错乱map 返回值语义错误核对DM_MAPIO_REMAPPED和SUBMITTED的使用场景使用 dm-crypt 后性能骤降加解密 CPU 开销检查 CPU 是否开启 AES-NI观察加密转换耗时table reload 后 IO 中断suspend/resume 与 IO 竞态确认 resume 前是否等待所有 in-flight IO 完成设备栈层级过深bio 递归路径多简化映射层次减少无谓的卷套卷结构6.4 一个具体的排查案例去年我排查过一次非常隐蔽的 IO 抖动问题。现象是某个 LVM 逻辑卷上的数据库每隔几分钟就会出现一次延迟尖峰持续时间大约几百毫秒。先用blktrace对比 dm 设备和底层 SSD 的请求时间线发现 dm 层的请求提交出现明显间隔再抓dm_bio_map事件发现这种间隔总是出现在某些特定扇区范围的 bio 上最后查看映射表发现这个逻辑卷由两个 target 组成而这两个 target 对应的底层物理盘完全不同其中一块盘是机械盘。问题就出在这儿数据库写入的数据跨了两个 target一部分落到 SSD一部分落到机械盘而机械盘所在的那块 PV 恰好又因为其他卷的 IO 压力出现抖动连累整个逻辑卷的提交路径被拖慢。根本解法是把机械盘上的数据迁移走、重新做条带化布局。这件事让我深刻体会到读 Device Mapper 的映射表不只是为了理解代码生产环境的每一个映射细节都会真实反映在性能数据上。7. 从源码阅读到实际扩展下一步可以怎么玩读 Device Mapper 的源码不只为 LVM 排障还能直接用来做自己的存储实验。最值得尝试的是写一个自己的 target 模块不用很复杂比如一个把特定扇区范围重定向到内存的“内存盘”或者一个打印日志的“旁路观察器”。写自定义 target 时的基本步骤是在dm-target.c里注册一个新的target_type实现map和ctr构造函数用于解析 table 参数两个回调然后把target_type导出到内核。用dmsetup create加载 table 时target 名字会在内核里被查询到之后 IO 就会走入你的代码。这个过程总共不过几百行代码但能让你对 Device Mapper 的理解完成质的飞跃——自己动手写过一遍再回头看dm-linear.c的感觉完全不一样。如果想深入优化还可以研究一下dm-clone或dm-zone这类较新的 target 实现它们处理的问题数据重映射、ZNS 设备支持比老 target 复杂得多但设计思路更加现代。我个人从dm-clone的代码里学到了大量关于“区域回放”和“后台迁移”的实现技巧这些技巧直接可以用到自研存储产品的开发中。最后想提醒一点读内核源码时不要恋战。Device Mapper 的位置注定了它会和块层、文件系统、设备驱动都有交互知识面太容易铺开。但真正需要精读的永远是那一条主路径——从submit_bio到map()再到endio把这条线吃透剩下的细节都可以在需要时再去查。