简介:一套基于FPGA的8B/10B编解码Verilog实现工程,定位面向FPGA学习者与高速串行接口设计人员,旨在帮助理解直流平衡编码原理,以及编解码器在真实硬件中的落地过程。压缩包共包含137个文件,整体约3.88MB,其中以Verilog源码、Quartus II工程文件、sof下载文件、仿真脚本及各类报告为主,从工程配置到验证结果一应俱全,解压后直接打开qpf工程即可运行整个设计流程。目前已有3745人学习下载。设计内部按默认编码、差异度计算、编码校正、并串转换和显示五个模块划分,完整覆盖编码、解码与差异度校验环节;配套ModelSim功能仿真和Quartus II逻辑综合,最终在Cyclone IV E系列的EP4CE6F17C8芯片上完成适配与下载测试。对希望快速掌握8B/10B编解码实现、参照完整FPGA工程结构,或从事高速串行传输验证的读者,这套资料提供了可直接复用和二次开发的起点。
1. 为什么搞8b10b:串行链路上的“平衡艺术”
做FPGA的同学迟早会碰到8b10b,不管你是调PCIE、USB3.0、千兆以太网,还是SATA、JESD204B,底层物理编码子层里都跑着8b10b或者它的小改款(比如64b/66b、128b/130b)。说是“迟早”,是因为只要链路是串行的、速度一上来,DC平衡问题就躲不掉。
先说一个最原始的问题:为什么不能直接把8bit数据怼到串行线上?因为接收端需要从数据流里恢复时钟,而恢复时钟依赖信号边沿的密度。如果你的数据是连续的0或者连续的1,接收端过一段时间就“失去参考”了,CDR(时钟数据恢复)直接罢工。再一个问题是直流漂移,长时间发送同一电平会让线路的直流分量偏移,长期运行误码率就上来了。
8b10b的核心思想就两个字:平衡。8bit数据映射成10bit码字,这10个bit里0和1的数量差异被严格控制,运行差异度(Running Disparity,简称RD)被限制在正负1之间。同时保证码字里连续的0或1不超过5个,这样接收端就有足够多的跳变沿来恢复时钟。
再往深一层看,8b10b的设计还有一个很多新手容易忽略的点:它不是一个简单的查表映射,而是分成了3b/4b和5b/6b两级子映射。为什么这么设计?因为如果直接做8bit到10bit的完整映射表,表规模是2^10=1024项,虽然Verilog里写case也写得下,但逻辑综合和时序优化会麻烦不少。分两级之后,每一级的逻辑都很小,组合逻辑路径短,时序更好收敛,这也是当年IBM那帮工程师的高明之处。
说实话,我个人做FPGA项目多年,真正对8b10b“开窍”是在自己用Verilog写过一遍编解码,并且调通了高速收发器(比如Xilinx的GTP/GTX)之后。光看协议文档,你永远没法真正理解RD状态为什么会对、那几根控制信号为什么要同步、K码为什么是干这个用的。这篇文章就把我手写8b10b编解码模块的完整思路、代码结构、仿真排错过程捋一遍,给要做相关项目的朋友一个可以“抄作业”的参考。
2. 8b10b编解码核心机制拆解
2.1 编码表与RD状态机的关系
8b10b编码的输入是一个8bit数据(D码)加上一个控制位(K码使能),输出是10bit码字。编码表本身不是随意的映射,它做了两个关键约束:
- 每个码字中1的个数和0的个数的差值只能是0、+2或-2(极少数特殊码字除外)
- 根据当前RD状态(RD+或RD-)来选择发送码字的极性,保证码流平均DC电平为0
RD状态机是怎么工作的?简单说:当前RD为正表示最近发出去的码字里1比0多,下一步倾向于发负极性的码字来拉平衡;RD为负则相反。具体计算规则如下:
- 如果码字里1比0多2个,且当前RD=+,则下个码字的RD变为-
- 如果码字里1比0多2个,且当前RD=-,则下个码字的RD变为+
- 如果码字里0和1一样多,RD保持不变
这个规则用Verilog实现就是几行always块的事,但前提是你得把编码表的极性分类搞清楚。
这里我补充一张常用于快速查找的编码表结构,方便大家对照代码理解。表格不是完整128项数据,只是把几个典型字符列出来,关键看规律。
| 输入数据 | 数据类型 | RD=-时输出 | RD=+时输出 |
|---|---|---|---|
| D0.0 (0x00) | D码 | 100111 0100 | 011000 1011 |
| D1.0 (0x01) | D码 | 011101 0100 | 100010 1011 |
| D21.5 (0xB5) | D码 | 101010 1010 | 101010 1010 |
| K28.5 (0xBC) | K码 | 001111 1010 | 110000 0101 |
看到D21.5这行的规律了吗?它本身就是一个平衡码(0和1各5个),所以不管RD是正是负,输出都一样。这也是10bit码字里少数能保持完全平衡的字符,K28.5则因为自带连续的1和0,成为接收端做码组对齐的黄金选择。
2.2 控制字符(K码)到底有什么用
K码在8b10b体系里不是用来传数据的,它承载的是“命令”或“标记”。协议里一共有12个K码,但真正在物理层用得最多的是K28.1、K28.5和K28.7。
- K28.5:常用作comma(逗号)字符,用于接收端做字节对齐。它的10bit码字里有一个唯一的“0011111”或“1100000”模式,连续的1或0不会在任何D码组合中出现
- K28.1和K28.7:常用于一些协议特定的控制序列,比如PCIE里的SKP(skip)字符会用K28.0/K28.3等
做FPGA实现的时候,K码使能信号(通常叫K或KCHAR)必须在发送端和接收端有统一约定。比如你的链路层数据是32bit还是64bit的,K码字节在整个通道里怎么分布,这些属于协议适配层的工作,物理编码子层只管按输入编码就行。
经验之谈:新手写编解码时,经常忽略K码使能与数据对齐的问题。如果你的数据位宽是8bit,那还好办,一个字节对应一个使能。但如果用了32bit的链路层接口(一个时钟周期4个字节),那么你既要判断4个字节各自的K使能,还要注意字节顺序对不对。曾经我调一个多通道的项目,发送端byte0放K码,接收端却按byte3的位置去取comma,结果对齐逻辑始终不触发,排查了整整一天才发现是字节通道映射的问题。
2.3 RD(Running Disparity)究竟怎么算
RD的计算是8b10b实现里最容易写错的地方。多数资料会告诉你“编码后统计1和0的个数差”,听起来很简单,但实际编码过程是分两步走的:
- 先对低5bit做5b/6b编码,根据当前RD算出中间RD
- 再对高3bit做3b/4b编码,根据中间RD算出最终RD
也就是说,RD在整个编码过程中会更新两次。这个细节非常关键,如果你的代码先编码完整10bit再统一更新一次RD,那么对于某些输入序列,结果可能碰巧对,但遇到边界情况就会出错。
具体的RD更新判断,其实用不着真的去数1的个数。更高效的做法是直接分类:6bit子码(输出为6bit)里有“000111”这种三位相同的结构,4bit子码(输出为4bit)里也有类似特征,这些特定的模式会直接决定RD翻转方向。分类判断加几个组合逻辑,比统计1个数少占用不少LUT。
我在实际工程里习惯把RD状态做成一个单独的模块,输出给编码器用,同时回传给上层状态机,好处是调试的时候可以单独看这个信号的变化轨迹,定位问题的时候效率极高。
3. 编码器Verilog实现:架构设计、代码结构与关键点
3.1 顶层模块如何划分
我建议把8b10b编码做成独立的模块,接口尽量标准化,方便后续复用到不同项目。顶层端口如下:
module enc_8b10b ( input wire clk, input wire rst_n, input wire [7:0] data_in, input wire k_in, // 1: K码使能, 0: D码 output reg [9:0] data_out, output reg rd_out // 当前码字发送后的RD状态 );为什么要把RD输出也拉出来?因为接收端的解码也需要RD,有时候调试系统级回环测试时,你需要把发射端的RD和接收端的RD做交叉比对。如果你的模块把这个状态锁在内部不让外面看,仿真调试就没法做了。
除了顶层,我通常会拆三个子模块:5b/6b编码器、3b/4b编码器、RD更新逻辑。这样拆的优势是每个子模块的逻辑都很纯粹,综合时时序路径短,后期如果要改协议参数(比如做64b/66b或自定义变种)也只需要换其中一小块。
有一个设计习惯值得推荐:把编码表用case语句罗列清楚,但不要把这些case放在组合逻辑块里,而是用casez匹配“XX”这种不关心的位。因为8b10b里有几个码字的映射是等价的(同一个输入在RD+和RD-下会输出两个不同码字),写casez可以让综合更清楚哪些分支可以合并优化。
3.2 编码器源码核心片段解析
这里给出最核心的5b/6b编码逻辑。
// 5b/6b encoder reg [5:0] coded_6b; always @(*) begin casez ({k_in, data_in[4:0]}) // D码 6'b0_00000: coded_6b = (rd_in) ? 6'b100111 : 6'b011000; 6'b0_00001: coded_6b = (rd_in) ? 6'b011101 : 6'b100010; 6'b0_00010: coded_6b = (rd_in) ? 6'b101101 : 6'b010010; // ... 中间省略 6'b0_11111: coded_6b = (rd_in) ? 6'b101011 : 6'b010100; // K码的5b部分 6'b1_11100: coded_6b = (rd_in) ? 6'b001111 : 6'b110000; // K28 default: coded_6b = 6'b000000; endcase end注意这里的rd_in是编码前的当前RD,而不是编码完成后的状态。在组合逻辑里用rd_in查表,然后在时序逻辑里根据coded_6b的极性更新rd_mid(中间RD),再把这个rd_mid送给3b/4b编码器。这一步顺序要是搞反了,整个码流就全乱了。
K码的5b部分一定要用独立分支,因为同一个输入D28(0x1C)在作为D码和K码时是完全不同的编码。比如5'b11100作为D码输出是01010这个序列的一部分,但作为K28时输出是001111或110000,两者差别巨大。
下面这段是3b/4b编码和RD更新:
// 3b/4b encoder reg [3:0] coded_4b; always @(*) begin casez ({k_in, data_in[7:5]}) 4'b0_000: coded_4b = (rd_mid) ? 6'b1011 : 6'b0100; // D.x.0 4'b0_001: coded_4b = (rd_mid) ? 6'b1001 : 6'b1001; // D.x.1 4'b0_010: coded_4b = (rd_mid) ? 6'b0101 : 6'b0101; // D.x.2 // ... 4'b1_010: coded_4b = (rd_mid) ? 6'b0101 : 6'b1010; // K.x.2 4'b1_111: coded_4b = (rd_mid) ? 6'b1110 : 6'b0001; // K.x.7 default: coded_4b = 4'b0000; endcase end // RD update always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rd_out <= 1'b0; // 复位后约定RD为负,也就是rd_in=0表示RD- end else begin case ({coded_6b, coded_4b}) 12'b000111_????: rd_out <= 1'b1; // 6b不平衡,取反极性 12'b111000_????: rd_out <= 1'b1; 12'b????_0011: rd_out <= 1'b1; // 4b部分导致的不平衡 12'b????_1100: rd_out <= 1'b1; default: rd_out <= rd_in; endcase end end最后组合起来,data_out = {coded_4b, coded_6b},位序是高位在前,低6位放5b/6b的结果,高4位放3b/4b的结果。这个位序是PCIE、千兆以太网等常用协议的标准做法,写代码前务必确认你的协议文档里的bit顺序定义,否则收发两端对不上会非常痛苦。
3.3 编码器的时序与复位策略
编码器全是纯组合逻辑加一级寄存器输出,所以时序收敛很容易。真正要小心的是复位策略和RD初始状态。
- 复位后RD必须明确约定为RD-(就是rd_in=0),两端一致
- 发送端和接收端如果复位不完全同步,在系统刚上电的那段时间RD状态可能短暂不一致,但这不影响后续稳定,因为8b10b的RD自我纠错能力很强,只要一个平衡码过去,RD就重新对齐了
还有一个小技巧:如果你要在FPGA里做多通道并行编码(比如4通道PCIE),每个通道的RD状态必须是独立维护的,不能用同一个全局RD。因为通道间初始相位可能不同,共用一个RD会让对端解码彻底错乱。这一点从协议文档里看不出来,但做过多通道集成的人都知道。
4. 解码器与时钟恢复对齐:工程真正复杂的部分
4.1 解码器输入与对齐逻辑
解码器的输入是连续的10bit码字流,但接收端如何知道哪里是一个码字的边界?答案是在数据流里搜索comma模式。物理层通常是先把串行数据一个bit一个bit地收进来,然后在bit流里找K28.5(comma)的特征序列“0011111”或“1100000”,找到后就把这个位置当作字节边界,后续数据都按10bit为粒度切分。
这个逻辑用Verilog实现时一般有两种方案:
- 滑动移位寄存器:把收到的bit不断移入一个10bit寄存器,每bit都检查是否匹配comma模式,匹配后输出一个对齐脉冲,同时产生load信号给后续的10bit打拍逻辑
- 状态机方案:维护一个bit计数器,如果当前窗内不匹配comma,则滑动1bit继续找,直到找到为止
方案1比较直观,代码就是移位寄存器加一个比较器,做高速率高带宽时可能资源稍多;方案2节省逻辑,但状态机控制要小心。我个人建议在GTP/GTX这类高速收发器里直接用IP核内部的comma检测,但在教学或者在低速自定义链路上,手写对齐逻辑能让你彻底理解底层原理。
对齐完成之后,数据就以10bit为单位进入解码主体。解码主体做的事刚好和编码器相反:根据码字查表找到对应的8bit数据和K码使能,同时用上一个码字的RD状态来验证当前码字的极性是否符合预期,并更新RD。
4.2 解码器源码核心片段解析
解码器的查表和编码器几乎对称,唯一的区别是解码器的输入是10bit码字,需要同时匹配RD+和RD-两种情况。
always @(*) begin casez (code_in) 10'b011000_0100: begin data_out = 8'h00; k_out = 1'b0; end 10'b100111_1011: begin data_out = 8'h00; k_out = 1'b0; end 10'b100010_0100: begin data_out = 8'h01; k_out = 1'b0; end 10'b011101_1011: begin data_out = 8'h01; k_out = 1'b0; end // ... 10'b110000_0101: begin data_out = 8'hBC; k_out = 1'b1; end 10'b001111_1010: begin data_out = 8'hBC; k_out = 1'b1; end default: begin data_out = 8'h00; k_out = 1'b0; err_out = 1'b1; // 无效码字 end endcase end注意default分支里的err_out信号,这是解码器里非常有价值的附属产出。通过统计err_out的脉冲个数,你能直接判断链路质量。如果误码率偏高,err_out会持续有脉冲;如果只是偶尔一个,可能只是噪声干扰。在调试系统时,用这个信号配合逻辑分析仪(比如ILA)观察,远比盯着波形数bit高效。
解码器的RD校验逻辑和编码器类似,也必须两级更新。这里有一个常见误区:只校验码字是否在合法编码表内,而忽略了RD极性。有些非法场景下,码字本身在表里存在,但极性顺序不对(比如连续出现两个RD+极性的码字),这属于非法码字序列,单纯查表是发现不了的。工业级实现通常会同时检查“码字合法”和“RD跳变合法”两个条件。
4.3 对齐后数据的字节序与位宽处理
真实工程里很少做8bit的编解码接口,因为串行链路的吞吐量太大了。比如千兆以太网是1.25Gbps的线速率,如果数据通路只有8bit宽,那需要250MHz的主频才能吞吐,FPGA上时序压力很大。常用的做法是内部数据通路做32bit甚至64bit宽,一个周期处理4个或8个字节。
位宽扩展后,对齐逻辑也要跟着变。如果你一次收到32bit的数据,comma可能出现在其中任意一个字节位置,所以搜索逻辑要同时检查4个位置,并且根据找到的位置决定输出数据怎么“挪”到对齐后的格式上。
看起来复杂,但原理就是一个桶形移位器配合mux选通。Xilinx和Altera的收发器IP核在RX端都自带这个功能,用FPGA开发时大部分情况可以直接用IP,不用自己写,但你要明白原理,否则调IP参数时面对一堆选项会很懵。
5. 仿真验证与上板调试:从“看起来对”到“跑得稳”
5.1 Testbench怎么写才最有效
8b10b模块的testbench,最理想的方式是回环测试:发送端先把数据编码,再把编码结果直接送给解码器,然后比对解码后的数据和原始数据是否一致。这个回环测试能在仿真阶段把编解码器的大部分逻辑错误暴露出来。
回环测试里要特别注意几个测试向量的选定:
- 遍历所有256个D码,每个D码分别在RD-和RD+两种状态下编码,确保查表全覆盖
- 遍历常用K码加随机数据混合,验证K码使能通道
- 构造连续的平衡码输入(比如D21.5),测试RD不翻转时模块是否稳定
- 构造连续的不平衡码输入(比如D0.0),测试RD翻转是否按预期交替
// 遍历所有D码的测试片段 integer i; initial begin for (i = 0; i < 256; i = i + 1) begin data_in = i[7:0]; k_in = 1'b0; #10; // 检查解码输出是否等于data_in if (dec_data_out !== i[7:0]) begin $display("ERROR at data=%02x", i); end end // 测试结束 $finish; endVivado自带的XSim或者ModelSim/QuestaSim都支持跑这个脚本。重点是波形覆盖率测试,一定要确保遍历完所有RD状态,否则某些输入在特定RD极性下的映射是查不到错误路径的。
5.2 上板调试实战:常见问题清单
调试经验告诉我们,这类编解码模块上板后80%的问题不是出在逻辑功能上,而是出在接口时序和复位顺序上。下面这几个故障,我在项目里至少各踩过一次:
| 故障现象 | 根本原因 | 解决方案 |
|---|---|---|
| 接收端始终无法对齐 | 发送端K码没发出来,或者comma被其他逻辑修改 | 先用ILA观测发送端发出的原始bit流,确认K28.5序列正确 |
| 能对齐但数据全是错的 | 解码器表和编码器表的bit顺序不一致 | 检查MSB/LSB定义,输出10bit是否高低位搞反 |
| 偶发误码,跑一段时间才出错 | 复位后两端RD初始状态不一致 | 统一复位策略,确认收发的RD初始化完全相同 |
| 速率上去以后时序不过 | 编码表用组合逻辑堆太大,路径延迟超标 | 拆分两级编码,中间加流水寄存器 |
第二个故障特别有意思。之前做一个自定义高速链路,编码器输出的码字是按照协议文档的bit序(高4位在前,低6位在后),但解码器表里我是按MATLAB脚本自动生成的,脚本里没注意位序,把高4位和低6位写反了。仿真回环时因为回环路径同一个模块,编码端和解码端的查表都“一致地”错了,所以仿真全通过——上板后收发两端一对接,全部乱码。后来我在testbench里故意加入了一个“反序注入”的测试用例,才把这个坑堵上。
还有一个调试技巧:如果链路能长时间跑通但偶尔误码,建议在发送端周期性插入K28.5,这样接收端可以定期做对齐复核。一旦数据错位,K28.5能让对齐逻辑在几个字节内快速恢复。这也是PCIE里SKP字符存在的部分原因。
5.3 结合IGT收发器(GTP/GTX)的联调注意事项
如果你是用FPGA的GTP/GTX高速收发器来做物理层,8b10b编解码一般可以直接使用收发器内部的编码器(用配置选项开启)。这样做的性能当然比自己写的Verilog模块要好,毕竟是硬核实现。但有时候你还是需要自己写编解码,比如:使用非标准的编码变种、需要对编码过程做插桩观测级调试、或者收发器的编码器不支持你需要的K码组合。
自写编解码模块接入收发器时,最需要注意的是时钟域和位宽的匹配。收发器的内部接口(FPGA侧接口)通常已经是并行数据加字节时钟,你拿到的是已经对齐好的并行数据,编码模块工作在这个字节时钟域。所以你的编码器主时钟直接取字节时钟即可,但要对做CDC(跨时钟域)的地方保持警觉。收发器复位时序和编码器复位时序如果要联动,最好用同一次复位。
6. 项目复盘:我踩过的坑和给新手的建议
做这个8b10b项目前后历时不到两周,但收获比预想中大很多。复盘下来有几个点值得分享:
第一,文档协议一定要自己推导一遍。8b10b的编码表网上到处都是,但如果你直接抄表然后一头扎进代码,你会发现自己根本不理解为什么某些码字有等价替代项,为什么某些码字要强制跳过。给自己半天时间,把编码表的生成逻辑理一遍,比多改十次代码都强。我最后用的是从标准里抄的编码表,但每一步都有手写推导的备查笔记。
第二,仿真用例要“故意做错”。一个高效的testbench不仅要测正确路径,还要故意注入非法码字、反序码字、错误的RD极性,看看异常路径有没有被检测到。你可以在解码器里加一个错误注入端口,用testbench把某个码字强制翻转几bit,然后检查err_out响应。这个特性在联调时特别有用,能帮你快速给出“链路误码来自物理层还是协议层”的判断依据。
第三,做高速串行项目,第一版就该考虑FPGA收发器的IP使用方式。自写8b10b作为学习完全值得,但如果商用项目时间紧,直接用收发器内建的8b10b编码器,把精力放在上层的链路调试上,会更高效。不是说自写没用,而是要有意识地区分“学习目的”和“工程交付”两种模式。
第四,代码要有记录波形导出的习惯。如果你想分析一个跑了很久的偶发误码,不要只靠仿真。在FPGA里把收发器的数据、RD状态、err信号接到ILA上,触发条件设为err==1,等误码发生的那一瞬抓下波形,基本一眼就能看出问题。没有抓住现场波形之前,不要轻易猜原因——串行链路的问题经常和你最初怀疑的地方完全无关。
最后再分享一个小技巧:如果你在做多通道的编解码,把各个通道的RD状态做成可以软件读取的寄存器,上板调试时通过串口或以太网远程观测每个通道的RD跳变频率。如果某个通道的RD跳变明显异常,那这个通道的物理链路大概率有质量问题。这个方法看着土,但在我做8通道JESD204B的时候,帮我在十分钟内定位到了一个PCB布线引起的通道间串扰问题。
本文还有配套的精品资源,点击获取