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.14 自测试程序——跑一遍全部操作

没有硬件,没有交叉编译,没有目标板

你在x86_64的Linux笔记本上,面前只有gcc和终端。

一行命令:

make && make test

然后终端里滚过12个测试用例的输出,最后打印一行:

  RESULTS: 18 / 18 tests passed

这~474行测试代码,验证了你前面学过的所有东西:双SB的mount/remount、CoW原子性、log-structured metadata replay、CRC32校验、磨损均衡、事件循环驱动的状态机、read的偏移裁剪、append的RMW、delete的块释放……

它不是嵌入式开发板上烧录firmware。它不是一个需要JTAG调试器的环节。它就是在你的电脑上直接跑的一个C程序——零依赖,只需要libc和一个C11编译器。但它的架构、它的状态机、它的数据结构,和工程化版本是同一个模具浇铸出来的。


测试框架:18行的极简主义

#include "knotfs.h"
#include <stdio.h>
#include <string.h>

static int ok_ct = 0;
static int all_ct = 0;

#define T(name) do { printf("  [%s] ", name); all_ct++; } while(0)
#define OK() do { printf("OK\n"); ok_ct++; } while(0)
#define FAIL(f, ...) printf("FAIL: " f "\n", ##__VA_ARGS__)

三个宏,撑起整个测试框架。T(name)声明一个测试点,打印名字并增加计数。OK()测试通过。FAIL(f, ...)测试失败并打印诊断信息。没有assert宏、没有setjmp/longjmp的异常处理、没有测试发现机制——就是最朴素的“C函数调用链“。

这很适合嵌入式文件系统的测试场景——测试的本质是“执行操作,检查结果“。API返回0就是成功(入队成功),drive()返回0就是操作完成。文件系统的中间状态都在内存里,可以直接检查sb.nodes[]sb.free_map


drive():事件循环的同义反复

static int drive(void) {
  int t = 0;
  while (knotfs_run()) {
    if (++t % 15 == 0)
      printf("    tick %d...\n", t);
  }
  if (t > 0 && t % 15 != 0)
    printf("    tick %d... done\n", t);
  return knotfs_get_result();
}

drive()不是什么特殊的函数。它就是 while (knotfs_run()); 加了一个进度打印(每15个tick打一个点)。它把异步事件循环变成了同步等待——这就是当你的代码没有OS时,你“等一个操作完成“的方式。

knotfs_get_result()返回的是cur.result——最后一个完成请求的返回值。0是成功,负值是错误码。


power_cycle():模拟MCU掉电重启

static void power_cycle(void) {
  knotfs_init(); /* clears runtime state, does NOT erase flash */
}

KnotFS的一切运行时状态——sbsb_altfdevq[]curmounted……全部在knotfs_init()中被清零。但flash[]数组不受影响——它模拟的是NOR Flash芯片,掉电后数据保持。这就是“模拟MCU断电“的全部秘密:清掉RAM,保留Flash。

在真实硬件上,MCU复位后RAM必然清零,Flash自动保持。测试代码用同一个机制来模拟这个过程——不需要真的拔电源,一行power_cycle()就够了。


测试结构:12个测试,18个断言

整个测试程序(main函数)就是一个顺序调用链:

int main(void)
{
    printf("\n");
    printf("  _  __            _   _____ ____  \n");
    printf(" | |/ /_ __   ___ | |_|  ___/ ___| \n");
    printf(" | ' /| '_ \\ / _ \\| __| |_  \\___ \\ \n");
    printf(" | . \\| | | | (_) | |_|  _|  ___) |\n");
    printf(" |_|\\_\\_| |_|\\___/ \\__|_|   |____/ \n");
    printf("\n");
    printf("  Teaching File System — Async + Log-Structured + Power-Loss Safe\n");
    printf("  Simulated NOR Flash: %u KB\n\n",
           KNOTFS_BLOCK_COUNT * KNOTFS_BLOCK_SIZE / 1024);

    knotfs_init();  /* first-ever boot: flash initialised to 0xFF */

    t1_format_and_mount();
    t2_write_and_read();
    t3_large_file();
    t4_partial_read();
    t5_overwrite();
    t6_append();
    t7_list();
    t8_delete();
    t9_nonexistent();
    t10_stats();
    t11_power_loss();
    t12_format_and_remount();

    printf("\n============================================================\n");
    printf("  RESULTS: %d / %d tests passed\n", ok_ct, all_ct);
    printf("============================================================\n\n");
    return (ok_ct == all_ct) ? 0 : 1;
}

Test 1:format_and_mount —— 最基础的生命周期。

T("format");
int r = knotfs_format();
if (r) { FAIL("enqueue=%d", r); return; }
r = drive();
if (r == 0) OK(); else { FAIL("result=%d", r); return; }

T("mount");
r = knotfs_mount();
if (r) { FAIL("enqueue=%d", r); return; }
r = drive();
if (r == 0 && knotfs_is_mounted()) OK(); else { FAIL("result=%d", r); return; }

T("stats");
uint32_t tot, fre;
knotfs_stats(&tot, &fre);
drive();
if (fre == KNOTFS_DATA_BLKS) OK(); else FAIL("free=%u expected=%u", fre, KNOTFS_DATA_BLKS);

format后14个数据块全部空闲。这个测试验证了:擦除所有16块 → 写入双SB → mount识别有效SB → free_bitmap显示所有数据块FREE。

Test 2:write_and_read —— 读写闭环。

{ const char *s = "Hello, KnotFS!";  uint32_t n = (uint32_t)strlen(s);
  T("write 14B");
  if (knotfs_write("greet.txt", s, n)) { FAIL("enqueue"); return; }
  if (drive()) { FAIL("result"); return; } OK(); }

{ char b[32];  uint32_t br;
  T("read back");
  if (knotfs_read("greet.txt", b, sizeof(b), 0, &br)) { FAIL("enqueue"); return; }
  if (drive()) { FAIL("result"); return; }
  if (br == 14 && !memcmp(b, "Hello, KnotFS!", 14)) OK();
  else FAIL("got='%.14s' len=%u", b, br); }

14字节——不到一个块的4%——通过完整的CoW写流程存到Flash,然后通过read状态机的偏移计算和tmp拷贝读回来。br == 14 && !memcmp(b, "Hello, KnotFS!", 14)是这个测试的唯一真理。

Test 3:large_file —— 跨块文件的模式验证。

static uint8_t pat[8192];
for (int i = 0; i < 8192; i++) pat[i] = (uint8_t)(i & 0xFF);

T("write 8KB");
if (knotfs_write("data.bin", pat, 8192)) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; } OK();

T("read & verify");
{ uint8_t v[8192];  uint32_t br;
  memset(v, 0, sizeof(v));
  if (knotfs_read("data.bin", v, 8192, 0, &br)) { FAIL("enqueue"); return; }
  if (drive()) { FAIL("result"); return; }
  if (br == 8192 && !memcmp(v, pat, 8192)) OK();
  else FAIL("br=%u match=%d", br, memcmp(v, pat, 8192) == 0); }

这个模式数据pat[i] = i & 0xFF很聪明——每个字节的值就是它的偏移量(模256)。这样读回后做memcmp,任何一个比特的错位(比如偏移1字节的错写)都会立即被发现。8192字节 = 2个整块 + 0字节。正好验证了块边界的正确处理。

Test 4:partial_read —— 偏移量不在块起点。

T("read offset");
uint8_t buf[32];  uint32_t br;
memset(buf, 0, sizeof(buf));
if (knotfs_read("data.bin", buf, 32, 2000, &br)) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }
uint8_t exp[32];
for (int i = 0; i < 32; i++) exp[i] = (uint8_t)((2000 + i) & 0xFF);
if (br == 32 && !memcmp(buf, exp, 32)) OK(); else FAIL("br=%u", br);

offset=2000,不是4096的整数倍。第一个块(块0)的偏移2000~2031处被读取。正好验证了ST_RD_COPY中的seg_s = max(rs, bs)src_o = seg_s - bs计算。

Test 5:overwrite —— CoW原子性的验证。

T("overwrite");
const char *n = "greet.txt";
const char *s = "This file has been replaced!";
uint32_t len = (uint32_t)strlen(s);
if (knotfs_write(n, s, len)) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }

char b[64];  uint32_t br;
if (knotfs_read(n, b, sizeof(b), 0, &br)) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }
if (br == len && !memcmp(b, s, len)) OK(); else FAIL("got='%s'", b);

greet.txt从“Hello, KnotFS!“变成“This file has been replaced!”——通过CoW的“分配新块+释放旧块+compact“完整链路。如果CoW失败,要么看到旧内容,要么看到损坏的内容。看到正确的新内容,证明CoW原子性成立。

Test 6:append —— RMW的验证。

T("append");
const char *add = " (appended)";
uint32_t alen = (uint32_t)strlen(add);
if (knotfs_append("greet.txt", add, alen)) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }

char b[128];  uint32_t br;
if (knotfs_read("greet.txt", b, sizeof(b), 0, &br)) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }
const char *exp = "This file has been replaced! (appended)";
if (br == (uint32_t)strlen(exp) && !strcmp(b, exp)) OK();
else FAIL("got='%s'", b);

append 12字节到已有文件——RMW的最后一块。文件从30字符变成42字符。验证了write_off在块内的正确偏移、memcpy(tmp + write_off, s, n)的正确性、和compact后的数据完整性。

Test 7:list —— 查看文件目录。

T("list");
knotfs_entry_t ents[8];  uint32_t n = 8;
knotfs_list(ents, &n);  drive();
printf("  %u files:\n", n);
for (uint32_t i = 0; i < n; i++)
    printf("    %-32s %u B\n", ents[i].name, ents[i].size);
if (n == 2) OK(); else FAIL("expected 2, got %u", n);

此时文件系统中有两个文件:greet.txt(42字节)和data.bin(8192字节)。list遍历sb.nodes[],跳过size==0的slot,输出文件名和大小。

Test 8:delete —— 删除并验证目录。

T("delete data.bin");
if (knotfs_delete("data.bin")) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }
knotfs_entry_t e[8];  uint32_t n = 8;
knotfs_list(e, &n);  drive();
if (n == 1 && !strcmp(e[0].name, "greet.txt")) OK(); else FAIL("n=%u", n);

删除8192字节的data.bin后,list应该只显示greet.txt一个文件。验证了memset(nd, 0, sizeof(knode_t))正确清除了entry,且free_bitmap正确回收了2个块。

Test 9:nonexistent —— 错误路径。

T("read missing");
char b[32];  uint32_t br = 99;
knotfs_read("nope.bin", b, sizeof(b), 0, &br);  int r = drive();
if (r != 0) OK(); else FAIL("should fail, got br=%u", br);

读取不存在的文件应该返回负值错误码(-20: file not found)。br初始化为99,如果测试通过它应该保持99(因为操作失败时不应该修改输出参数)。

Test 10:stats —— 空间统计。

uint32_t tot, fre;
knotfs_stats(&tot, &fre);  drive();
printf("  total: %u  free: %u  used: %u\n", tot, fre, tot - fre);
T("stats");
if (tot == KNOTFS_DATA_BLKS && fre < tot) OK(); else FAIL("tot=%u free=%u", tot, fre);

总数据块14,空闲块应该小于14(因为greet.txt占用了块)。这个测试不关心具体的数字,只验证统计功能正常工作。

Test 11:power_loss —— 本章的核心验证。

/* capture state before power-cycle */
knotfs_entry_t before[8];  uint32_t bn = 8;
knotfs_list(before, &bn);  drive();

char b64[64];  uint32_t br64;
knotfs_read("greet.txt", b64, sizeof(b64), 0, &br64);  drive();
printf("  Before reset: '%s'\n", b64);
printf("  Simulating MCU power-cycle (in-memory state lost, flash intact)...\n");

/* power-cycle */
power_cycle();

T("remount");
if (knotfs_mount()) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }
if (!knotfs_is_mounted()) { FAIL("not mounted"); return; }
OK();

knotfs_entry_t after[8];  uint32_t an = 8;
knotfs_list(after, &an);  drive();
printf("  After reset: %u files\n", an);

char a64[64];  uint32_t ar64;
knotfs_read("greet.txt", a64, sizeof(a64), 0, &ar64);  drive();
printf("  After reset: '%s'\n", a64);

T("data intact");
if (bn == an && ar64 == br64 && !strcmp(b64, a64)) OK();
else FAIL("before='%s' after='%s'", b64, a64);

这是整套测试中最关键的测试。它在“断电“(power_cycle)前后对比:

  • 文件数量是否一致(bn == an)?
  • 文件大小是否一致(ar64 == br64)?
  • 文件内容是否一致(!strcmp(b64, a64))?

如果三个都是true,说明:

  1. mount正确选择了有效的SB副本
  2. log replay正确恢复了bitmap状态
  3. 文件内容完整无损

Test 12:format_and_remount —— 从头格式化的循环验证。

/* fresh format */
power_cycle();
knotfs_format();  drive();
printf("  Fresh format done.\n");

T("mount fresh");
if (knotfs_mount()) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }
OK();

/* write a file and remount */
const char *msg = "Persistent content across boots.";
uint32_t mlen = (uint32_t)strlen(msg);
knotfs_write("boot.txt", msg, mlen);  drive();

power_cycle();

T("remount & verify");
if (knotfs_mount()) { FAIL("enqueue"); return; }
if (drive()) { FAIL("result"); return; }
char chk[64];  uint32_t cr;
memset(chk, 0, sizeof(chk));
knotfs_read("boot.txt", chk, sizeof(chk), 0, &cr);  drive();
if (cr == mlen && !memcmp(chk, msg, mlen)) OK();
else FAIL("cr=%u expected=%u '%.32s'", cr, mlen, chk);

format → mount → write → power_cycle → remount → verify。这是完整的“冷启动“流程。它验证了format从零开始创建双SB的能力,以及在全新文件系统上经过一次写入后,断电重启仍能正确恢复。


测试的输出

  _  __            _   _____ ____
 | |/ /_ __   ___ | |_|  ___/ ___|
 | ' /| '_ \ / _ \| __| |_  \___ \
 | . \| | | | (_) | |_|  _|  ___) |
 |_|\_\_| |_|\___/ \__|_|   |____/

  Teaching File System — Async + Log-Structured + Power-Loss Safe
  Simulated NOR Flash: 64 KB


============================================================
  Test 1: Format & Mount  (64 KB NOR Flash)
============================================================
  Flash layout: 16 blocks x 4096 B = 65536 bytes
  Data region: blocks 2–15  (56 KB)
  [format]     tick 15...
    tick 30...
    tick 45...
    tick 60...
    tick 69... done
OK
  [mount]     tick 4... done
OK
  [stats]     tick 1... done
OK

============================================================
  Test 2: Write & Read (small file)
============================================================
  [write 14B]     tick 12... done
OK
  [read back]     tick 4... done
OK

============================================================
  Test 3: Large File  (2 blocks = 8192 B)
============================================================
  [write 8KB]     tick 15...
OK
  [read & verify]     tick 6... done
OK

============================================================
  Test 4: Partial Read  (offset=2000, size=32)
============================================================
  [read offset]     tick 4... done
OK

============================================================
  Test 5: Overwrite  (atomic Copy-on-Write)
============================================================
  [overwrite]     tick 12... done
    tick 4... done
OK

============================================================
  Test 6: Append
============================================================
  [append]     tick 10... done
    tick 4... done
OK

============================================================
  Test 7: List Files
============================================================
  [list]     tick 1... done
  2 files:
    greet.txt                        39 B
    data.bin                         8192 B
OK

============================================================
  Test 8: Delete
============================================================
  [delete data.bin]     tick 6... done
    tick 1... done
OK

============================================================
  Test 9: Read Nonexistent
============================================================
  [read missing]     tick 1... done
OK

============================================================
  Test 10: Stats
============================================================
    tick 1... done
  total: 14  free: 13  used: 1
  [stats] OK

============================================================
  Test 11: Power-Loss Recovery  (remount after simulated reset)
============================================================
    tick 1... done
    tick 4... done
  Before reset: 'This file has been replaced! (appended)'
  Simulating MCU power-cycle (in-memory state lost, flash intact)...
  [remount]     tick 4... done
OK
    tick 1... done
  After reset: 1 files
    tick 4... done
  After reset: 'This file has been replaced! (appended)'
  [data intact] OK

============================================================
  Test 12: Full Remount After Format
============================================================
    tick 15...
    tick 30...
    tick 45...
    tick 60...
    tick 69... done
  Fresh format done.
  [mount fresh]     tick 4... done
OK
    tick 12... done
  [remount & verify]     tick 4... done
    tick 4... done
OK

============================================================
  RESULTS: 18 / 18 tests passed
============================================================

format 需要擦除 16 个块(每块 3 tick = 48 tick)+ 写入 2 个 SB(每块 2 tick = 4 tick)+ 状态机调度与 compact 开销,实测约 69 tick。write 小文件约 12 tick,read 约 4 tick。每个 tick 对应 fdev_tick() 的一次调用,进度打印每 15 tick 输出一次(drive() 中的 % 15 逻辑)。


测试覆盖了什么,没覆盖什么

覆盖了的:

  • 所有API的正常路径(format/mount/write/read/append/delete/list/stats)
  • 错误路径(读不存在的文件)
  • 边界条件(跨块读写、偏移量不对齐、文件大小恰好是块大小的整倍数)
  • 掉电恢复(power_cycle ×2)
  • 磨损均衡(write分配新块,wear_count递增)
  • CRC32校验(mount选择SB时隐式验证)

没覆盖的:

  • Flash全满的场景(write时没有空闲块,应该返回-6)
  • 文件名冲突的极端情况(8个文件全满后再创建第9个)
  • Log compact阈值触发(需要大量write操作积累214+条log记录)
  • 间接块(KnotFS没有)
  • 垃圾回收(KnotFS没有后台GC)
  • 真实硬件上的Flash写入延迟和时序

如果你来设计

试着回答:Test 11(power_loss)在什么情况下会失败?

答案:有两种主要可能。①compact没有成功完成——如果在power_cycle之前,write或append的compact只写到了一半(fdev_write触发了但ticks_left还没降到0),那么新SB是不完整的。但drive()在return之前会等到knotfs_is_idle()为true——即所有操作都完成。所以只要drive()返回0,compact就一定已经完成。②mount时SB选择错误——如果两个SB的sequence号相等且内容不同(这可能发生在format的ST_FMT_WR_SB1写入了一半时断电),mount会选择第一个通过校验的SB。但KnotFS的测试流程保证了只有drive()完成后才power_cycle,所以sequence号一定正确。


下集预告

到这里,你已经看完了KnotFS的全部源码和全部测试。你知道了一个文件系统从format到power-loss recovery的完整生命周期,理解了CoW的原子性保证、事件循环驱动的状态机、log-structured的元数据管理、CRC32的校验哲学。

但 KnotFS 只是教学版。从它到能在真实硬件上稳定运行的文件系统,中间还有一段无法在教材中完全展开的距离。最后一节,我们不写代码,不画状态机——我们站远一点,看看这根两万年的接力棒,看看你手里的那个结。