尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Verilog入门:从软件思维到硬件思维的转变与实践

Verilog入门:从软件思维到硬件思维的转变与实践 1. 从“软件思维”到“硬件思维”的认知跃迁如果你是一名软件工程师或者对编程有一定了解第一次接触Verilog时大概率会感到一种熟悉的“错觉”。它的语法看起来很像C语言有if-else有for循环有case语句甚至还有function和task。你可能会想“这不就是另一种编程语言吗我写个always (posedge clk)块里面写点逻辑不就能描述硬件了”这正是Verilog入门路上最大的认知陷阱。我见过太多新手包括当年的我自己带着写软件的惯性思维一头扎进去结果写出来的代码要么仿真结果诡异要么综合后电路面积和时序一塌糊涂甚至根本综合不出预期的电路。Verilog的全称是硬件描述语言关键词是“描述”而不是“编程”。它的核心是让你用一种高级的、文本化的方式向综合工具“描述”你想要的数字电路结构。工具再根据你的描述把它“翻译”成由门电路、触发器、连线等基本单元组成的物理网表。举个最简单的例子。在软件里你写a b c;CPU会按顺序执行从内存读b读c送到ALU做加法结果写回a。这是一个时间序列上的操作。但在Verilog里你写assign a b c;你描述的是一个空间结构你要求综合工具生成一个加法器电路它的两个输入端口永远连着b和c的信号线输出端口永远连着a的信号线。只要b或c的值变化a几乎“同时”在考虑了电路延迟后就会跟着变化。这里没有“先读b再读c”的顺序它们是并发的。所以学习Verilog的第一步不是去死记硬背语法而是彻底扭转思维模式从“顺序执行”的软件思维切换到“并行发生”的硬件思维。你要时刻想象你写的每一行代码最终都会变成硅片上的晶体管、连线和时钟驱动的寄存器。这篇文章我就以一个过来人的身份带你绕过那些我踩过的坑从最核心的思维转换开始一步步构建起对Verilog和数字电路设计的坚实理解。2. 构建你的第一个“数字世界”环境、工具与Hello World在开始描述电路之前我们需要一个“数字实验室”。这个实验室不需要昂贵的FPGA开发板初期只需要两样东西一个文本编辑器和一个仿真工具。我强烈推荐使用VS Code作为编辑器配合诸如Verilog-HDL/SystemVerilog等插件可以获得优秀的语法高亮、自动补全和代码格式化体验。这能极大提升编码效率和减少低级错误。2.1 仿真工具选型Modelsim vs. Icarus Verilog仿真工具是我们的“虚拟示波器”和“逻辑分析仪”。主流选择有两个Modelsim (或 Questasim)业界标准功能强大图形化界面友好调试能力极强。对于学生或初学者Intel (原Altera) 和 AMD (原Xilinx) 都提供功能受限的免费版本如 ModelSim-Intel FPGA Starter Edition。它的波形查看器是学习时序概念的绝佳工具。Icarus Verilog (iverilog)开源、轻量、命令行驱动。搭配GTKWave查看波形构成了一个完全免费、跨平台的开源仿真环境。虽然功能不如Modelsim全面但对于学习语法和进行中小型模块仿真完全足够而且其“原始”的特性迫使你更理解编译和仿真的流程。对于纯新手我建议从Icarus Verilog GTKWave开始。理由很简单安装配置简单没有许可限制能让你更专注于语言本身而不是复杂的IDE操作。当你需要更复杂的调试如查看多维数组、复杂数据结构或与特定厂商的IP核协同仿真时再迁移到Modelsim也不迟。注意安装Icarus Verilog时Windows用户建议直接下载预编译的安装包。Linux用户通过包管理器如apt install iverilog gtkwave安装即可。确保安装后能在命令行中执行iverilog -v和gtkwave命令。2.2 第一个模块点亮“逻辑灯”我们不写“Hello World”我们点亮一盏“逻辑灯”。在数字电路里最基本的“灯”就是一个输出端口。让我们创建一个最简单的模块一个反相器NOT gate。// 文件名not_gate.v // 模块定义模块名端口列表 module not_gate ( input wire in, // 输入端口声明为wire类型 output wire out // 输出端口 ); // 持续赋值语句描述一个组合逻辑 // 它意味着out 这个信号线永远等于 (~in) 这个逻辑值。 assign out ~in; endmodule这就是一个完整的Verilog模块。我们来拆解一下module ... endmodule定义一个模块这是Verilog描述功能的基本单元。input/output声明端口的方向。wire是默认的、最常见的线网类型用于连接组合逻辑。assign持续赋值语句。它描述的不是一个“动作”而是一种永恒的“关系”。只要in变化out就会立即在仿真中或经过一个极短的门延迟在实际电路中变为in的反相值。2.3 编写测试平台给电路注入“激励”电路模块设计好了我们怎么验证它是对的呢这就需要编写一个测试平台。测试平台本身也是一个Verilog模块但它通常不会被综合成实际电路使用特殊的initial块和系统任务$display,$monitor等。它的作用是实例化待测模块并模拟输入信号的变化观察输出是否符合预期。// 文件名tb_not_gate.v timescale 1ns / 1ps // 时间单位 / 时间精度 module tb_not_gate(); // 测试平台的内部信号 reg in; // reg类型用于在initial/always块中驱动信号 wire out; // wire类型连接待测模块的输出 // 实例化待测模块将测试平台的信号连接到模块端口 not_gate u_not_gate ( .in (in), // .模块端口名连接信号名 .out (out) ); // 初始化块只在仿真开始时执行一次用于生成测试序列 initial begin in 0; // 初始时刻in为0 #10; // 等待10个时间单位根据timescale是10ns in 1; // 10ns后in变为1 #10; in 0; #10; $display(Simulation finished at time %0t, $time); $finish; // 结束仿真 end // 可选将信号变化记录到VCD文件用于GTKWave查看波形 initial begin $dumpfile(wave.vcd); // 波形文件名称 $dumpvars(0, tb_not_gate); // 记录本模块及其下所有子模块的信号 end endmodule2.4 运行仿真并查看波形保存好两个文件后在命令行中执行以下步骤编译iverilog -o not_gate.vvp not_gate.v tb_not_gate.v-o not_gate.vvp指定编译输出的可执行文件。后面列出所有需要编译的源文件。运行仿真vvp not_gate.vvp这会执行编译好的仿真程序生成wave.vcd波形文件并在控制台输出$display的信息。查看波形gtkwave wave.vcd打开GTKWave添加信号in, out到波形窗口。你将看到out信号始终是in信号的反相直观地验证了反相器的功能。恭喜你已经完成了从代码到仿真的完整流程成功“点亮”了你的第一个数字电路。这个过程虽然简单但包含了模块定义、实例化、测试平台编写、仿真流程等所有核心环节的雏形。3. 深入核心可综合逻辑的两种描述风格当你写的Verilog代码最终要变成真实的电路通过FPGA或ASIC实现时这部分代码必须是“可综合的”。综合工具只理解特定的编码风格。可综合逻辑主要分为两大类组合逻辑和时序逻辑。描述它们有两种主要风格数据流描述和行为描述。3.1 数据流描述用assign描绘信号间的连接数据流描述使用assign持续赋值语句直接描述信号之间的逻辑函数关系。它最适合描述简单的、没有状态变化的组合逻辑。module dataflow_example ( input wire a, b, c, output wire y1, y2 ); // 描述一个与门和一个或门 assign y1 a b; // y1 a AND b assign y2 (a | b) c; // y2 (a OR b) AND c endmodule综合工具看到assign y1 a b;就知道需要生成一个二输入的与门输入端接a和b输出端接y1。这种描述非常直接与最终生成的电路结构一一对应。3.2 行为描述用always块定义电路行为行为描述使用always块它描述的是在特定条件下寄存器reg值如何更新。这是描述复杂逻辑尤其是时序逻辑需要时钟的主要方式。always块的敏感列表决定了它何时被执行。组合逻辑的always块 敏感列表包含所有在右侧表达式中出现的输入信号。使用阻塞赋值。module behavioral_comb ( input wire [1:0] sel, input wire [3:0] d0, d1, d2, d3, output reg [3:0] out // 在always块中赋值的信号必须声明为reg类型 ); always (*) begin // (*) 或 (sel, d0, d1, d2, d3)表示任何输入变化都触发 case (sel) 2b00: out d0; 2b01: out d1; 2b10: out d2; 2b11: out d3; default: out 4b0000; // 良好的编码习惯提供default避免锁存器 endcase end endmodule这个例子描述了一个4选1的多路选择器。always (*)块意味着只要sel、d0、d1、d2、d3中任何一个发生变化块内的语句就会重新计算并更新out的值。这里综合出的就是一个纯粹的组合逻辑MUX。关键陷阱不完整的敏感列表与锁存器如果你写always (sel)而忽略了数据输入d0-d3那么当sel不变而d0变化时always块不会执行out将保持旧值。为了保存这个旧值综合工具会被迫生成一个锁存器锁存器在ASIC设计中需要谨慎处理在同步电路设计中通常被视为不受欢迎的“毛刺敏感”元件。因此对于组合逻辑永远使用always (*)是最安全的选择它能自动推断出完整的敏感列表。时序逻辑的always块 敏感列表通常只包含时钟边沿posedge clk或negedge clk和可选的异步复位信号。使用非阻塞赋值。module behavioral_seq ( input wire clk, input wire rst_n, // 低电平有效的异步复位 input wire [7:0] d, output reg [7:0] q ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 异步复位只要rst_n变低立即在时钟沿之前将q清零 q 8b0; end else begin // 时钟上升沿触发在clk上升沿到来时将d的值存入q q d; end end endmodule这个例子描述了一个带异步复位的8位寄存器D触发器。q d;是非阻塞赋值它意味着“在同一个时钟沿所有非阻塞赋值右边的表达式被同时计算然后在时间步结束时同时更新左边的寄存器”。这是描述同步电路的关键。3.3 阻塞赋值与非阻塞赋值的黄金法则这是Verilog初学者最容易混淆和出错的地方。记住这个简单的法则可以避免99%的时序逻辑错误在描述组合逻辑的always (*)块中使用阻塞赋值。在描述时序逻辑的always (posedge clk)块中使用非阻塞赋值。为什么阻塞赋值像软件里的“顺序执行”。在同一个always块中a b; c a;执行后c得到的是已经更新过的a的值即b的值。这在组合逻辑中是正确的因为它模拟了信号通过组合逻辑的瞬时传播。非阻塞赋值模拟了硬件寄存器的并行更新。在同一个时钟沿的always块中a b; c a;执行时右侧的a和b使用的是它们在这个时钟沿到来之前的旧值。所以这两条语句是“同时”计算的c得到的是a的旧值而不是刚被赋予的b的值。这精确对应了在时钟上升沿所有触发器的D端输入被采样并准备更新到Q端而Q端的新值要到下一个时钟沿才生效。违反这条法则常常会导致仿真行为与综合后电路行为不一致产生难以调试的竞争冒险问题。4. 从模块到系统设计方法与常见电路实现掌握了基本描述方法后我们可以尝试构建更复杂的功能。数字系统设计通常采用自顶向下或自底向上的方法。这里我们通过几个经典电路来实践模块化设计。4.1 状态机设计清晰、安全、可维护状态机是数字逻辑控制的灵魂。一个清晰的状态机设计至关重要。推荐使用“三段式”描述法它将状态转移逻辑、状态寄存器更新和输出逻辑分离结构清晰易于综合和调试。module fsm_sequence_detector ( input wire clk, input wire rst_n, input wire data_in, output reg sequence_found ); // 第一部分状态定义 parameter S_IDLE 2b00; parameter S_1 2b01; parameter S_11 2b10; parameter S_110 2b11; reg [1:0] current_state, next_state; // 第二部分时序逻辑状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state S_IDLE; else current_state next_state; end // 第三部分组合逻辑下一状态计算 always (*) begin next_state current_state; // 默认保持当前状态 case (current_state) S_IDLE: next_state (data_in 1b1) ? S_1 : S_IDLE; S_1: next_state (data_in 1b1) ? S_11 : S_IDLE; S_11: next_state (data_in 1b0) ? S_110 : S_11; S_110: next_state (data_in 1b1) ? S_1 : S_IDLE; default: next_state S_IDLE; endcase end // 第四部分组合逻辑输出计算Moore型输出仅与当前状态有关 always (*) begin sequence_found 1b0; // 默认输出 if (current_state S_110) sequence_found 1b1; end endmodule这个状态机检测输入序列“1101”。三段式这里把输出逻辑算作一段共四段的好处是第二部分一定是纯粹的时序逻辑综合出来就是一组触发器第三和第四部分是纯粹的组合逻辑。这种分离让综合工具能更好地优化也让代码更容易理解和维护。4.2 计数器与分频器时序的基础计数器是数字系统中最基本的时序元件之一。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; // 使能时加1 // else 保持无需显式写出 end endmodule这个简单的二进制递增计数器每个时钟沿在en有效时加1。基于计数器可以轻松实现分频器。例如要实现一个N分频N为偶数的时钟可以生成一个占空比50%的方波module clock_divider #( parameter N 10 // 分频系数假设为偶数 )( input wire clk_in, input wire rst_n, output wire clk_out ); reg [$clog2(N)-1:0] cnt; // $clog2是系统函数计算位宽 reg clk_out_reg; always (posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt 0; clk_out_reg 1b0; end else begin if (cnt N/2 - 1) begin // 计数到一半时翻转 clk_out_reg ~clk_out_reg; cnt 0; end else begin cnt cnt 1; end end end assign clk_out clk_out_reg; endmodule4.3 存储器与FIFO数据缓冲的基石小容量存储器可以用寄存器阵列实现例如一个双端口RAM先读后写。module simple_ram #( parameter ADDR_WIDTH 4, parameter DATA_WIDTH 8 )( input wire clk, input wire we, // 写使能 input wire [ADDR_WIDTH-1:0] addr, input wire [DATA_WIDTH-1:0] din, output reg [DATA_WIDTH-1:0] dout ); // 用reg数组定义存储器 reg [DATA_WIDTH-1:0] mem [(2**ADDR_WIDTH)-1:0]; always (posedge clk) begin if (we) mem[addr] din; // 写操作 dout mem[addr]; // 读操作输出在时钟沿后更新是同步读 end endmodule对于更复杂的数据流控制FIFO是必不可少的组件。一个同步FIFO的核心是读写指针的控制和空满状态的判断。判断空满状态时通常会让指针多出一位[ADDR_WIDTH:0]当最高位不同而其余位相同时表示“满”当所有位都相同时表示“空”。这是FIFO设计的经典模式网上有大量成熟可靠的代码可以参考和修改。自己动手实现一个参数化的FIFO是理解指针操作和状态机结合的绝佳练习。5. 仿真、调试与代码质量从能跑到可靠写出来的代码能通过编译和基础仿真只是第一步。确保它在各种边界条件下都能正确工作并且综合出的电路性能良好才是工程师价值的体现。5.1 编写完备的测试平台一个糟糕的测试平台比没有更危险因为它会给你虚假的信心。好的测试平台应该覆盖所有功能点包括正常功能和所有定义的异常情况。进行边界测试对计数器测试满量程和溢出对FIFO测试写满和读空。加入随机激励使用$random或更高级的约束随机验证方法可以发现确定性测试难以覆盖的角落情况。实现自检查在测试平台中自动比较输出与预期值并用$error报告失败而不是依赖人工看波形。// 一个简单的自检查测试平台片段 initial begin integer error_count 0; for (int i0; i1000; ii1) begin (posedge clk); data_in $random; // 随机输入 expected_out some_function(data_in); // 计算预期输出 #1; // 等待一个小的delta时间让待测模块输出稳定 if (dut_out ! expected_out) begin $error(Mismatch at time %0t: input%h, dut_out%h, expected%h, $time, data_in, dut_out, expected_out); error_count; end end if (error_count 0) $display(Test PASSED!); else $display(Test FAILED with %0d errors., error_count); $finish; end5.2 利用波形调试当测试失败时波形是你的第一现场。学会高效使用波形查看器定位问题时间点找到输出第一次出现错误的时间。回溯信号沿着出错的信号向前追溯看是哪个模块、哪个输入导致的。检查关键信号对于状态机检查current_state和next_state是否按预期跳转对于计数器检查使能、清零信号是否有效。注意仿真与现实的差距仿真中的#1延迟在实际电路中不存在。要小心由仿真延迟掩盖的竞争冒险问题。一个在仿真中完美的设计可能因为布线延迟在实际电路中产生毛刺。对于时钟、复位等关键信号必须保证其满足建立时间和保持时间。5.3 可综合代码风格与最佳实践为了让综合工具生成更优的电路并避免潜在问题请养成以下习惯明确所有情况在if-else和case语句中为所有可能的输入组合指定输出否则会推断出锁存器。使用default分支或赋初值。避免异步逻辑除了全局复位/置位尽量避免使用异步逻辑。异步逻辑的时序难以分析容易导致系统不稳定。寄存器输出对于模块的输出信号尽量在顶层用寄存器打一拍再输出。这有助于改善时序将关键路径限制在模块内部。参数化设计使用parameter和localparam来定义常数而不是直接写死数字。这极大提高了代码的可复用性和可维护性。代码注释与命名信号名、模块名要有意义。wr_en比we更好data_fifo比df好得多。详细的注释尤其是对复杂算法或状态机转移条件的注释对于后续维护和团队协作至关重要。目录结构规范建立清晰的目录结构如rtl/存放设计代码sim/存放测试平台doc/存放文档syn/存放综合脚本和报告。学习Verilog就像学习一门新的“建筑语言”你用它在描述一个并发的、空间化的数字世界。从扭转思维开始通过搭建一个个小模块与门、寄存器、计数器、状态机逐渐掌握描述复杂系统处理器、通信协议、图像处理流水线的能力。这个过程充满挑战但当你在示波器上看到自己设计的电路按照预想运行时那种成就感是无与伦比的。记住多看优秀的开源代码多动手仿真和调试多思考“这行代码会变成什么样的电路”你就能在这条路上越走越稳。
返回列表