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 = 2100sblk = 2000 / 4096 = 0(从第0块开始)eblk = (2100 + 4095) / 4096 = 1(需要读块0)
如果请求 offset=2000, size=5000,文件大小5000字节:
end = 5000(被裁剪到文件末尾)sblk = 2000 / 4096 = 0eblk = (5000 + 4095) / 4096 = 2(需要读块0和块1)
scratch[0] = sblk、scratch[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_OK或ST_ERR),才能从队列中出队。
但在真实的生产级部署中(比如运行在FreeRTOS上),读操作和写操作可能在不同的任务中执行——这就引出了“文件系统并发控制“的问题。生产级方案会通过把File Table和元数据缓存放在共享内存中,并保证同一时间只有一个操作在修改元数据,来避免读-写竞争。
如果你来设计
试着回答:为什么ST_RD_COPY中src_o = seg_s - bs而不是src_o = cur.r_off - bs?
答案:因为cur.r_off是用户请求的总起始偏移,它在整个读操作过程中是不变的。而每个块的偏移计算应该以当前块和请求区间的重叠起点为基准。假设请求offset=2000,块0的seg_s=2000, bs=0,src_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,同时擦除元数据中的痕迹。下一节,两段状态机,文件的变与灭。