Iomap 02 Iter

发布于 2026年11月4日

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;
}

要点:

  1. 它自己不做任何映射工作。 真正的活都在 ops->iomap_next(新写法)或 iomap_iter_next(兼容老写法)里。
  2. iter->status = 0 在取回下一段映射之后清零——上一段循环体留下的 status 已经在 iomap_iter_continue() 里被消费过了,新的一段从头开始。
  3. 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 必须保证:

  1. 映射不能从 pos 之后才开始(不能跳着给);
  2. 长度必须非零(否则循环无法前进);
  3. 映射必须覆盖 pos(offset + length > pos,即当前位置必须落在段内);
  4. 产出时不能还带着 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 写路径里最容易看错的一行。

写操作有个读操作没有的麻烦:拿到映射之后、真正写之前,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 的迭代协议可以压缩成三句话:

  1. FS 每次交出一段覆盖当前 pos 的非零长度映射(四条 WARN 就是这份契约的机器可读版本);
  2. 通用层处理完必须自己调用 iomap_iter_advance() 推进,一点没推进就终止(除非 STALE);
  3. iomap_begin 产出、iomap_end 结算(written = 实际推进量)、iomap_valid 兜住并发变化,三者构成一个可重入、可重取的循环。

思考题

  1. iomap_iter() 在取到新映射后把 iter->status 清零(iter.c:128),而循环体正是靠设置 iter->status 来中止循环的。这两者之间隔了一次 iomap_iter_continue() 的读取。请说明:如果循环体处理到最后一段时设置了负的 status,但 iter->len 已经变成 0,最终返回给调用者的是哪个值?(顺着 iomap_iter_continue() 的分支走一遍。)

  2. iomap_end 收到的 written 是「实际推进的字节数」而不是「映射长度」。设想一次 buffered write 在第 3 个 folio 上遇到 IO 错误:iomap_end 会收到什么样的参数组合?FS 应当据此对「已分配但未写」的部分做什么处理?如果不做,会留下什么状态?

  3. 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() 必须据此做两件事:

  1. 对 written 范围做提交:例如把 unwritten extent 转换成 written(IOMAP_UNWRITTEN → IOMAP_MAPPED)、更新 inode 的块计数与 i_size;
  2. 对 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 上反复重取——不会前进,也不报错。真正兜住它的是三件事:

  1. FS 侧的 cookie 必须收敛:XFS 的 validity_cookie 取自 inode sequence(只在 extent map 真的变化时递增),ext4 取 i_es_seq 同理。并发写入是有限的,cookie 终究会停在一个值上。
  2. 循环体里可被打断:写路径的 iomap_write_begin() 一进来就检查 fatal_signal_pending(current)(buffered-io.c:1048),且每轮都要重新取锁、可能被调度走,进程收到致命信号会退出。
  3. iomap_iter_continue() 只在 stale 为真时放行同 pos 重取(iter.c:84),不放行「既没推进又不 stale」的死循环——它防的是另一种死法。

(标注:第 1、2 条是基于代码结构的推断,我没在树里找到显式的重取次数上限。)