Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5.10 读取流程——按块寻址与偏移计算

数据像一排书架,你要的只是其中的一小截

你有一本1000页的书。你要从第423页的第8行开始,读50行。这50行可能跨过第423页的结尾、第424页、甚至第425页的开头。

在文件系统里,这就是读操作的日常:用户请求的偏移量和长度,几乎从来不与块边界对齐。 你的工作是把“用户视角的连续字节区间“翻译成“块设备视角的若干块读取 + 裁剪拼接“。

KnotFS的do_read用三个状态完成这件事:ST_RD_LOOKUP、ST_RD_DATA、ST_RD_COPY。其中ST_RD_DATA和ST_RD_COPY交替执行,形成一个“读一块→拷贝一段→读下一块→拷贝下一段“的循环。


第一状态:ST_RD_LOOKUP —— 找到文件,算好区间

case ST_RD_LOOKUP:
    nd = find_node(cur.name);
    if (!nd) {
      cur.result = ERR_RD_NOFILE;
      cur.st = ST_ERR;
      return;
    }
    if (cur.r_off >= nd->size) {
      if (cur.r_out)
        *cur.r_out = 0;
      cur.st = ST_OK;
      return;
    }
    {
      uint32_t end = cur.r_off + cur.r_sz;
      if (end > nd->size)
        end = nd->size;
      sblk = cur.r_off / KNOTFS_BLOCK_SIZE;
      eblk = (end + KNOTFS_BLOCK_SIZE - 1) / KNOTFS_BLOCK_SIZE;
      if (eblk > nd->block_count)
        eblk = nd->block_count;
    }
    scratch[SCR_RD_SBLK] = sblk;
    scratch[SCR_RD_EBLK] = eblk;
    subst = sblk;
    prog = 0;
    cur.st = ST_RD_DATA;
    return;

LOOKUP做了三件事:

第一,边界检查。 如果请求的偏移量已经超过了文件大小,直接返回0字节——这不是错误,而是“文件读完了“的语义。cur.r_out(一个uint32_t *bytes_read输出参数)被设为0。

第二,区间裁剪。 end = cur.r_off + cur.r_sz是请求区间的结尾。如果end > nd->size,说明用户想读的比文件实际存在的更多,裁到文件末尾。这是POSIX read()系统调用的标准行为:返回实际读到的字节数。

第三,块号映射。 这是关键步骤:

sblk = cur.r_off / KNOTFS_BLOCK_SIZE       ← 起始块号
eblk = (end + KNOTFS_BLOCK_SIZE - 1) / KNOTFS_BLOCK_SIZE  ← 结束块号(向上取整)

如果请求 offset=2000, size=100,文件大小5000字节:

  • end = 2100
  • sblk = 2000 / 4096 = 0(从第0块开始)
  • eblk = (2100 + 4095) / 4096 = 1(需要读块0)

如果请求 offset=2000, size=5000,文件大小5000字节:

  • end = 5000(被裁剪到文件末尾)
  • sblk = 2000 / 4096 = 0
  • eblk = (5000 + 4095) / 4096 = 2(需要读块0和块1)

scratch[0] = sblkscratch[1] = eblk把这些计算出的区间保存下来。subst = sblk作为“当前正在处理哪个块“的游标,prog = 0作为“已经拷贝了多少字节到用户buffer“的累计值。


第二状态:ST_RD_DATA —— 读整块到tmp

case ST_RD_DATA: {
    nd = find_node(cur.name);
    if (!nd) {
      cur.result = ERR_RD_NODE;
      cur.st = ST_ERR;
      return;
    }
    eblk = scratch[SCR_RD_EBLK];
    if (subst >= eblk) {
      if (cur.r_out)
        *cur.r_out = prog;
      cur.st = ST_OK;
      return;
    }
    fdev_read(nd->blocks[subst], tmp, KNOTFS_BLOCK_SIZE);
    cur.st = ST_RD_COPY;
    return;
}

这里的逻辑直观:如果当前的块号subst已经超出了结束块号eblk,说明所有需要的块都读完了,把prog(累计拷贝字节数)写入cur.r_out,返回ST_OK。

如果还没读完,就发起一次异步读操作:fdev_read(nd->blocks[subst], tmp, KNOTFS_BLOCK_SIZE)。它把整个块(4KB)读到全局的tmp缓冲区内。fdev_read有1个tick的延迟(ticks_left = 1),然后跳转到ST_RD_COPY。

为什么读整个块? 因为Flash的读操作是按块进行的——你无法只读一个块里的某个偏移区间。你必须把整块读进内存(tmp缓冲区),然后在内存里做裁剪。


第三状态:ST_RD_COPY —— 从tmp裁剪到用户buffer

case ST_RD_COPY: {
    nd = find_node(cur.name);
    if (!nd) {
      cur.result = ERR_RD_COPY;
      cur.st = ST_ERR;
      return;
    }

    uint32_t bs = subst * KNOTFS_BLOCK_SIZE;
    uint32_t be = bs + KNOTFS_BLOCK_SIZE;
    uint32_t rs = cur.r_off;
    uint32_t re = cur.r_off + cur.r_sz;
    if (re > nd->size)
      re = nd->size;

    uint32_t seg_s = (rs > bs) ? rs : bs;
    uint32_t seg_e = (re < be) ? re : be;
    uint32_t seg_n = (seg_e > seg_s) ? seg_e - seg_s : 0;
    uint32_t src_o = seg_s - bs;

    memcpy((uint8_t *)cur.r_buf + prog, tmp + src_o, seg_n);
    prog += seg_n;
    subst++;
    cur.st = ST_RD_DATA;
    return;
}

这一段是读操作中最核心也最容易出错的偏移计算。让我们拆解:

变量定义:
  bs = 当前块的"文件绝对偏移"起始位置(例如块0: 0~4095, 块1: 4096~8191)
  be = 当前块的"文件绝对偏移"结束位置 + 1
  rs = 用户请求的起始偏移
  re = 用户请求的结束偏移(被裁剪到文件大小)

交集计算:
  seg_s = max(rs, bs)        ← 请求区间和当前块的"重叠起点"
  seg_e = min(re, be)        ← 请求区间和当前块的"重叠终点"
  seg_n = seg_e - seg_s      ← 重叠的长度
  src_o = seg_s - bs         ← 在tmp缓冲区中的偏移量

举一个具体的例子。假设文件大小5000字节,用户请求offset=2000, size=3000

文件布局:┌─────────块0─────────┐┌──块1──┐
字节偏移:0...1999|2000...4095|4096...4999|
            ▲           ▲          ▲
            │     rs=2000    re=5000(=nd->size)

块0(subst=0):
  bs=0, be=4096
  seg_s = max(2000, 0) = 2000
  seg_e = min(5000, 4096) = 4096
  seg_n = 4096 - 2000 = 2096
  src_o = 2000 - 0 = 2000
  拷贝: tmp[2000..4095] → r_buf[0..2095]  ← 2096字节

块1(subst=1):
  bs=4096, be=8192
  seg_s = max(2000, 4096) = 4096
  seg_e = min(5000, 8192) = 5000
  seg_n = 5000 - 4096 = 904
  src_o = 4096 - 4096 = 0
  拷贝: tmp[0..903] → r_buf[2096..2999]  ← 904字节

prog = 2096 + 904 = 3000 → *bytes_read = 3000

边界情况:四个角落的陷阱

情况一:偏移量在第一个块的中间。

cur.r_off不能被KNOTFS_BLOCK_SIZE整除时,第一个块的src_o不为0——你从tmp的中间开始拷贝。这是最常见的场景。

情况二:读取区间小于一个块。

如果请求offset=100, size=50,那么sblk=0, eblk=1。在ST_RD_COPY中,重叠区间的seg_n只会有50字节。只拷贝这50字节,不影响其他区域。

情况三:读取超出文件末尾。

在LOOKUP阶段已经裁剪了end = nd->size。所以ST_RD_COPY中re不会超过文件大小。最后一个块的重叠区间自然被截断。

情况四:文件大小正好是块大小的整数倍。

如果文件正好4096字节,请求offset=4096, size=100。LOOKUP检测到cur.r_off >= nd->size,直接返回0字节。不会尝试读取一个不存在的第1块。


tmp缓冲区:一个scratch space的故事

static uint8_t tmp[KNOTFS_BLOCK_SIZE]是整个read操作唯一需要的额外内存。它只有4KB,但足以作为“中转站“——从Flash读整块进tmp,在tmp里做裁剪,把需要的片段拷到用户buffer。

这个模式叫“read into scratch, copy segment“。它不是KnotFS的发明——几乎所有块设备的读操作都是这么做的。区别在于:Linux内核的bread()把块读到page cache中,KnotFS用的是一个全局的4KB数组。

为什么不能直接把Flash读到用户buffer的对应位置?因为用户buffer的起始位置通常不对齐块边界。假设用户请求offset=3000, size=500,用户buffer的前500字节需要来自块0的偏移3000处——但块0的前3000字节不需要。如果你直接从Flash读块0到用户buffer起始位置,你覆盖了用户buffer的前3000字节——这不是用户想要的。所以你必须先读进临时缓冲区,再memcpy。


读取的三段式循环

ST_RD_LOOKUP ──→ ST_RD_DATA ──→ ST_RD_COPY ──→ ST_RD_DATA ──→ ST_RD_COPY ──→ ST_OK
                   ▲____________________│_____________________│
                     循环: 读一块 → 拷贝一段 → 读下一块

这个循环终止的条件是subst >= eblk——当前块号已经超过了需要读取的块范围。

每次循环做两件事:ST_RD_DATA发出异步读(1个tick),ST_RD_COPY做内存拷贝(同步,很快)。两个状态合起来构成一次“读取-拼接“原子操作。


一个微妙的问题:如果文件在读取过程中被修改了怎么办

在KnotFS里,这不会发生——因为所有操作都被请求队列串行化了。当你在读一个文件时,不可能有其他线程在写同一个文件。写操作要等到读操作完成(cur.st == ST_OKST_ERR),才能从队列中出队。

但在真实的生产级部署中(比如运行在FreeRTOS上),读操作和写操作可能在不同的任务中执行——这就引出了“文件系统并发控制“的问题。生产级方案会通过把File Table和元数据缓存放在共享内存中,并保证同一时间只有一个操作在修改元数据,来避免读-写竞争。


如果你来设计

试着回答:为什么ST_RD_COPYsrc_o = seg_s - bs而不是src_o = cur.r_off - bs

答案:因为cur.r_off是用户请求的总起始偏移,它在整个读操作过程中是不变的。而每个块的偏移计算应该以当前块和请求区间的重叠起点为基准。假设请求offset=2000,块0的seg_s=2000, bs=0src_o=2000——这和cur.r_off - bs = 2000 - 0 = 2000巧合相同。但如果请求offset=2000,处理块1时seg_s=4096(因为块1从4096开始),而cur.r_off - bs = 2000 - 4096 = -2096——越界了。所以必须用seg_s - bs,这是块内正确的裁剪起点。


下集预告

读操作是按块拼装的静态快照。写操作是CoW的原子事务。但还有一种更微妙的操作:在文件末尾追加数据——它不是“整文件替换“,也不是单纯的“读+校验“。它是一种Read-Modify-Write(读-改-写):先读到最后一个块,把新数据追加进去,再写回去。而删除操作……删除是最朴素也最危险的操作——它要把所有块归还给free_bitmap,同时擦除元数据中的痕迹。下一节,两段状态机,文件的变与灭