ARTICLE DETAIL

资讯详情

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

FPGA入门必看:Verilog核心语法与硬件设计思维

FPGA入门必看:Verilog核心语法与硬件设计思维 1. 先把Verilog放在正确的位置上是“描述”而不是“编程”学习FPGA的第二个坎基本就是Verilog语法。你可能已经点灯成功、跑过流水灯甚至把数码管搞亮过但翻开源码一看assign、always、posedge、nonblocking这些词堆在一起虽然勉强能看懂可真要自己写一个功能模块脑子里还是一团浆糊。这个阶段太正常了我当初从C语言转过来的时候前面的两周几乎每天都在怀疑人生。这里必须先立一个观念Verilog不是编程语言它是硬件描述语言。它描述的不是“步骤”而是“电路结构”。C语言是“先做什么、再做什么”的命令序列Verilog是“哪些线连到了哪里、哪个触发器在时钟边沿采样什么数据”的连线图。如果你始终抱着写C的思路去写Verilog那你写的代码就算能在开发板上跑起来也是运气成分居多——出了问题根本无从查起。卡在语法上不可怕可怕的是把语法当成目的。实际工程里Verilog的语法其实很少用得最熟练、最高频的就是模块声明、信号类型、阻塞与非阻塞赋值、always块、assign连续赋值、parameter参数化、有限状态机、testbench仿真控制。这些加起来并不比你学Python的基础语法多多少。真正考验你的是你对这些语法背后的硬件行为理解得够不够深。所以这一篇我不会给你铺开一整本语法手册而是从“描述电路”的角度把最常用的语法结构拆开讲透配合可直接仿真的代码示例再补上我在实际项目中踩过的一些坑。文章面向刚入门的FPGA学习者也适合那些已经写了点代码但总感觉“能跑但不懂为什么”的开发者。1.1 硬件描述语言和软件语言的本质区别理解Verilog的第一步先忘掉“从上到下执行”这个惯性。芯片里的电路是并联存在的一上电所有逻辑同时开始工作没有主函数没有调用返回没有排队执行这一说。你写的每一行代码最终都会映射成真实的逻辑门和触发器它们一通电就各自干各自的活。有一个很经典的类比C语言像菜谱一步一步炒菜炒完这步才能进行下一步Verilog像一张装修图纸图纸上画的插座、水管、灯具是同时存在、同时工作的你说“把灯放在开关旁边”不是说“先装开关再装灯”而是它们天然就是同时存在于那里。Verilog代码也是你描述的是“这个模块里有哪些逻辑单元它们怎么连接”而不是“谁来先运行”。所以你会看到Verilog里几乎没有“循环遍历”这种思维。就算写了for循环它也是给综合工具展开用的——写成for的代码最终会被展开成一组并联的硬件逻辑而不是一段按次数执行的指令。理解这一点之后再看always块、assign、模块例化这些语法就会顺畅很多。1.2 初学阶段最值得花时间的两个方向语法五花八门但初学阶段请抓住两条主线第一能用Verilog描述组合逻辑和时序逻辑第二能写testbench验证自己描述的逻辑对不对。前者是设计能力后者是调试能力。这两个能力互相配合才是FPGA开发的真实节奏。很多初学者一上来就奔着高深语法去非要搞明白function、task、generate、define结果基础模块还写不利索。实际上这些高级语法在工程里都是锦上添花核心永远是那几板斧模块、线网、寄存器、组合逻辑、时序逻辑、状态机。把这几样吃透你就能实现串口收发、PWM输出、按键消抖、数码管扫描等等绝大多数入门到进阶的功能。我自己带过一些新人发现一个普遍现象仿真和上板脱节。代码写完直接往板子上烧跑通了万事大吉跑不通全凭运气去猜。这是效率极低的学习路径。所以你在这篇文章里会看到我所有的示例都强调先仿真验证再谈上板。仿真验证不是可选项而是必需品。2. 模块与信号搭建逻辑的基本单元2.1 module的写法与端口声明Verilog的基本单元是模块module可以理解成一个“黑盒子”。黑盒子外面只看到它的端口黑盒子里面是你实现的逻辑。一个完整的设计通常顶上有一个顶层模块下面挂着若干个子模块一层一层例化下去像套娃一样。module led_controller ( input clk, input rst_n, input [3:0] sw, output reg led ); // 逻辑写在这里 endmodule端口声明里最核心的语法就是方向input表示信号进入模块output表示信号输出到模块外部inout表示双向端口FPGA里极少用IO结构才需要初学可以完全忽略。还有一个细节很多人一开始容易漏输出端口如果用reg类型需要在output后面加reg关键词常见写法是output reg如果输出是连续赋值驱动的组合逻辑就写output wire甚至不写类型默认就是wire。端口方向声明完之后你还要注意位宽。[3:0] sw表示这个端口是4位信号范围从bit3到bit0。Verilog里的位宽声明必须和实际的逻辑匹配多写一位少写一位综合工具都会通过警告提醒你但仿真阶段不报错等上板了才发现信号对不上就晚了。2.2 wire和reg什么时候该用哪个这个坑几乎每一个Verilog初学者都踩过。简单说module内部声明的信号不是wire就是reg。wire是线网类型代表物理连线reg是寄存器类型代表存储单元。但请注意一个反常识的地方reg不一定会被综合成寄存器assign驱动的信号也不一定只是连线。这个命名习惯历史遗留问题初学者别被它绕晕。我的实践经验给你一条简单粗暴的判断规则如果这个信号是在always块里被赋值的就声明成reg。如果这个信号是用assign连续赋值、或者是模块例化时连接的线就声明成wire。组合逻辑如果用always (*)写输出也必须是reg类型但这个reg综合出来并不是寄存器它只是一个组合逻辑信号。如果你必须让某个信号经过触发器采样那就写在always (posedge clk)块里声明为reg。这是真正的时序逻辑会综合出寄存器。初学阶段把这条规则记牢可以避免编译器一堆报错。2.3 位宽、常量、参数与localparamVerilog里的常量用位宽进制数值的格式表示比如4b1010是4位二进制的10108d255是8位十进制的255。写代码时不写位宽直接写数字虽然不报错但强烈建议每次都写清楚位宽不然很容易出现信号截断的隐蔽bug。参数化设计是模块复用最重要的一环module counter #( parameter CNT_WIDTH 8, parameter CNT_MAX 100 )( input wire clk, input wire rst_n, output reg [CNT_WIDTH-1:0] cnt ); // 计数器逻辑 endmodule#(...)这一段是参数列表例化这个模块的时候可以重新指定参数不改代码就能生成不同位宽、不同计数上限的计数器。localparam则是模块内部常量外部不可覆盖适合定义状态编码、分频系数这类内部固定值。为什么这么设计因为FPGA开发中模块复用频率极高。同一个串口模块波特率可能9600、115200甚至更高同一个计数器用作毫秒计时和用作帧同步时序计数上限完全不同。把这些数值做成参数一套代码通吃所有场景这是工程化的基本素养。3. 三种核心逻辑组合逻辑、时序逻辑、状态机3.1 组合逻辑assign与always (*)组合逻辑的特点是输出只取决于当前输入没有时钟参与没有任何记忆。最简单的例子就是与门、或门、多路选择器、译码器。Verilog里写组合逻辑有两种方式一种是assign连续赋值一种是always (*)过程块。// 方式一assign连续赋值 assign led (btn 1b1) ? 1b1 : 1b0; // 方式二always块描述组合逻辑 always (*) begin if (sel 1b0) data_out data_a; else data_out data_b; end赋值方式上有个核心区别assign只能用阻塞赋值而always块里组合逻辑最好也用阻塞赋值时序逻辑必须用非阻塞赋值。原因在后面的小节详细说这里先记住结论。always (*)中间的(*)是敏感列表表示只要右边用到的信号发生变化这个块就会被触发重新计算。你不需要手动写全所有敏感信号*会自动推导。这个特性极大降低了漏写敏感列表的风险。组合逻辑有一个很容易犯的错误条件分支不完整导致latch。比如always (*) begin if (en 1b1) data_out data_in; end这个代码没有else分支当en为0时data_out是多少在仿真里它保持上一次的值综合工具为了满足这个行为会推断出一个锁存器latch。latch在FPGA里非常不受待见容易引发时序问题而且很难排查。初学阶段写组合逻辑务必把if和case的分支写完整别留任何“默认保持”的口子。3.2 时序逻辑always (posedge clk)与非阻塞赋值时序逻辑是FPGA设计的绝对主角。它的特征是输出不仅取决于当前输入还取决于之前的状态——也就是说它“记得”历史。实现记忆的最小单元是寄存器时序逻辑就是围绕寄存器展开的。always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 8b0; else if (cnt_en) cnt cnt 1b1; else cnt cnt; end这个代码就是标准的时序逻辑模板。posedge clk表示上升沿触发negedge rst_n表示异步复位低电平有效。几乎所有时序逻辑都是这个套路复位清零然后在时钟边沿按条件改变寄存器值。这里必须强调一个FPGA学习中最核心的语法规则在always (posedge clk)块里赋值必须用非阻塞赋值绝对不要用。非阻塞赋值的硬件语义是先把右边表达式都算出来然后统一在时钟边沿更新到左边。这模拟了真实寄存器的工作方式——同一时刻采样的数据同时被写入寄存器。如果用阻塞赋值写时序逻辑仿真里会出现竞争现象综合出的电路也可能不符合预期。我见过一个新手写的串口模块发送端和接收端数据老是错位最后查了一整天就是always块里混用了和。这类问题仿真很多时候能跑通上板就抽风极其隐蔽。3.3 状态机有限状态机就是你的“导演脚本”当你开始接触协议类功能串口、SPI、I2C、以太网等状态机就是绕不开的设计结构。状态机本质是一个“导演脚本”它定义了系统有哪些状态状态之间怎么跳转每个状态下输出什么。Verilog里主流写法有三种一段式、二段式、三段式。一段式把状态转移和输出写在同一个always块里代码短但可读性差二段式把状态转移和输出分开写三段式把状态转移、次态组合逻辑、输出逻辑完全拆开。我最推荐的是三段式因为它的时序最清晰。// 状态编码 localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; // 第一段状态转移时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段次态判断组合逻辑 always (*) begin case (state) IDLE: next_state (start_sig) ? START : IDLE; START: next_state DATA; DATA: next_state (bit_done) ? STOP : DATA; STOP: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑组合逻辑或时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) tx_data 1b1; else if (state START) tx_data 1b0; else if (state DATA) tx_data data_to_send[bit_index]; else tx_data 1b1; end三段式的好处是状态转移和输出逻辑互相独立每部分都很好调试。实际工程里状态机的次态判断要用case写完整一定加default不然不仅可能推断出latch还有可能出现错误状态跳不回来的情况。另外状态码用localparam定义别用裸数字代码可读性会好得多。4. 实用的代码骨架计数器、分频器、UART发送器4.1 参数化计数器含复位、使能设计计数器是FPGA里最基础也最常用的结构几乎所有功能模块里都有它的身影。你点灯需要计数延时USB转串口需要计数波特率ADC采样需要计数采样点数。所以计数器怎么写规范决定了你后续所有工程的代码质量。module counter #( parameter CNT_WIDTH 32 )( input wire clk, input wire rst_n, input wire cnt_en, output reg [CNT_WIDTH-1:0] cnt, output wire cnt_done ); assign cnt_done (cnt (CNT_MAX-1)) ? 1b1 : 1b0; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt d0; else if (cnt_en) begin if (cnt_done) cnt d0; else cnt cnt 1b1; end end endmodule这里有两个经验点。第一复位要异步复位同步释放代码里用negedge rst_n是异步复位的写法但是上板时如果复位信号来自外部按键必须加同步器否则很容易导致寄存器进入亚稳态第二cnt_done用组合逻辑判断不要单独设一个寄存器去锁存“完成信号”否则产生完成标志的时序会和计数更新错开下游模块容易采不到。4.2 时钟分频器的正确写法避免毛刺初学的时候很多人喜欢写计数器生成一个低频“时钟信号”比如把50MHz分频成1Hz的方波然后用这个方波的posedge去驱动另一个模块。这在实验里能跑但工程上不推荐。分频后的信号不是FPGA的全局时钟网络用它去做时钟会造成时钟偏斜、时序约束困难而且这个分频时钟边沿很容易出现毛刺。我的建议是主时钟永远是FPGA板子上的那个固定频率晶振时钟所有时序逻辑统一用主时钟驱动。需要低频信号时用计数器生成“使能脉冲”或者“标志信号”而不是生成一个新的时钟。// 生成1Hz的时钟使能信号而不是分频时钟 reg [31:0] cnt; wire clk_1hz_en (cnt 50000000-1); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 32d0; else if (clk_1hz_en) cnt 32d0; else cnt cnt 1b1; end // 需要用1Hz的模块全部用clk驱动判断clk_1hz_en always (posedge clk or negedge rst_n) begin if (!rst_n) led 1b0; else if (clk_1hz_en) led ~led; end这个写法的好处是所有时序逻辑仍然由同一个主时钟驱动不存在跨时钟域问题也不容易产生毛刺。等以后学到时钟域交叉处理你就会明白这个习惯有多重要。现在养成这个习惯比以后写了一大堆分频时钟再回头重构成本低得多。4.3 一个最简单的UART发送模块含状态机写一个可以仿真验证的真实模块是检验语法掌握程度的最好方式。UART发送就是绝佳的练习内容它既有分频波特率计数又有状态机空闲、起始位、数据位、停止位又有移位输出麻雀虽小五脏俱全。module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg tx ); localparam BAUD_CNT CLK_FREQ / BAUD_RATE - 1; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state, next_state; reg [15:0] baud_cnt; reg [2:0] bit_index; reg baud_clk_en; // 波特率计数 always (posedge clk or negedge rst_n) begin if (!rst_n) baud_cnt 16d0; else if (baud_cnt BAUD_CNT) baud_cnt 16d0; else baud_cnt baud_cnt 1b1; end assign baud_clk_en (baud_cnt BAUD_CNT); // 状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 次态判断 always (*) begin case (state) IDLE: next_state tx_start ? START : IDLE; START: next_state baud_clk_en ? DATA : START; DATA: next_state (baud_clk_en bit_index 3d7) ? STOP : DATA; STOP: next_state baud_clk_en ? IDLE : STOP; default: next_state IDLE; endcase end // 数据位计数 always (posedge clk or negedge rst_n) begin if (!rst_n) bit_index 3d0; else if (state DATA baud_clk_en) bit_index bit_index 1b1; else if (state ! DATA) bit_index 3d0; end // 发送输出 always (posedge clk or negedge rst_n) begin if (!rst_n) tx 1b1; else begin case (state) IDLE: tx 1b1; START: tx 1b0; DATA: tx tx_data[bit_index]; STOP: tx 1b1; default: tx 1b1; endcase end end endmodule这个模块可以直接在仿真里跑观察tx引脚在空闲时为高电平发送时先拉低一个波特率周期起始位然后依次输出8位数据最后拉高停止位。你把它做成IP后外部可以直接用它配合串口助手收发数据。这里提醒一句次态判断里的(baud_clk_en bit_index 3d7)是组合逻辑判断它依赖bit_index这个时序逻辑信号。在仿真里这样写没问题但为了防止组合逻辑产生毛刺工程上更稳妥的做法是让baud_clk_en作为时钟使能信号在always (posedge clk)里判断。这个细节等你做完这个模块后可以再对比优化。5. 仿真思维写testbench打通“代码-仿真-上板”闭环5.1 为什么说仿真比上板重要很多新手觉得仿真麻烦总想直接上板看现象。但实际上调试FPGA最有效的工具不是逻辑分析仪而是仿真。仿真里你可以看到每一个信号的精确变化可以在任意时刻打$display打印信息可以设置复杂的输入激励甚至可以在波形里精确测量时间间隔。上板调试则受限于引脚数量、逻辑分析仪采样深度、时钟频率效率低得多。我见过不少学习者写了代码直接上板现象不对就在开发板上反复改引脚、换按键浪费大量时间。正确的流程应该是写完代码先写testbench仿真验证逻辑正确再上板看现象。上板现象和仿真一致就说明你理解对了不一致再回头查仿真环境或约束文件。这样把问题的边界一步步缩小排错效率会高很多。5.2 基础testbench骨架与常用控制任务testbench的本质是例化你要测试的模块给它提供时钟和复位再模拟外部输入信号最后观察输出。它本身不需要综合所以你能用到很多只在仿真里有效的语法比如initial块、#延时、$display。timescale 1ns / 1ps module tb_uart_tx; reg clk; reg rst_n; reg tx_start; reg [7:0] tx_data; wire tx; // 被测模块例化 uart_tx #( .CLK_FREQ (50_000_000), .BAUD_RATE (115200) ) u_uart_tx ( .clk (clk), .rst_n (rst_n), .tx_start (tx_start), .tx_data (tx_data), .tx (tx) ); // 时钟生成20ns周期即50MHz initial clk 1b0; always #10 clk ~clk; // 测试激励 initial begin rst_n 1b0; tx_start 1b0; tx_data 8h00; #100; rst_n 1b1; #100; tx_data 8hA5; tx_start 1b1; #20; tx_start 1b0; // 等待足够时间观察完整发送过程 #120000; $finish; end // 打印关键信息 initial begin $monitor(time%0t state%d tx%b, $time, tb_uart_tx.u_uart_tx.state, tx); end endmodule这段testbench里值得强调的是$monitor它会在信号变化时自动打印一次是验证状态机跳转是否正常的利器。其他常用的还有$display只打印一次、$finish结束仿真、$stop暂停仿真。仿真工具里#20表示延时20个时间单位配合timescale一起使用。这里1ns / 1ps表示时间单位是1ns精度是1ps。你把这些代码放到Vivado、Quartus或开源的Icarus Verilog里跑一遍就能看到完整的仿真波形。我个人非常推荐新手用Icarus Verilog它免费、轻量、命令行就能用配合VS Code的Verilog插件改代码和跑仿真都在编辑器里搞定不需要打开笨重的IDE。等到项目规模大了再迁移到Vivado的XSim或Modelsim也不迟。5.3 仿真错误的几个经典坑仿真代码写起来虽然自由但也有几个常见的坑我替你总结在这里第一timescale写错会导致仿真时间对不上。如果你写1ns/1ps但模块内部按照1ns的周期运行而你的时钟生成延时写的是#20那实际周期就是40ns。不同模块之间如果timescale不一致用的时候要格外小心。第二initial块里的延时只适用于仿真不可综合。所以这些代码只能出现在testbench里不能混进功能模块。你如果看到有人把#100写在可综合模块里那一定是代码不规范。第三仿真波形“看起来对”不代表逻辑对。比如异步信号可能会出现毛刺即使功能仿真通过了时序仿真或者上板后依然可能出错。所以仿真过了之后最好再对关键信号做一次人工检查特别是跨时钟域的信号、边沿检测信号确保没有毛刺隐患。6. 新手避坑指南从写出来到能综合6.1 最容易被忽略的latch推断前面在组合逻辑一节提过latch这里单独拿出来再强调是因为它太常见了。if没有else、case没有default、或者在组合逻辑always块里对信号自己赋值都可能推断出latch。latch最大的问题是它由电平触发不受时钟控制时序上非常难约束尤其在高扇出、复杂逻辑里极易引起竞争冒险。综合工具一般会给出warning提醒你推断出latch了但很多新手看到一堆warning都不当回事。我的建议是每次综合完优先检查有没有关于latch的warning。有就回去改代码把分支补全。养成这个习惯能帮你避免掉后期大量不可控的时序问题。6.2 阻塞赋值与非阻塞赋值的误区这一点再怎么说都不为过。用一句话记组合逻辑写always (*)用阻塞赋值时序逻辑写always (posedge clk)用非阻塞赋值。两条规则不要混。如果你在时序逻辑里写成阻塞赋值仿真工具可能不会报错但综合出来的电路行为会和预想不一致上板会出现各种奇怪的连锁反应。更隐蔽的一个坑是在同一个always块里既用阻塞赋值又用非阻塞赋值。这种写法在部分综合工具里直接报错有些工具能忍但不保证行为正确。新手千万别试规规矩矩每个块只用一种赋值方式。如果要同时处理组合逻辑和时序逻辑分开写两个always块不要合并。6.3 工具链选择与综合技巧这里顺便回应很多人的问题Altera的FPGA用什么软件Xilinx的用什么软件为什么我装的环境这么重。市面上主流的FPGA厂商基本是三足鼎立XilinxAMD用VivadoIntelAltera用QuartusLattice用Diamond/Radiant。如果你的开发板是国产的比如高云、易灵思、紫光同创都有自己的专用IDE。初学阶段我的建议是不要纠结工具链优劣选一个打得开、能跑的例子最多的就行。Vivado功能最全但安装包巨大Quartus稍微轻一点Icarus Verilog纯粹命令行但完全没有图形界面上手成本最低。无论选哪个都要养成“先仿真后综合再上板”的习惯。综合前多看一眼warning时序报告里多扫一眼有没有failing这些都是基本功。还有一个小建议用VS Code配合插件写Verilog很舒服语法高亮、自动补全、代码检查都很不错写完后一键用Icarus跑仿真效率非常高。一开始我是在Vivado编辑器里折腾的后来换到VS Code再也没回去。不过这只代表个人偏好你用惯哪个就用哪个关键是别在工具上浪费太多时间。7. 写在最后从语法到设计思维的转变Verilog语法本身不难难的是用硬件思维去思考问题。我在带人学习FPGA的时候最明显的一个分水岭就是从“照着模板写代码”变成“看着需求能自己拆解模块、画时序图、写状态机”。到这一步你才算真正把Verilog当成工具而不是把语法当成负担。对你个人的建议是找一个小目标比如把一个串口发送模块从头到尾独立实现一遍包括testbench仿真、波形分析、板级验证。做完这个再试试UART接收、按键消抖、数码管动态扫描、PWM蜂鸣器每一个都按同样的流程走。等这些基础模块都能独立实现了后续学I2C、SPI、图像采集、以太网就都只是“再套一层状态机”的事。根据我个人的经验FPGA学习最忌讳的就是囤积代码和教程打开一百个网页不如亲手跑通一个仿真。语法忘记了、不熟练了翻翻这一篇再对照自己写的代码琢磨琢磨回来看基本就能捡起来。Verilog就那么多你的时间多花在思考和调试上而不是背语法上这个方向一定不会错。
返回列表