news 2026/10/6 4:15:57

Linux Device Mapper核心机制与IO路径源码解析:从框架到dm-linear实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux Device Mapper核心机制与IO路径源码解析:从框架到dm-linear实践

我最早读 Device Mapper(简称 DM)的代码,纯粹是被一个线上故障逼的:某台机器上的逻辑卷突然出现大量 IO 延迟,dmesg 里刷着奇怪的 bio 拆分报错,用 dmsetup 一层层看下去才怀疑是底层的映射表出了问题。从那之后我就意识到,凡是接触 Linux 存储、虚拟化或者容器的人,绕不开 DM 这个基础设施。LVM 的逻辑卷、dm-crypt 磁盘加密、软 RAID、Docker 早期的 devicemapper 存储驱动,底层全是这套机制在干活。

这篇文章想做的不是概念科普,而是直接切进代码实现:先讲清楚 DM 这个框架到底解决了什么问题,再逐层拆它的核心数据结构、bio 的流转路径,然后用一个最典型的 dm-linear target 走完整条调用链,最后把我实际排查 DM 问题时踩过的坑整理成可供参考的经验。适合对内核块设备层有基本认识、想真正读懂 DM 源码的同学,如果你只是用 dmsetup 做日常运维,也能从中看到一些排查底层的思路。

1. DM 的整体设计思路:一个“虚拟磁盘配电房”

1.1 它到底卡在内核哪一层

我接触过的很多开发者,对文件系统下面的东西了解不多。文件系统(ext4、xfs 这类)通过 submit_bio 把 IO 请求往下交,下面就是通用块层(block layer),再往下才是具体的驱动,比如 NVMe、SATA 或者虚拟化里的 virtio-blk。DM 就处在通用块层和真实块设备驱动之间,它把自己伪装成一个块设备,承接上层下来的 bio,再根据你定义好的映射关系,把 bio 转交给一个或多个真实块设备。

听起来有点绕,你可以把 DM 想象成一栋楼里的配电房:楼上住户(文件系统)只知道“我要用电”,配电房(DM)按电路规划把电分流到不同回路(真实磁盘分区),住户根本不用关心自家这条线路到底接在哪。这层抽象的工程价值很大——你可以把多块磁盘拼成一个逻辑盘(linear/striped),可以给一个盘做实时快照(snapshot),也可以在上面叠加密、缓存、RAID,而上面的文件系统完全无感。

1.2 那种“一张表搞定一切”的设计很优雅

DM 代码最核心的思想,说出来其实很简单:整个框架维护一张映射表,表里的每一项是一个 dm_target。每个 target 规定了一段逻辑扇区范围,以及这段范围对应的下家设备和处理方式。一个 bio 进入 DM 设备时,框架按照它所在的扇区范围去查表,找到对应的 target,再调用这个 target 预先注册好的处理函数。

我第一次在 drivers/md/dm.c 里看到这个流程时,第一反应是这设计比我想象的朴素太多,但朴素的背后是极强的高内聚低耦合。控制面和数据面是分开的:用户态用 dmsetup 或者 LVM 通过 ioctl 去创建设备、加载表、挂起/恢复设备,内核这边则只关心表的结构和 bio 的搬运。target 的开发者只需要实现 ctr、map、status 几个回调函数,就可以无缝接入框架,不需要关心 DM 设备怎么注册、bio 怎么拆分这些底层细节。

1.3 选 DM 而不直接改块设备驱动,是划算的

有人可能问,这种“虚拟磁盘”功能为什么不直接写在某个具体驱动里?答案很简单:复用性。如果 RAID 逻辑写在 SCSI 驱动里,那 NVMe 怎么办?virtio-blk 怎么办?DM 在块设备层之上做了一层统一的虚拟化接口,任何块设备到任何块设备之间的变换逻辑都能以 target 模块的方式插进来。所以内核里 drivers/md/ 目录下才会堆着 dm-linear.c、dm-stripe.c、dm-crypt.c、dm-thin.c 这些各自独立的 target 实现,它们互不干扰,却共用同一套核心框架。这个思路对做软件架构的人也有启发——把一个稳定的核心框架抽出来,把可变的部分全部插件化。

2. 核心数据结构:先搞懂这三个结构体

2.1 mapped_device:DM 设备的“户口本”

在内核里,struct mapped_device(经常被简写成 md)代表一个 DM 逻辑设备,注册之后你会看到 /dev/dm-0、/dev/dm-1 这样的设备节点。这个结构体是 DM 设备的老大,里面汇聚了设备运行需要的几乎一切信息。

可以重点看几类字段。第一是通用块设备的句柄,比如 request_queue 的指针,它决定了这个设备怎么接收上层的 IO 请求;第二是表的管理,它保存着一份当前活跃的映射表指针以及等待交换的预备表指针,这为在线修改映射表提供了基础;第三是一堆同步和内存管理设施,包括各种锁、等待队列、拆分 bio 时需要用到的内存池,以及同一个设备上一次可能处于多个逻辑卷组合里的场景需要用到的引用计数。这个结构体在编译内核时会被包含到 dm-core.h 里,但它不应该被各个 target 直接访问,target 拿到的只是 dm_target。

2.2 dm_target:每一段映射的“接线说明”

struct dm_target 是映射表的基本单位,它描述了一段连续的逻辑扇区范围。它有两组关键信息:一组是几何信息,比如这段映射覆盖的逻辑起始扇区、扇区数、最大 IO 大小、最大扇区数等;另一组是操作回调,也就是这个 target 对应的 type 结构体指针。

type 结构体里放着的就是 target 的生命周期函数:ctr 负责根据传给它的参数做初始化,dtor 负责释放,map 负责把上层的 bio 映射到下层设备,还有 status、message、prepare_ioctl 这类辅助接口。我刚开始读代码时有个误区,以为 target 是直接拿 bio 做所有事,其实不对,map 函数只是完成地址变换和 IO 后端设备的替换,真正的提交动作还是要走 block layer 重新提交。这样每个 target 只要专注自己负责的那一段逻辑,框架复杂度就被大大分摊了。

2.3 dm_table:把这些接线全部“登记造册”

dm_table 就是那张映射表。它里面一个很重要的成员是一组 dm_target 指针数组,数组按逻辑扇区顺序排列。框架每收到一个 bio,按 bio 的起始扇区在 table 里做查找,找到对应的 dm_target 之后调用它的 map。

除了保存 target 列表,dm_table 还干了很多统计和校验的活。比如它要统一计算这个 DM 设备对外暴露的容量、最大 IO 尺寸、discard 支持能力等,这些信息来自所有 target 的共同属性,任何一个 target 说我不支持 discard,整个设备可能就得禁掉 discard。加载一张表时,内核还会检查各个 target 覆盖的扇区范围是否连续、有没有重叠、整体扇区数是否一致。这一步校验很关键,否则一张错误的表会直接导致 IO 发出逻辑错误或数据覆盖。

2.4 三个结构体的协作关系

一张动图胜过千言万语,但用文字说也不难。mapped_device 是 DM 设备的化身,它持有一张 dm_table;dm_table 里有很多个 dm_target;每个 dm_target 关联一个 dm_target_ops 结构体,以及这个 target 自己的私有数据 private。

当 IO 到达 mapped_device 对应的 request_queue 时,dm_submit_bio 会被调用。它拿出 active_table,然后按照 bio 的扇区在 dm_table 里找 target,找到后调用 target 的 map。map 可能返回几种结果:REMAPPED 说明 bio 已经改好目标设备,可以直接往下提交;SUBMITTED 说明 target 自己已经把 bio 处理了,框架不用管;REQUEUE 则说明现在处理不了,要等下一次机会重试。这套约定就是整个 IO 搬运的核心契约。

3. IO 路径源码解读:一个 bio 的奇幻漂流

3.1 入口:从 request_queue 到 dm_submit_bio

在较新的内核版本里,块设备驱动不再需要自己写 make_request_fn,而是用 blk_mq 的 queue_rqs 机制,但 DM 依然保留了通过 request_queue 的 make_request_fn 拦截 bio 的能力。DM 设备创建时,会把队列的 make_request_fn 设置为 dm_make_request,于是每次上层提交 bio 时,都会走进 DM 自己的处理流程。

进入 dm 之后会经过设备挂起状态的检查。如果设备处于 SUSPENDED 状态,bio 不能直接处理,要排进等待队列,等恢复时再统一处理。这个机制是 LVM 能安全做在线扩展的基础——先把 IO 停住,改表,再恢复,IO 继续时不至于摸到一半新一半旧的映射。在实际调试时,如果你看到 IO 卡住不动的现象,第一个怀疑点其实是设备是不是一直没 resume。

3.2 拆分:bio 太大不行,越界不行

DM 里 bio 拆分发生在 io 路径的中段,核心函数是 __split_and_process_bio。拆分的根本原因是 bio 的扇区范围可能跨了多个 target,或者超出了某个 target 自己声明的 IO 大小上限,只能把它拆成几个小 bio,分头处理。

判断逻辑里最常用的是 dm_target 的 max_io_len 函数。这个函数会结合 target 的最大 IO 扇区数和当前 bio 起始扇区在 target 中的位置,精确算出一个允许提交的扇区数,相当于框定“最多能往前走多远”。拆分之后,原来的 bio 变成了一个或者多个 clone bio,每个 clone 只对应一个 target 的一段范围,再逐个进入 target 的 map。

这个环节我见过最多的 bug 出在 max_io_len 计算上,尤其是 target 的 io 边界如果不是扇区对齐,边界条件差一格都会导致 bio 被拆成碎片,甚至无限循环。所以如果你要写自己的 target,这个地方一定要拿数学算清楚。

3.3 映射与提交:改地址,下场设备,然后走人

clone bio 准备好后,框架会调用 target 的 map 函数。以 linear target 为例,map 干的事情非常简单:把 bio 的目标设备改成下层真实设备,再把 bio 的起始扇区加上 target 记录的线性偏移量。改完之后把返回值设为 DM_MAPIO_REMAPPED,框架一看这个状态,就知道 bio 已经被“重组”过了,于是直接调用 generic_submit_bio 把它重新提交给块设备层。

有意思的是,这一个 bio 重新进入块设备层后,如果它面对的下层设备又是另一个 DM 设备,那它就会再次走进 dm_make_request,形成嵌套。所以设备栈可以叠得很深,比如 磁盘→分区→加密映射→线性映射→快照映射。理论上每层都有 IO 拆分、内存分配、锁等待,所以设备栈深了之后性能会明显劣化,这也是用 DM 做复杂存储方案时一个很现实的考量。

3.4 完成路径与错误返回

bio 在下层设备完成后,请求结束的回调会触发 endio。DM 在构造 clone bio 时会把原 bio 以及相关上下文放进 clone 的 bi_private,然后在 endio 里做合并计数、错误码透传和原 bio 的完成操作。IO 错误在 DM 层有个细节:如果 target 支持 IO 错误记录,比如 mirror、raid 这类有冗余的 target,它们在 endio 里就会拦截错误并做重试或切换,而像 linear 这种没有容错能力的 target,错误就原样上传。

看这个流程时能体会到,DM 框架本身对错误处理持很保守的态度,大多数错误都是“上传”而不是“吞掉”。所以从上面文件系统视角看,底下一个盘的 IO 错误基本能原封不动地浮出来,这其实是好事,避免存储层把故障藏起来,导致数据损坏发生得很隐蔽。

4. 手把手拆一个 target:dm-linear 全流程只用了三个回调

4.1 linear 的定位和表参数含义

如果你想写一个最简单的 DM target,dm-linear 是最理想的范本。它的作用是把一段连续的逻辑扇区映射到另一个块设备的一段连续区域上,纯粹做地址平移,不加冗余、不加性能分层。dmsetup 加载 linear 时通常会传这样的参数:

参数含义
0 20971520左边是逻辑起始扇区,右边是映射长度(扇区数)
lineartarget 类型标识
/dev/sdb1下层设备路径
2048下层设备上的起始扇区

也就是说,DM 设备从扇区 0 开始的 10GB 空间,全部映射到 /dev/sdb1 的 2048 扇区开始的位置。整个映射关系是一段完全线性的对应,所以叫 linear。

4.2 ctr:参数解析就那么几行

代码在 drivers/md/dm-linear.c,核心结构体 Linear_c 只有两个成员:下层设备指针和起始扇区。ctr 回调要用 dm_get_device 去查这个设备是否合法、能不能写、存不存在,然后把起始扇区记录下来。这里有几个容易踩的点:设备名如果传的是 /dev/sdb1,内核会通过 devname 找到对应的 block_device;如果传的是 major:minor 号,就直接解析成 dev_t。实际开发中,我建议 target 参数尽量用 major:minor,避免路径解析依赖 devtmpfs 造成的不确定性。

ctr 里还有一个校验逻辑,就是判断这个 target 的扇区数是否超过了底层设备的剩余容量。如果底层从 2048 开始没有 10GB 的空间,ctr 会直接失败并输出警告。这个校验保证了映射的合法性,但又因为是启动时就检查,所以如果底层盘之后被缩容了,后续 IO 仍可能出现 bio 越界的问题,需要靠 block layer 的错误路径兜住。

4.3 map:平移 bio 地址就这么简单

线性映射的 map 回调基本是 DM 所有 target 里最短的:

static int linear_map(struct dm_target *ti, struct bio *bio) { struct linear_c *lc = ti->private; bio_set_dev(bio, lc->dev->bdev); bio->bi_iter.bi_sector += lc->start; return DM_MAPIO_REMAPPED; }

这段代码做的事情就是修改 bio 的设备指针,然后给起始扇区加上偏移量,接着返回 REMAPPED。bio_set_dev 这行很关键,它同时会更新 bio 里的磁盘引用计数,防止下层设备在 IO 进行中被意外移除;如果你自己写 target 时漏了这一步,很可能会出现 use-after-free 或设备错乱。

我也频繁被问,linear 为什么不做 bio 拆分?答案是 linear 因为地址是连续平移,天然不改变 IO 的物理连续性,所以拆分交给上层的 dm_table 框架按 max_sectors 处理就够了。这个“能不拆就不拆”的原则在后面写复杂 target 时同样值得参考——拆分不仅是性能损耗,还意味着要管理更多 clone bio 的生命周期,出错概率直线上升。

4.4 status、ioctl 和消息回调

linear 的 status 回调用来输出当前映射的状态。如果用户态执行 dmsetup status,它会返回 target 类型、底层设备名和地址偏移,这些信息对排查设备栈特别有用。还有 prepare_ioctl 回调,用来解决一部分 ioctl 透传问题,比如文件系统想要直接向底层设备发 BLKFLSBUF,就需要 DM 设备判断清楚之后把 ioctl 透传给对应的底层设备。

我在写自己的 target 时,有一个体会:很多刚开始接触 DM 开发的人只关心 map,不关心 status 和 ioctl,这是不对的。status 写得好不好直接影响线上运维能不能快速定位问题;ioctl 透传处理不好,文件系统特性可能莫名其妙失效。别看 linear 只有几十行核心代码,要做得对,必要的回调一个都不少。

4.5 注册 target 的方式

所有 target 都在模块入口函数中用 dm_register_target 注册自己的类型名称和回调函数。注册之后,用户态在写表时只要指定对应的名字,内核就能找到这个 target 的构造函数。编译时如果你要加入自己的 target,最简单的方式是仿照 linear 写一个 .c 文件放进 drivers/md/,然后改 Kconfig 和 Makefile 开一个新的 config 项,也可以额外编译成模块。

这里有个工程细节:开发调试阶段,我建议先把 target 编成模块,这样改完代码 rmmod 再 modprobe 就能重新加载,不用反复重启整机;而且 DM 框架也支持在 target 模块没有加载时,表加载 ioctl 返回 “unknown target type”,方便你确认是不是忘了加载模块。

5. 读 DM 代码和排查问题时的实战心得

5.1 表加载老是 EBUSY 或 EEXIST

我最早用 dmsetup create 时踩过不少次 EBUSY。它不是设备不存在,而是设备名已经被占用,或者设备在 suspend 状态下正在做表切换。另一个新手容易搞混的错误是 EEXIST,通常出现在你试图重复创建一个已经存在的 DM 设备。排查时第一件事就是用 dmsetup ls 看看已有设备的名字和状态,而不是一顿乱试。

加载表还有一个隐蔽约束:如果目标设备是当前 DM 设备自己的下层依赖,内核会检测出设备栈循环。假设你把 /dev/mapper/test 又当作 test2 的下层设备,然后让 test2 反过来作为 test 的下层设备,这种成环的依赖在表加载阶段会被识别并拒绝。实际中这种情况不常见,但在自动化脚本里如果创建顺序写错,就很有可能踩到。

5.2 bio 拆分无限循环或性能骤降

在编写或调试自定义 target 时,最让人头疼的是 bio 被反复拆分、提交,然后又拆分。通常的表现是 IO 吞吐极低,CPU 上涨明显,有时候还会伴随大量内存分配失败。这个问题的根子往往出在 target 的 max_io_len 计算和目标设备能力没有对齐上,比如 target 声称支持 1MB 的 IO,但底层设备只能接收 512KB,拆分出来的 bio 再次走到 target 时还是超限,就陷入了循环拆分。

排查这类问题建议先用 blktrace 观察下发 IO 的大小分布。如果看到大量 4KB 的小 IO 在 DM 设备上打转,多半是拆分逻辑有问题。更直接的办法是在 map 回调里加临时的 trace_printk,打印每个 bio 的扇区范围,然后对照 dmsetup table 输出的映射范围看,很容易定位到是哪个 target 的边界算错了。

5.3 挂起设备时 IO 卡死

在测试快照或调整表结构时,我会频繁用到 dmsetup suspend/resume。如果 suspend 之后 dmsetup resume 迟迟不执行,上层文件系统就可能卡在 IO 等待上。这本身是设计行为,但有一个常见坑:有人会在 suspend 状态下用 dmsetup table 查看映射,发现表没变,就误以为设备还在正常服务。这时大量 IO 其实都堵在 dm 的等待队列里,堆多了还会触发内存回收压力。

遇到这类情况,我通常用 dmsetup info 查看设备的 suspended 字段确认状态,再查 IO 是否积压,可以看 /sys/block/dm-0/stat 里的 IO 计数是不是长时间不增长。确认是挂起导致的卡顿后,及时执行 resume 或者检查是不是有进程一直没释放对 DM 设备的引用,否则恢复流程也会被拖住。

5.4 用 bpftrace 和 ftrace 快速看 IO 走向

代码分析不能只停留在“看”,我更推荐用动态追踪工具验证自己的理解。在内核 4.x 之后的版本上,可以用 ftrace 的 tracepoint 直接看 block:block_bio_queue、block:block_bio_remap 这些点,快速确认一个 bio 在 DM 设备上的 remap 情况。而 bpftrace 可以挂 kprobe/dm_map 之类的位置,实时打印 target 的私有数据变化。

举个例子,我想确认 dm-linear 是不是真的把扇区 1000 偏移到了 3048,直接写几行 bpftrace 挂在 linear_map 上打印 bi_sector 和 lc->start,一次 IO 就能看到清晰的变化。这种“代码分析+动态验证”的方式,比我干读源码痛苦地啃半天可靠太多了。尤其对于刚接触内核的新手,先在 qemu 或者开发机上用这类工具配合源码读,理解速度会快很多。

5.5 深入代码前先把设备栈画出来

最后分享一个很土但很实用的做法:排查 DM 问题时,先不要急着打开源码,拿 dmsetup ls --tree 把设备栈结构一层层画出来,再结合挂载点对比。绝大多数 IO 异常,根源都是一张不合理的映射表或者设备依赖关系。代码分析只是帮你确认“这个映射为什么导致了这个结果”,但发现问题入口往往还是在设备栈本身。

比如我看过一份报障,说某个逻辑卷性能从 200MB/s 掉到 20MB/s,打开设备栈一看,底层不知不觉叠了 linear——crypt——thin——snapshot 四层。这个性能损耗不是某一层代码的 bug,而是整个映射链路的固有代价。这种经验讲给运维同学听,他们立刻就知道该怎么去避免类似的方案设计。

6. 想继续深入的话,从哪里入手

如果你从这篇文章读出了点感觉,准备自己深入源码,我的建议是不要一开始就啃 dm.c 这个几千行的大文件,而是按“外到内”的顺序走:先用 dmsetup 操作 linear 和 striped 制造出设备栈,然后用 blktrace 观察 IO,再回到源码里找对应的 map 函数;接着看 dm-table.c 里表是怎么构建和校验的,最后才是 mapped_device 的整个生命周期管理。

我自己的习惯是准备一台能随意折腾的虚拟机或者开发机,准备好内核编译环境,边改代码边加载模块,用 dmsetup create 创建测试设备,然后配合 fio 灌数据验证。代码分析和活体实验两头走,很多“哦,原来是这么回事”的时刻就是这么来的。DM 这套框架非常经典,读懂它不只是为了处理 LVM 或者容器存储的问题,更是理解 Linux 块设备层设计思想的一条捷径。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 4:15:04

TypeScript装饰器与元数据反射实战:依赖注入与参数校验落地

在TypeScript的日常开发里,装饰器算是一个既熟悉又陌生的家伙。说熟悉,是因为你在NestJS里面随处看到Controller()、Injectable(),在类库源码里也经常撞见各种deprecated标记;说陌生,是因为绝大多数业务项目里&#xf…

作者头像 李华
网站建设 2026/10/6 4:14:53

SpringBoot潮玩交易系统实战:防超卖、订单闭环与毕设避坑指南

简介:电商后端开发中,交易系统的数据一致性一直是工程实践的核心挑战。SpringBoot作为Java生态主流的微服务开发框架,以其自动化配置和快速部署能力,成为构建中小型交易系统的首选。在抢购、限量商品等场景下,库存超卖…

作者头像 李华
网站建设 2026/10/6 4:13:53

Agent-Reach:让AI Agent真正操作业务系统的工程框架与实战

1. 项目概述:为什么叫“Agent-Reach”,它到底解决什么问题我一直在琢磨一个事:大模型的能力边界已经铺得很开了,能写文案、能总结文档、能写代码,但真要让它“动手办事”——比如去内部系统里拉一份数据、在某个后台页…

作者头像 李华
网站建设 2026/10/6 4:13:19

C/Java/Go/Rust/Julia内存管理对决:分配策略与性能选型

做后端这些年,我发现自己有个职业病:看到任何一门编程语言,第一反应不是语法糖好不好用,而是它怎么管内存。内存管理这四个字往上能扯到操作系统,往下能扯到CPU缓存,中间还隔着编译器、运行时和一堆奇奇怪怪…

作者头像 李华
网站建设 2026/10/6 4:11:40

COMSOL烧蚀仿真:饱和蒸汽压力与水平集源项建模实践

做材料加工的仿真,最难啃的骨头之一就是烧蚀。激光、电子束、等离子弧打上去,表面材料温度升高、熔化、蒸发,一眨眼就少了一层。这个过程中不仅有温度场的急剧变化,还有界面移动、流体流动、蒸汽反冲,纯靠一个物理场根…

作者头像 李华
网站建设 2026/10/6 4:09:54

DBeaver 数据库连接与取数实战:从 JDK 配置到 GoldenDB 验证

简介:这是一份面向数据库开发人员与运维人员的 DBeaver 常用操作速查 PDF,聚焦日常开发中高频使用的功能和效率技巧。内容围绕查看表结构、优化查询返回条数、SQL 模板调用、结果集导出等实用场景展开,也介绍了连接管理、执行计划、批量脚本等…

作者头像 李华