从教科书汉明码到 FMC 硬件 ECC:两周填坑小记

看懂了,但手不会

Posted by cj on October 29, 2026

从教科书汉明码到 FMC 硬件 ECC:两周填坑小记

0. 起因

f429-hal 系列做到 047,要在 NAND 上写一个简易 FTL(Flash Translation Layer)。写页、读页、搬运、磨损均衡,这些都还好说,真正把我卡住的是 ECC。

F429 的 FMC 外设自带硬件 ECC:往 NAND 写数据的时候,FMC 模块会顺手把校验码算好放进 ECCR 寄存器,你只需要把它读出来存到 spare 区;读的时候再算一遍,两份一比,就知道哪一位坏了,翻转之。

翻出正点原子的例程(实验42 FATFS 实验,Drivers/BSP/NAND/nand.c),核心就这么几行:

for (res = 0; res < eccnum; res++) {
    FMC_Bank2_3->PCR3 |= 1 << 6;                    /* 使能ECC校验 */
    for (i = 0; i < NAND_ECC_SECTOR_SIZE; i++) {    /* 每512字节算一个 */
        *(volatile uint8_t*)NAND_ADDRESS = *(volatile uint8_t*)pbuffer++;
    }
    while (!(FMC_Bank2_3->SR3 & (1 << 6)));         /* 等待FIFO空 */
    nand_dev.ecc_hdbuf[res + eccstart] = FMC_Bank2_3->ECCR3; /* 读硬件ECC */
    FMC_Bank2_3->PCR3 &= ~(1 << 6);                 /* 禁止ECC校验 */
}

看代码的时候:开关一夹,寄存器一读,嗯,很简单嘛。

等真要在自己的 FTL 里把纠错逻辑写出来的时候:这个 32 位的 ECCR 里面每个 bit 是什么意思?syndrome 怎么算?坏位地址怎么从里面抠出来?为什么例程里拿两个 ECC 值异或一下就能定位?全抓瞎。。。

行吧,绕不过去就啃。我的路线是从教科书原理开始一步一步自己实现,最后和 FMC 硬件逐位对拍。这一走就是两个星期,回头一看路线图长这样:

  • M 系列(13 步):教科书式”校验位穿插在数据位中间”的汉明码,从最土的 vector<int> 一路优化到查表
  • 然后发现:真实世界(FMC、Linux MTD)根本不是这么存的——数据与校验分离,校验码单独放 spare 区
  • F 系列(6 步):模仿 FMC 硬件的编码方式重新实现一遍
  • 真机标定:拿 F429 开发板上的 FMC 硬件当裁判,30 项对拍全过
  • 纯 C 定版:整理成 emb_ecc_hamming.c,查表加速,512 字节 0.32 µs

环境:

  • STM32F429IGT6(正点原子阿波罗 V2 开发板),NAND 挂 FMC BANK3,8 位总线
  • 宿主机验证代码跑在 MSVC x64 的测试框架里

一、教科书路线:把原理亲手推一遍(M 系列)

参考的是 B 站那期「全站讲的最清晰」的海明码原理(见文末 Reference,直接空降到 25:08),讲得确实清楚。核心就一句话:

校验位放在码字的 2 的幂位置(1、2、4、8…),第 i 个校验位管辖所有”逻辑位置与 i 按位与不为 0”的数据位,校验值就是被管辖位的偶校验。

1.1 M:最土的版本,反而最重要

对着视频敲的第一版,对 8 位字符 'M'(0x4D)编码,期望结果是 0100 1110 0101。一位一个 int,塞在 std::vector<int> 里:

std::vector<int> m = {0};  // 第0位哨兵,因为教程从1开始计数
// 构造整个数据包,校验位以0占位未计算,数据位依次填充
for (int i = 1, mbits = 8; mbits; i++) {
    if (is_pow2(i)) {
        m.push_back(0);
    } else {
        m.push_back(c & 1);
        c >>= 1;
        mbits--;
    }
}

第一个坑当场就来了:教科书从 1 开始数位置,C 数组从 0 开始。只好在 vector 前面塞个 0 当哨兵。当时没意识到,这个”逻辑位置”和”数组下标”的错位会纠缠我整整一周。

算校验位的归组判断,就一行:

if (j & i) {          // j 是否归校验位 i 管
    check ^= m[j];
}

纠错更漂亮:每个校验位重算一遍,不成立的把它的位置号异或起来——拼出来的数就是出错位置:

if (check) {
    err_idx |= i;
}
// ...
m[err_idx] = !m[err_idx];  // 翻回来

视频里 25 分钟讲完的东西,跑通了,很爽。然后 check() 里故意把第 6 位弄反,果然定位、恢复,全对。

到此为止我以为我懂了。后面证明:这只是”看懂了”。

1.2 M2:data 和 ecc 分开存

教科书码字里校验位是穿插在数据位中间的,真实存储不会这么干(你总不能让数据位给校验位让座吧)。于是 M2 把 data 和 ecc 拆成两个数组。

拆开容易,算的时候要来回翻译坐标:逻辑位置 j 上的数据位,在 data[] 里的真实下标 di 是多少?得数 j 前面有多少个非 2 的幂的位置:

int di = 0;
for (int k = 1; k < j; k++) {
    if (!is_pow2(k))
        di++;  // 数 j 前面非 2 幂位置的个数
}

每个数据位、每个校验位都要翻译一遍,难受到怀疑人生。这一步最大的收获是想明白了一件事:穿插存储和分离存储只是同一个码的两种摆法,ecc_to_v() 把分离的两截重新摊回逻辑位置,算出来的校验值一模一样。

1.3 M3~M5:换遍历方向,然后撞见”末 3 位”

M3 把遍历方向反过来:不再”每个校验位扫全表找自己管辖的位”,而是”每个数据位把自己贡献给各个校验位”:

for (int j = 0; j < p; j++) {
    if ((1 << j) & i) {         // 逻辑校验位 & 逻辑数据位置不为0说明该数据归该校验位管
        ecc.ecc[j] ^= ecc.data[di];
    }
}

M4 把 vector<int> 换成紧凑的 buf 位操作。M5 干脆先把整体逻辑位流 w 构造出来(数据摊进最终布局,校验位留 0),然后按字节处理 w。在这里撞见了整个 M 系列最重要的规律——掩码:

/**
 * P1负责监管的模式是
 * |pos|7|6|5|4|3|2|1|0|
 * |---|-|-|-|-|-|-|-|-|
 * |W.b|7|6|5|4|3|2|1|0|
 * |P1 |y| |y| |y| |y| |
 * mask = 0b10101010 = 0xAA
 */
uint8_t supervisored = v & 0xAA;
uint8_t parity = PARITY_IMPL(supervisored);

P1 管 0xAA 的位,P2 管 0xCC,P4 管 0xF0。为什么?因为归组规则是”逻辑位置第 j 位是 1”,而一个字节内部,低 3 位地址恰好就是字节内位号。

顺着推下去就是字节版归组规则(只适用于 j ≥ 3):

字节参与第 j 个校验位 ⇔ 字节下标的第 (j − 3) 位是 1。

因为 k = 8j + i,8j 的低 3 位恒为 0,所以”末 3 位归字节内位号、高位归字节下标“两段无缝拼起来。

这 16 个字(外加那张掩码表)是后面一切加速的地基,先记在这。

1.4 M6~M9:小步重构,把”贡献”洗清楚

接下来四步没有什么戏剧性:M6 把 j<3 的三条路径统一成 masks[3] = {0xAA, 0xCC, 0xF0};M7 把 j<3 和 j≥3 再统一;M8 把内层计算抽成 calc_parity(v, j, k);M9 把内外层循环交换。

现在回头看,这几步的真正价值不是性能(一点都没有),是把”一个字节对校验位的贡献“这个概念洗得越来越清楚,洗到 M10 那张表自己浮出来为止。

1.5 M10:查表的诞生

洗到 M9,规律已经写在脸上了:j<3 的贡献只跟字节值有关,j≥3 的贡献只跟字节下标有关。那就把”字节值 → 四份贡献”做成 256 项的表:

// 一个字节的奇偶贡献(byte parity):4 个"是/否"答案,各占 1 bit
struct byte_parity_t {
    uint8_t p1 : 1;            // bit0 给 P1 的那份 = parity(v & 0xAA)
    uint8_t p2 : 1;            // bit1 给 P2 的那份 = parity(v & 0xCC)
    uint8_t p4 : 1;            // bit2 给 P4 的那份 = parity(v & 0xF0)
    uint8_t whole_parity : 1;  // bit3 整个字节的整体奇偶性 = parity(v)

    // = value() & 7 —— P1/P2/P4 三份合成一个数:只看内容、不看地址
    uint8_t p1_p2_p4_bits() const { return (uint8_t)((p1 << 0) | (p2 << 1) | (p4 << 2)); }
};
byte_parity_t T[256] = {0};

主循环变成:一次查表更新低三位校验,字节的 whole_parity 为 1 时才去处理 j≥3 的那些位。M11 再把 j≥3 的内层循环按 ecc 的字节对齐摊开(ecc[ei] ^= chunk << 3),这里不再展开。

1.6 M12/M13:SECDED,然后收尾

M12 干了两件事。一是结构化输入输出,二是第一次引入逻辑位置 0 的整体校验位——也就是传说中的 SECDED(Single Error Correct, Double Error Detect):

汉明码本身只能纠 1 位错;两位错它会”自信地纠错”,越纠越错。加一位覆盖全体的总校验位,才能区分”错的那位在数据里”还是”错太多了,放弃治疗”。

校验位个数公式也顺手记下:2^p ≥ 数据位数 + p + 1。

M13 连 w 都不构造了,一边流式读数据一边虚拟构造。至此 M 系列 13 个版本全部互相验证通过(每一版都跑同一组测试向量,包括 'M'、全 0、全 F、0x55、0xAA、任意长度 (i*37+11) 图案),完结撒花。

然后我志得意满地去看真实世界的实现,然后人傻了。

二、转折:真实世界不是这么存的

M13 做完回头看 FMC 和 Linux MTD 的实现,发现两个世界长得完全不一样:

  M 系(教科书穿插码) FMC 硬件 ECC
校验位放哪 插在数据位中间,占座位 不占座位,只存 spare 区
校验位个数 满足 2^p ≥ n + p + 1 的最小 p 坐标宽度 c = log2(8N)
总校验位 单独占一位 不单独存,复制 16 份摊进去
syndrome 还要翻译(di / ei) 直接就是坏位地址
地址 0 特例 有 没有

FMC 吐出来的 ECCR 是 32 位,结构是16 对互补奇偶交错:

bit 2m   = E_m   (地址第 m 位是 0 的那半数据位,全体 XOR)
bit 2m+1 = O_m   (地址第 m 位是 1 的那半数据位,全体 XOR)

O_m 我认识,就是 M 系的 P[m],换了个字母。E_m 是什么鬼?教科书根本不存”地址第 m 位是 0”的那半的校验啊!

再一看,人家根本不是”两份独立账本”,而是一个恒等式:

E_m ⊕ O_m = W(两半加起来 = 全体),W 就是 M 系那个总奇偶。

所以 W = 1 时 E = ~O(每一位都相反),W = 0 时 E = O。硬件存 E 的动机也清楚了:它不想单独存 W,就把 W 复制 16 份摊进 E_m,反正 32 位寄存器闲着也是闲着。妙啊。

解码也跟着变了天。syndrome 定义为重算的 ECC ⊕ 存储的 ECC(32 位按位异或),然后:

O_syn 的第 m 位 = 坏位地址 k 的第 m 位——拆出来直接就是地址,不用翻译!

为什么?每个维度 m 只回答一个是非题:”坏位在我这一半吗?”坏位的地址第 m 位是 1,它就归 O_m 管;它翻了,O_m 这一半的奇偶就翻了;于是 O_syn_m = 重算 ⊕ 存储 = 1。c 个维度拼起来就是完整的 k。

M 系折磨我一周的 di/ei 坐标翻译,在 F 系里整个不存在。

顺便,地址 0 特例也没了:M 系里”位置 0 出错”会让所有校验位都算出 0,和”没错”无法区分(著名的 d0 陷阱);F 系里 k=0 时 O_syn = 0,但 E_syn = ~0 ≠ 0,靠 E 那一半兜住了。

然后我看着满屏的 i、k、j,意识到一个新的坑已经在等我了:这两套体系连字母都不通用——M 系的 i 是校验位编号,F 系的 i 是字节内位号;M 系的 k 是码字字节坐标,F 系的 k 是数据位线性下标。这个坑后面单说。

三、F 系列:模仿 FMC 硬件

路线定了:扔掉穿插码,照 FMC 的定义重新实现一遍。每一步还是老规矩——上一步跑通的测试向量一个不能红。

3.1 F1:照定义直译

最老实的版本,三重循环,每个数据位 k、每个维度 m,按 (k >> m) & 1 分进 E 或 O:

for (size_t m = 0; m < sizeof(out.O) * 8; m++) {
    if (((k >> m) & 1) == 0) {
        // 累积进 out.E 的第 m 位
    } else {
        // 累积进 out.O 的第 m 位
    }
}

然后照恒等式打包:ecc 的 bit 2m = E_m、bit 2m+1 = O_m。

这版是纯定义直译,512 字节窗口内层循环跑 6 万多次,测试套件 1566 ms。慢归慢,它是后面所有版本的参考实现,也是和真机对拍的基准。

3.2 F2:E 是 O 的镜像

教科书思维的惯性让我在 F1 里老老实实算了两份账。反应过来 E_m = O_m ⊕ W 之后,E 根本不用算,打包的时候补:

uint16_t E = O;
if (W) {
    E = (uint16_t)~O;
}

思想上这是个小转折:E 从”另一半的账本”降级成了 O 的镜像。测试时间 1566 ms → 1128 ms,就快了这么点——因为 O 还是逐位逐维度算的,真正的大头没动。

3.3 F3:O 就是置 1 位地址的异或(全文最爽的一步)

盯着 F2 的内层看:O_m 是”地址第 m 位是 1 的所有置 1 数据位的 XOR”。把 16 个 O_m 叠成一个 16 位数 O,这个判断本身就可以消失:

uint16_t k = (uint16_t)(i * 8 + j);  // 数据的位坐标
if ((buf[i] >> j) & 1) {
    O ^= k;
}

O 就是所有置 1 数据位的地址的异或。 单个置 1 位 k:O = k;两个:O = a ^ b。16 次”归不归我管”的判断,塌缩成一次 O ^= k。

测试时间 1128 ms → 19 ms,约 60 倍。没有任何数据结构、没有查表,纯数学。

这步还有个副产品:单位探针的性质——窗口里只有第 k 位是 1 时,O = k、E = ~k、W = 1。这个性质后面在真机标定里当裁判用。

3.4 F4/F5:字节内预处理(白忙一场,但不亏)

F4 把一个字节的贡献先在字节内算好(kp),再 XOR 进 O。F5 更进一步,把 (i * 8 + j) 拆开:

kp ^= (uint16_t)(i * 8);  // buf[i] 有几个bit1就与i*8异或几次,奇数次相当于一次,偶数次相当于无操作, 与j无关
kp ^= (uint16_t)j;        // buf[i] 的哪些位号是1,O与这些位号全体异或,与i无关,可以构造lut查找加速

注意后半句注释:“与 i 无关”——置 1 位号的异或只跟字节值有关!这已经是把查表递到手边了。

但是 benchmark 给我泼了盆冷水:

[bench] F3-512   2.300 us/call
[bench] F4-512   2.310 us/call
[bench] F5-512   2.330 us/call

F4、F5 和 F3 几乎没有任何差别。手工拆循环省下的那点判断,早被编译器优化吃掉了。我以为的”优化”在 -O2 面前就是个笑话。。。

不过不亏:F5 的拆法(i*8 归字节下标、j 归字节内位号——就是 M 系那个”末 3 位”规则的 F 系翻版)正是下一步查表的直接原料。没有 F5 的拆法,就没有 F6 的表。

3.5 F6:查表

把”字节值 → 两份答案”做成 256 项的表,和 M10 殊途同归:

struct lut_t {
    uint8_t bit1_addrs_xor : 3;  // 该字节的哪些位号的比特是1,将这些位号异或起来
    uint8_t whole_parity : 1;    // 该字节中比特1的数量的奇偶性,奇数为1,偶数为0
};
lut_t T[256];

encode 主循环每字节三次操作:

const auto& t = T[buf[i]];
O ^= t.bit1_addrs_xor;      // 低 3 位:字节内位号的贡献
W ^= t.whole_parity;
if (t.whole_parity) {
    O ^= (uint16_t)(i * 8); // 奇数个置 1 位才把字节下标异或进去,偶数个等于没异或
}

看,”末 3 位归字节内位号、高位归字节下标”在这里兑现了:O 的低 3 位来自查表,高位来自字节下标,两段拼起来就是完整的 O。M 系和 F 系两条路线在查表这一步合流。

512 字节 2.30 µs → 0.33 µs,7 倍。测试时间 19 ms 不变(瓶颈在断言打印上,内核开销被淹没了——这也是为什么后来专门写了个”纯内核计时”函数,见第 5 节)。

3.6 decode:判决表

解码就是”重算 ⊕ 存储”得到 syndrome,然后按表判决:

*syndrome = emb_ecc_encode(buf, len) ^ ecc;
uint16_t Esyn = 0, Osyn = 0;
emb_ecc_calc_eo_by_ecc(*syndrome, &Esyn, &Osyn);
if (*syndrome == 0) {
    return EMB_ECC_OK;
} else if ((Esyn == (uint16_t)~Osyn) && Osyn < 8ULL * len) {
    return EMB_ECC_ERROR_CORRECTABLE;  // 坏位地址 k = Osyn
} else if (emb_popcount_impl(*syndrome) == 1) {
    return EMB_ECC_ERROR_ECC;          // ECC 自己坏了一位,数据无损
}
return EMB_ECC_ERROR_UNCORRECTABLE;
条件 结论
syn 32 位全 0 干净
E_syn == ~O_syn 且 O_syn < 8N 单位错,翻 buf[k>>3] 的第 k&7 位
syn 只有 1 位为 1 存的 ECC 自己坏了,数据无损
其余 ≥2 位错,不可纠,丢弃

三条纪律写死在测试里:逐位翻转 512B 窗口的每一个 bit(4096 轮)都必须判 CORRECTABLE 且恢复后重算 ECC 与原值相等;双错必须判 UNCORRECTABLE;1 位数据错 + 1 位 ECC 错也必须判 UNCORRECTABLE。

以及一条边界,测试注释里原话写着:

3 位错伪装单位错是 SECDED 固有边界,能力之外一律丢弃,这是算法边界不是实现问题。

3 位错会让 W 翻奇数次,恰好使 E == ~O 成立,”纠”完实际变 4 位错。这不是 bug,是 SECDED 的天灵盖,Linux 和正点原子也一样。

四、真机标定:让硬件当裁判

软件 F1 到 F6,谁对谁错?我在开发板上做了个标定实验(047.ftl.main.c,按 KEY_RIGHT 触发,块 0,收尾自动擦除恢复”已映射未写”语义)。

数学基础想通了就不慌:ECC 在 GF(2) 上是线性的——code(a ^ b) == code(a) ^ code(b)。所以不需要穷举 4096 个 bit,只要”单位探针 + 线性校验”就能确定整个码空间。

四组实验:

A1 基线:全 00 / 全 FF 的窗口 → O = E = W = 0、code = 0。

A2 单位探针:窗口内只有第 k 位是 1,数据构造就一行:

ecc_wbuf[k >> 3] |= (uint8_t)(1u << (k & 7));

探针选了 12 个坐标位 2 的幂(1、2、4、…、2048)加边界值(4095、4094)、任意值(1234),再加 chunk1 的两项跨块验证。期望 O = k、E = ~k。

A3 线性:两探针叠加写入,读出的 code 必须等于两个单位码的异或。

B 页级窗口:ECCPageSize = 2048 时整页算一个码,坐标宽度会不会塌缩成 12 位?用 k = 0/4095/4096/8192/12288/16383 六个探针验证——四个 4KB 象限同位置单 bit 的码互异,说明坐标宽度 ≥ 14 位,2048B 页级可用;外加验证写路径算的码和读路径算的码一致(写页时 FMC 也会算一份)。

512B 的分块窗口怎么实现?FMC 的 ECC 是跟着数据流走的,所以用「ECC 开关对夹住 512B 读」:开关一夹,有效窗口就是开关之间的字节。

结果:30/30 全过。真机日志里的锚点值,比如(512B 窗):

k=1     → 0x55555556
k=2     → 0x55555559
k=2048  → 0x55955555
k=4095  → 0x55AAAAAA
k=1234  → 0x5565A659
(2048B 页级窗)
k=0     → 0x55555555
k=16383 → 0x5AAAAAAA

看懂 0x55555555 这个数之后这些值就不用背了:它是 k=0、W=1 的基线(O=0,E=~0=0xFFFF,全部摊在偶数位上)。单位探针恒有 W=1,所以锚点值全是 0x55555555 挖掉 k 的补码那几位的形状。

这 22 个锚点值现在原样固化在 f1_fmc_anchors 测试里,注释写着”047.ftl 日志逐条抄录”。以后每次跑测试,都相当于让真机再当一遍裁判。

真机还顺手裁决了一个历史悬案,见下面卡点清单第 5 条。

五、纯 C 定版与性能

F6 验证通过后整理成 libemb/ecc/emb_ecc_hamming.c:T[256] 直接写成 const 数组(MCU 上进 rodata),对外就 encode / decode / correct 三个口子,外加 (E,O) ↔ ECCR 的打包拆包工具函数。4 字节 ECC 最大支持 8K 数据。

性能数据(MSVC x64,同一台机器,100000 次取均值,含预热):

len (字节) F3 F4 F5 F6 (查表) 纯 C 定版
512 2.300 µs 2.310 µs 2.330 µs 0.330 µs 0.320 µs
1024 4.560 µs 4.590 µs 4.610 µs 0.620 µs 0.630 µs
2048 9.070 µs 9.140 µs 9.100 µs 1.230 µs 1.250 µs
4096 18.050 µs 18.290 µs 18.340 µs 2.460 µs 2.460 µs
8192 36.390 µs 36.660 µs 36.520 µs 4.880 µs 4.910 µs

结论就两条:

  1. 真正的跃迁只有两次:算法塌缩(F2→F3)和查表(F5→F6)。中间那些”手工拆循环”的微操在 -O2 面前全是零。
  2. 查表版严格线性,约 0.6 ns/字节,8K 窗口 4.9 µs——对 FTL 里每次搬页算一次 ECC 的频率来说,可以忽略不计了。

另一个视角是测试套件总时间:f1 1566 ms、f2 1128 ms、f3~f6 都是 19 ms。大测试量(512B 窗 4096 轮逐位翻转纠错)下,算法常数因子的差距比微基准醒目得多。

关于这个 bench 本身,三条纪律值得抄下来(测性能的朋友都用得上):

  1. 循环内不许有任何打印、不许有 EXPECT——否则量到的是框架开销,不是内核的。测试套件 19 ms 和 bench 0.33 µs 的巨大反差就是这么来的。
  2. 预热 + sink:先空跑 1/10 圈让 cache 和分支预测进入稳态;结果异或进一个 sink 变量,逼编译器每轮都真算,防止整个循环被优化删除。
  3. 不要跨机器、跨编译器比较。在另一套 gcc 环境里我拿教科书式参考实现和查表版对比过,差距从 32 字节的 8.8 倍一路拉到 4096 字节的 200 倍——量级感受可以,横比没有意义。

六、卡点清单

两周下来,比代码更值钱的是这份清单。按疼的程度排序。

1. 两套坐标系连字母都撞车。 M 系的 i 是校验位编号,F 系的 i 是字节内位号;M 系的 k 是码字字节坐标,F 系的 k 是数据位线性下标。同一份笔记里两边都用,一周后自己都看不懂自己写了啥。解法是立纪律:提到哪套就写全名(”逻辑位置”还是”字节下标”),变量名必须能看出是哪套。代价是行文啰嗦,收益是思维不再串台。

2. 原始域和 syndrome 域的同名量混用。 W(原始数据的总奇偶)和 W_syn(syndrome 里的)不是同一个东西,但打包/拆包函数对两个域是通用的(恒等式 E_m = O_m ⊕ W 在两个域里形式相同),于是函数本身不会报错——出错的是调用方,比如把 W 传进了要 W_syn 的位置。这种错没有 assert 帮你,只有变量名带域后缀的纪律能防。通用性是有代价的:多一个自由度就多一个犯错面积。

3. O_syn = 0 不等于没错。 k=0 的单错会让 O_syn 整个是 0,和”干净”长得一样。判决必须先看 syn 整体(32 位全 0 才是干净),再拆半看 E_syn/O_syn。顺序反了就是错案。

4. 0x5A 是零图案。 0x5A 有 4 个置 1 位,乘以 2048 字节后每组都偶数个 → ECC 全 0,测不出任何东西。测试向量一律用 (i*37+11) 这种非周期图案。血泪:曾经有俩实现”对拍全绿”,其实两边都输出 0,等于没测。后来测试里专门加了 EXPECT_FALSE(ws.empty()) 防”两边都空也算通过”。

5. 拆包必须拆满 32 位。 正点原子的 nand_ecc_correction 只读 ECCR 的低 24 位——512B 窗口视角下刚好够(k ≤ 4095 用 12 对),但通用实现必须把 16 对全拆出来。真机标定的 2048B 页级探针(k 达到 16383,要动第 13、14 对)替我验证了这一点:只看 24 位的话高 4 对的判决直接漏掉。

6. 教科书从 1 计数,C 从 0。 从第一行代码的 vector 哨兵位开始疼,一直疼到 di/ei 翻译公式。没有解法,只能每次交接时显式写清楚现在用的是哪套坐标。

7. SECDED 的天灵盖要显式写进测试。 双错、1+1 错必须断言 UNCORRECTABLE,3 位错伪装成单位错是算法边界不是 bug——注释里写清楚,防止半年后的自己当成 bug 去”修”。

后记

复盘这两周:

视频 25 分钟看完,”看懂了”;M 系列 13 个版本走完,以为”会了”;直到对着 FMC 的 ECCR 发呆半天想不明白 E 为什么存在,才知道前两步都只是”看懂了”。

有意思的是,M 系列其实是绕路——真实世界根本不用穿插码,F 系列六个版本一天就走完了。但正是绕的这一路,把归组规则、”末 3 位归字节内位号、高位归字节下标”、SECDED 这些弹药攒齐了,F 系列才会快。M10 的查表和 F6 的查表,两条路线在同一个 256 项的表上合流,那一刻挺有仪式感的。

以及一个副产品:这次是跟着 AI 一对一从教科书推起来的,过程中我被迫立了一堆”术语纪律”(一次只许引入一个新记号、引用旧结论必须整段复述、变量名带域后缀)。事后看,这些纪律对防”我懂了”的幻觉比什么都管用——能写出让下一个自己不迷路的符号系统,才算真懂。

完。

Reference