☰
Device Mapper源码深度解析:核心结构、IO映射与性能调优实践
2026/10/6 13:44:16 网站建设 项目流程

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,但它和传统的软 RAID(mdraid)是两套不同的代码,只是在同一个目录里维护而已。

核心文件可以按功能分成三组。第一组是框架本身,包括dm.c(设备生命周期与 IO 入口)、dm-table.c(映射表管理)、dm-core.h(核心数据结构定义)、dm-ioctl.c(用户态接口)。第二组是公共基础设施,比如dm-target.c(target 注册)、dm-stats.c(IO 统计)、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 是 0,len 是 1000000,type 是 linear,private 指向“物理设备 + 起始偏移”;第二个 target 的 begin 是 1000000,len 是 2000000,type 同样是 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、crypt

4.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 的配置参数。

观测手段方面,我强烈建议用blktrace+btt组合来量化每一跳的时间分布。先对 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观察锁 contention
io 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,把这条线吃透,剩下的细节都可以在需要时再去查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询