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的一切运行时状态——sb、sb_alt、fdev、q[]、cur、mounted……全部在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,说明:
- mount正确选择了有效的SB副本
- log replay正确恢复了bitmap状态
- 文件内容完整无损
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 只是教学版。从它到能在真实硬件上稳定运行的文件系统,中间还有一段无法在教材中完全展开的距离。最后一节,我们不写代码,不画状态机——我们站远一点,看看这根两万年的接力棒,看看你手里的那个结。