第一次接触 Chisel 时,很多人会被它 Scala 语言的外壳吓到,觉得这又是一个“学术玩具”。但真正在 FPGA 项目里用 Chisel 实现一个基础 UART 模块后,你会发现它的价值远不止“用 Scala 写硬件”这么简单——它真正改变的是硬件设计的验证效率和代码可维护性。
就拿 UART 来说,用 Verilog 写一个基础收发器可能只要一两百行,但加上测试用例、参数化配置、异常处理,代码量会迅速膨胀。而 Chisel 的类型安全、高阶函数和面向对象特性,让一个可配置、可测试的 UART 模块能在保持代码简洁的同时,直接生成可综合的 Verilog。更重要的是,你可以在同一个语言环境下完成模块设计、测试激励生成和仿真验证,不用在 Verilog、SystemVerilog、Python 脚本和 Makefile 之间反复切换。
但 Chisel 的学习曲线也确实存在。如果你直接从一个复杂项目开始,很容易被 Scala 语法、Chisel 的隐式转换和函数式编程风格劝退。UART 之所以适合作为 Chisel 工程实战的第一个项目,是因为它的协议简单(起始位、数据位、校验位、停止位),时序清晰,但又包含了状态机、计数器、移位寄存器等基础硬件元素,能完整体现 Chisel 在“描述硬件行为”和“生成硬件结构”上的优势。
1. 先理解 Chisel 解决的不是语法问题,而是硬件设计流程问题
很多人第一次用 Chisel 时,会纠结“为什么要把简单的 always @(posedge clk) 写成 when(io.clockEdge) { ... }”。这种语法差异只是表面,Chisel 的核心价值在于把硬件设计从“线级连接”提升到“组件级抽象”。
1.1 传统 UART 设计的痛点:验证成本高且难以复用
用 Verilog 写一个最简单的 UART 发送模块,你可能会这样写:
这段代码看起来直接,但存在几个问题:
- 参数化困难:如果想支持不同的波特率、数据位宽、校验位,需要修改代码或使用 `ifdef。
- 测试验证麻烦:需要另外写 Verilog 或 SystemVerilog 的 testbench,仿真调试周期长。
- 代码重复:接收模块和发送模块的状态机、计数器逻辑类似,但很难直接复用。
在中小规模项目中,这些痛点可能还不明显。但当项目复杂度上升,需要支持多种配置、频繁迭代验证时,Verilog 的设计效率瓶颈就会暴露。
1.2 Chisel 的解决方案:用高级语言抽象硬件组件
Chisel 允许你像写软件一样定义硬件组件的行为和接口:
SCALA
2
val baudRate: Int = 115200,
4
val hasParity: Boolean = false,
5
val parityType: String = "even" // "even" or "odd"
7
require(dataBits >= 5 && dataBits <= 8, "Data bits must be between 5 and 8")
10
class UARTTx(params: UARTParams) extends Module {
11
val io = IO(new Bundle {
12
val txData = Input(UInt(params.dataBits.W))
13
val send = Input(Bool())
14
val tx = Output(Bool())
15
val busy = Output(Bool())
这种写法的优势在于:
- 类型安全:
UInt(params.dataBits.W) 确保数据宽度与参数一致,编译时就能发现错误。
- 参数化内置:通过
UARTParams 类封装所有配置,修改配置不需要触及核心逻辑。
- 接口明确:
IO(new Bundle { ... }) 定义了清晰的模块边界,便于集成测试。
更重要的是,Chisel 的测试框架允许你在 Scala 环境下直接编写测试用例:
SCALA
1
test(new UARTTx(defaultParams)) { c =>
3
c.io.txData.poke(0x55.U)
6
c.io.busy.expect(true.B)
这种“设计-测试”一体化的流程,大幅减少了上下文切换成本。
1.3 什么时候适合用 Chisel:从学习到生产的判断标准
Chisel 不是万能的,在选择是否使用 Chisel 时,可以考虑以下几个维度:
| 场景 |
推荐程度 |
理由 |
| 学习硬件设计基础 |
★★★☆☆ |
Verilog/VHDL 更接近硬件底层,适合理解基础概念 |
| 中小规模 FPGA 项目 |
★★★★☆ |
Chisel 能提升代码质量和验证效率 |
| 需要参数化设计的 IP 核 |
★★★★★ |
Chisel 的面向对象特性非常适合 IP 核开发 |
| 算法密集型设计 |
★★★★★ |
Scala 的函数式编程适合描述复杂算法 |
| 与软件协同设计 |
★★★★★ |
同一语言环境下实现硬件/软件协同验证 |
对于 UART 这类基础外设,用 Chisel 实现的价值不仅在于模块本身,更在于为后续更复杂的设计建立了一套可扩展的框架。
2. UART 协议在 Chisel 中的实现思路:从状态机到可配置组件
UART 协议看似简单,但实现一个稳健的收发器需要考虑时钟域交叉、亚稳态处理、错误检测等细节。Chisel 提供了更抽象的方式来表达这些硬件概念。
2.1 波特率生成:用计数器替代分频器思维
在 Verilog 中,波特率生成通常用分频计数器实现:
VERILOG
1
reg [15:0] baud_counter;
2
always @(posedge clk) begin
3
if (baud_counter == CLKS_PER_BIT) begin
8
baud_counter <= baud_counter + 1;
Chisel 可以用更声明式的方式表达同样的逻辑:
SCALA
1
val baudCounter = RegInit(0.U(16.W))
2
val baudTick = baudCounter === (clockFreq / params.baudRate - 1).U
4
baudCounter := Mux(baudTick, 0.U, baudCounter + 1.U)
这种写法的优势是逻辑更清晰:baudTick 直接定义为计数器达到特定值的条件,而不是通过时序逻辑赋值。
2.2 发送状态机:用枚举类型替代魔数
Verilog 中常用参数定义状态:
VERILOG
1
localparam IDLE = 2'b00;
2
localparam START = 2'b01;
3
localparam DATA = 2'b10;
4
localparam STOP = 2'b11;
Chisel 提供了类型安全的状态定义:
SCALA
1
object TxState extends ChiselEnum {
2
val idle, start, data, parity, stop = Value
5
val stateReg = RegInit(TxState.idle)
这样定义的状态机在仿真和调试时更有可读性,编译器也能检查状态转移的完整性。
2.3 完整的 UART 发送模块实现
SCALA
1
class UARTTx(params: UARTParams) extends Module {
2
val io = IO(new Bundle {
3
val txData = Input(UInt(params.dataBits.W))
4
val send = Input(Bool())
5
val tx = Output(Bool())
6
val busy = Output(Bool())
10
val clockFreq = 100000000 // 100MHz
11
val baudDivisor = (clockFreq / params.baudRate).toInt
12
val counter = RegInit(0.U(32.W))
13
val baudTick = counter === (baudDivisor - 1).U
15
counter := Mux(baudTick, 0.U, counter + 1.U)
18
val stateReg = RegInit(TxState.idle)
19
val dataReg = RegInit(0.U(params.dataBits.W))
20
val bitCount = RegInit(0.U(3.W))
22
io.tx := true.B // 默认高电平(空闲状态)
23
io.busy := stateReg =/= TxState.idle
28
stateReg := TxState.start
35
io.tx := false.B // 起始位
37
stateReg := TxState.data
41
io.tx := dataReg(bitCount) // 从LSB开始发送
43
when(bitCount === (params.dataBits - 1).U) {
44
if (params.hasParity) {
45
stateReg := TxState.parity
47
stateReg := TxState.stop
51
bitCount := bitCount + 1.U
这个实现展示了 Chisel 的几个关键特性:
- 模式匹配:
switch/is 语句比 Verilog 的 case 语句更类型安全。
- 条件编译:
if (params.hasParity) 在生成硬件时会被静态求值,不会产生额外的硬件开销。
- 参数化位宽:
UInt(params.dataBits.W) 确保硬件位宽与参数一致。
2.4 接收模块的同步化处理
UART 接收的关键在于正确采样异步输入信号。Chisel 提供了 RegNext 和 ShiftRegister 等原语来简化同步链的实现:
SCALA
1
class UARTRx(params: UARTParams) extends Module {
2
val io = IO(new Bundle {
4
val data = Output(UInt(params.dataBits.W))
5
val valid = Output(Bool())
9
val syncReg = RegNext(RegNext(io.rx))
12
val prevRx = RegNext(syncReg)
13
val startEdge = !syncReg && prevRx // 检测起始位下降沿
16
val samplePoint = counter === (baudDivisor / 2 - 1).U
19
val samples = ShiftRegister(syncReg, 3) // 3个周期的采样窗口
20
val filteredBit = samples.asBools.count(_ === true.B) > 1
这种实现方式比传统的 Verilog 写法更易读,而且 Chisel 会在生成 Verilog 时优化掉不必要的中间信号。
3. Chisel UART 的测试策略:从单元测试到系统验证
Chisel 的测试能力是其最大优势之一。传统的 FPGA 验证流程中,仿真往往是最耗时的环节,而 Chisel 的测试框架可以大幅提升验证效率。
3.1 基于 poke/expect 的单元测试
对于 UART 发送模块,可以编写针对性的测试用例:
SCALA
1
class UARTTxTest extends AnyFlatSpec with ChiselScalatestTester {
2
"UARTTx" should "correctly send a byte" in {
3
test(new UARTTx(defaultParams)).withAnnotations(Seq(WriteVcdAnnotation)) { c =>
5
c.io.busy.expect(false.B)
6
c.io.tx.expect(true.B) // 空闲状态应为高电平
8
// 发送数据 0x55 (01010101b)
9
c.io.txData.poke(0x55.U)
10
c.io.send.poke(true.B)
12
c.io.send.poke(false.B)
15
c.io.tx.expect(false.B)
16
c.io.busy.expect(true.B)
19
while (!c.io.tx.peek().litToBoolean) {
20
c.clock.step(baudDivisor)
24
for (i <- 0 until 8) {
25
val expectedBit = (0x55 >> i) & 0x1
26
c.io.tx.expect(expectedBit.B)
27
c.clock.step(baudDivisor)
31
c.io.tx.expect(true.B)
32
c.clock.step(baudDivisor)
33
c.io.busy.expect(false.B)
这种测试的优点是:
- 执行速度快:在 JVM 上运行,比 Verilog 仿真快几个数量级。
- 调试方便:可以直接在 Scala IDE 中设置断点,查看信号值。
- 可自动化:可以集成到 CI/CD 流程中。
3.2 基于事务级的系统测试
对于完整的 UART 环回测试,可以构建更抽象的测试环境:
SCALA
1
class UARTLoopbackTest extends AnyFlatSpec with ChiselScalatestTester {
2
"UART loopback" should "correctly transmit and receive data" in {
3
test(new UARTLoopback(defaultParams)).withAnnotations(Seq(WriteVcdAnnotation)) { c =>
4
val testVectors = Seq(0x00, 0x55, 0xAA, 0xFF)
6
for (testByte <- testVectors) {
8
c.io.txData.poke(testByte.U)
11
c.io.send.poke(false.B)
15
while (!c.io.valid.peek().litToBoolean && timeout < 1000) {
21
c.io.valid.expect(true.B)
22
c.io.rxData.expect(testByte.U)
24
c.io.valid.expect(false.B)
3.3 基于随机测试的边界情况覆盖
ChiselTest 支持随机测试,可以自动发现边界情况:
SCALA
1
"UARTTx" should "handle random data correctly" in {
2
test(new UARTTx(defaultParams)).withAnnotations(Seq(WriteVcdAnnotation)) { c =>
3
val rng = new scala.util.Random(42) // 固定种子便于重现
5
for (_ <- 0 until 100) {
6
val randomData = rng.nextInt(256)
7
c.io.txData.poke(randomData.U)
10
c.io.send.poke(false.B)
13
while (c.io.busy.peek().litToBoolean) {
3.4 测试覆盖率收集和分析
虽然 Chisel 本身不直接提供代码覆盖率工具,但可以通过以下方式确保测试质量:
- 路径覆盖:确保状态机的所有状态和转移都被测试到。
- 边界值测试:测试数据位宽、波特率的边界情况。
- 错误注入:模拟线路噪声、波特率偏差等异常情况。
- 性能验证:验证最大可持续波特率。
4. 从 Chisel 到 FPGA 实战:工程化考虑和优化策略
生成 Verilog 只是第一步,将 Chisel 设计部署到实际 FPGA 中还需要考虑资源占用、时序约束、调试支持等工程问题。
4.1 生成优化的 Verilog 代码
Chisel 默认生成的 Verilog 代码比较直接,可能包含一些优化空间。可以通过以下方式改进:
SCALA
2
class OptimizedUARTTx(params: UARTParams) extends Module {
3
// 使用 Mem 而不是 Reg 存储大数据
4
val buffer = Mem(16, UInt(params.dataBits.W))
6
// 使用 Log2 计算地址位宽,避免资源浪费
7
val addrWidth = log2Ceil(16)
8
val writeAddr = RegInit(0.U(addrWidth.W))
11
val baudCounter = Counter(baudDivisor)
12
val baudTick = baudCounter.value === (baudDivisor - 1).U
生成 Verilog 时,可以添加优化选项:
BASH
1
sbt "runMain uart.UARTGenerator --target-dir generated --inline"
4.2 时序约束和时钟域处理
在 FPGA 项目中,需要为生成的 Verilog 添加适当的时序约束:
TCL
2
create_clock -period 10.000 -name clk [get_ports clk]
4
# 输入延迟约束(针对 UART RX 输入)
5
set_input_delay -clock clk -max 2.000 [get_ports rx]
8
set_false_path -from [get_ports rx] -to [get_clocks clk]
对于多时钟域设计,Chisel 提供了 withClock、withReset 等域安全操作:
SCALA
2
val oversampleClock = Wire(Clock())
3
withClock(oversampleClock) {
4
val oversampleCounter = RegInit(0.U(4.W))
4.3 调试和观测性设计
在实际部署中,需要添加调试接口来观测内部状态:
SCALA
1
class DebugUARTTx(params: UARTParams) extends Module {
2
val io = IO(new Bundle {
4
val txData = Input(UInt(params.dataBits.W))
5
val send = Input(Bool())
6
val tx = Output(Bool())
7
val busy = Output(Bool())
9
val debugState = Output(UInt(3.W))
10
val debugBitCount = Output(UInt(3.W))
11
val debugBaudTick = Output(Bool())
15
io.debugState := stateReg.asUInt
16
io.debugBitCount := bitCount
17
io.debugBaudTick := baudTick
这些调试信号可以通过 FPGA 的 JTAG 或嵌入式逻辑分析仪(如 Xilinx ILA)来观测。
4.4 资源使用估算和优化
对于 UART 这种简单外设,资源占用通常不是问题,但了解估算方法对复杂设计很重要:
| 资源类型 |
估计用量 |
优化策略 |
| LUT |
50-100 |
使用共享计数器,优化状态编码 |
| FF |
30-60 |
减少不必要的寄存器 |
| BRAM |
0 |
小容量 FIFO 用分布式 RAM 实现 |
可以通过 Chisel 的 firrtl 后端生成资源报告,或者在综合后查看 FPGA 工具的报告。
4.5 与现有 IP 核的集成
在实际项目中,UART 可能需要与已有的 Verilog IP 核集成。Chisel 支持黑盒(BlackBox)机制:
SCALA
1
class VerilogFIFO extends BlackBox with HasBlackBoxResource {
2
val io = IO(new Bundle {
3
val clk = Input(Clock())
4
val rst = Input(Bool())
5
val wr_en = Input(Bool())
6
val din = Input(UInt(8.W))
7
val rd_en = Input(Bool())
8
val dout = Output(UInt(8.W))
9
val full = Output(Bool())
10
val empty = Output(Bool())
14
addResource("/verilog/fifo.v")
17
class UARTWithFIFO(params: UARTParams) extends Module {
18
val fifo = Module(new VerilogFIFO)
19
val uart = Module(new UARTTx(params))
22
fifo.io.rd_en := uart.io.send && !fifo.io.empty
23
uart.io.txData := fifo.io.dout
这种机制使得 Chisel 可以逐步引入到现有项目中,而不是要求一次性重写所有代码。
5. Chisel UART 设计的进阶应用和扩展思路
掌握了基础 UART 实现后,可以进一步探索 Chisel 在更复杂场景下的应用。
5.1 支持高级特性的可配置 UART
一个生产级的 UART IP 核需要支持更多特性:
SCALA
1
class AdvancedUARTParams(
2
val baudRate: Int = 115200,
4
val hasParity: Boolean = false,
5
val parityType: String = "even",
6
val stopBits: Int = 1, // 支持 1, 1.5, 2 停止位
7
val flowControl: Boolean = false, // 支持硬件流控
8
val fifoDepth: Int = 16 // 内置 FIFO 深度
10
require(stopBits == 1 || stopBits == 2, "Stop bits must be 1 or 2")
13
class AdvancedUART(params: AdvancedUARTParams) extends Module {
14
val io = IO(new Bundle {
16
val tx = Output(Bool())
17
val rx = Input(Bool())
19
val rts = Output(Bool()) // 请求发送
20
val cts = Input(Bool()) // 清除发送
22
val txIrq = Output(Bool()) // 发送完成中断
23
val rxIrq = Output(Bool()) // 接收完成中断
26
// 使用 Chisel 的 Diplomatic 接口标准
27
val regSlave = TLIdentityNode()
28
val intNode = IntSourceNode(IntSourcePortSimple())
5.2 基于 Chisel 的验证组件开发
可以开发专门的验证组件来构建复杂的测试环境:
SCALA
1
class UARTSource(dut: UARTTx, clock: Clock) {
2
def send(data: Int): Unit = {
3
dut.io.txData.poke(data.U)
4
dut.io.send.poke(true.B)
6
dut.io.send.poke(false.B)
9
while (dut.io.busy.peek().litToBoolean) {
15
class UARTSink(dut: UARTRx, clock: Clock) {
16
def expect(data: Int, timeout: Int = 1000): Boolean = {
18
while (!dut.io.valid.peek().litToBoolean && cycles < timeout) {
23
if (cycles >= timeout) false
25
val received = dut.io.data.peek().litValue
5.3 性能分析和优化
对于高速 UART 应用,需要关注时序性能:
SCALA
1
class PerformanceUART(params: UARTParams) extends Module {
3
val txPipeline = Module(new Pipeline(UInt(params.dataBits.W), 2))
6
val baudDivisorReg = RegInit((clockFreq / params.baudRate).U(32.W))
7
val nextBaudTick = Wire(Bool())
13
baudCounter := baudCounter + 1.U
16
nextBaudTick := baudCounter === baudDivisorReg
17
baudTick := RegNext(nextBaudTick)
5.4 从 UART 到更复杂的通信协议
掌握了 UART 的 Chisel 实现后,可以扩展到更复杂的协议:
- SPI 控制器:状态机更简单,但需要处理主从模式和时钟相位。
- I2C 控制器:需要处理总线仲裁和时钟拉伸。
- 自定义串行协议:利用 Chisel 的灵活性快速实现项目特定协议。
这些协议在 Chisel 中的实现思路类似,都是通过状态机、计数器和移位寄存器的组合,但每个协议都有其独特的时序和错误处理要求。
通过 UART 这个相对简单但完整的项目,我们看到了 Chisel 在硬件设计现代化中的价值:它不仅仅是一种新的硬件描述语言,更是一种提升设计质量、验证效率和代码复用性的工程方法。对于想要在数字设计领域保持竞争力的工程师来说,掌握 Chisel 这类现代硬件构造语言正在从"加分项"变成"必备技能"。