☰
FPGA实战:CRC8并行校验四步实现与Verilog代码解析
2026/9/28 13:47:46 网站建设 项目流程

写 CRC8 并行校验这个题目,算是我给自己挖过最深的一个坑之一。早年在调试某条串行数据链路时,业务侧要求必须做完整包校验,数据速率又高,串行 LFSR 一个时钟只能吞一个 bit,根本跑不满带宽。那时候就意识到,想在 FPGA 里把 CRC8 做成高速并行版本,不是“优化”,而是绕不过去的硬需求。这篇文章我把整个思考过程理了一遍,核心就是 4 步:定参数、写串行参考模型、做并行展开、封装验证。Verilog 代码我都会贴出来,尽量让刚开始接触 FPGA 并行校验的同学能照着推下来,也顺便把我踩过的位序、复位、时序这些坑讲清楚。

CRC8 并行校验在 DDR、SPI、以太网、传感器数据链路里都特别常见,尤其是那种要求每个时钟周期处理一整个字节、不能接受逐个 bit 慢慢蹭的场景。如果你已经在做 FPGA 接口方向,或者正在准备校招面试,这篇文章会很有用。你不需要很高的数学功底,只要看懂状态转移,一步步来,就能写出能用的并行 CRC 模块。

1. 思路拆解:为什么必须上并行 CRC8

1.1 串行 CRC 的天花板

先把最基础的东西捋一遍。CRC 校验的本质是拿数据比特流去做一次模 2 多项式除法,最后得到的余数就是校验码。硬件实现上,最常见的结构是线性反馈移位寄存器,也就是 LFSR。这种结构的好处是非常节省逻辑,一个触发器链加几个异或门就完事,工作频率也可以做到很高。它的代价是:每来一个时钟,只能处理 1 个 bit。

假设你要对 8 个字节做一次 CRC8 校验,串行结构需要跑 8×8=64 个时钟周期。这在低速 UART 或者 I2C 场景中没什么问题,可一旦放到千兆以太网、PCIe、DDR 这类接口上,数据吞吐量是几十 Gbit/s 甚至更高,靠一个 bit 一个 bit 地迭代,时序和吞吐率都会非常难看。这也是为什么在实际工程中,CRC 模块几乎都是并行版本,至少是 8bit 并行、16bit 并行,甚至 32bit 并行。

我习惯用一个类比来解释串行和并行的差异:串行 CRC 就像单车道收费站,所有车必须排成一队慢慢过,车一多就堵;并行 CRC 等于一次性开 8 个甚至 32 个收费口,每个 bit 可以同时被处理。硬件上,串行展开成并行以后,面积会增加,但换来的是“一个时钟处理一个完整字节”的吞吐能力,这笔账在高速接口里算起来非常划算。

1.2 先把四项参数定死,后面才不会翻车

CRC 最容易让人抓狂的还不是并行化,而是“参数不一致”。CRC8 不是一个只有一种定义的算法,它是一族算法。同样叫 CRC8,不同协议可能用不同的生成多项式、不同的初值、不同的位序处理方式、不同的输出异或方式。哪怕只是其中一个参数不同,收发两端算出来的校验码都对不上。

这里列几种常见 CRC8 变体:

CRC 版本多项式 poly初值 init输入反射 refin输出反射 refout输出异或 xorout
CRC-8/ATM0x070x00falsefalse0x00
CRC-8/ROHC0x070xFFtruetrue0x00
CRC-8/MAXIM0x310x00truetrue0x00
CRC-8/SAE-J18500x1D0xFFfalsefalse0xFF

后面所有的推导和 Verilog 代码,我统一按最常用的 CRC-8/ATM 来写,也就是生成多项式 x^8 + x^2 + x + 1,poly 取 0x07,init 取 0x00,refin 为 false,refout 为 false,xorout 为 0x00。你在自己项目里用其他变体时,参数要对齐,否则仿真看着没问题,上板后和上位机对不上,那时候再排查就很被动。

1.3 并行化的思路到底是什么

并行 CRC 的核心思想并不玄妙:既然串行结构是根据当前 LFSR 状态和输入 bit 计算下一个状态,那我把 8 个 bit 的输入一次性注入,相当于让 LFSR 连续走 8 步,然后把这 8 步的状态转移表达式全部展开,变成一组纯组合逻辑。这样做之后,一个时钟周期就能完成 8 个 bit 的 CRC 迭代。

展开方式有几种。最直接的是“逐位展开”,也就是把这 8 步写成 8 级 function 调用或 8 段组合逻辑赋值,代码可读性好,也容易 debug。另一种更数学化的方式是矩阵法,把 LFSR 的状态转移写成矩阵形式,并行 N bit 就相当于求转移矩阵的 N 次幂,业界经典的 XAPP523 应用笔记就是这个思路。矩阵法适合写脚本自动生成任意位宽的并行 CRC,但人工去推矩阵比较烦。本文先用逐位展开的方式讲清楚,最后我会提一下怎么扩展成多字节并行。

2. 四步实现 CRC8 并行校验

2.1 第一步:从生成多项式写出串行参考模型

做并行 CRC 之前,我建议先写一个串行参考模型。它有两个作用:一是帮你验证对多项式、初值的理解是否正确;二是后面做并行展开时,可以拿串行结果和并行结果逐拍对拍,出问题能快速定位到是数学推导出错还是代码写错。

我们用的生成多项式是 x^8 + x^2 + x + 1,二进制展开是 1_0000_0111。其中 x^8 对应移出位,真正参与反馈的是低 8 位,也就是 0x07。

对于 MSB-first 的 LFSR,假设当前 CRC 寄存器值是crc[7:0],当前输入数据位是data_in[i],那么反馈位是:

feedback = data_in[i] ^ crc[7];

然后寄存器做一次移位,并按照多项式低 8 位中哪些位是 1 来决定异或反馈位。由于 0x07 的 bit1 和 bit2 为 1,所以状态转移是:

crc_next[0] = feedback; crc_next[1] = crc[0] ^ feedback; crc_next[2] = crc[1] ^ feedback; crc_next[3] = crc[2]; crc_next[4] = crc[3]; crc_next[5] = crc[4]; crc_next[6] = crc[5]; crc_next[7] = crc[6];

你可能会问:为什么反馈位是异或到 bit1 和 bit2,而不是 bit0?因为新移入的 feedback 本身就是放在 bit0 的,所以 bit0 不需要额外再异或。这个点很多初学者会写错,直接在{crc[6:0], feedback}基础上整体异或一个 0x07,那样 bit0 就被额外翻转了一次,结果肯定不对。

用 Verilog function 封装成单 bit 步进函数:

function automatic [7:0] crc8_bit_step; input [7:0] c; input d; reg feedback; begin feedback = d ^ c[7]; crc8_bit_step[0] = feedback; crc8_bit_step[1] = c[0] ^ feedback; crc8_bit_step[2] = c[1] ^ feedback; crc8_bit_step[3] = c[2]; crc8_bit_step[4] = c[3]; crc8_bit_step[5] = c[4]; crc8_bit_step[6] = c[5]; crc8_bit_step[7] = c[6]; end endfunction

这个函数就是串行模型的核心。后面做并行展开时,我会反复调用它。

2.2 第二步:按位展开,得到 8 位并行组合逻辑

串行模型搞清楚了,并行展开其实就是在组合逻辑里连续调用 8 次crc8_bit_step。每一次调用对应一个输入 bit 的处理。

CRC-8/ATM 的 refin 是 false,也就是 MSB-first,所以第一个被处理的是 data_in[7],依次到 data_in[0]。代码可以这么写:

wire [7:0] c1 = crc8_bit_step(crc_cur, data_in[7]); wire [7:0] c2 = crc8_bit_step(c1, data_in[6]); wire [7:0] c3 = crc8_bit_step(c2, data_in[5]); wire [7:0] c4 = crc8_bit_step(c3, data_in[4]); wire [7:0] c5 = crc8_bit_step(c4, data_in[3]); wire [7:0] c6 = crc8_bit_step(c5, data_in[2]); wire [7:0] c7 = crc8_bit_step(c6, data_in[1]); wire [7:0] c8 = crc8_bit_step(c7, data_in[0]);

这里crc_cur是当前寄存器的值。如果是第一次计算,且复位后寄存器是 0x00,那crc_cur就是 0x00。每一级的 wire 都代表 LFSR 走一步之后的状态。最终c8就是处理完 8 个 bit 之后的 CRC 值。

完整模块可以写成下面这样。这个模块支持连续多字节流式校验:每个时钟周期进来一个字节,en有效时把数据吸收进 CRC 寄存器,crc_out实时输出当前累计值。

module crc8_parallel #( parameter CRC_INIT = 8'h00 )( input wire clk, input wire rst_n, input wire [7:0] data_in, input wire en, output reg [7:0] crc_out ); function automatic [7:0] crc8_bit_step; input [7:0] c; input d; reg feedback; begin feedback = d ^ c[7]; crc8_bit_step[0] = feedback; crc8_bit_step[1] = c[0] ^ feedback; crc8_bit_step[2] = c[1] ^ feedback; crc8_bit_step[3] = c[2]; crc8_bit_step[4] = c[3]; crc8_bit_step[5] = c[4]; crc8_bit_step[6] = c[5]; crc8_bit_step[7] = c[6]; end endfunction reg [7:0] crc_cur; wire [7:0] c1 = crc8_bit_step(crc_cur, data_in[7]); wire [7:0] c2 = crc8_bit_step(c1, data_in[6]); wire [7:0] c3 = crc8_bit_step(c2, data_in[5]); wire [7:0] c4 = crc8_bit_step(c3, data_in[4]); wire [7:0] c5 = crc8_bit_step(c4, data_in[3]); wire [7:0] c6 = crc8_bit_step(c5, data_in[2]); wire [7:0] c7 = crc8_bit_step(c6, data_in[1]); wire [7:0] c8 = crc8_bit_step(c7, data_in[0]); wire [7:0] next_crc = c8; always @(posedge clk or negedge rst_n) begin if (!rst_n) crc_cur <= CRC_INIT; else if (en) crc_cur <= next_crc; end assign crc_out = crc_cur; endmodule

注意参数CRC_INIT。CRC-8/ATM 的 init 是 0x00,所以复位后寄存器是 0x00;如果你换到其他协议,比如 init 是 0xFF,那复位值就要改成 8'hFF,否则算出来的 CRC 是错的。

逻辑上,这个模块是“当前拍输入 data_in,下一拍 crc_out 才更新”。因为crc_cur是寄存器输出,c8组合出来后要到下一个时钟上升沿才被锁存。理解这个时序很重要,后面写 testbench 时会专门讲到。

2.3 第三步:写 Testbench,串并行逐拍对拍

代码写完之后,不要急着上板。先写一个 testbench,用串行参考模型做交叉验证。这一步骤能帮你过滤掉绝大多数粗心错误。

参考模型可以写成独立函数,用循环语句逐位迭代。为保持一致,这里也是 MSB-first:

function automatic [7:0] crc8_ref; input [7:0] d; reg [7:0] c; integer i; begin c = 8'h00; for (i = 7; i >= 0; i = i - 1) begin if (d[i] ^ c[7]) c = {c[6:0], 1'b1} ^ 8'h06; else c = {c[6:0], 1'b0}; end crc8_ref = c; end endfunction

这段代码里{c[6:0],1'b1} ^ 8'h06就是前面讲的串行一步:新移入的 feedback 放在 bit0,同时按多项式把 bit1 和 bit2 翻转。8'h06对应 bit1 和 bit2,不要写成8'h07。

然后写一个简单的 testbench:

module tb_crc8; reg clk; reg rst_n; reg en; reg [7:0] data_in; wire [7:0] crc_out; crc8_parallel dut ( .clk (clk), .rst_n (rst_n), .data_in (data_in), .en (en), .crc_out (crc_out) ); always #5 clk = ~clk; integer i; reg [7:0] expect; initial begin clk = 0; rst_n = 0; en = 0; data_in = 0; #20 rst_n = 1; #10; for (i = 0; i < 200; i = i + 1) begin data_in = $random; en = 1; #10; expect = crc8_ref(data_in); #10; if (crc_out !== expect) $error("Mismatch at %0d: got %h, expect %h", i, crc_out, expect); en = 0; #10; end $finish; end endmodule

这里有一个时序细节容易搞混。由于crc_cur是寄存器,crc_out是在 en 有效的下一个时钟沿之后才变成新值。所以在 testbench 里,en 拉高后要隔一个周期再去比较crc_out。上面的写法是:en=1,过一个 #10 时钟沿,再过一个 #10 等到寄存器稳定,之后比较。如果你在 en 拉高后的同一拍就去读crc_out,读到的是上一拍的值。

如果是对连续多字节流式校验,可以参考模型也要做持续累加,不能只算单字节。你可以把expect也写成寄存器,每个 en 有效的时钟沿都做一次crc8_ref的迭代,这样就能和模块输出逐拍对拍。

2.4 第四步:封装复用模块并处理时序收敛

验证通过后,就可以把模块封装进自己的代码库。封装时要考虑两件事:参数化和时序裕量。

参数化方面,至少把CRC_INIT做成参数。多项式如果也想做成参数,会复杂一些,因为不同多项式对应的反馈异或位不一样,function 里写死的位序就不适用了。我的做法是:如果项目里多项式不固定,就写一个 Python 脚本,根据多项式自动生成 Verilog 代码。这个方案比在 Verilog 里做一堆 generate 和 case 更省事,也更不容易出错。

时序方面,8bit 并行的组合逻辑链并不长,一级 function 大约是一到两个异或门,8 级串起来,在常规工艺和主流 FPGA 上跑到 200MHz 问题不大。但如果你继续扩展到 32bit、64bit 并行,组合逻辑链会拉得很长,时序收敛就可能出问题。这时候要在中间插入寄存器,把一次大并行拆成两拍或三拍流水线。例如 32bit 可以拆成 4 个 8bit 并行计算单元,每个单元一拍,中间寄存器缓存中间 CRC 状态,最终结果是延迟 4 拍,但工作频率可以拉得更高。

3. 实操中常见的坑与排查技巧

3.1 位序方向:MSB-first 和 LSB-first 到底怎么选

这是我见过出错率最高的问题。单看 CRC 计算函数,很多人会默认从 data_in[0] 开始处理,也就是 LSB-first。但 CRC-8/ATM 标准要求 refin=false,意思是 MSB-first,即数据流的第 1 个 bit 是字节的最高位 data_in[7]。如果搞反了,计算结果完全不同。

怎么确认自己有没有搞反?拿一个已知向量去试。例如数据 0xAB,用 CRC-8/ATM 的标准参数算出来的校验值是固定的。你可以用在线计算工具先算一个参考值,然后把自己的模块结果和它对比。如果模块里从 data_in[0] 开始展开,而在线工具选择的是 MSB-first,那结果必然对不上。

反过来,如果你的项目协议明确说 LSB-first(refin=true),那并行展开的顺序就要反过来,从 data_in[0] 开始,一路到 data_in[7]。代码本身很简单,只需要把 2.2 节里那 8 行 wire 的级联顺序反转就行。

3.2 初值、复位和最终异或值千万别漏

很多同学写完串行模型后,发现与在线工具差一个固定值,其实就是初值或者 xorout 的问题。CRC 寄存器的复位值不一定是 0x00,有些协议 init 是 0xFF;计算完成后,也可能需要把结果再异或一个值,比如 xorout=0xFF。这些细节都要在写代码之前就从协议文档里确认好。

我之前踩过一次坑:协议里写 CRC-8/SAE-J1850,多项式是 0x1D,init 是 0xFF,xorout 还是 0xFF。我只改了多项式和初值,忘了 xorout,结果每一帧都差一个固定值,排查了大半天。从那以后我就在模块里把 init、poly、xorout 全部放在注释最前面,一眼能看到。

3.3 连续数据流与单字节校验的时序差异

CRC 模块在上板后常见的问题是:单字节算对了,连续多字节却不对。这通常不是因为 CRC 逻辑本身错了,而是因为你没搞明白模块是“流水式累加”的。

模块内部crc_cur是持续保持的,en 有效时每个时钟都会用当前寄存器的值去吸收一个新字节。所以如果数据包是多字节,en 必须持续有效,而且每个字节都要在正确的采样沿进入模块。如果你把 en 理解成“单次触发”,只在第一个字节有效,后面字节没被吸收,那最终 CRC 肯定不对。

另外,很多协议会把 CRC 校验码追加在数据包末尾一起发送。接收端有两种常见处理方式:一是先对数据段算一遍 CRC,再把收到的 CRC 字节和本地算出来的值比较;二是把数据段和校验字节一起送入 CRC 模块,最后模块输出的值应该是固定常数,比如 0x00。第二种方式对时序要求更严格,校验字节也要经过同样的 en 控制,不能漏。

3.4 实战问题速查表

现象大概率原因排查方法
单字节结果和在线工具对不上位序方向/初值/多项式参数不一致核对 refin、init、poly、xorout
连续多字节结果错误en 控制没做好,CRC 没有持续累加抓仿真波形看 crc_cur 每个有效沿是否变化
组合逻辑时序不收敛并行位宽太大,级联链太长拆分流水线,中间插寄存器
仿真正确、上板错误复位时序、en 产生沿不对用复位信号拉长,检查 en 是否与数据对齐
输出与协议固定值相差固定数漏了 xorout 或 init 不对把 init 和 xorout 都列出来对照

4. 扩展:多字节并行、检错能力与通用生成思路

4.1 多字节并行展开怎么做才不吃面积

有些场景下,不仅希望一个时钟处理一个字节,最好一个时钟处理四个甚至八个字节。最直接的做法是例化多个单字节并行模块做级联:前一个模块的输出 CRC 作为后一个模块的输入初始值。这样做逻辑清晰,代码也不容易错。

但级联多次会增加数据路径上的组合逻辑延迟。比如 4 字节并行就是 4 个 8bit 展开串起来,相当于 32 级函数调用链。对于高性能设计,我会在字节之间插入寄存器,把一次 4 字节计算拆成 4 拍流水线。这样数据吞吐率不变,但每拍之间的组合逻辑长度回到单个字节的量级,时序收敛会宽松很多。

如果你要做得更灵活,可以通过脚本生成任意位宽的并行 CRC Verilog 代码。脚本按照 LFSR 状态转移矩阵展开一次次的迭代,最后输出很规整的assign c_next = ...表达式。这样不仅不易出错,还方便切换不同多项式。早期工程里我用 Python 干过这个活,生成的代码在综合后面积和时序都比手写更可控。

4.2 CRC8 的检错能力与漏检概率

CRC8 只输出 8 bit 校验码,所以理论上在完全随机的错误模型下,任意一种错误的漏检概率约为 1/256,也就是 0.39% 左右。看起来不高,但对于几十 bit 的小帧来说,这个概率已经够用;对于大帧或者误码率较高的信道,8 bit 校验码可能不够,需要考虑 CRC16 或更宽的校验。

CRC8 的另一个优势是对突发错误的检测能力比较强。当错误集中在连续若干个 bit 内,如果突发长度不超过校验宽度,CRC 是可以做到 100% 检出的。这也是 CRC 在通信链路中比单纯校验和高位宽累加更适合硬件实现的原因。在选择生成多项式时,不同协议已经替你做了不少优化,直接用协议规定的多项式最稳妥。

4.3 CRC8 与 Checksum、奇偶校验怎么选

方案检错能力硬件开销适用场景
奇偶校验只能查奇数个错误,能力弱极小,一个异或门低速、简单链路
Checksum 校验和对多位错误检测一般,容易碰撞加法器软件协议栈常用
CRC8检测突发错误能力强,漏检率 1/256 级别纯异或和寄存器,资源小短帧、硬件链路、传感器数据

FPGA 里做 CRC 非常合适,因为整个计算过程全是异或和移位操作,不用乘法器也不用查找表,组合逻辑和寄存器开销都不大。Checksum 虽然看起来简单,但在硬件里往往需要做进位链处理,反而不如 CRC 的异或链清爽。

4.4 这个展开思路还能用在哪些场景

并行展开的思想不止适用于 CRC。FPGA 里很多算法都有“串行迭代变并行组合”这一招:图像处理里的滑动窗口滤波,本质是把一个 3×3 窗口的多次计算展开成并行乘加;定点数运算里的位宽扩展和算术逻辑展开,也遵循同样的思路;甚至 CORDIC 这类通过多次迭代求 arctan、三角函数、开方的算法,也可以把多次迭代变成多级流水线或组合逻辑。

我个人的体会是,把一个串行迭代模型真正吃透,再把它并行化,这个路径比直接抄一个并行公式要扎实得多。你搞懂 CRC 的并行展开之后,再看滑动窗口、图像卷积、乘累加阵列这些逻辑,会感觉本质上是同一个套路:先把数学模型写对,再考虑用时间换空间还是空间换时间。

最后再分享一个小技巧:验证 CRC 模块时,别只跑随机数。一定要加上全 0、全 1、单 bit 翻转这类边角数据。全 0 最容易暴露初值问题,全 1 最容易暴露位序方向问题,单 bit 翻转能检查出反馈位置有没有写错。这几个向量跑通了,CRC 模块上板基本就稳了。这个习惯是我踩过无数次坑之后养成的,现在每次写完 CRC 模块都会先跑一遍这组“三板斧”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询