1. 为什么高速串行链路绕不开8B/10B编码
第一次接触8B/10B,大多是在看千兆以太网PHY手册或者PCIe Gen1/Gen2的物理层章节时撞见的。那时候我刚从并口转过来,满脑子都是"8位数据为什么要用10位来传"——凭空多出25%的开销,图什么?
答案藏在高速串行传输的物理约束里。把并行总线拉长,速率一上到Gbps级别,线间不等长造成的偏斜能吃掉大半个UI,几十根线同步翻转还会给电源地平面带来巨大的同步开关噪声。于是从千兆以太网、光纤通道、XAUI,到PCIe Gen1/Gen2、SATA、InfiniBand,设计者都把并行数据搬到一对或几对差分线上串行发送,由接收端的时钟恢复电路从数据流里"抠"出时钟。
这一搬,就搬出了三个绕不过去的问题。第一,直流平衡:如果线上连续跑长串的1或0,交流耦合电容会逐渐偏置,变压器也会饱和,眼图直接合不上。第二,时钟恢复:接收端没有独立时钟线,全靠数据跳变沿来锁定相位,长时间不变的电平会让CDR环路失去参考。第三,码组对齐:串行流没有字节边界,接收端必须能在一串随机比特里快速找到"10位一组"的切口。
8B/10B编码就是用25%的带宽开销,把这三个问题一次性解决掉。它把每个8位字节映射成一个10位码组,保证任意码流中"0"和"1"的累计数量大致相当,连"1"或连"0"的长度不超过5位,并且保留了一批特殊码组充当同步标记。这套方案最早由IBM的Widmer和Franaszek在1983年提出并申请专利,作为ESCON和后来光纤通道的底层编码,一用就是几十年。
这篇文章我会按自己的理解顺序展开:先讲清楚它到底怎么切分、怎么选码,再把运行不一致性这套状态机掰开揉碎,然后给出一份可对照的编码演算和RTL实现思路,最后聊聊实际调试中容易踩的坑,以及为什么更高速率上它会被64B/66B取代。不管你是刚开始看PHY手册的FPGA工程师,还是想搞明白线路码到底在防什么的硬件同行,应该都能从里面找到对你有用的部分。
2. 拆解8B/10B的核心机制
2.1 5B/6B加3B/4B的切分逻辑
8B/10B最巧妙的地方在于它没有去做一张256项的大表,而是把一个字节拆成两段分别编码。
一个字节8位,按位序从低到高记作ABCDEFGH,其中A是最低位(LSB),H是最高位(MSB)。编码器把它切成两部分:
- 低5位 EDCBA(也就是A、B、C、D、E这5位),送进5B/6B编码器,输出6位;
- 高3位 HGF(F、G、H这3位),送进3B/4B编码器,输出4位。
两部分拼起来正好10位。为什么要这么切?因为5位有32种组合,6位有64种组合;3位有8种组合,4位有16种组合。单独看都不够宽裕,但组合起来,可用的6B和4B码组数量就足够覆盖所有数据字符和需要的控制字符,同时还能保证每个子码组自身具备可控的不一致性。
这种切分的另一个好处是编码表的规模被压到最小。5B/6B只需要一张32项的映射表(每项还有RD-和RD+两个版本),3B/4B只需要8项。整张表加起来比一张256项的大表小得多,硬件实现时可以用纯组合逻辑查表,面积和延迟都容易控制。
字符的命名规则也是从这里来的:低5位EDCBA的十进制值记为x,高3位HGF的十进制值记为y,组合成Dx.y的形式表示数据字符,Kx.y表示控制字符。比如D10.2,就是低5位=01010(十进制10)、高3位=010(十进制2)的那个数据字节0x4A。
2.2 不一致性与运行不一致性的量化
要理解编码器怎么选码,必须先弄清楚"不一致性"这个词。
不一致性(Disparity)指的是一个码组里1的个数减去0的个数。对6B码组来说,可能的值是-6到+6之间的偶数;对4B码组是-4到+4之间的偶数。8B/10B编码表里实际用到的码组,不一致性只取三个值:-2、0、+2。0叫中性码组,+2叫正不一致性码组,-2叫负不一致性码组。
**运行不一致性(Running Disparity,RD)**则是编码器内部维护的一个状态量,表示截止目前发送出去的比特流里,1的总数相对0的总数是偏多还是偏少。它只有两个状态:
- RD-:当前1的数量偏少(或者说累计偏差为负);
- RD+:当前1的数量偏多。
编码时,编码器根据当前RD状态,从表里选出对应的那个版本。如果RD-,就优先选一个正不一致性(或中性)的码组,把累计偏差往回拉;如果RD+,就优先选负不一致性(或中性)的码组。这样一来,无论数据怎么随机,线上的累计直流分量都会被持续地往零点附近拽。
这里有个容易搞混的点:同一个数据字节,在RD-和RD+下对应的是两个不同的10位码组。这两个码组通常互为按位取反。比如某个字符在RD-下是0110001011,在RD+下就是1001110100。接收端只要盯着RD状态,就能反推出编码器当时用的是哪个版本,从而正确解码。
注意:中性码组(不一致性为0)在RD-和RD+下的编码是相同的。这也意味着连续发送中性码组时RD状态不会翻转,如果长时间只发中性字符,直流平衡的调节能力会下降。不过由于5B/6B和3B/4B两段会同时作用,实际数据流中纯中性字符连发的情况很少,真正需要警惕的是控制序列里刻意堆叠中性码组的场景。
2.3 K码存在的意义与逗号序列
数据字符之外的12个控制字符,是8B/10B能用于同步的关键。
这12个控制字符是K28.0到K28.7,加上K23.7、K27.7、K29.7、K30.7。其中最重要的一个,是在几乎所有串行协议里都能见到的K28.5。
K28.5的编码是这样的:
| RD状态 | 10位编码 | 不一致性 |
|---|---|---|
| RD- | 0011111010 | +2 |
| RD+ | 1100000101 | -2 |
看RD-下的0011111010,它的前7位是0011111,中间包含了连续5个1;看RD+下的1100000101,前7位是1100000,中间包含连续5个0。这两段特殊的比特模式,就是所谓的逗号序列(Comma Sequence),也可以叫逗号模式。
逗号序列的价值在于:它不可能出现在任何其他合法的10位码组中,也不会跨越两个相邻码组的边界偶然出现(其实是刻意设计成不会的)。接收端只要检测到0011111或1100000这个7位模式,就能立刻确定一个10位码组的起始位置。
这个机制解决的是码组对齐问题。串行流里没有字节边界,接收端的CDR恢复出时钟后,得到的是一串连续的比特。按10位一组切分,有10种可能的相位。只有当相位切对了,检出的逗号序列才会每隔10位稳定出现;切错了,逗号序列就会"消失"。对齐状态机就是靠在多个相位上同时搜索逗号序列,找到那个能稳定命中的相位,完成10位边界锁定。
除了K28.5,其余K码各有分工。K28.3常用作空闲序列的填充,K28.1、K28.2、K28.4在不同协议里被指定为同步、起始、结束标记。你会在千兆以太网的/I1/、/I2/、/C1/、/C2/有序集里看到它们的组合用法。
3. 运行不一致性的完整演算与编码实现
3.1 以K28.5为例把状态机走一遍
光看定义容易懵,我拿K28.5走一遍完整的编码流程。
假设链路刚上电,编码器把RD初始化为RD-(大多数实现默认从RD-开始,也有从RD+开始的,只要收发两侧一致即可)。
第一个要发的字符是K28.5,当前RD-,查表得到0011111010。数一下其中1的个数:位置1、2、3、4、5、7、9上是1,一共6个;0有4个。不一致性=+2。因为这是一个正不一致性码组,发完之后RD从RD-翻转到RD+。
第二个字符还是K28.5,当前RD+,查表得到1100000101。1的个数是4,0的个数是6,不一致性=-2。发完后RD从RD+翻回RD-。
第三个K28.5,又回到RD-,发0011111010,RD再翻到RD+。
所以连续发送K28.5时,线上会交替出现0011111010和1100000101,形成一个稳定的、自带大量跳变沿的周期性图案。这也是为什么很多协议把它用作链路空闲时的填充字符——它既保证了直流平衡,又给CDR提供了密集的时钟信息,还持续提供逗号序列供接收端维持对齐。
3.2 数据字符的不一致性组合规则
数据字符的编码比控制字符稍微复杂一点,因为5B/6B和3B/4B两段各自独立选取,还要保证两段加起来的一致性总量可控。
先看5B/6B这一段。32个输入中,有部分输入自身就可以被映射成中性码组(3个1、3个0)。对这些输入,编码表给出的是两个互为反码的中性码组,实际选哪个由RD状态决定。剩下的输入只能映射到正不一致性或负不一致性的码组,编码表同样给出两个版本,RD-时选正向版本,RD+时选负向版本。
3B/4B这一段规则类似。8个输入里,一部分映射到不一致性为±2的4B码组,另一部分(比如D.x.2、D.x.3、D.x.5、D.x.6这几类)映射到中性码组。
那么一个完整字节的总不一致性怎么算?就是两段之和。差值可能是-4、-2、0、+2、+4。编码器的约束是:整个10位码组的累计不平衡不能失控,具体是通过在5B/6B和3B/4B两段上分别选择来配合实现的。
我拿一个具体的字节算一下,比如0x00,也就是D0.0(低5位=00000,高3位=000):
- 5B/6B段:输入00000,在RD-下编码表给出的6B码组是
100111。数一下,4个1、2个0,不一致性+2。 - 3B/4B段:输入000,在RD-下给出的4B码组是
1011。3个1、1个0,不一致性+2。 - 拼接结果:
1001111011。整组7个1、3个0,不一致性+4。
这个+4的码组在RD-状态下发出后,把RD强烈地推向RD+。反向情况下,RD+时D0.0会编码为0110000100,不一致性-4,把RD拉回RD-。
这也解释了为什么8B/10B的直流平衡能力相当强:即便数据流里全是0x00这种极端字节,编码器也会持续在正负4之间来回摆动,累计偏差不会单调积累。
3.3 编码器的RTL结构思路
写一个8B/10B编码器,核心是两张查找表和一个小状态机。
状态机部分就是一位的RD寄存器:
// 8B/10B编码器骨架示意 module encoder_8b10b ( input wire clk, input wire rst_n, input wire kin, // 1表示控制字符,0表示数据字符 input wire [7:0] din, output reg [9:0] dout, output reg rd // 当前运行不一致性:0=RD-,1=RD+ ); // 5B/6B查找表,每项包含RD-和RD+两个6位码组 // 3B/4B查找表,每项包含RD-和RD+两个4位码组 // 篇幅原因不展开,实际项目中通常用case语句或ROM实现 wire [5:0] code6_rd_minus, code6_rd_plus; wire [3:0] code4_rd_minus, code4_rd_plus; wire disp6_pos, disp4_pos; // 选定码组的正负不一致性标志 // 查表逻辑(略) always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rd <= 1'b0; // 从RD-开始 dout <= 10'b0; end else begin if (rd == 1'b0) begin dout <= {code6_rd_minus, code4_rd_minus}; rd <= (disp6_pos ^ disp4_pos) ? 1'b1 : 1'b0; end else begin dout <= {code6_rd_plus, code4_rd_plus}; rd <= (disp6_pos ^ disp4_pos) ? 1'b1 : 1'b0; end end end endmodule这段代码里有几个细节值得说:
第一个是RD翻转判断。上面用disp6_pos ^ disp4_pos这种简化写法其实只适用于两段不一致性都非零的情况。真实实现里需要更细致的判断,通常的做法是把每个子码组的不一致性符号(正/负/中性)做一次合成,中性用0表示,正用+1,负用-1,加起来之后根据符号决定新RD。如果你直接照抄上面的异或,遇到中性码组就会出错。
第二个是延迟。上面的写法是寄存器输出,从din到dout有一拍延迟。如果下游需要组合逻辑输出,把查表结果直接连出去,RD状态仍然用寄存器,但要注意建立时间。高速设计里通常会把编码器流水化,分成查表、选码、输出三级。
第三个是K码处理。数据字符和控制字符走的是不同的查表入口,或者用同一个表的不同字段。K码的编码逻辑本质上是用另一组预定义的码组替换数据字符的默认编码,硬件上通常用kin信号做多路选择。
解码器结构更简单一些,反过来查表,同时不断检查收到的10位码组是否符合编码表、是否符合当前RD预期。一旦出现"这个码组在RD-下不该出现"的情况,就说明链路发生了误码,解码器会拉高code_err或disp_err指示信号。
提示:实际项目中,我强烈建议不要把编码表手写进RTL。用脚本从标准的8B/10B编码表生成Verilog的case语句或者初始化ROM的.mif文件,既省事又不容易出错。手写表的项目我见过的,几乎没有一个不出过一两个字符的编码错误,而且这类错误往往要跑到链路层联调时才会暴露,排查成本很高。
4. 实操中常见的坑与排查方法
4.1 编码表方向搞反的经典错误
这是新手最容易踩的坑,没有之一。
问题出在对RD-和RD+的定义理解上。不同资料里的命名习惯略有差异,有的把RD-定义为"当前1偏少",有的把它定义为"上一个码组是负不一致性"。如果你照着资料A实现了编码器,又照着资料B写了测试向量,两边对不上,链路就永远同步不上。
我的建议是把判断标准统一到"当前状态下,接下来要发的码组应该把累计不平衡往哪个方向拉"这一个口径上。在RD-(1偏少)状态下,选正不一致性码组;在RD+(1偏多)状态下,选负不一致性码组。定好这个口径之后,无论看哪份资料,都先把它翻译成这个口径再对照。
排查这类错误的方法很直接:拿示波器或者协议分析仪抓一段连续发送固定字符(比如全K28.5)的波形,看线上交替出的两个码组是不是0011111010和1100000101。如果发现是反的,或者干脆一直出同一个码组不翻转,那就说明RD状态机的翻转逻辑写错了。
4.2 码组对齐失败的排查路径
链路起来了但收不到数据,或者间歇性丢包,很多情况下是**码组对齐(Word Alignment)**没做对。
对齐状态机的思路通常是这样的:接收端在CDR恢复的比特流上尝试10种不同的相位,对每个相位跑一个K28.5检测器,统计哪个相位上逗号序列出现的频率最高、最稳定。当某个相位的命中计数连续N次超过阈值(N通常取3到5),就锁定这个相位作为10位边界。
这里有三个常见故障点:
第一,逗号检测逻辑本身写错。逗号序列是7位,0011111或1100000。注意检测时不能简单用10位全匹配,因为逗号可能跨越两个10位码组的边界——虽然标准设计上避免了这一点,但误码时会出现假匹配。稳妥的做法是同时检查7位模式的位置是否在码组内的合法窗口。
第二,对齐状态机一直在不同的相位之间来回跳。这通常是因为阈值设得太低,或者没做"锁定后需要连续多次失败才释放"的迟滞处理。我在一个项目里就遇到过,因为没做锁定迟滞,链路在对齐和失步之间反复横跳,表现为周期性丢包,查了整整两天。
第三,多通道之间的对齐不同步。XAUI这类多通道接口,每个通道独立做10位对齐之后,还需要做通道间去偏斜。如果只做了单通道对齐,通道间的10位边界不一致,后续的通道绑定一定会失败。
4.3 误码定位速查表
实际调试时,把常见现象和对应原因整理成一张表会省很多时间。下面这张表是我自己项目里积累的,供参考:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 链路完全起不来,无逗号检出 | 极性接反;CDR未锁定;参考时钟偏差过大 | 交换差分对极性;测量CDR锁定指示;核对参考时钟ppm |
| 偶然能对齐但很快失步 | 对齐阈值过低;信噪比不足;均衡参数不当 | 提高对齐命中阈值;扫描发送端预加重和接收端均衡 |
| 解码频繁报不一致性错误 | 编码表错误;RD状态机翻转逻辑错误;码间干扰 | 回环测试;对比标准编码表;加大均衡 |
| 数据能收到但字节序错乱 | 10位边界锁定在错误相位;接收端字节序处理错误 | 用固定图案(如K28.5序列)确认边界;核对小端大端 |
| 特定字节永远解错 | 该字符的编码表项有误;K码/D码判别逻辑有误 | 单独构造该字节的测试向量;检查kin信号时序 |
4.4 几个实操心得
心得一:先做数字回环再上真实链路。编码器和解码器直接对接,中间不过信道,能快速分离"编码逻辑错误"和"信道问题"。我在项目里的标准流程是:数字回环通过 → 板内短距离回环通过 → 连接真实对端。跳过前两步直接上链路,出了问题根本不知道该查哪。
心得二:用固定图案压测对齐逻辑。构造一段连续发送K28.5的测试流,跑上几小时,统计对齐状态机的切换次数。如果切换次数不为零,说明对齐逻辑的迟滞或阈值有问题。这个测试比随机数据测试更容易暴露对齐缺陷。
心得三:把RD状态引出来观察。在FPGA里加一根测试引脚或者放进ILA,观察RD状态是否在持续翻转。如果发现RD长时间停在一个状态不动,说明数据流里有大量中性码组,或者RD翻转逻辑根本没生效。这在早期验证阶段特别有用。
5. 从8B/10B到64B/66B的演进逻辑
8B/10B统治了从1Gbps到3.125Gbps这一档的串行链路,但速率再往上走,25%的开销就变得难以接受了。
到了10Gbps时代,Intel和IEEE在万兆以太网上选了另一条路:64B/66B。它把64位数据加上2位的同步头,总共66位传出去,开销只有3.125%。代价是放弃了8B/10B那套精细的直流平衡机制,改用**扰码(Scrambler)**来打散长连0和长连1。同步头只用01和10两种模式,配合扰码后的数据流提供块同步能力。
再往后,PCIe Gen3及以上用了128B/130B,开销进一步压到1.56%,原理和64B/66B类似,只是块更大、同步头更短。
那么问题来了:既然64B/66B这么省,为什么不在所有速率上都换?
原因在于8B/10B有一个64B/66B替代不了的优势:确定性延迟和低实现复杂度。8B/10B的编码解码都是纯组合逻辑查表,延迟固定在一两个时钟周期内,不需要扰码器的线性反馈移位寄存器跑起来找对齐。对于延迟敏感的场合,比如某些存储协议、工业总线、低速率背板互连,8B/10B依然是更省心的选择。
另一个原因是生态惯性。光纤通道、千兆以太网、SATA、USB 3.0的早期版本这些协议栈都建立在8B/10B之上,改动编码方案意味着整条链路从PHY到MAC都要重新验证,投入产出比不划算。所以你会看到即使在今天,很多2.5Gbps、3.125Gbps速率的接口依然跑着8B/10B。
我个人判断一个接口该用哪种编码时,会看三个指标:速率是否超过5Gbps(超了就优先考虑64B/66B及以上)、对延迟是否敏感(敏感就留在8B/10B)、是否需要与已有协议互通(互通就跟随对端编码)。三个指标里有两个指向同一边,基本就不用纠结了。
至于8B/10B本身,吃透它的价值不只是在实现一个编码器。它是一套完整的"如何在物理层用冗余换可靠性"的设计范式:用25%的开销换来了直流平衡、时钟恢复和块同步三件事,而且实现简单到几乎不需要验证。理解了这套权衡逻辑,再看扰码、前向纠错、判决反馈均衡这些技术,思路会清晰很多——它们都是在用不同的方式,回答同一个问题:在这条线上,怎么用最少的代价,让接收端把数据正确地拿回来。
最后分享一个我调试8B/10B链路时总结的小习惯:每次遇到同步问题,先别急着看信令和眼图,先抓一段原始比特流按10位一切,手工数一数逗号序列出现的位置。如果位置每隔10位稳定出现,问题就在编码逻辑或者上层协议;如果位置跳来跳去甚至找不到,问题才在物理层。这个三分法帮我省下了大量在示波器和代码之间来回折腾的时间。