
1. 先解决一个灵魂问题Verilog到底是写代码还是画电路很多刚接触Verilog的朋友一上来就把它当成C语言或者Python来学结果越学越迷糊。为什么因为Verilog虽然长着一副代码的样子本质上却是一门硬件描述语言你写下的每一行最终都会变成真实的电路逻辑门而不是一条一条按顺序执行的指令。我打个比方你就明白了C语言像是给一个员工写操作手册告诉它第一步做什么、第二步做什么而Verilog像是给一家工厂画流水线图纸你画的是多个工位同时开工的结构每个工位都在并行运转。这个区别直接决定了学习方式完全不同。C/Python是顺序执行写完第一行再执行第二行一个时刻只做一件事。Verilog是并行执行所有always块、assign语句理论上同时生效描述的是电路里每个模块各自干各自的活。所以学习Verilog的第一步不是急着背语法而是先把自己脑子里的软件思维切换成硬件思维。比如你写a b c在软件里是一次计算在硬件里意味着你画了一组加法器b和c的导线接进去输出端就永远是a的结果只要b和c变了a就跟着变不需要谁去触发它。同样初学时最容易卡住的一个点always块里的变量赋值要用非阻塞赋值不能用。原因也藏在硬件里——的意思是在时钟边沿到来时所有信号一起更新模拟的是寄存器触发器的行为时钟到达的那一瞬间旧值捕获、新值锁定。如果混用在仿真时会发现电路行为诡异波形里出现一堆毛刺。这篇教程我按自己带新人的经验来组织先把环境跑通再建立信号概念然后用计数器走上正轨最后把仿真和常见坑一次讲清楚。适合完全没接触过数字电路、没写过硬件代码的读者也适合学过一点但总感觉没通的朋友。2. 环境准备用免费工具把第一行代码跑起来2.1 工具怎么选别一上来就装全家桶很多教程会让你直接装Quartus或者Vivado这两个是工业级IDE功能强是真的强但它们的目标用户是做工程的人而不是刚学着写代码的新手。安装包几个GB起步编译一次几十秒光是工程配置的IDE界面就能劝退不少人。所以我建议新手先用Icarus Verilog简称iverilog配合GTKWave看波形。这套组合免费、开源、跨平台安装包才几十MB装完直接用命令行就能跑仿真非常适合把注意力集中在语言本身而不是工具操作上。各平台的安装方式操作系统安装命令 / 方法Ubuntu/Debiansudo apt install iverilog gtkwaveWindows去Icarus Verilog官网下载最新版本安装包一路Next即可macOSbrew install icarus-verilog brew install --cask gtkwave提示Windows用户装完记得把安装目录下的bin文件夹加入系统环境变量PATH否则命令行里输入iverilog会提示找不到命令。装好之后打开终端输入iverilog -V能显示版本信息就说明环境OK了。2.2 先别建工程先跑一个最小例子很多新手卡在不知道该建什么类型文件。Verilog源文件就是纯文本扩展名用.v你用记事本都能写。入门阶段别建工程直接建两个文件一个设计文件一个测试文件testbench够够的。新建led.v输入以下代码module led( input wire clk, output reg led_out ); always (posedge clk) begin led_out ~led_out; end endmodule这就是一个最简单的分频闪烁器每个时钟上升沿led_out翻转一次。从电路来看它就是一个T触发器。再看testbench新建led_tb.vmodule led_tb; reg clk; wire led_out; // 生成50MHz时钟周期20ns每10ns翻转一次 initial begin clk 0; forever #10 clk ~clk; end // 给10微秒的仿真时间后结束 initial begin #10000 $finish; end // 实例化被测模块 led u_led( .clk(clk), .led_out(led_out) ); // 打印输出波形文件 initial begin $dumpfile(led.vcd); $dumpvars(0, led_tb); end endmodule在终端运行iverilog -o led.vvp led.v led_tb.v vvp led.vvp然后输入gtkwave led.vcd你会看到一个波形窗口里面clk每隔10ns翻转一次led_out在每条clk上升沿之后也跟着翻转频率正好是clk的一半。到这里你的第一行Verilog就跑通了。看到波形的那一瞬间你就理解了什么叫硬件在跑而不是代码在跑。2.3 关于仿真报错的几个常见情况有朋友在Quartus里遇到过这样的报错error: failure to obtain a verilog simulation license这个错误很典型不是代码写错了是Quartus自带的仿真器ModelSim需要license授权而新手装的是Web版免费版有些版本不自带完整的仿真授权。解决办法有两个一是用Quartus内置的免费仿真器如果你用的版本支持二是绕开IDE用上面提的iverilog命令行工具做纯Verilog仿真。我的建议是直接用第二种轻量、省心、不碰license问题。3. 核心语法速通从信号而不是变量开始3.1 wire和reg到底是什么刚学Verilog遇到的第一对冤家肯定是wire和reg。网上的说法常常是wire对应组合逻辑reg对应时序逻辑这么说其实不准确还容易让新手记错。我更推荐这么理解wire就是一根导线。它自己不会保存值信号从哪里来它的值就是什么典型来源是assign连续赋值或者模块输出端口。reg一个能记住值的存储元件。它在某个时刻被写入之后即使输入端变化它的值也保持到下一次写入为止。在always块里被赋值的信号必须是reg类型在assign语句里被驱动的信号必须是wire类型。这是新手最容易报语法错误的地方。但要注意reg不一定综合成寄存器。如果你在always (*组合逻辑的always块里用reg它综合出来还是导线逻辑门不会变成触发器。所以reg寄存器这个印象要纠正过来准确说应该是reg是被过程赋值语句驱动的信号综合结果由实际代码决定。// 组合逻辑reg综合成wire reg result; always (*) begin if (a b) result 1b1; else result 1b0; end // 时序逻辑reg综合成触发器 reg count; always (posedge clk) begin count count 1b1; end你看同样是reg第一段没有时钟综合出来是一堆比较器和多路选择器第二段有时钟综合出来是D触发器。3.2 三种赋值方式assign、和把赋值逻辑搞懂Verilog就学懂了一小半。assign只能驱动wire表示持续连接。它没有时间点的概念右边的信号一变左边的值立刻更新。这是描述组合逻辑的核心工具。assign sum a b;阻塞赋值只用在always块里表示立即生效先算右边立即赋给左边然后才执行下一条语句。它描述的是组合逻辑的行为或者过程代码里暂存中间值的场景。非阻塞赋值也只用在always块里表示在时序边沿统一更新先算右边的值但不立即更新而是等always块结束、仿真时间前进一个delta周期后才更新。这就保证了同一时刻触发的多个触发器不会出现你改了我才改的连锁反应真实硬件行为就是这样。核心记忆点写时序逻辑用写组合逻辑用或assign。如果always块里时钟触发内部变量用基本不会出错。这是FPGA开发里无数个波形诡异问题的最终答案。3.3 组合逻辑与时序逻辑怎么判断该用哪种很多小白拿到一个需求不知道从哪下手。我分享一个最简单的判断方法这个输出的值是瞬时的组合结果还是需要记住之前的状态瞬时的例如两个数相加判断是否大于某个值→ 组合逻辑用assign或always (*)。需要记住的例如计数器当前值状态机当前状态→ 时序逻辑用always (posedge clk)。再进一步大部分数字电路都可以拆成时序逻辑存状态 组合逻辑算下一拍两部分。你写的代码心里始终要清楚哪些是触发器reg哪些是连线wire这样代码就不会乱。4. 计数器实战Verilog世界的Hello World4.1 从需求到代码的完整推导如果说每种语言都有一个入门实例那Verilog的入门实例非计数器莫属。计数器虽然简单但五脏俱全有时钟、有复位、有寄存器、有比较器、有进位输出。热搜词里verilog计数器排名靠前说明这是大家普遍的第一个实战项目。下面我用一个每计数到5翻转一次LED的小例子带你完整走一遍从需求到代码的思考过程。需求拆解一个8位计数器每个时钟周期加1当计数到5时计数归零同时LED翻转上电后先复位让系统从确定状态启动对应代码module counter_led( input wire clk, input wire rst_n, output reg led ); reg [7:0] cnt; // 计数器核心 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 8d0; else if (cnt 8d5) cnt 8d0; else cnt cnt 1b1; end // LED翻转控制 always (posedge clk or negedge rst_n) begin if (!rst_n) led 1b0; else if (cnt 8d5) led ~led; else led led; end endmodule注意两个细节第一复位用的是rst_n低电平有效。名字里的_n是业内惯例表示active low。条件里写if (!rst_n)而不是if (rst_n)别搞反。第二cnt 8d58d5表示8位宽的十进制数5。如果你写5工具可能会推断成32位整数在比较时出现位宽不匹配警告。养成写位宽的习惯是专业选手和业余选手的分水岭。4.2 测试文件怎么写才算合格看完设计代码再看testbench。很多新手写testbench只会给一个时钟然后干瞪眼等到信号变了也不知道对不对。合格的testbench至少要干三件事产生时钟、给复位、设计激励。timescale 1ns / 1ps module counter_led_tb; reg clk; reg rst_n; wire led; // 时钟10ns周期5ns高5ns低 initial clk 0; always #5 clk ~clk; // 复位一开始低电平100ns然后拉高 initial begin rst_n 0; #100; rst_n 1; end // 仿真总时长 initial #1000 $finish; // 实例化 counter_led uut( .clk(clk), .rst_n(rst_n), .led(led) ); // 波形 initial begin $dumpfile(counter_led.vcd); $dumpvars(0, counter_led_tb); end endmodule这段测试的时序设计是前100ns保持复位状态让所有信号归零100ns后释放复位计数器开始工作。你观察led引脚应该看到它每6个时钟周期翻转一次计数0~5共6拍。这里有个实用技巧把#1000改大一点比如#100000用GTKWave打开VCD文件后放大波形区间找到led翻转的位置再往前看cnt的变化整个人就对时序这两个字有感觉了。5. 仿真才是重头戏testbench应该怎么敲5.1 为什么说写完代码必须仿真硬件设计和软件最大的不同你没法在真实芯片上printf调试。写软件时打几行日志就能定位问题写Verilog时如果直接烧进FPGA出了问题你连看都看不到内部信号。而testbench就是你的示波器 调试器 自动化测试工具它让你在电脑上完整观察电路内部每个信号的行为。我刚带新人的时候反复强调一句话设计代码可以简单testbench不允许简单。 因为代码写得再漂亮仿真没过关就是零。testbench的核心作用有三个给被测模块DUT产生精确的输入激励模拟真实场景自动检测输出是否和预期一致高手用$display或者断言来做把所有信号波形记录下来供GTKWave等工具查看5.2 initial块和always块在testbench里的用法testbench里最常用的两个结构initial用于一次性时序描述always用于周期性描述比如时钟。// 一次性复位逻辑 initial begin rst_n 0; #100; rst_n 1; end // 周期时钟 always #5 clk ~clk;注意initial块不是可综合语法它只存在于仿真中。这意味着你不能把testbench拿去综合它是独立的仿真文件。这也是为什么工程里要把设计和测试分开两个文件放。更高级一点你可以在testbench里用$monitor自动打印信号变化initial begin $monitor(time%0t cnt%0d led%b, $time, uut.cnt, led); end这样仿真时控制台会实时打印计数器数值和LED状态省得一直盯着波形看。新手强烈建议用这个功能能把仿真过程可视化变成文字立刻就知道代码行为对不对。5.3 用GTKWave看波形时重点看什么波形打开了别瞎看。我按新手容易漏掉的重点列一下复位信号确认rst_n从低变高的时间点和预期一致。时钟边沿确认每个posedge clk处计数器是否加1。比较器的行为cnt到达5时下一拍是否清零。输出时序led翻转是否发生在cnt5之后的那个时钟边沿而不是同一时刻。特别注意第三点非阻塞赋值生效的时机。在波形里cnt等于5的那个时钟上升沿cnt并不会立刻就变成0而是在下一个时钟上升沿才更新。如果你在仿真时发现为什么cnt5持续了一个周期这不是bug这正是寄存器行为的正确表现。很多人刚学时在这里纠结半天以为代码写错了。6. 进阶语法的入门视角状态机、task与parameter6.1 parameter让代码可配置而不是写死写完计数器接触的第二组重要语法就是parameter。它的作用相当于C语言的#define给你定义可复用的常量。module counter #( parameter WIDTH 8, parameter MAX_CNT 5 )( input wire clk, input wire rst_n, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (cnt MAX_CNT) cnt {WIDTH{1b0}}; else cnt cnt {{WIDTH-1{1b0}}, 1b1}; end endmodule这样实例化时可以按需定制counter #(.WIDTH(4), .MAX_CNT(9)) u_counter( .clk(clk), .rst_n(rst_n), .cnt(cnt) );写参数化模块的意义在于你的模块从一次性用品变成了可复用组件。公司里写代码最忌讳把常数直接写在代码里项目换需求就得满文件搜索改数。用parameter改一行实例化即可。6.2 task把重复激励打包写复杂testbench时你可能会发现喂数据的操作反复出现。举个例子模拟一个SPI设备每次发8比特数据你总不能复制粘贴8段初始代码吧这时task就派上用场了。task send_data; input [7:0] data; integer i; begin for (i 0; i 8; i i 1) begin sclk 0; sda data[7-i]; #10; sclk 1; #10; end end endtask调用initial begin send_data(8hA5); send_data(8h5A); end注意task只能在testbench仿真环境里使用综合工具不认它。如果你要写可综合的代码把共用逻辑封装成module更合适。但写测试脚本时task确实能大幅减少重复代码。6.3 三段式状态机进阶路上的必经关卡verilog 三段式状态机也是热搜高频词。为什么要学状态机因为凡是带流程控制的模块——比如UART收发、I2C读写、按键消抖——本质上都是一个状态机。三段式指的是第一段时序逻辑负责状态寄存器的更新第二段组合逻辑负责计算下一状态第三段时序或组合逻辑负责根据当前状态输出以UART发送的简化流程为例localparam IDLE 2b00; localparam START 2b01; localparam DATA 2b10; localparam STOP 2b11; reg [1:0] state, next_state; // 第一段状态更新 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_flag ? START : IDLE; START: next_state DATA; DATA: next_state (bit_cnt 3d7) ? STOP : DATA; STOP: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出 always (posedge clk or negedge rst_n) begin if (!rst_n) uart_tx 1b1; else begin case (state) IDLE: uart_tx 1b1; START: uart_tx 1b0; DATA: uart_tx tx_data[bit_cnt]; STOP: uart_tx 1b1; endcase end end三段式最大的优点是状态更新、状态跳转、输出逻辑三者分离代码结构清晰定位问题快。新手学这一段可能会有点吃力但这是从写得出到写得专业的关键一步咬咬牙啃下来很值。关于task调用的补充很多UART或I2C模块需要使用task在testbench中模拟主机行为两人一碰头代码立刻清爽不少。试着把你之前写的计数器testbench改成用task驱动多个不同的复位/使能场景你会有原来testbench还能这么写的感觉。7. 新手特别容易踩的坑都是血压换来的7.1 位宽不匹配一眼看上去对波形却全乱这是新手最常见、也最隐蔽的错误。比如写count 1count是4位1默认是32位整数综合工具会把加法的结果扩展成32位再截断到4位仿真时有时能过有时会出现意想不到的毛刺。规则很简单数字面量永远带上位宽比如4d1或1b1。还有一个类似的坑比较操作时位宽不一致。cnt 5和cnt 4d5仿真结果可能不同因为Verilog生成环境$$time会提示整数位宽差异。养成所有常量都携带位宽的习惯能省掉一大半为什么结果不对的排查时间。7.2 在if-else里漏掉else锁存器被偷偷生成写组合逻辑时if语句漏掉else分支会导致综合工具推断出一个锁存器latch。它不像触发器那样有明确的时钟控制而是靠电平保持数据容易产生毛刺是硬件设计里要尽量避免的东西。// 这样会生成latch always (*) begin if (en) q d; end // 这样才是纯组合逻辑 always (*) begin if (en) q d; else q 1b0; end怎么快速发现在综合报告里搜latch或者inferred latch一搜一个准。如果你想设计一个寄存器请用always (posedge clk)而不是靠if控制信号通断。7.3 竞争冒险用就对了两个always块同时驱动同一个信号或者在一个时钟上升沿读到的值还是旧值——这类问题排查起来异常痛苦因为波形看起来明明有值就是不对。比如这样写// 错误示范同一个变量在组合逻辑中先读后写 always (posedge clk) begin a b c; // 阻塞赋值 d a e; // 读到的a是刚更新的 end在真实电路里d的输入是寄存器a的旧值还是新值取决于综合方式结果不确定。如果改成a和d都会在时钟边沿同时更新d读到的一定是旧a行为确定。所以再次强调时序逻辑用组合逻辑用。如果一段代码你在always (posedge clk)里用了并且变量又在块内被后续使用停下来检查一下是不是该分解成两段逻辑。7.4 手写代码时容易忽略begin/endVerilog里if内的多条语句必须用begin/end括起来否则只有第一条属于if。这个错误不是编译期报错而是行为完全不符合预期特别容易在调试时让人怀疑人生。// 错误只有第一句受if控制 if (rst_n) cnt 0; led 0; // 这一句不受if控制 // 正确 if (rst_n) begin cnt 0; led 0; end同样case的每个分支里多条语句也要用begin/end包起来。这类排版问题建议从一开始就养成缩进和括号的强迫症。很多代码风格指南把这个列为第一优先级不是没道理的。7.5 别用#延时写可综合代码#10这样的延时语句只存在于testbench里。如果你把它写进设计代码iverilog仿真时能跑看起来也对但拿到Quartus或者Vivado里综合会直接报错或者工具自动忽略这个延时行为和仿真完全不同。设计代码里唯一的时间参考是时钟和复位任何时间控制都交给外面的世界。这也是硬件思维的一个关键体现你无法用软件那套先等10毫秒再执行的思维来设计硬件电路。想等待一段时间那就设计一个计数器数到对应的时钟周期数这才是硬件的延迟方式。我在带新人时发现凡是写#延时上瘾的基本都是还在用软件的思维写硬件。如果你也有这个习惯早点戒掉转成计数器或状态机整个人的Verilog水平会上一个台阶。