layout: post
title: IOMAP 迭代协议
subtitle:
date: 2026-11-04
author: icecube
header-img: img/bluelinux.jpg
catalog: true
tags:
- fs
02 · 迭代协议:一次 I/O 是怎么被切成一段一段走完的
前置知识:01.md 的
struct iomap字段语义、五种 type、两类标志;C 里的函数指针回调 难度:进阶 预计阅读时间:30 分钟
正文内容
1. 循环长什么样
所有 iomap 入口(读、写、DIO、fiemap、seek、zero)的骨架都是同一段代码:
struct iomap_iter iter = {
.inode = inode,
.pos = start,
.len = length,
.flags = IOMAP_WRITE, /* 或其它操作标志 */
};
while ((ret = iomap_iter(&iter, ops)) > 0)
iter.status = /* 处理 [iter.pos, iter.pos + iomap_length(&iter)) 这一段,然后推进 */;
return ret < 0 ? ret : /* 累计结果 */;
三个约定要记住:
- 返回值
> 0表示「还有下一段」,== 0表示走完,< 0是错误。 循环体一旦看到<= 0就必须退出,不能再次进入循环体。 - 循环体只有两种离开方式:保持
iter.status不变(正常继续),或把它设成负 errno(中止)。iter.status是循环体向迭代器传话的唯一通道。 iter.pos/iter.len在循环体里当作只读,推进通过iomap_iter_advance()完成。
struct iomap_iter(include/linux/iomap.h:263-274)里四个状态字段的分工:
| 字段 | 谁改 | 含义 |
|---|---|---|
pos |
iomap_iter_advance() |
当前要处理的文件位置 |
len |
iomap_iter_advance() |
还剩多少字节要走 |
iter_start_pos |
iomap_iter_done() |
当前这段映射的起点,用来算 end() 的长度与 written |
status |
循环体设置,迭代器清零 | 上一段处理的结果 |
iter_start_pos 的存在是个容易忽略的细节:因为循环体会把 pos 往前推,等到要调用 ->iomap_end() 结算上一段时,已经没有「这段从哪开始」的信息了。所以迭代器在每次产出新映射时把起点记下来。
2. iomap_iter():只有 20 行的调度器
fs/iomap/iter.c:116-135,完整看完:
int iomap_iter(struct iomap_iter *iter, const struct iomap_ops *ops)
{
int ret;
trace_iomap_iter(iter, ops, _RET_IP_);
if (ops->iomap_next)
ret = ops->iomap_next(iter, &iter->iomap, &iter->srcmap);
else
ret = iomap_iter_next(iter, &iter->iomap, &iter->srcmap,
ops->iomap_begin, ops->iomap_end);
iter->status = 0;
if (ret > 0)
iomap_iter_done(iter);
else if (ret < 0)
iomap_iter_clean_fbatch(iter, &iter->iomap);
return ret;
}
要点:
- 它自己不做任何映射工作。 真正的活都在
ops->iomap_next(新写法)或iomap_iter_next(兼容老写法)里。 iter->status = 0在取回下一段映射之后清零——上一段循环体留下的 status 已经在iomap_iter_continue()里被消费过了,新的一段从头开始。ret > 0时调iomap_iter_done(),它做的正是第 3 节那组不变量检查。
3. 三个 WARN_ON_ONCE 就是迭代器对 FS 的全部要求
fs/iomap/iter.c:35-47:
static inline void iomap_iter_done(struct iomap_iter *iter)
{
WARN_ON_ONCE(iter->iomap.offset > iter->pos); /* ① */
WARN_ON_ONCE(iter->iomap.length == 0); /* ② */
WARN_ON_ONCE(iter->iomap.offset + iter->iomap.length <= iter->pos); /* ③ */
WARN_ON_ONCE(iter->iomap.flags & IOMAP_F_STALE); /* ④ */
iter->iter_start_pos = iter->pos;
...
}
翻译成人话,FS 的 iomap_begin 必须保证:
- 映射不能从
pos之后才开始(不能跳着给); - 长度必须非零(否则循环无法前进);
- 映射必须覆盖
pos(offset + length > pos,即当前位置必须落在段内); - 产出时不能还带着 STALE 标记(STALE 是循环体要求重取的,重取后必须先清掉)。
换句话说:「映射必须覆盖当前 pos」是 FS 侧的硬契约。FS 想「跳过去」处理某个位置是不允许的——要跳过,得靠 iomap_iter_advance() 让 pos 走过去。
第 ③ 条还解释了为什么 iomap->offset 允许小于 pos:映射可以从 pos 之前开始(FS 返回的是它内部的 extent 边界,未必对齐到 pos),只要把 pos 包住就行。于是每次计算有效长度都必须「截掉 pos 之前的部分」,这就是 iomap_length_trim() 的活。
4. iomap_iter_next():结算上一段 → 决定是否继续 → 取下一段
include/linux/iomap.h:356-386。它是所有 FS 的 ->iomap_next() 的公共实现:
static __always_inline int iomap_iter_next(const struct iomap_iter *iter,
struct iomap *iomap, struct iomap *srcmap,
iomap_iter_begin_fn begin, iomap_iter_end_fn end)
{
int ret = 0;
if (iomap->length) { /* ① 上一段存在才需要结算 */
if (end) {
ssize_t advanced = iter->pos - iter->iter_start_pos;
loff_t len;
len = iomap_length_trim(iter, iter->iter_start_pos,
iter->len + advanced);
ret = end(iter->inode, iter->iter_start_pos, len,
advanced, iter->flags, iomap);
}
ret = iomap_iter_continue(iter, iomap, srcmap, ret);
if (ret <= 0)
return ret;
}
ret = begin(iter->inode, iter->pos, iter->len, iter->flags, iomap,
srcmap);
return ret < 0 ? ret : 1;
}
顺序是:先 end() 上一段 → 再判定继续与否 → 最后 begin() 下一段。
两个细节值得记住:
- 给
end()的written参数是advanced,不是iomap->length。advanced = iter->pos - iter->iter_start_pos,即循环体实际推进了多少字节。回看iomap_end的注释(iomap.h:225-232):written 是需要提交的长度,剩下的(len - written)是要取消预留的。所以 FS 的->iomap_end()语义是「这段里我实际写了 advanced 字节,其余你帮我退掉」——短写、出错、只写了一部分的场景全靠这个参数收尾。 len的计算把已推进的加回去:iomap_length_trim(iter, iter_start_pos, iter->len + advanced)。iter->len是「剩余量」,加上本段已走的advanced才还原出「从本段起点看还剩多少」。这个绕一下的算式常被读错。
5. iomap_iter_continue():终止条件的四条分支
fs/iomap/iter.c:64-98:
int iomap_iter_continue(const struct iomap_iter *iter, struct iomap *iomap,
struct iomap *srcmap, int ret)
{
const bool stale = iomap->flags & IOMAP_F_STALE;
const ssize_t advanced = iter->pos - iter->iter_start_pos;
if (ret < 0 && !advanced)
return ret; /* ① end() 报错且没推进 → 报错返回 */
if (WARN_ON_ONCE(iter->status > 0))
ret = -EIO; /* ② 循环体不许返回正数 */
else if (iter->status < 0)
ret = iter->status; /* ③ 循环体要求中止 */
else if (iter->len == 0 || (!advanced && !stale))
ret = 0; /* ④ 走完了,或原地踏步 */
else
ret = 1;
iomap_iter_clean_fbatch(iter, iomap);
if (ret <= 0)
return ret;
memset(iomap, 0, sizeof(*iomap)); /* ⑤ 清空,准备装下一段 */
memset(srcmap, 0, sizeof(*srcmap));
return ret;
}
第 ④ 条是最值得琢磨的:!advanced && !stale 就停止。即「本段一点没推进」视为终止——这是防死循环的设计:循环体如果既不做工作也不推进 pos,迭代器不会再给它机会。唯一的例外是 stale:映射被标记为失效时,允许以同样的 pos 重新取一次映射,拿到的新映射继续处理。这就是为什么 IOMAP_F_STALE 必须存在,也解释了 iomap_iter_done() 的第 ④ 条 WARN——重取后 FS 不能把 STALE 再带出来。
另外注意 iomap_iter_clean_fbatch()(iter.c:13-23):大 folio 批量处理用的 folio_batch 在这里统一释放,保证无论从哪条分支退出都不会漏引用。
6. iomap_length_trim():为什么要截两刀
include/linux/iomap.h:288-296:
static inline u64 iomap_length_trim(const struct iomap_iter *iter, loff_t pos,
u64 len)
{
u64 end = iter->iomap.offset + iter->iomap.length;
if (iter->srcmap.type != IOMAP_HOLE)
end = min(end, iter->srcmap.offset + iter->srcmap.length);
return min(len, end - pos);
}
有效长度是三者取小:请求剩余量 len、目标映射(dstmap)的末端、源映射(srcmap)的末端。
第二刀为什么存在?因为 reflink / COW 场景下,「写到哪」和「从哪读」不是同一块盘上区域。写一段共享的 extent 时,FS 会准备一个新的目标位置(COW 目的地),同时原始位置仍需要被读出来填充 folio 的其余部分。于是:
iter->iomap(dstmap):写要去的地方;iter->srcmap:读要从哪里取(用于 read-modify-write 的「读」那一步、以及 COW 的源)。
iomap_iter_srcmap()(iomap.h:326-331)把这个选择封装起来:srcmap 非空则返回 srcmap,否则退化成 dstmap。所以不关心 COW 的文件系统压根不用填 srcmap,通用层会自动退化——这也是 EROFS 这类只读 FS 可以只填 dstmap 就完事的原因。
在写路径里你会反复看到这个模式,例如 fs/iomap/buffered-io.c:1038:
const struct iomap *srcmap = iomap_iter_srcmap(iter);
读那一半用 srcmap,写那一半用 iomap,这是 iomap 写路径里最容易看错的一行。
7. validity_cookie 与 .iomap_valid():长写操作中的映射失效
写操作有个读操作没有的麻烦:拿到映射之后、真正写之前,FS 内部的 extent map 可能已经变了(并发 I/O 转换了 unwritten 状态、内存回收把部分写过的页回收了、别的进程做了 COW)。此时继续用旧映射去「填零」或「写」,就会静默地损坏数据。
iomap 的解法是一个轻量版本号:FS 在产出映射时把内部序号写进 iomap->validity_cookie,通用层在真正动手前回问一句 .iomap_valid():
/* fs/xfs/xfs_iomap.c:75-93 —— XFS 用 inode 的 sequence number */
static bool xfs_iomap_valid(struct inode *inode, const struct iomap *iomap)
{
if (iomap->type == IOMAP_HOLE)
return true;
if (iomap->validity_cookie != xfs_iomap_inode_sequence(ip, iomap->flags)) {
trace_xfs_iomap_invalid(ip, iomap);
return false;
}
return true;
}
ext4 的版本更短,拿 extent status tree 的序号比(fs/ext4/inode.c:3601-3605):
static bool ext4_iomap_valid(struct inode *inode, const struct iomap *iomap)
{
return iomap->validity_cookie == READ_ONCE(EXT4_I(inode)->i_es_seq);
}
通用层怎么用这个回答?fs/iomap/buffered-io.c:1074-1082:
if (write_ops && write_ops->iomap_valid) {
bool iomap_valid = write_ops->iomap_valid(iter->inode, &iter->iomap);
if (!iomap_valid) {
iter->iomap.flags |= IOMAP_F_STALE;
status = 0;
goto out_unlock;
}
}
发现失效 → 打上 IOMAP_F_STALE → 释放 folio → 回到迭代器 → 因为 stale 所以允许以同一个 pos 重新 begin() 一次,拿到反映最新 extent map 的映射。注意它并不报错:这是一条正常的重试路径,不是错误路径。
两个提醒:
.iomap_valid()挂在struct iomap_write_ops(iomap.h:163-195)上,不是iomap_ops。它是写路径专属的扩展点。- 只有真的有并发改 extent map 风险的 FS 才需要实现它(本树里 XFS、ext4、ntfs 有)。只读 FS(如 EROFS)没有这个问题——它的映射一旦建立就不会变。
8. iomap_next 新写法 vs iomap_begin/iomap_end 老写法
struct iomap_ops(iomap.h:242-246)有三个槽:
struct iomap_ops {
iomap_iter_begin_fn iomap_begin;
iomap_iter_end_fn iomap_end;
iomap_iter_next_fn iomap_next;
};
而 iomap_iter() 的分发逻辑是「有 iomap_next 就用它,否则用 begin/end 组合」(iter.c:122-126)。也就是说 begin/end 是老式写法,iomap_iter_next() 是它的公共实现——只是这个实现被抽成了 __always_inline 的辅助函数(iomap.h:356),由 FS 用宏包一层:
/* include/linux/iomap.h:388-396 */
#define DEFINE_IOMAP_ITER_NEXT_END(name, begin_fn, end_fn) \
int name(const struct iomap_iter *iter, struct iomap *iomap, \
struct iomap *srcmap) \
{ \
return iomap_iter_next(iter, iomap, srcmap, begin_fn, end_fn); \
}
#define DEFINE_IOMAP_ITER_NEXT(name, begin_fn) \
DEFINE_IOMAP_ITER_NEXT_END(name, begin_fn, NULL)
换来什么?一次间接调用变成直接调用。__always_inline + 编译期常量的 begin_fn/end_fn,让编译器把 iomap_next → iomap_iter_next → begin 这三层展平,直接生成 call erofs_iomap_begin 这样的指令。在 6.6+ 内核普遍开启间接调用防护(CFI / IBT)之后,这个改动是实打实的收益。
本树里新写法已经是主流(36 处使用),老式直接填 .iomap_begin 的只剩少数,可以自己验证:
grep -rn "DEFINE_IOMAP_ITER_NEXT" fs/ | wc -l # 36
grep -rn "\.iomap_begin\s*=" fs/ # fs/nilfs2/iomap.c:71, fs/fuse/ext_map.c:214 ...
两种写法在语义上完全等价,只是调用开销不同。读代码时看到 .iomap_begin = xxx 不要以为它是另一套机制——它走的是同一条 iomap_iter_next()。
9. 本篇的一句话总结
iomap 的迭代协议可以压缩成三句话:
- FS 每次交出一段覆盖当前
pos的非零长度映射(四条 WARN 就是这份契约的机器可读版本); - 通用层处理完必须自己调用
iomap_iter_advance()推进,一点没推进就终止(除非 STALE); iomap_begin产出、iomap_end结算(written = 实际推进量)、iomap_valid兜住并发变化,三者构成一个可重入、可重取的循环。
思考题
-
iomap_iter()在取到新映射后把iter->status清零(iter.c:128),而循环体正是靠设置iter->status来中止循环的。这两者之间隔了一次iomap_iter_continue()的读取。请说明:如果循环体处理到最后一段时设置了负的 status,但iter->len已经变成 0,最终返回给调用者的是哪个值?(顺着iomap_iter_continue()的分支走一遍。) -
iomap_end收到的written是「实际推进的字节数」而不是「映射长度」。设想一次 buffered write 在第 3 个 folio 上遇到 IO 错误:iomap_end会收到什么样的参数组合?FS 应当据此对「已分配但未写」的部分做什么处理?如果不做,会留下什么状态? -
IOMAP_F_STALE允许以同一个pos重新取映射,而!advanced && !stale是终止条件。如果一个 FS 的iomap_begin每次都返回同一段 STALE 映射(比如validity_cookie每次都在变),这个循环会怎样?内核里应当靠什么机制兜住它?(提示:想iomap_iter_done()的第 ④ 条 WARN 能不能拦住,以及 XFS 的xfs_iomap_valid()在什么情况下会一直说「不」。)
参考答案
第1题: 最后一段设置了负的 iter->status,但 iter->len 已经是 0,返回给调用者的是哪个值?
返回负的 status,
len == 0不会盖掉它。 关键在iomap_iter_continue()的分支顺序(fs/iomap/iter.c:79-87):if (WARN_ON_ONCE(iter->status > 0)) ret = -EIO; else if (iter->status < 0) ret = iter->status; /* ← 先判 status */ else if (iter->len == 0 || (!advanced && !stale)) ret = 0; /* ← 后判 len */ else ret = 1;
status < 0的分支排在len == 0之前,所以「循环体中途中止」优先于「范围已走完」。语义上这也说得通:len == 0是正常结束,而 status 为负是出错,出错不能被正常结束掩盖——否则一次写了一半就失败的 I/O 会被上层当成成功。write(2)的返回值正是靠这条区分「写了 N 字节」和「返回 -EIO」。
第2题: 第 3 个 folio 上遇到 I/O 错误时,iomap_end 收到什么参数?FS 该做什么?不做会留下什么?
按
iomap_iter_next()的算式(include/linux/iomap.h:368-375):
pos = iter_start_pos(本段起点)len = iomap_length_trim(iter, iter_start_pos, iter->len + advanced)—— 本段从起点看还剩多少written = advanced—— 只统计到出错前真正推进过的字节也就是「本段前两个 folio 的字节数是 written,其余(含出错的那个 folio 之后的部分)是 len - written」。FS 的
->iomap_end()必须据此做两件事:
- 对 written 范围做提交:例如把 unwritten extent 转换成 written(
IOMAP_UNWRITTEN→IOMAP_MAPPED)、更新 inode 的块计数与 i_size;- 对
len - written范围做撤销:当初为该范围预留的块(delalloc 预留、XFS 的xfs_bmapi_reserve_delalloc)要释放。不撤销的后果是预留空间泄漏:块已经被从分配池里扣走、却没有对应的数据落盘,表现为文件系统可用空间莫名变少、
df与 fsck 的块账不一致,XFS 在 umount 时会直接报 「Freed blocks not in the allocation btree / reserved blocks leak」这类告警。短写/出错路径的资源回收,全部由iomap_end的这一个written参数驱动——这是读路径完全没有、写路径独有的一块复杂度。
第3题: 如果 FS 每次都返回 STALE(cookie 一直变),循环会怎样?靠什么兜住?
先纠正一个容易混的点:STALE 不是
iomap_begin设的,而是循环体(通用层)设的(fs/iomap/buffered-io.c:1078)。iomap_iter_done()里那条WARN_ON_ONCE(iomap->flags & IOMAP_F_STALE)(fs/iomap/iter.c:40)拦的是「FS 产出映射时还带着 STALE 标记」这种违约,它拦不住「循环体每轮重新打上 STALE」的情形。所以严格讲:迭代协议本身没有给重取次数设上限。如果
.iomap_valid()永远返回 false,循环会在同一个pos上反复重取——不会前进,也不报错。真正兜住它的是三件事:
- FS 侧的 cookie 必须收敛:XFS 的
validity_cookie取自 inode sequence(只在 extent map 真的变化时递增),ext4 取i_es_seq同理。并发写入是有限的,cookie 终究会停在一个值上。- 循环体里可被打断:写路径的
iomap_write_begin()一进来就检查fatal_signal_pending(current)(buffered-io.c:1048),且每轮都要重新取锁、可能被调度走,进程收到致命信号会退出。iomap_iter_continue()只在stale为真时放行同 pos 重取(iter.c:84),不放行「既没推进又不 stale」的死循环——它防的是另一种死法。(标注:第 1、2 条是基于代码结构的推断,我没在树里找到显式的重取次数上限。)