从教科书汉明码到 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 |
结论就两条:
- 真正的跃迁只有两次:算法塌缩(F2→F3)和查表(F5→F6)。中间那些”手工拆循环”的微操在 -O2 面前全是零。
- 查表版严格线性,约 0.6 ns/字节,8K 窗口 4.9 µs——对 FTL 里每次搬页算一次 ECC 的频率来说,可以忽略不计了。
另一个视角是测试套件总时间:f1 1566 ms、f2 1128 ms、f3~f6 都是 19 ms。大测试量(512B 窗 4096 轮逐位翻转纠错)下,算法常数因子的差距比微基准醒目得多。
关于这个 bench 本身,三条纪律值得抄下来(测性能的朋友都用得上):
- 循环内不许有任何打印、不许有 EXPECT——否则量到的是框架开销,不是内核的。测试套件 19 ms 和 bench 0.33 µs 的巨大反差就是这么来的。
- 预热 + sink:先空跑 1/10 圈让 cache 和分支预测进入稳态;结果异或进一个 sink 变量,逼编译器每轮都真算,防止整个循环被优化删除。
- 不要跨机器、跨编译器比较。在另一套 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
- 「全站讲的最清晰」海明码原理(B 站,空降 25:08)
- ST RM0090 Reference manual - FMC NAND controller, ECC computation
- 正点原子 STM32F429 HAL 库实验 42 FATFS 实验(nand.c)
- R. W. Hamming, Error Detecting and Error Correcting Codes, Bell System Technical Journal, 1950
- Linux 内核 MTD 子系统(drivers/mtd/nand/,分离式 ECC 的工业实现)