Chisel实战:FPGA上实现带CRC校验的UART通信模块
这类项目最值得先看的不是功能列表,而是它到底解决了硬件开发中一个什么具体问题。一个带CRC校验的UART通讯模块,核心价值在于把数据可靠性和串口通信这两个基础但关键的能力,在FPGA上用Chisel高效地实现出来。它适合两类人:一是正在学习Chisel,想找一个比计数器、状态机更“有料”的实战项目练手的人;二是实际做嵌入式或FPGA开发,需要为串口数据增加校验,但又不想在Verilog里反复造轮子的人。
我建议先从最小样例开始,把单次发送接收的流程跑通,再考虑批量数据、错误重试和性能优化。下面按实际落地顺序拆一遍。
1. 先搞清楚“带CRC的UART”到底要做什么
很多人一看到“CRC”、“UART”就觉得是简单拼接,但实际做的时候才发现,时序、数据流和错误处理才是最容易出问题的地方。
1.1 核心任务拆解:不止是加个校验位
这个项目的目标,是在标准的UART串行通信基础上,为每一帧数据附加一个循环冗余校验码。听起来简单,但落地时需要明确几个边界:
- 发送端:不是把数据和CRC码简单拼接后发送。通常流程是:1) 准备有效数据;2) 根据数据计算CRC值;3) 将数据和CRC值按照约定的帧格式(例如:起始位+数据位+CRC位+停止位)组装;4) 通过UART TX引脚按位串行发出。
- 接收端:更复杂一些。它需要:1) 正确接收串行比特流,恢复出数据和CRC码;2) 根据接收到的数据重新计算一次CRC;3) 将重新计算的CRC与接收到的CRC进行比较;4) 根据比较结果,产生一个明确的“校验通过”或“校验失败”信号,供上层逻辑使用。
- 关键设计选择:
- CRC参数:使用哪种生成多项式?CRC宽度是8位、16位还是32位?初始值是什么?输入输出是否反转?这些必须和通信对端严格一致。常见的比如CRC-8、CRC-16-CCITT。
- 数据与CRC的拼接顺序:CRC是跟在数据字节后面,还是穿插在数据中?这决定了接收端如何解析。
- 错误处理:校验失败后,是丢弃该帧,还是需要重发?重发机制是在本模块内实现,还是由上层控制?这些要在设计初期定好。
1.2 为什么用Chisel来做这件事?
如果只用Verilog/SystemVerilog,你需要分别编写UART收发器、CRC计算模块(可能是查表法或线性反馈移位寄存器LFSR实现),再用一个顶层模块把它们连起来,并处理好状态机。调试时,信号多了看不过来。
用Chisel的优势在于:
- 抽象与复用:你可以用Scala函数定义一个
CRCModule,参数化生成多项式、位宽。再定义一个UARTModule,参数化波特率、数据位。最后,用几行清晰的代码将它们实例化并连接,比Verilog连线更直观,不易出错。 - 可测试性:Chisel可以很方便地生成测试激励(通过Scala的测试框架如scalatest),进行模块级和系统级的仿真,验证CRC校验是否正确捕获错误。
- 代码更简洁:状态机、计数器可以用更高级的抽象(如
when、switch)来描述,减少胶水代码。
2. 环境准备与项目骨架搭建
在动手写代码前,先把环境理顺。不要一上来就写核心逻辑,路径和依赖问题能卡住半天。
2.1 基础开发环境清单
你需要准备以下环境,版本尽量选稳定的,避免最新版可能存在的兼容性问题:
- Java环境:Chisel运行在JVM上。安装JDK 8或JDK 11(长期支持版本)。用
java -version确认。 - Scala构建工具:推荐使用sbt。这是Chisel项目最常用的构建工具,它能帮你管理依赖、编译和运行测试。安装后,用
sbt sbtVersion确认。 - 代码编辑器:VSCode是很好的选择,安装Scala(Metals)插件可以获得语法高亮、代码提示和跳转支持。当然,IntelliJ IDEA with Scala插件也是专业之选。
- 仿真工具:虽然Chisel可以生成Verilog,但为了快速验证逻辑,我们通常先在Chisel层面做仿真。Chisel自带的
testers2库(如PeekPokeTester)就够用了。最终生成Verilog后,你可以用Modelsim、Vivado Simulator或开源的Verilator、Icarus Verilog进行更底层的仿真。
2.2 创建Chisel项目结构
不要手动创建文件。使用Chisel官方模板是最高效的方式。在终端中执行:
sbt new chipsalliance/chisel-template.g8然后按照提示输入项目名称,例如uart-crc。这个命令会生成一个标准的项目结构:
uart-crc/ ├── build.sbt # 项目构建定义,在这里添加Chisel库依赖 ├── src/ │ ├── main/ │ │ └── scala/ │ │ └── yourpackage/ │ │ └── UartCrc.scala # 你的主设计文件 │ └── test/ │ └── scala/ │ └── yourpackage/ │ └── UartCrcSpec.scala # 你的测试文件 └── project/ └── build.properties # sbt版本定义接下来,打开build.sbt文件,确保包含了Chisel的依赖。模板通常已经配好,类似这样:
scalaVersion := "2.13.10" // 使用模板指定的稳定版本 libraryDependencies += "org.chipsalliance" %% "chisel" % "6.0.0" // 确认版本 libraryDependencies += "edu.berkeley.cs" %% "chiseltest" % "6.0.0" % "test"注意:Chisel版本更新较快,请以 Chisel官网 或模板生成时的最新稳定版为准。如果遇到编译问题,首先检查版本兼容性。
3. 核心模块设计与实现
我们采用自底向上的方式,先实现CRC计算模块,再实现UART收发模块,最后将它们集成。
3.1 CRC计算模块:参数化设计
CRC计算本质是模2除法,在硬件里常用LFSR实现。我们要设计一个可重用的模块。
在src/main/scala/yourpackage/下创建CRC.scala:
package yourpackage import chisel3._ import chisel3.util._ /** * 参数化的CRC计算模块 * @param width CRC位宽,如8, 16, 32 * @param polynomial 生成多项式,以整数形式表示,例如0x07代表CRC-8 * @param initValue 初始值 * @param reflectIn 输入数据是否按位反转 * @param reflectOut 输出CRC值是否按位反转 * @param finalXor 计算完成后,是否与一个值进行异或 */ class CRC(val width: Int, val polynomial: BigInt, val initValue: BigInt = 0, val reflectIn: Boolean = false, val reflectOut: Boolean = false, val finalXor: BigInt = 0) extends Module { val io = IO(new Bundle { val in = Input(Valid(UInt(8.W))) // 每次输入一个字节 val crc = Output(UInt(width.W)) // 当前的CRC值 val reset = Input(Bool()) // 同步复位,将CRC寄存器重置为initValue }) // CRC状态寄存器 val crcReg = RegInit(initValue.U(width.W)) // 处理输入字节 when(io.reset) { crcReg := initValue.U }.elsewhen(io.in.valid) { var data = io.in.bits if (reflectIn) { data = Reverse(data) // Chisel的Reverse函数实现位反转 } // 简易LFSR更新逻辑(以8位输入,16位CRC为例,需根据多项式扩展) // 此处为示意,实际需要根据多项式实现逐位或字节的移位-异或逻辑 // 一个更通用的实现是使用查找表或预计算的逻辑,为简化,这里展示概念 val shiftReg = Cat(crcReg(width-9, 0), data) // 假设宽度>8,将新数据移入 // 这里应是根据polynomial进行异或操作,实际代码会更复杂 // 例如:crcReg := shiftReg ^ (polynomial.U << (width-8)) // 真实项目建议使用成熟的Chisel库或已验证的RTL代码 } // 输出处理 val crcOut = crcReg val reflectedOut = if (reflectOut) Reverse(crcOut) else crcOut io.crc := reflectedOut ^ finalXor.U }关键点:上面的代码是概念框架。实际CRC LFSR的实现需要仔细根据多项式推导异或逻辑。对于生产环境,强烈建议使用经过验证的代码或开源IP。这里重点是展示Chisel如何通过参数(
width,polynomial)来生成不同的硬件。
3.2 UART收发模块:状态机是核心
UART的核心是波特率生成和移位寄存器。创建UART.scala:
package yourpackage import chisel3._ import chisel3.util._ class UARTParams(val baudRate: Int, val clockFreq: Int, val dataBits: Int = 8) { // 计算波特率分频系数 val divisor = (clockFreq / baudRate).toInt require(divisor > 0, "波特率过高,分频系数非正") } class UARTTx(params: UARTParams) extends Module { val io = IO(new Bundle { val txd = Output(Bool()) // 串行输出 val in = Flipped(Decoupled(UInt(params.dataBits.W))) // 待发送数据 val busy = Output(Bool()) // 发送忙标志 }) val shiftReg = RegInit((1 << (params.dataBits + 1)) - 1) // 全1,包括停止位 val cntReg = RegInit(0.U(log2Ceil(params.divisor).W)) val bitsSent = RegInit(0.U(log2Ceil(params.dataBits + 2).W)) // 数据位+起始位+停止位 val idle :: sending :: Nil = Enum(2) val stateReg = RegInit(idle) io.in.ready := stateReg === idle io.txd := shiftReg(0) // 输出最低位 io.busy := stateReg =/= idle switch(stateReg) { is(idle) { when(io.in.valid) { // 组装帧:起始位(0) + 数据位 + 停止位(1) shiftReg := Cat(1.U(1.W), io.in.bits, 0.U(1.W)) cntReg := 0.U bitsSent := 0.U stateReg := sending } } is(sending) { when(cntReg === (params.divisor - 1).U) { cntReg := 0.U shiftReg := Cat(1.B, shiftReg >> 1) // 右移,高位补1(停止位) bitsSent := bitsSent + 1.U when(bitsSent === (params.dataBits + 1).U) { // 所有位已发送 stateReg := idle } }.otherwise { cntReg := cntReg + 1.U } } } } class UARTRx(params: UARTParams) extends Module { val io = IO(new Bundle { val rxd = Input(Bool()) // 串行输入 val out = Decoupled(UInt(params.dataBits.W)) // 接收到的数据 val error = Output(Bool()) // 可扩展:帧错误等 }) // 类似Tx,需要状态机进行起始位检测、数据采样和移位 // 包含波特率采样计数器、移位寄存器、位计数器 // 篇幅所限,此处省略具体实现,结构与Tx类似但逻辑相反 // 重点:需要在波特率中点采样,以提高抗干扰能力 }3.3 顶层集成:连接CRC与UART
这是最关键的一步,在顶层模块UartCrc.scala中实例化并连接所有子模块。
package yourpackage import chisel3._ import chisel3.util._ class UartCrc(params: UARTParams, crcParams: CRCParams) extends Module { val io = IO(new Bundle { // 外部UART信号 val txd = Output(Bool()) val rxd = Input(Bool()) // 用户侧接口 - 发送 val sendData = Flipped(Decoupled(UInt(8.W))) // 要发送的原始数据 val sendBusy = Output(Bool()) // 用户侧接口 - 接收 val recvData = Decoupled(UInt(8.W)) // 接收到的有效数据(已校验) val recvError = Output(Bool()) // 接收帧CRC错误标志 val recvCrcValid = Output(Bool()) // 接收帧CRC校验通过标志 }) // 实例化模块 val crcGen = Module(new CRC(crcParams.width, crcParams.polynomial, crcParams.initValue)) val uartTx = Module(new UARTTx(params)) val uartRx = Module(new UARTRx(params)) // 发送路径:数据 -> CRC计算 -> 与CRC拼接 -> UART发送 // 需要控制状态:发送数据阶段、发送CRC阶段 val txDataReg = Reg(UInt(8.W)) val txCrcVal = Reg(UInt(crcParams.width.W)) val txState = RegInit(0.U(2.W)) // 0:空闲,1:发数据,2:发CRC // 连接CRC模块输入 crcGen.io.in.valid := io.sendData.valid && txState === 0.U crcGen.io.in.bits := io.sendData.bits crcGen.io.reset := reset.asBool // 或每帧开始时复位 // 状态机控制发送流程 when(txState === 0.U && io.sendData.valid) { txDataReg := io.sendData.bits txState := 1.U // 启动UART发送数据字节 uartTx.io.in.bits := io.sendData.bits uartTx.io.in.valid := true.B }.elsewhen(txState === 1.U && !uartTx.io.busy) { // 数据字节发送完毕,获取CRC并准备发送 txCrcVal := crcGen.io.crc txState := 2.U // 这里需要根据CRC宽度拆分发送(例如16位CRC分两次发送) uartTx.io.in.bits := txCrcVal(7,0) // 发送CRC低字节 uartTx.io.in.valid := true.B }.elsewhen(txState === 2.U && !uartTx.io.busy) { // CRC发送完毕,回到空闲 txState := 0.U // 可以在这里复位CRC模块,为下一帧准备 crcGen.io.reset := true.B }.otherwise { uartTx.io.in.valid := false.B } io.sendData.ready := txState === 0.U io.sendBusy := txState =/= 0.U || uartTx.io.busy io.txd := uartTx.io.txd // 接收路径:UART接收 -> 解析数据和CRC -> 重新计算CRC并比较 // 需要状态机:接收数据阶段、接收CRC阶段、校验阶段 val rxDataReg = Reg(UInt(8.W)) val rxCrcReg = Reg(UInt(crcParams.width.W)) val rxCalcCrc = Wire(UInt(crcParams.width.W)) val rxState = RegInit(0.U(2.W)) // 0:等待数据,1:接收数据,2:接收CRC,3:校验 // 连接UART接收端到状态机 // 当uartRx.io.out.valid有效时,根据rxState存入rxDataReg或rxCrcReg // 同时,接收到的数据字节也需要输入到另一个CRC计算实例(用于校验) val crcCheck = Module(new CRC(crcParams.width, crcParams.polynomial, crcParams.initValue)) crcCheck.io.reset := reset.asBool // 或每帧开始复位 when(uartRx.io.out.valid) { switch(rxState) { is(0.U) { // 假设有帧起始标识,这里简化处理 rxState := 1.U rxDataReg := uartRx.io.out.bits crcCheck.io.in.bits := uartRx.io.out.bits crcCheck.io.in.valid := true.B } is(1.U) { // 接收完所有数据字节后(可能需要计数器),切换到接收CRC状态 rxState := 2.U // 开始接收CRC字节 } is(2.U) { // 接收完CRC字节 rxCalcCrc := crcCheck.io.crc rxState := 3.U // 进入校验状态 } } } // 校验状态 when(rxState === 3.U) { io.recvCrcValid := (rxCalcCrc === rxCrcReg) io.recvError := !io.recvCrcValid when(io.recvCrcValid) { io.recvData.bits := rxDataReg // 输出有效数据 io.recvData.valid := true.B }.otherwise { io.recvData.valid := false.B // 可以选择丢弃数据或上报错误 } rxState := 0.U // 校验完毕,回到初始状态 crcCheck.io.reset := true.B }.otherwise { io.recvData.valid := false.B io.recvError := false.B io.recvCrcValid := false.B } // 连接外部RX线 uartRx.io.rxd := io.rxd }这个顶层模块勾勒出了数据流:发送时,数据经过CRC计算,然后将数据和CRC依次发出;接收时,先收数据并实时计算CRC,再接收对方发来的CRC,最后比较两者是否一致。
4. 仿真测试与问题排查
代码写完后,不要急于上板。用仿真把基本功能测通,能节省大量调试时间。
4.1 编写Chisel测试
在src/test/scala/yourpackage/下创建UartCrcSpec.scala:
package yourpackage import chisel3._ import chiseltest._ import org.scalatest.flatspec.AnyFlatSpec class UartCrcSpec extends AnyFlatSpec with ChiselScalatestTester { behavior of "UartCrc" it should "calculate CRC correctly for a single byte" in { test(new CRC(16, 0x1021, 0xFFFF)) { c => // CRC-16-CCITT c.io.reset.poke(true.B) c.clock.step() c.io.reset.poke(false.B) c.io.in.bits.poke(0x41.U) // ASCII 'A' c.io.in.valid.poke(true.B) c.clock.step() c.io.in.valid.poke(false.B) // 经过若干周期后,读取CRC值,与预期值比较(例如用软件计算好的) c.clock.step(5) // c.io.crc.expect(0xE8C1.U) // 示例值,需要根据算法确认 } } it should "transmit data with CRC and receive correctly" in { test(new UartCrc(new UARTParams(115200, 100000000), new CRCParams(16, 0x1021))) { dut => // 1. 发送测试 dut.io.sendData.bits.poke(0x55.U) // 测试数据 dut.io.sendData.valid.poke(true.B) dut.clock.step() // 等待发送忙信号变低 while (dut.io.sendBusy.peek().litToBoolean) { dut.clock.step() } // 此时可以检查txd引脚上的波形(通过打印或波形查看器) // 2. 接收测试(模拟对端发送) // 先将rxd置为空闲位(高电平) dut.io.rxd.poke(true.B) dut.clock.step(100) // 模拟发送一个起始位(低电平) dut.io.rxd.poke(false.B) dut.clock.step(868) // 根据波特率分频数等待1位时间 // 模拟发送数据位 0x55 (01010101b) 从最低位开始 // ... 此处省略具体的位驱动代码 // 模拟发送CRC字节 // ... // 最后检查接收端输出 dut.io.recvData.valid.expect(true.B) dut.io.recvData.bits.expect(0x55.U) dut.io.recvCrcValid.expect(true.B) dut.io.recvError.expect(false.B) } } }运行测试:在项目根目录下执行sbt test。如果测试通过,说明基本逻辑正确。
4.2 常见问题与排查顺序
仿真或上板遇到问题时,按这个顺序查:
没波形/没输出?
- 先查时钟和复位:确认顶层模块的时钟和复位信号是否正确连接并激活。在仿真开头强制复位几个周期。
- 再查输入激励:你的测试代码真的给
io.sendData.valid和io.sendData.bits赋值了吗?用poke之后有没有step? - 看内部状态:不要只看外部引脚。把关键状态机变量(如
txState、rxState)、计数器(cntReg)、寄存器(shiftReg)都加到波形里看。
CRC校验总是不通过?
- 核对CRC参数:这是最高频的错误。发送端和接收端的生成多项式、初始值、输入输出反转、最终异或值必须完全一致。用一个已知的软件CRC计算器(如在线工具)验证你的硬件CRC模块算得对不对。
- 检查数据顺序:你是先发数据字节,再发CRC字节吗?CRC字节的顺序是高字节在前还是低字节在前?接收端解析顺序对吗?
- 时序问题:CRC计算是在数据发送完成后才进行,还是在发送过程中并行计算?确保在发送CRC之前,用于计算CRC的数据是完整的、稳定的。
UART通信乱码或丢数据?
- 确认波特率:顶层模块
UARTParams里的clockFreq(时钟频率)你设置对了吗?是真实的FPGA时钟频率(如100MHz),不是仿真时钟频率。 - 检查分频系数:
divisor = clockFreq / baudRate计算是否正确?确保是整数,且足够大以保证精度。 - 仿真时序对齐:在仿真中模拟对端发送时,你等待的周期数(
step(868))必须精确对应一个位的时长。算错就会采样到错误的位置。 - 过采样:实际的UART接收模块为了抗干扰,通常会在一个位周期内采样多次(如16倍),取中间值。如果你的设计是单点采样,在噪声环境下容易出错。
- 确认波特率:顶层模块
资源占用过高或时序违例?
- 看综合报告:生成Verilog后,用FPGA工具(Vivado/Quartus)综合,看报告里的LUT、FF使用量。如果CRC模块用查找表实现,数据位宽太大会消耗大量资源。
- 优化CRC实现:对于高速或资源敏感场景,考虑使用流水线化的CRC计算,或者如果数据是字节流,可以使用预计算的字节查找表(虽然消耗一些存储,但速度快)。
- 检查关键路径:时序报告会指出从哪个寄存器到哪个寄存器延迟最大。通常是CRC组合逻辑链太长。可以考虑插入流水线寄存器。
5. 进阶优化与生产考量
把基本功能跑通只是第一步。如果要用于实际项目,还需要考虑更多。
5.1 功能增强
- 多字节数据帧:当前设计是单字节数据+CRC。实际中一帧可能包含多个数据字节。需要修改状态机,添加字节计数器,并控制CRC模块在接收完所有数据字节后才进行最终计算/比较。
- 可配置帧格式:通过参数支持不同的数据位长度(7/8/9)、停止位数量(1/1.5/2)、奇偶校验位。
- 硬件流控:增加RTS/CTS信号线,防止缓冲区溢出。
- 中断与DMA支持:为接收完成、发送完成、校验错误等事件产生中断信号。设计与DMA控制器的接口,实现大数据块的高效传输。
5.2 性能与可靠性
- 错误注入测试:在仿真中,故意翻转接收到的数据或CRC中的某些位,验证
io.recvError信号是否能正确拉高。 - 亚稳态处理:如果
rxd信号来自异步时钟域,必须使用同步器(两级或多级触发器)进行同步,避免亚稳态导致系统崩溃。 - 看门狗与超时:在接收状态机中增加超时计数器。如果长时间收不到停止位或下一帧起始位,应自动复位接收状态,恢复到空闲,避免“卡死”。
- 参数化与可移植性:将所有关键参数(波特率、时钟频率、CRC类型、帧格式)集中定义在
UartCrcParams这样的Case Class中,让模块实例化时一目了然,也便于在不同项目间复用。
5.3 从Chisel到FPGA比特流
- 生成Verilog:在Chisel中,你的模块继承自
Module。在测试通过后,可以编写一个简单的对象来生成Verilog代码:
运行它,会在object UartCrcDriver extends App { (new chisel3.stage.ChiselStage).emitVerilog( new UartCrc(new UARTParams(115200, 100000000), new CRCParams(16, 0x1021)), Array("--target-dir", "generated") ) }generated/目录下得到UartCrc.v文件。 - 集成到FPGA工程:在Vivado、Quartus等工具中新建工程,将生成的Verilog文件添加为设计源文件。
- 添加引脚约束:根据你的FPGA开发板,创建XDC或QSF文件,将
txd、rxd信号分配到正确的物理引脚上。 - 综合、实现、生成比特流:这个过程和纯Verilog开发完全一样。
- 上板调试:使用逻辑分析仪(如ILA)、串口调试助手,抓取实际信号,与仿真波形对比。
我个人更建议先把单字节数据+CRC的单次收发在仿真中彻底调通,波形看得明明白白,再逐步增加多字节、错误处理等复杂功能。这个项目麻雀虽小,五脏俱全,涵盖了状态机设计、模块集成、参数化、仿真验证和硬件实现的全流程,是掌握Chisel进行数字系统设计的绝佳练手项目。