ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Verilog设计如何映射到FPGA硬件?从计数器到逻辑综合的工程实践指南

Verilog设计如何映射到FPGA硬件?从计数器到逻辑综合的工程实践指南 很多人拿到一块FPGA板子第一件事是点灯第二件事就是写计数器。计数器确实是Verilog入门最常规的项目但很多人在写了几十个计数器之后依然不清楚自己写出来的RTL代码在综合工具眼里到底是什么样子。这也就是Verilog设计和逻辑综合之间那道看不见的坎。你写的每一行always、每一个assign、每一次模块例化综合工具都会把它翻译成实际的LUT、触发器和布线资源。如果只停留在仿真通过的水平那项目永远只能在电脑里跑上不了板子。这篇内容我打算直接用实际代码来拆从最简单的计数器开始一路做到滑动窗口滤波、I2C读写EEPROM、QSPI读写Flash再到出租车计费系统把Verilog设计里最常见的编码风格、状态机写法、参数传递、模块例化和逻辑综合的对应关系讲透。顺便把Icarus Verilog、Yosys这类开源工具链的实际用法也过一遍。适合已经会写基础Verilog、但还没真正跑过综合的人也适合那些仿真能过、一上板就出问题的朋友对照排查。1. 从一段计数器的代码聊起Verilog设计的基本功1.1 模块、端口与always块为什么这么写先说一个最容易被忽视的问题模块端口到底该怎么声明。SystemVerilog里推荐用logic但很多老工程仍然用reg和wire。逻辑综合对这两者的处理其实没有本质区别最终映射出来的都是物理连线或者触发器输出端口。关键点是输出端口如果是时序逻辑驱动的声明成reg然后放在always块里赋值如果是组合逻辑声明成wire用assign连续赋值。这个规矩不是给别人看的是为了让综合工具能准确判断信号属性。module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] count ); always (posedge clk or negedge rst_n) begin if (!rst_n) count {WIDTH{1b0}}; else if (en) count count 1b1; end endmodule这段代码里有个隐藏知识点count count 1b1我为什么不用count count 1b1因为这里需要的是寄存器行为。是非阻塞赋值它保证在同一个时钟沿上所有右边的值先被采样然后统一更新左边的寄存器。这在多个变量同时更新时特别重要。综合工具看到非阻塞赋值就知道这里应该推断出一组D触发器而不是生成组合逻辑环路。很多新手的错误是把敏感列表写成always (count)还配了阻塞赋值结果综合出来的东西要么是锁存器要么是异步逻辑时序根本收敛不了。1.2 参数化设计为什么推荐用parameter而不是直接写死上面代码里我用了一个parameter WIDTH 8。很多初学者不理解为什么一个计数器非要做成参数化。直接reg [7:0] count不就行了等你的工程里同时需要4位、8位、16位计数器的时候就知道疼了。参数化设计意味着同一个模块可以在不同场景下被例化出不同位宽而不用复制粘贴三份代码。比如在出租车计费系统里里程计费部分需要16位累加器计时部分需要20位计数器如果都用同一个参数化模块例化时传参就能搞定。参数不只影响位宽还能影响综合面积。8位计数器综合出来大概是8个触发器加少量进位逻辑16位就是16个触发器加更长的进位链。参数化以后综合报告里能一眼看出每个实例的资源占用便于定位面积开销大的模块。另外参数传递在逻辑综合中有一个重要注意事项参数在综合时必须是常量。如果试图用parameter接收运行时的信号值综合工具会直接报错。这一点后面聊到模块例化时再细说。2. 逻辑综合的原理从RTL到门级的那些事2.1 可综合与不可综合的写法逻辑综合要做的事情本质上是把你写的RTL代码翻译成门级网表。但这个翻译不是随便翻它只认硬件能实现的东西。所以Verilog里很多语句天生就是不可综合的比如initial、#10延时、wait、fork/join。有些人在仿真里用initial给寄存器赋初值以为上板也能这么初始化实际上FPGA里寄存器的初值是靠配置比特流时写入的不是靠initial。如果你的代码里有个变量只在initial里赋值综合工具要么忽略它要么直接报错。还有一个高频踩坑点是for循环。Verilog里的for在可综合代码里不是用来在时间上循环的而是用来在空间上展开的。比如写一个移位寄存器用for循环生成8个寄存器赋值操作综合后展开成8个触发器。这种写法没问题。但如果有人试图在一个always块里用for循环做类似软件循环100万次的事情结果就是综合后的面积爆炸因为工具会把循环体完全展开这显然不是你想要的结果。再看组合逻辑。组合逻辑的黄金法则是所有输入信号变化后输出必须能在组合延迟内稳定。如果用always (*)块那么块里每个分支都要给所有输出赋值否则综合工具会推断出锁存器。下面这段经典反面教材always (*) begin if (sel) out a; // 没有 elseout 在 sel0 时保持原值 end这段代码综合出来不是多路选择器而是一个锁存器。锁存器在FPGA里会消耗专门的资源而且时序分析很麻烦。修复方法很简单要么补上else要么在块的开头给所有输出赋默认值。我个人的习惯是组合逻辑优先用assign只有复杂条件才用always (*)并且保证每个分支都赋值。2.2 时序约束与时钟域很多人都觉得时序约束是综合之后才要做的事其实写代码的时候就该想好。比如上面的计数器模块如果clk端口接入的是一个外部晶振那么综合时需要告诉工具时钟频率是多少工具才能判断触发器之间的路径延迟是否满足该频率的要求。Vivado里用create_clock约束Yosys里用sdc文件声明。在Verilog设计阶段最容易影响时序的写法就是深组合逻辑链。比如这样assign y a b c d e f g h;这一句话综合出来是一长串加法器的进位链。8个操作数相加组合逻辑延迟可能达到几十纳秒如果时钟频率很高这条路径必然时序违例。解决办法是插入流水寄存器把一次大加法拆成两步或者使用DSP硬核FPGA里的专用乘法累加单元。逻辑综合工具一般都有自动重定时功能但最好在编码时就想好组合逻辑别超过10级左右。时钟域交叉更是老生常谈。很多项目用两个时钟一个100MHz一个25MHz如果直接让信号从一个时钟域跑到另一个时钟域不用同步器综合后可能会出现亚稳态。写Verilog的时候就要针对这些跨时钟信号做两级触发器同步或者用异步FIFO。逻辑综合不会替你检查这种逻辑错误它只保证每个时钟域内部的时序收敛。3. 进阶实例滑动窗口滤波器用Verilog怎么做3.1 滑动窗口滤波的思路与寄存器组滑动窗口滤波在信号处理里很常见说通俗点就是取最近N个采样值求平均然后随着新数据到来窗口整体往前移动。这种方法对随机噪声有不错的抑制效果而且算法简单特别适合FPGA实现。和软件实现不同的是硬件里要真正的把N个历史数据都存在寄存器里而不是用数组下标访问内存。设计思路并不复杂把输入数据打拍形成一个移位寄存器链每次时钟沿来新数据进到最左边最右边的数据被挤出。然后对寄存器链里所有数据求和再做除法。关键问题是“求和”和“除法”在硬件里怎么高效实现。N如果取2的幂次比如4、8、16除法就能用右移代替综合时只消耗很少的逻辑。这里我以N8为例。打个拍这件事在Verilog里的实现就是一个寄存器数组通过always块实现。你可以在每次时钟沿把din赋给shift_reg[0]然后把shift_reg[i]赋给shift_reg[i1]。如果用手写的方式就是一堆非阻塞赋值。更优雅的做法是用generate循环位宽和深度都用参数控制方便复用。3.2 完整代码与仿真下面直接给出一个参数化的滑动窗口均值滤波器。窗口深度WINDOW_SIZE固定为8数据位宽是12位输出位宽为了不过度压缩精度给到20位因为8个12位数相加最大是15位右移3位后约12位但累加器中间值需要更多位宽。module sliding_window_filter #( parameter DATA_WIDTH 12, parameter WINDOW_SIZE 8 )( input wire clk, input wire rst_n, input wire valid_in, input wire [DATA_WIDTH-1:0] din, output reg [DATA_WIDTH-1:0] dout, output reg valid_out ); localparam SUM_WIDTH DATA_WIDTH $clog2(WINDOW_SIZE) 1; reg [DATA_WIDTH-1:0] shift_reg [0:WINDOW_SIZE-1]; reg [SUM_WIDTH-1:0] sum; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i WINDOW_SIZE; i i 1) shift_reg[i] {DATA_WIDTH{1b0}}; sum {SUM_WIDTH{1b0}}; dout {DATA_WIDTH{1b0}}; valid_out 1b0; end else if (valid_in) begin // 更新寄存器组新数据进最旧数据丢弃 shift_reg[0] din; for (i 1; i WINDOW_SIZE; i i 1) shift_reg[i] shift_reg[i-1]; // 重新计算累加和 sum 0; for (i 0; i WINDOW_SIZE; i i 1) sum sum shift_reg[i]; dout sum[DATA_WIDTH$clog2(WINDOW_SIZE)-1:$clog2(WINDOW_SIZE)]; valid_out 1b1; end end endmodule这里有个细节累加值我在右移之前其实已经把精度拓宽了。如果你只用sum 3会把低位截掉均值向零取整。这里我用了一个位切片取出需要的位宽。这种写法在综合时是纯组合逻辑加寄存器的组合不会生成除法器代价很低。仿真时用Icarus Verilog跑一下给一组带噪声的正弦波数据看看输出是不是平滑了很多。Icarus Verilog是完全免费的开源仿真工具支持大部分Verilog-2005特性对学习阶段够用了。具体的运行命令我后面会在工具链章节统一讲。4. 接口类实战I2C读写EEPROM、QSPI读写Flash的代码框架4.1 I2C主控制器状态机I2C协议应该是FPGA工程师接触最多的串行接口之一。如果你只想在仿真里看波形那很简单但如果要在FPGA上真正和EEPROM芯片通信就必须把SCL时钟的时序精确控制住包括起始条件、停止条件、应答位、数据位。这里面最容易出错的是状态机设计。设计一个I2C主控制器我习惯用“字节级状态机”而不是“位级状态机”。也就是说状态机只管理“发送起始→发送字节→接收应答→发送停止”这样的大步骤而每一个字节的8个bit再用一个内部计数器循环。好处是状态数少逻辑清晰不容易混乱。下面给出一个最基本的I2C写字节状态机框架。localparam IDLE 3d0; localparam START 3d1; localparam SEND_BYTE 3d2; localparam CHECK_ACK 3d3; localparam STOP 3d4; localparam WAIT 3d5; reg [2:0] state; reg [3:0] bit_cnt; reg scl_internal; reg sda_out_internal; reg sda_dir; // 1: output, 0: input在SEND_BYTE状态里每个时钟周期推进一个bit先发送最高位data[7]然后左移。当bit_cnt计到8时把SDA释放成高阻输入然后在CHECK_ACK状态检测SDA是否被从机拉低。这个设计的关键在于SCL高电平期间SDA必须稳定SCL低电平期间才能切换数据。很多第一次写的人会踩着这个坑在SCL高时改了SDAEEPROM直接把这一位读错。写EEPROM的完整流程是先发器件地址比如7位地址写位然后发寄存器地址最后发数据。而读EEPROM要先写地址再重启起始条件再发送器件地址读位然后读数据同时主机回非应答。这些流程最好用一个更上层的状态机来控制避免把字节级逻辑和业务逻辑混在一起。4.2 QSPI Flash读写的关键点QSPI接口比I2C复杂的地方在于它有4条数据线而且命令集因厂商而异但最常见的JEDEC标准命令大家都得认。比如页编程0x02、读数据0x03、快速读0x0B、写使能0x06、读状态寄存器0x05。如果你用的是QSPI模式大概率还会用到0x6B这样的四线快速读命令。QSPI控制器的核心也是一个状态机但比I2C多一个麻烦命令阶段、地址阶段、数据阶段的线宽不一样。命令通常用单线发送地址可以用单线也可以切四线数据在QSPI模式下是四线并行。所以状态机里必须有一个“当前阶段”信号决定IO[3:0]里哪些是输出、哪些是输入。推荐的做法是单独写一个spi_phase_ctrl模块决定每个cycle是命令、地址、还是数据。然后数据结构上用“移位寄存器位计数”的方式比较通用。Flash读写还有一个容易忽略的点状态轮询。页编程命令发完之后Flash内部需要时间把数据写进存储阵列这期间它会把状态寄存器的WIP位置1。所以代码里必须循环发读状态命令、检查WIP位直到它变成0才能进行下一次操作。这个“忙等”过程在硬件里其实就是一个轮询状态机千万别以为发完命令就完事了。下面给出一个读Flash ID的简单示例框架可以作为QSPI控制器的起点。module qspi_controller #( parameter CLK_DIV 10 )( input wire clk, input wire rst_n, output reg [3:0] io, output reg cs_n, input wire start, output reg busy, output reg [7:0] read_data ); reg [7:0] shift_reg; reg [3:0] bit_index; reg [2:0] state; localparam IDLE 3d0, CMD 3d1, ADDR 3d2, READ 3d3; ... endmodule说实话完整代码在文章里展开太长一般我会把核心的时序逻辑抽出来讲。这里重点就是命令、地址、数据三个阶段的时序要用统一的时钟节拍命令和地址在发送时用“最高位在前”的移位方式读数据时在SCLK的上升沿采样、下降沿换数据。这个基本功掌握了无论换什么Flash的指令集改起来都很快。5. 把项目做完整出租车计费系统的设计思路5.1 需求拆解“出租车计费系统”其实是很多高校数电实验课的综合题目网上经常看到有人问全套代码。但认真拆解一下它并不是一个大工程关键难点是需求复杂度和状态管理。假设一个简化版的需求起步价10元包含3公里之后每公里2.5元等待时间每5分钟加收1元白天和夜间费率可能有区别这里可先不做。系统输入通常有一个“行驶里程”脉冲一个“等待计时”脉冲还有一个“启停”信号。输出则是金额通过数码管或者串口显示。按照这个需求我需要拆成三个核心模块里程计数器、等待计时器、金额计算器。它们之间用简单的握手信号连接。里程计数器对脉冲计数每计满一个单位比如100米就产生一个mile_tick。等待计时器则用系统时钟分频产生秒信号每5分钟产生一个wait_tick。金额计算器接收这两个tick按照起步价里程价等待价的公式计算金额。关键在于金额计算不能用浮点数。系统里处理“2.5元”这样的小数最简单的方法是把金额单位换算成“角”。也就是说金额寄存器存的是角数显示时再除以10。用整数运算就完全避开了浮点综合的问题面积小、时序好。这个技巧在很多控制类项目里都适用本质上是定点数思想。5.2 模块划分与代码骨架顶层模块建议用实例化方式对接底层模块不要把所有逻辑堆在一个文件里。下面给出顶层例化的示意注意参数的传递方式。module taxi_meter_top #( parameter MILE_TICKS_PER_KM 10, // 每公里10个里程脉冲 parameter WAIT_TICKS_PER_MIN 60 // 每秒一个脉冲60秒为1分钟 )( input wire clk, input wire rst_n, input wire mile_pulse, input wire wait_pulse, input wire start_stop, output reg [15:0] fare_amount // 单位角 ); ... endmodule底层模块distance_counter负责对mile_pulse计数产生mile_ticktimer_counter类似。在顶层里用always块或状态机去综合费率判断。这里有个实际工程中容易出错的地方mile_pulse可能是外部按键产生的抖动信号。如果直接用这个信号做计数器时钟会产生毛刺导致多计或少计。正确做法是把外部信号先做一次同步和消抖然后再当时的“事件”或“脉冲”提取出来。一个典型消抖计数器是每隔10ms采样一次连续采样到相同电平才认为有效。金额计算部分建议也做成一个状态机先判断当前是否在运营状态起步阶段固定10元100角超过起步里程后每个mile_tick加25角每个wait_tick加10角。这部分的RTL也就是几个加法器加一个条件判断综合后资源占用很小。整体上这个系统练的就是“需求拆分”和“模块接口设计”。代码不是越短越好而是要让每个模块可以单独仿真、单独综合验证。如果所有逻辑塞在一个always里出了问题很难定位。通过参数化设计以后把计价规则改成“每公里3元”或“夜间加价”只需要改顶层参数和对费率寄存器的赋值底层模块完全不用动。6. 仿真与综合工具链Icarus Verilog、Yosys、Vivado的配合使用6.1 Icarus Verilog做仿真很多新人对工具链有误解以为必须装庞大的Vivado或Quartus才能学Verilog。其实用Icarus Verilog加GTKWave完全可以完成百分之八十的仿真验证工作。Icarus Verilog简称iverilog支持Verilog-2005和部分SystemVerilog命令行操作极其轻量。安装之后仿真流程就是两步编译和运行。我习惯把编译和运行写在一个小脚本里编译时加上-o指定输出文件然后vvp运行最后打开GTKWave看波形。一个典型命令是iverilog -o tb_sim tb_top.v filter.v # 编译测试文件和设计文件 vvp tb_sim # 运行仿真 gtkwave dump.vcd # 打开波形文件注意测试文件里要生成VCD波形文件。VCD是通用的波形交换格式GTKWave可靠读取。测试代码里用initial块来控制时钟翻转用#10延时产生激励这些都是仿真专用的语法跟综合没有关系。测试代码写得越规范验证的效率越高。我会在testbench里加$display打印关键信号也会加上简单的断言检查比如输出符合预期时打印一条信息不符合时打印错误并$finish。这样比肉眼盯着波形判断要可靠得多。6.2 Yosys做逻辑综合验证如果你手头没有大型FPGA综合工具或者只想快速看看代码能不能综合Yosys是一个很好的选择。Yosys是一个开源的逻辑综合工具对Verilog的支持相当不错可以把RTL综合到常见的FPGA网表格式也可以输出标准的门级网表用于后续仿真。在Ubuntu或Windows WSL上安装后直接运行yosys -p read_verilog filter.v; synth_generic; stat这个命令里read_verilog读入RTL文件synth_generic执行通用的综合流程stat打印统计信息比如触发器数量、逻辑单元数量。注意synth_generic并不是针对特定FPGA器件而是用通用的逻辑门库综合但它能帮你快速发现代码里的综合问题。比如你写了不可综合的语句这里会直接报错你写了容易产生锁存器的代码统计信息里会多出意外的锁存器数量。更实用的是Yosys配合下一阶段“等价性检查”。你可以先用Yosys把RTL综合成门级网表然后写一个简单的门级仿真对比RTL仿真和门级仿真的输出是否一致。这一步能在实际布局布线之前发现很多功能问题比如位宽截断、状态机编码冲突等。对于学习逻辑综合的人来说Yosys是性价比最高的工具。6.3 综合后的网表仿真所谓综合后的网表仿真就是把你认为“能综合”的代码综合成门级模型后再跑一遍仿真。在Vivado里综合完成后可以自动生成仿真模型但很多初学者从来不跑这一步。实际上这一步特别重要因为综合工具可能会对代码做优化重组比如去掉冗余逻辑、展开循环、推断出不同的状态机编码RTL仿真通过不等于网表仿真通过。一个典型的例子在很多RTL里组合逻辑输出有毛刺RTL仿真时采样的点刚好避开了毛刺所以看到的结果是干净的。但网表仿真会带上门级延迟毛刺被放大下游寄存器采样时可能采到毛刺功能就错了。这种问题用RTL仿真基本看不出来必须靠门级仿真。如果你用Yosys综合可以把输出网表用Icarus Verilog再跑一遍仿真就实现了“RTL→综合→网表仿真”的闭环。需要注意的是网表仿真需要额外的标准单元库模型Yosys的synth_generic使用的是内置的cells.lib可以直接调用非常方便。7. 常见问题与排查技巧实录7.1 仿真波形不对的几种典型原因我在帮人看代码的时候最多遇到的仿真问题就三类信号一直是X、计数器不翻转、状态机跳飞。这三种问题各有套路。信号一直是X基本可以肯定是复位问题。要么是testbench里没有给复位信号要么是设计里对某个寄存器没有复位寄存器初始值为X。X会在组合逻辑里传播导致一大片信号都显示为X。排查方法是先在波形里找到第一个出X的信号然后看它是不是被正确复位了。计数器不翻转先查时钟和复位是否真正到达了计数器模块。很多人例化时把端口顺序搞反了明明连的是clk实际上接到了rst_n。这种错很隐蔽。还有一种是敏感列表问题比如异步复位的always块里漏了negedge rst_n导致复位永远不生效。状态机跳飞则多是因为状态编码有重叠或者case语句没有写default。综合工具在遇到未定义状态时如果RTL里没有default它可能把未知状态映射到任意一个已有状态表现就是状态机突然乱跳。修复很简单case后面加default: state IDLE;并且在状态变量上做同步复位。这个习惯一定要养成。7.2 综合报告里面积和时序不达标的调整思路综合报告里最核心的两个指标就是面积和时序。面积超标先去看哪些模块占的LUT或触发器最多。在大部分综合工具里面积报告能按模块层级展开直接用排序就能找到“大户”。通常是大型乘法器、大型比较器或者深度FIFO。针对乘法器可以考虑使用DSP硬核或者把乘法改成移位相加。针对大型比较器如果只是判断“是否大于某个常量”可以优化编码把比较改成检查某一位从而省掉很多LUT。时序违例的时候先看违例路径是从哪个寄存器到哪个寄存器。路径延迟大通常是因为组合逻辑太深。简单粗暴的做法是在中间插入流水寄存器把路径切短。但要注意插入寄存器会改变时序行为如果是数据通路相对独立插流水没问题如果是反馈回路比如状态机跳转逻辑就不能随便插否则状态更新晚一个周期功能就错了。这种情况下要回头重构状态机把每个状态内的组合逻辑尽量拆薄。还有一个经常被忽略的因素是扇出。一个信号如果接到几千个触发器时钟使能端布线延迟会很大。解决办法是复制寄存器在RTL里用(* max_fanout 64 *)之类的综合属性去指导工具。很多工具自己也会复制高扇出信号但显式声明往往效果更可控。7.3 关于celldefine、参数传递、task的注意事项热词里有verilog celldefine这里顺便说一下。celldefine是Verilog宏定义里的一个分支主要用在标准单元库的定义文件里普通用户极少直接使用。如果某个库文件里有celldefine和endcelldefine它的作用是告诉仿真和综合工具该模块是单元库单元可能在延时注释、功耗建模上有特殊处理。如果你在综合自己的设计时看到了这个关键字多半是引用了某些IP核库文件不要随便删。参数传递时要注意parameter和localparam是不同的。parameter可以由上层模块在例化时覆盖localparam则不行。而且localparam用的地方更多它通常用来定义状态编码、常量、位宽计算等内部值不对外暴露接口。例化时用#( .WIDTH(16) )传参如果参数被设置成了localparam外面传不进去综合时会报错。所以设计模块时只有真正需要被外部定制的量才设成parameter其余一律localparam。task和function在Verilog里都能用来组织代码但可综合性差别很大。task内部可以包含时序控制延时、等待这在仿真里很有用但综合工具不支持task里的时序控制所以在可综合代码里task只能是纯粹的组合逻辑或简单时序逻辑封装。我建议编写可综合模块时尽量不用task来写复杂的时序流程而是改用状态机。状态机在综合后是明确的硬件结构而task展开的逻辑往往会让综合工具产生不可预测的结果。如果只是在testbench里用task完全可以随便写。还有一点是关于generate循环。使用generate做参数化结构生成时里面的变量声明要注意作用域。综合工具会为每个生成实例创建独立层次所以同一个generate块里的模块例化不会冲突。但如果你在generate里用always注意它和全局的always一样会被综合成独立硬件不要在同一信号上重复赋值。最后分享一点自己的经验写Verilog和做逻辑综合这件事更像是在做翻译。你写下的每一句话都得想清楚硬件会怎么实现它。学了再多语法不如自己动手写一个计数器、一个状态机、一个接口控制器然后看着综合报告去理解它到底变成了什么。我个人建议初学者不要上来就用大而全的开发工具自带模板先用手写代码配Icarus Verilog仿真再用Yosys综合一遍观察面积和结构变化。等到你脑子里能大概预测某段代码综合出来是什么样子时序问题多数都能在编码前避免。这篇里的代码都是可以直接拿走的但更希望你能把每个模块的改法、参数、状态转移自己推演一遍踩过坑才能记得牢。
返回列表