拓冰建站拓冰建站
首页 / 资讯中心 / 正文

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) 根据接收到的数据重新计算一次CRC3) 将重新计算的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是很好的选择安装ScalaMetals插件可以获得语法高亮、代码提示和跳转支持。当然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.scalapackage 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.scalapackage 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:接收CRC3:校验 // 连接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.scalapackage 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进行数字系统设计的绝佳练手项目。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门