Verilog核心基础详解:从模块结构到状态机设计

发布时间:2026/10/13 16:07:18
Verilog核心基础详解:从模块结构到状态机设计 学Verilog这件事我一直觉得入门不算难但真正用好的不多。尤其是从C语言思维转过来的朋友第一眼看到Verilog代码往往会觉得这不就是带位操作的C吗。我第一次写的时候也这样想直到综合时被一堆时序警告打脸才意识到Verilog的思维方式跟软件完全不是一回事。这份笔记是逻辑语言基础部分也就是Part 2我会把模块结构、wire与reg、赋值方式、组合逻辑与时序逻辑、参数化设计、状态机这六块内容从头过一遍同时把我踩过的坑和现在回头看的关键理解一并写出来给正在学Verilog、或者学过一遍但没太吃透的朋友做个参考。1. 先把模块结构里里外外拆一遍1.1 模块是设计的基本单元端口先行我最早学Verilog的时候习惯先打开编辑器就写内部逻辑写着写着才发现端口定义没想清楚最后又回来改端口列表整体结构就会很乱。后来我养成了一个习惯任何模块设计的第一步先把端口定义写好再写内部实现。这个顺序很重要因为FPGA设计跟写软件不一样模块的端口就是它对外部世界的全部“表情”端口定不下来内部逻辑写多少都是白搭。Verilog的基本单元是module一个module从module关键字开始到endmodule结束。一个典型模块的骨架长这样module module_name #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] data_in, output reg [DATA_WIDTH-1:0] data_out ); // 内部逻辑 endmodule注意这个顺序先是模块名然后是参数列表#(...)再是端口列表(...)。初学者常常漏掉参数列表直接写端口后面实例化的时候发现位宽没法灵活控制又回头加参数属于早晚要补的课。1.2 端口方向、位宽与类型怎么定端口声明里最关键的是三个信息方向、位宽、类型。方向用input、output、inout三种关键字表示。inout用的少只有像I2C、UART这种真正需要双向往来的总线接口才会用到而且用inout端口时要配合三态门控制比如inout wire sda; assign sda (sda_en) ? sda_out : 1bz;这里1bz表示高阻态也就是“释放总线”。初学者看到1bz经常懵其实把它理解成“断开连接、不驱动这根线”就对了。inout端口的时序约束和仿真都比较麻烦如果不是做总线接口前期可以少碰但要认识。位宽用[MSB:LSB]或[LSB:MSB]表示比如[7:0]是8位[31:0]是32位。这里有一点要特别说明[7:0]和[0:7]定义的位宽字节顺序不一样前者bit7是最高位后者bit0是最高位。大多数RTL设计习惯用[N-1:0]的大端写法因为这样向量中下标最小的[0]在拼接、截位时更直观。同一个工程里建议统一约定一种写法混用容易在顶层连线时把高低位接反。类型分为wire和reg。这个我会在下一章细讲这里先记住一个最基础的口诀wire通常被assign连续赋值驱动reg通常在always块中被过程赋值驱动。端口声明时input端口一般声明为wireoutput端口如果是在always块里赋值就声明为reg。1.3 一个完整的最小模块说到最小模块不要直接写那种“hello world”式的空module我建议从有实际逻辑的模块开始。比如下面这个8位寄存器它麻雀虽小但包含了端口、参数、时序逻辑三样基本要素module reg8 #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] d, output reg [WIDTH-1:0] q ); always (posedge clk or negedge rst_n) begin if (!rst_n) q {WIDTH{1b0}}; else q d; end endmodule这个模块的端口定义里能看到#(parameter WIDTH 8)这种写法它的作用是可以让这个模块对外灵活适配位宽。实例化的时候想用16位就用16位不需要复制粘贴代码改注释这就是参数化设计后文会专门说。2. wire与reg数据宽度和“类型”背后的硬件本质2.1 wire是连线reg是变量容器但不是寄存器的同义词我最开始学wire和reg时老师给的区分是“wire是线、reg是寄存器”。这个说法对了一半但也很容易制造误解并不是reg变量综合出来就一定是寄存器。 reg只是Verilog语法里用来表示“过程赋值目标”的关键字在组合always块里声明成reg的变量综合出来可以是一个纯组合逻辑输出没有任何存储功能。反过来有些wire信号在顶层例化时连的也可能是寄存器的输出端口。我比较推荐用“连线”和“容器”来理解这两个概念。wire描述的是物理连接它本身不保存数据只负责把驱动源和接收端连起来所以只能被assign连续赋值。reg描述的是一个可以保存值的容器它既可以保存组合逻辑算出来的结果也可以保存触发器在时钟沿采到的数据所以可以在always块中被过程赋值。用一个比喻帮助记忆wire像一根水管水流过但不留在管子里reg像一个水杯可以把水装起来。但“水杯”不一定非要放在寄存器仓库里它也可以只是一个临时容器最终由组合逻辑驱动。2.2 位宽的定义与数据运算中的位宽陷阱位宽这事看起来简单实际上一不留神就会在运算中出问题。最常见的坑是运算结果位宽不足导致截断。比如两个8位整数相加理论上结果可能需要9位但如果把结果赋给一个8位reg最高位会被静默丢弃仿真的波形看起来就是结果“跳变”了查半天查不出原因。reg [7:0] a, b; reg [7:0] sum; sum a b; // 如果 a200, b100sum300 截断为 44正确做法是给结果预留位宽或者让综合工具自动扩展reg [8:0] sum; sum {1b0, a} {1b0, b}; // 显式扩展一位 或 wire [8:0] sum_wire; assign sum_wire a b; // assign右侧表达式在Verilog-2001中可取max位宽这里要说明一下assign连续赋值时综合工具会根据左值位宽自动扩展而always块里的过程赋值有时会出现位宽不足的截断。不同工具处理方式可能略有差异所以我的建议是涉及加法、乘法这类可能扩大位宽的运算提前把结果变量位宽设计好不要指望工具自动帮你救场。位宽还有一个容易被忽略的点常量的写法要明确给定宽度不写宽度的常量0可能会被当成32位。比如q 0;在工具看来是32h00000000赋给8位寄存器时相当于取低8位能正常工作但不够严谨而q 8d0;一眼就能看出宽度是8。写RTL代码时尽量“逢常量必带宽度”别人review代码时也轻松。2.3 常量怎么写数字格式与parameter的作用Verilog的数字字面量有三种常见写法二进制b、十六进制h、十进制d。基本格式是位宽 进制 数值。比如8hFF表示8位十六进制数2554b1010表示4位二进制数10。这里很多人会省略位宽写成hFF意思就是“不确定但至少能放下这个值的位宽”能用但可读性差团队协作时容易埋雷。参数化设计里parameter经常和常量配合使用。比如定义FIFO深度、状态编码、计数器目标值等。有一个坑在同一个模块中如果既要用parameter又要用可被外部改写的常量不如直接用localparam。parameter可以在模块实例化时被外部覆盖而localparam只能模块内部使用不能从外部改。区分清楚这两个能避免很多“顶层改了下参数结果子模块全乱套”的奇怪问题。3. 赋值运算符阻塞赋值与非阻塞赋值的真实区别3.1 阻塞赋值与非阻塞赋值的仿真行为差异如果让我选一个“Verilog里最值得花时间搞懂的知识点”我会选阻塞赋值与非阻塞赋值的区别。这个知识点不弄明白写出来的时序逻辑仿真波形很可能跟你预期完全不一样。阻塞赋值用特点是“先算右边马上赋给左边”就像软件里的普通赋值一样语句顺序会直接影响结果。比如a b; c a;执行完以后c拿到的是b的值因为第二句读a时a已经被上一句更新了。非阻塞赋值用特点是“右边先全部算完等到always块结束时统一更新左边”。也就是说a b; c a;这个块执行完c拿到的是a在块开始前的旧值不是b。因为它俩的更新发生在同一时刻互相看不到对方的“当前时刻最新值”。那在硬件里这代表什么呢非阻塞赋值的行为其实非常接近真实寄存器在时钟沿采样时的效果多个寄存器在同一个时钟边沿被同时更新谁也不用等谁。而阻塞赋值模拟的是一种强制性顺序如果一个逻辑块里有多个触发器要同时更新用写出的代码会引起仿真上的数据竞争。3.2 综合后的电路差异为什么时序逻辑要用很多人只记住“时序逻辑用”但不知道为什么。我实际看过不少综合报告也对比过同一个功能用两种写法的综合结果可以负责任地说大多数情况下综合工具会给出相同的电路因为工具会做依赖分析但仿真结果可能天差地别。这个现象就很危险。综合后电路对仿真却不对你查了半天最后发现问题是赋值方式导致仿真行为跟电路行为不一致。反之有些写法在仿真里看起来没问题综合后却悄悄多了一个不该有的组合回路。理解这个问题的关键在并发模型Verilog的always块在硬件里并不是一段顺序执行的程序而是一组并行逻辑的描述。非阻塞赋值在always块结束时统一更新正好模拟了“同一时钟沿多个寄存器并行更新”的物理行为阻塞赋值则把并行逻辑强行写成了顺序逻辑仿真时会让信号产生额外一个时钟周期的错位。业界有一个广泛流传的经验规则我建议直接背下来时序逻辑always (posedge clk)里用非阻塞赋值组合逻辑always (*)或assign里用阻塞赋值。两者不要混在一个always块里写。3.3 经典踩坑场景阻塞赋值写移位寄存器我当年学移位寄存器时用写了一个8位移位寄存器仿真波形看起来完全正常直到后来把它改成才发现原来的版本在硬件上确实有问题。来看这个场景写一个“左移一位最低位补0”的移位寄存器。如果用阻塞赋值always (posedge clk) begin data data 1; data[0] 1b0; end因为阻塞赋值是顺序执行第一条先把data的左移结果写入data第二条再把最低位清零。看起来结果对但如果中间还有其他赋值语句或者某些工具做优化时中间状态可能被综合成额外的组合路径。一个更常见的写法是always (posedge clk) begin data data 1; data[0] 1b0; end两条非阻塞语句并行执行data[0] 1b0覆盖的范围是当前数据的最低位不会影响移位结果的其他位。这样写才是真正符合寄存器并行更新语义的。用版本在某些仿真器下也能得到正确结果但那是因为仿真器顺序执行掩盖了问题一旦换成复杂点的流水线设计就很容易翻车。4. 组合逻辑与时序逻辑RTL设计的两个基本世界4.1 组合逻辑assign与always (*)在RTL设计里我习惯把逻辑先分成两个大抽屉组合逻辑和时序逻辑。组合逻辑的特点是“输入一变输出立刻跟着变”没有记忆功能也没有时钟节拍。它适合用来做译码、选路、加减法这类纯运算。组合逻辑有两种写法。一种是用assign连续赋值assign sum a b; assign carry (a b) | (a sum) | (b sum);另一种是用always块配(*)”表示“括号里所有信号变化都触发”always (*) begin if (sel 1b0) begin out a; end else begin out b; end end需要注意一个新手容易踩的坑组合always块里必须把所有可能的分支都赋值否则会综合出锁存器。比如上面的if语句只有if没有else或者case语句没有default工具会认为“某些输入组合下变量保持旧值”于是自动生成一个锁存器。锁存器在时序分析里常常是让人头疼的对象所以写组合逻辑时务必养成习惯if语句一定配elsecase语句一定写default同一个变量在同一个组合块的所有分支里都要赋值。4.2 时序逻辑时钟沿与异步复位时序逻辑的核心是“记忆”它依靠时钟沿和触发器存储状态。代码层面几乎所有时序逻辑都会写成always (posedge clk ...)这样的形式。这里有个细节值得多说一句复位方式的选择。常见的有同步复位和异步复位两种。同步复位是“时钟沿来了且复位有效才复位”异步复位是“复位一有效立刻复位与时钟无关”。它们的写法分别是// 同步复位 always (posedge clk) begin if (!rst_n) q 0; else q d; end // 异步复位 always (posedge clk or negedge rst_n) begin if (!rst_n) q 0; else q d; end注意异步复位的写法把rst_n放进了敏感列表表示这个always块不仅由时钟触发还由复位信号的下降沿触发。实际工程中异步复位用的很多因为复位响应快但要注意异步复位释放时可能和时钟沿发生竞争所以往往还要加“异步复位、同步释放”电路。这个话题比较大这里先留个印象。另外一个细节是几乎所有人都会忽略的时序逻辑块里面不要写复杂的组合逻辑长链。比如在一个always块里写十几行if-else嵌套来算一个结果综合工具确实能综合出来但这条路径的时序裕量往往很紧张最后你可能要在代码里花很长时间去优化时序。我把这类问题归结为“布局意识”先想清楚哪些是组合逻辑部分、哪些是时序逻辑部分再动手写代码。4.3 计数器实例把两个世界连起来计数器是连接组合逻辑和时序逻辑的经典入门案例。一个带异步复位、使能和位宽参数的计数器大概是这样的module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (en) cnt cnt 1b1; end endmodule这个计数器里cnt 1b1是组合逻辑计算cnt寄存器存储当前计数值。两者在同一个always块里配合完成“每个时钟周期加一”的功能。这里最关键的一点是加1的操作虽然写在时钟块里但它本质上是组合逻辑在帮时序逻辑“算下一个状态”。把这一点理解透了后面写状态机时你就能自然地想到“次态逻辑”和“状态寄存器”的拆分了。5. 参数化设计位宽、常量与generate的工程价值5.1 parameter在实例化时改位宽我在学校或自学时写的模块经常是“写死的”比如计数器直接写reg [7:0] cnt觉得反正只用一个宽度。但一到项目和团队协作里参数化就是刚需同一个计数模块在A模块里需要12位在B模块里需要20位如果没有参数化就得复制两份代码改起来非常痛苦。参数化设计最基础的手段就是parameter。看下面这个例子module counter #( parameter WIDTH 8, parameter MAX_COUNT 255 )( 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_COUNT) cnt {WIDTH{1b0}}; else cnt cnt 1b1; end endmodule在顶层例化时可以直接覆盖默认参数counter #( .WIDTH(12), .MAX_COUNT(4095) ) u_cnt ( .clk(clk), .rst_n(rst_n), .cnt(cnt_12b) );这里有两个常见问题。第一个是位宽匹配参数定义的MAX_COUNT虽然是parameter类型但它本质是一个常量参与比较时工具会自动扩展位宽但如果你在localparam里用它做乘法就要注意位宽是否足够。第二个问题是MAX_COUNT与WIDTH配合时如果WIDTH8但你传MAX_COUNT1000比较逻辑需要9位才能放下1000的二进制表示而cnt只有8位那么cnt MAX_COUNT永远不成立。这种参数之间互相打架的问题要在定义处就把取值范围想清楚。5.2 generate批量生成模块比parameter更进一步的是generate语句它可以按条件或循环“批量”生成逻辑。这在处理位宽不一致的接口、多通道数据通路时特别有用。下面是一个用generate循环生成多路寄存器的示例module multi_reg #( parameter CH_NUM 4, parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire [CH_NUM*WIDTH-1:0] din, output reg [CH_NUM*WIDTH-1:0] dout ); genvar i; generate for (i 0; i CH_NUM; i i 1) begin : gen_ch reg [WIDTH-1:0] data_r; always (posedge clk or negedge rst_n) begin if (!rst_n) data_r {WIDTH{1b0}}; else data_r din[i*WIDTH : WIDTH]; end assign dout[i*WIDTH : WIDTH] data_r; end endgenerate endmodule这里我用了一个“部分选择位”的写法din[i*WIDTH : WIDTH]它是Verilog-2001引入的表示从i*WIDTH开始取WIDTH位。用固定写法的din[i*WIDTH : i*WIDTHWIDTH-1]也完全可以但:写起来更简洁也少出低级错误。generate循环体里的begin : gen_ch必须加名字这是为了给每个循环复制出来的模块块一个唯一标签仿真时在层级面板里看得到排查问题也方便。6. 状态机让逻辑按“步骤”跑起来6.1 状态机的三段式写法只要逻辑里出现“先做什么、再做什么、根据条件跳到哪里”状态机就是最自然的结构。即使是写一个简单的通信时序我基本都会选用状态机而不是一连串计数器去硬凑因为状态机的可读性和可维护性高出好几个量级。状态机最常见的两种分法是Moore型和Mealy型。Moore型输出只取决于当前状态Mealy型输出取决于当前状态和输入。工程里用三段式的写法最多也就是把状态寄存器、次态逻辑、输出逻辑分别用三个always块写。// 状态编码 localparam IDLE 2b00; localparam START 2b01; localparam DATA 2b10; localparam DONE 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 next_state state; case (state) IDLE : next_state start_en ? START : IDLE; START : next_state DATA; DATA : next_state (data_cnt 3d7) ? DONE : DATA; DONE : next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑 always (*) begin tx_en 1b0; done 1b0; case (state) START : begin tx_en 1b1; end DONE : begin done 1b1; end endcase end注意在这里我习惯把第三段的输出逻辑写成组合逻辑并且在每个分支开始处先给默认值。这个习惯很重要只要case覆盖不全或某些信号在某个state下没赋值综合工具就会给这个输出变量插一个锁存器。在时序逻辑里输出锁存器往往不明显但会在时序分析里头疼很久。我在初学状态机时被这个坑折腾过所以现在写状态机前一定先把所有输出信号的默认值列出来再写分支。6.2 状态编码的选择与工程取舍状态编码看起来只是一个细节实际会影响面积和时序。三种常见编码方式二进制编码状态用顺序递增的二进制数表示用的触发器最少适合状态数不多、对面积敏感的模块。格雷码相邻状态之间只有一位发生变化适合状态跳转路径较长的场景能降低毛刺概率。独热码每个状态用一个bit表示N个状态需要N个触发器。综合后输出逻辑简单时序更容易满足状态数少于十几个时很流行。独热码的做法很多人刚学时觉得浪费但实际FPGA资源里触发器相对富裕而组合逻辑查找表才是稀缺资源。独热码让状态译码逻辑最简单常常更容易把时序跑收敛。我在实际项目里状态数少于8个时无脑用独热码状态数多到超过十几个才去仔细评估二进制或格雷码。最后说一个状态机调试时的建议如果仿真波形里状态跳转不对先不要急着看整个信号树先把state和next_state两个信号加到波形面板里对照。这两根信号一个能告诉你当前在哪一个能告诉你接下来要去哪一旦两者对不上次态逻辑里一定有某个case分支没有覆盖到。这是我排查状态机问题最快的路径几乎每次都管用。这篇笔记写到这里我自己回头看发现真正的重点其实不在于语法本身而在于用硬件并发和时序的眼光去理解每一行代码背后的电路。Verilog的语言基础知识点本来就不少但把它们串起来的主线永远是那句老话你写的是电路不是程序。下一部分我打算接着写常用接口模块的实现思路到时候再把这个基础框架往里填肉。就先聊到这。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询