
1. 从这代码能综合成啥说起理解Verilog的双重身份前阵子带一个刚入行的兄弟调板子他写了个模块仿真波形怎么看怎么对功能仿真全绿可一上板子就乱跳。折腾了一下午最后发现是他对Verilog的理解出了根本问题——他一直把Verilog当成画波形图的高级语言却忘了它描述的是物理存在的电路。这件事让我觉得很多初学者甚至有些干了几年的人对Verilog描述电路和Verilog描述信号的流动这两句话的理解其实都停留在表面。先把这个根本问题说清楚。Verilog这个语言有双重身份一方面它是描述电路结构的语言你可以用它来例化门、例化模块、连线本质上是用文本来画电路图另一方面它是描述信号行为的语言你可以用它来描述当clk上升沿到来时reg_a reg_b 1本质上是用代码来规定信号怎么变。前一种叫结构描述后一种叫行为描述。真实工程里99%的代码是行为描述但最终行为描述又会被综合工具转回结构——也就是真正的门级网表。这两者的关系决定了Verilog不像C语言那样怎么写就怎么执行而更像是你写一份需求说明书编译器给你翻译成电路。既然是需求说明书你写的每一句话都要能被翻译成物理上存在的东西。你写a b c综合器就会真的去给你找一组加法器门电路你写always (posedge clk)综合器就会真的去给你找一个带时钟端的触发器。写Verilog的人脑子里必须时刻有一张电路图否则你写出来的东西仿真能过、上板就挂原因就在这里。这篇文章我想从描述电路和描述信号流动这两个视角切入把Verilog最核心的思维方式讲透。内容不会有太多花架子都是我自己写代码、调板子、看综合报告时积累下来的理解尽量讲大白话。适合刚学Verilog的入门者也适合那些能写代码但总觉得差点意思的人。搞懂这两个概念你会发现自己看代码的眼光完全不一样。2. 两种描述方式先弄清楚代码和电路怎么对应2.1 结构化思维Verilog代码就是电路图的文字版我们先看最直观的描述电路。这种写法在教科书里叫结构描述说白了就是你像搭积木一样把一个个门电路、一个个模块用线连起来。举个最简单的例子你要实现一个与门module and_gate( input wire a, input wire b, output wire y ); and (y, a, b); // 例化一个与门输入a和b输出y endmodule这就是电路图的文字化and是门(y, a, b)是接线括号里第一个是输出后面是输入。综合工具看到这段代码基本不会做什么聪明的事它直接映射一个AND门进去。你画电路图是什么样综合出来就是什么样。更接近工程的是模块例化。比如你要做一个8位加法器你不会自己从门级开始画而是直接例化一个现成的加法器模块adder_8bit u_adder ( .a (data_a), .b (data_b), .cin (carry_in), .sum (data_sum), .cout (carry_out) );这种写法就像你在PCB上摆芯片u_adder是芯片位号.a(data_a)表示这个引脚接到哪根网络。用过原理图工具的人对这种描述方式会特别亲切因为它本质上就是原理图的文本化。2.2 行为化思维Verilog代码是在规定信号的动作与结构描述相对的是行为描述。行为描述不关心电路长什么样只关心什么时候什么条件满足哪个信号应该变成什么值。这是现在绝大多数RTL代码的形式。// 上升沿触发的D触发器 always (posedge clk) begin q d; end这段代码你一读就知道意思时钟上升沿的时候q把d的值采进来。但注意这里没有任何关于电路里有什么的描写。综合器看到这句话会自己去判断哦这是时序逻辑那我放一个D触发器进去。同理// 组合逻辑assign 连续赋值 assign y (sel 1b1) ? a : b;这是一句话它描述的是y这个信号随时跟着sel、a、b的变化而变化——一种流动的状态。综合器看到这句话会去实现一个多路选择器的结构。Verilog描述信号的流动最核心的载体就是assign语句和always块。assign描述的是信号持续跟随的流动always描述的是信号在事件触发时瞬间变化的流动。你写Verilog的时候本质上就是在规定信号的源头在哪、什么时候动、怎么动。2.3 为什么这两种区分这么重要综合器的翻译规则很多人写代码的时候意识不到你写的是行为描述但综合器最终会把它变成结构描述。也就是说你在行为描述里写的每一句话综合器都在背后给你翻译成一种固定的电路模板你写的代码综合器翻译成的电路assign y a b;一个与门或等效逻辑assign y (sel) ? a : b;多路选择器always (*)里的 if/else优先编码器结构的组合逻辑always (posedge clk)触发器组寄存器always (posedge clk)里用了触发器 加法器组合这个翻译规则意味着同一个功能可以有无数种写法但综合出来的电路质量完全不同。我见过有人写一个简单的计数器用了三层if嵌套综合出来一个巨大的LUT阵列时序收敛一堆问题。为什么因为if的嵌套天然映射成优先编码器的链式结构即使你从逻辑上看这些分支根本不会同时成立综合器也猜不到你脑子里想的我其实是想写一个多路选择器。所以写Verilog的时候你要么在想我要连接什么电路要么在想我要生成什么电路模板。这两个视角必须随时切换。结构化的思维帮你看懂模块之间的关系行为化的思维帮你把算法转换成电路两者缺一不可。3. 计数器与状态机最经典的行为描述实例里信号是怎么流的3.1 一个计数器代码的两种读法先说个最经典的例子——计数器。这是几乎所有人入门Verilog写的第一个时序逻辑网上搜verilog计数器有一大堆写法。最常见的版本是这样的module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, output reg [WIDTH-1:0] count ); always (posedge clk or negedge rst_n) begin if (!rst_n) count {WIDTH{1b0}}; else count count 1b1; end endmodule一般来说教程会告诉你这是计数器时钟上升沿计数加一复位清零。但我要说的是另一种读法这个代码里信号的流动路径是什么样的第一注意count是reg类型它在always块里被赋值。凡是always (posedge clk)块里赋值的变量综合后必然是触发器不会是纯组合逻辑。第二看赋值表达式count 1b1这个加法器的输入是当前count的输出而加法结果又回到count的输入。这就形成了一条回路触发器输出 - 加法器 - 触发器输入并在下一个时钟沿被采样。这就是描述信号的流动最直观的例子。信号不是凭空产生的它是从某一个状态出发流经一段逻辑加法器在时钟边界处被锁存成新的状态然后再流出去。你写代码时脑子里想的应该是这条流动路径而不仅仅是加一这件事。3.2 reg与wire类型背后的物理意义在我带过的所有新人里几乎每个人都被reg和wire搞晕过。有人背口诀always块里用regassign里用wire背是背住了但遇到复杂一点的情况就翻车。我换个方式讲。wire对应的是物理导线它的值不是自己存出来的而是由驱动源持续决定的驱动源没了它就成了高阻Z。reg对应的是存储单元——综合后的物理实现通常是触发器在always时序块里或者只是一个节点在always组合块里。需要强调一点Verilog里的reg不一定真是寄存器它只表示在过程块中被赋值这个语法属性。真正决定它是触发器还是纯组合逻辑取决于你在什么样的always块里给它赋值。这里给新手一个判断方法assign语句只能给wire赋值对应持续信号流动always (*)块给reg赋值综合后通常变成组合逻辑你是不是觉得逻辑上存了点什么其实综合器会帮你化简成组合网络always (posedge clk)块给reg赋值综合后必然是触发器。1. assign y a b; // y是wire对应一个组合逻辑输出 2. always (*) begin // y2是reg对应组合逻辑 if (sel) y2 a; else y2 b; end 3. always (posedge clk) // q是reg对应触发器 q d;第2种情况虽然y2声明为reg但综合结果不会是一个寄存器因为always块不是由时钟驱动的而是当右侧变化时就执行这跟组合逻辑的行为完全一致。记住这个很多混淆自然解开。3.3 状态机里的信号流动比计数器复杂一个量级的例子状态机是另一个典型的信号流动场景。以一段式状态机为例很多初学者最大的问题是把状态转移和输出逻辑混在一个always里代码越写越乱时序越来越差。我推荐拆成两块或三块来写// 状态寄存器 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: if (start) next_state RUN; RUN: if (done) next_state IDLE; default: next_state IDLE; endcase end看到这里的信号流动了吗state是一个触发器组它输出给组合逻辑块组合逻辑块根据state和外部输入算出next_statenext_state又被state触发器在下一个时钟沿采入。一条环形流水状态输出 - 组合决策 - 下一状态输入 - 再锁存。这种流水的思维是理解所有时序电路的关键。你不需要把状态机想象成什么高大上的东西它就是一个带反馈的电路环只不过反馈回路中间加了一个叫时钟沿的阀门每个阀门周期只允许信号通过一次。4. 流水线设计信号流动最密集的场景也是新手最容易翻车的场景4.1 为什么要打拍子从一段数据链路说起如果你搜流水线 verilog会看到大量关于流水线的讨论但很多都停留在概念层面。我这里用最直白的例子说明信号是怎么流动起来的。假设你要实现一个三级处理链路第一级做乘法第二级做加法第三级做比较。最粗暴的写法是always (posedge clk) begin result (a * b c) threshold; end这段代码功能上没错但综合器会告诉你组合逻辑路径太长寄存器的Q端到下一级寄存器的D端中间夹了一个乘法器加一个加法器加一个比较器。在FPGA上查找表LUT的延迟是固定的你链条越长最高时钟频率就越低。流水线的思路是把一条长链路切成几段每段中间插入寄存器。第一级只做乘法把结果存下来第二级用第一级的结果做加法再存下来第三级做比较。从信号的角度看数据像传快递一样一站一站往下传。代价是多了一些寄存器硬件资源收益是最高时钟频率大幅提升。// 第一级乘法 always (posedge clk) begin mul_result a * b; end // 第二级加法 always (posedge clk) begin add_result mul_result c; end // 第三级比较 always (posedge clk) begin final_result (add_result threshold); end4.2 打拍到底是在打什么很多人听到打拍这个词可能觉得玄乎。其实打拍就是用寄存器把信号往后延迟一个时钟周期。为什么要打拍因为数字电路的时序分析是站在触发器视角看的。一个信号从源触发器到目的触发器中间经历的组合逻辑时延不能超过一个时钟周期。如果组合逻辑太长时延超了目的触发器采到的信号就可能是不稳定的中间值这就是时序违例。打拍子相当于在组合逻辑中间加了中转站把一次长距离传输拆成几段短距离传输。但你也会付出代价输出比输入延迟了多个周期。如果你的设计有反馈回路——比如状态机根据输出调整输入——打拍会导致环路延迟增大这个在设计中要特别小心。4.3 流动中的对齐容易踩的坑流水线最大的坑是信号对齐。比如一条数据通路经过3级流水线但某个控制信号只打了1拍就到下游模块了结果控制信号和数据的时序对不上下游模块在错误的时间采样了错误的数据。这种问题在仿真里经常会以竞争冒险的形式出现在真实芯片上可能就是偶发功能错误非常难排查。我的习惯是在做流水线时单独写一个对齐检查的断言property align_check; (posedge clk) valid_in |- valid_out [*3]; endproperty意思是valid_in为高后经过3拍valid_out必须为高。这个断言在仿真中能帮你快速定位到底哪一级没对齐省下大量查波形的时间。5. 综合工具背后的电路思维行为描述是如何落地为真实电路的5.1 综合过程到底做了什么前面讲了行为描述会被综合器翻译成电路。但翻译不是简单的映射它在背后做了一系列优化。综合的三大步骤语法分析 - 逻辑综合 - 门级映射。逻辑综合阶段又分为转化成布尔方程RTL编译、逻辑优化化简、公共因子提取、工艺映射映射到你选定的FPGA或ASIC库上。我提这个是想告诉你你写代码的风格直接影响综合器能不能做好这些优化。综合器不是万能的它在你写的RTL之上做的优化受限于代码的结构。比如你写case综合器通常会生成多路选择器网络你写if...else综合器倾向于生成优先编码器结构。同样的功能case和if...else综合出来的延时、面积往往差别很大。当分支条件互斥时用case更合理。5.2 阻塞赋值与非阻塞赋值一个决定电路正确性的细节在行为描述里和的选择是个老生常谈的话题但我还是想再说一次因为它直接关系信号流动的语义。组合逻辑用阻塞赋值时序逻辑用非阻塞赋值。这是RTL编码的铁律违反它的设计在仿真和综合后行为不一致的概率非常大。// 这样写是正确的时序逻辑用 always (posedge clk) begin a b; c a; end上面这段代码中两个赋值是同时完成的。c在下一个时钟沿采到的a是旧值b也是旧值。如果用就变成了先算a b再算c a而这里的a已经是新值了逻辑完全不同。综合器是按照物理电路规则来理解代码的一个触发器输出到另一个触发器输入采样到的必然是一个时钟周期之前的状态。只有才能准确表达这种旧状态 - 新状态的流动。5.3 长组合逻辑 vs 流水线的经典取舍开头提到过很多初学者写完代码发现时序收敛不了归根结底是组合逻辑链太长。这时候有两条路一是把组合逻辑拆开插入流水线寄存器提高频率)二是优化算法本身的复杂度减少逻辑级数。在FPGA上除了插入流水线寄存器还可以用FPGA自带的DSP单元来替代乘法器的LUT实现因为DSP硬核的路径通常是经过精心优化的时延比LUT搭建的组合逻辑短得多。我在实际项目中遇到过这样的情况一个18x18的有符号乘法加一个28位的加法直接写在assign里在-2速度等级的Kintex-7上跑到200MHz已经是极限。改用DSP inference即在写法上让综合器推断使用DSP单元后频率直接上到350MHz。写Verilog不光是写功能还要了解你手里的器件有什么料用什么写法能把这些料的性能榨出来。6. 仿真与原语从波形看清信号流动的真相6.1 用波形看信号流动前面说的都是理论真正的信号流动肉眼是看不到的。这就引出了仿真在Verilog设计里的特殊地位。搜modelsim如何仿真verilog文件能找到一堆教程但我发现很多人把仿真仅仅理解为跑一下看波形对不对其实仿真更大的价值是调试信号流动的逻辑。我一般会在仿真里做三件事设置足够长的仿真时间确认上电与复位阶段的信号行为符合预期对内部关键信号添加监视追踪一条数据从输入到输出的完整流动链路亲手确认途中的每一级寄存器延迟是否如设计所料故意注入异常输入观察信号是如何被传播到后续模块的这能帮你评估模块对错误输入的容错能力。仿真不是目的是手段。你真正要搞清楚的是那个信号它是从哪来的经过了哪些逻辑为什么会变成现在这个值6.2 仿真数据导出、ILA抓信号实战中常用的验证手段如果做FPGA开发除了功能仿真外还有两块很常用的验证手段一是仿真结果导出到文件二是上板后用ILA抓信号。网上搜verilog仿真数据存成文件能找到$fopen、$fwrite的用法。我这里给个更为完整的例子integer fp; initial begin fp $fopen(sim_result.txt, w); end always (posedge clk) begin if (data_valid) begin $fwrite(fp, %d %d\n, data_in, result_out); end end把这个仿真跑完后打开txt就能直观看到每个有效周期里输入输出数据的长相。至于ILAIntegrated Logic Analyzer真正调试时如果抓信号没有反应不外乎几个原因ILA的时钟域和被测信号时钟域不一致、触发条件设置太苛刻、探针信号被优化掉了综合器把没用到的信号优化没ILA自然抓不到。解决信号被优化掉的办法是把要观测的信号用(* keep true *)标记强制保留(* keep true *) wire debug_signal;这是我在实际调试中踩过很多次坑后总结出来的经验。6.3 从仿真正确到上板正确的距离仿真绿色通过不代表上板一定正确很多因素仿真里根本模拟不到。最常见的几类差异包括仿真环境真实环境时钟是理想信号有时钟抖动、skew信号是理想电平有毛刺、上升/下降时间模块间无时序偏差有时序违例风险复位瞬间理想有复位撤销时间约束所以规范的流程一定是功能仿真通过后还要做时序仿真后仿真然后把工程跑上板用ILA抓真实信号。只有三步都通了设计才算真正完成。7. 从会写到会设计结构描述与行为描述的正确打开方式7.1 该用结构描述的时候别偷懒虽然大多数情况下行为描述能搞定一切但在某些场景结构描述是不可替代的。比如端口复用、参数化模块例化、跨时钟域处理中的同步器等用结构描述能让设计意图更清晰也让代码的可维护性更好。拿跨时钟域同步器来说每个同步器就两个触发器串在一起你当然可以写always (posedge clk2) begin sync_reg1 async_signal; sync_reg2 sync_reg1; end但更好的做法是把同步器封装成一个独立模块上面那一句放在模块内部。功能上完全一样但封装成模块之后你可以给它加约束、加注释、甚至在结构上约束布局——这在多时钟域模块里价值极大。7.2 层次化设计是描述电路的骨架我在工作中见过很多一个大always块写300行的代码。这种代码功能上可能没错但无论是仿真调试还是后期维护都是灾难。好的RTL设计一定是层次化的顶层只做模块例化和连接具体的功能在下一层实现下一层又可以分为更小的模块。顶层模块化描述看起来就像这样module top ( input wire clk_50m, input wire rst_n, output wire [7:0] led ); wire clk_100m; wire rst_sync; clk_gen u_clk_gen ( .clk_in (clk_50m), .clk_out (clk_100m) ); rst_sync u_rst_sync ( .clk (clk_100m), .rst_n_in (rst_n), .rst_n_out (rst_sync) ); led_driver u_led ( .clk (clk_100m), .rst_n (rst_sync), .led (led) ); endmodule顶层的每个模块就像芯片一样有明确的接口信号你一眼就能看出系统的骨架时钟从哪来、复位怎么同步、功能模块有哪些。这种看顶层就知道系统长什么样的可读性就是你说的描述电路——只不过是在更高一层用结构描述描述电路。7.3 状态编码方式信号流动的另一维度状态机作为行为描述的代表还有一个细节值得一说状态编码。独热码one-hot在FPGA上很流行因为每个状态只用一位为1状态译码只需要比较一位所以组合逻辑路径短、时序好。代价是寄存器多。二进制编码反之寄存器少但译码逻辑复杂。格雷码适合状态连续跳变的场景相邻状态只有一位变化能降低毛刺风险。比如搜轮询仲裁器verilog很多人第一版写成case (state) 3b000: grant req[0]; 3b001: grant req[1]; 3b010: grant req[2]; //... endcase用二进制编码看着挺省寄存器但状态跳变时多个位同时翻转如果采样时机不对可能采到中间态。改为独热码比如8b00000001、8b00000010…虽然浪费了寄存器但状态判断变得极其简单时序也更稳。这是典型的用资源换时序的设计选择。7.4 端口、参数与模块化让代码真正可复用最后提一下可复用性。好的模块应该通过参数和端口把变化的部分暴露给使用者核心逻辑封装在内部。以UART模块为例module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire [7:0] data_in, input wire data_valid, output reg txd ); // 内部实现 endmodule这样写的价值在于换一块50M的板子或者改成9600波特率你不需要改模块内部任何一行代码只要改顶层例化的参数就行。这不是什么高深技巧但它能直接减少你日常改代码的时间。工程里大量的时间是花在适应新环境上的参数化把这种适应成本降到最低。8. 个人经验与建议说了这么多最后分享一点我自己的体会。写完Verilog代码一定要做代码审查。找个有经验的人帮你看一遍代码比自己埋头查效率高太多。代码审查的要点不在于功能对不对而在于这个代码是否能被综合器高效地翻译成电路。我见过的很多问题——比如组合逻辑过长、敏感列表不完整、非阻塞赋值误用成阻塞赋值——都是在这种代码审查里发现的而不是在仿真波形里查出来的。另外做FPGA开发一定要学会读综合报告。ISE/Vivado/Quartus都会输出资源占用、时序报告。资源占用能告诉你每个模块消耗了多少LUT、FF、DSP时序报告能告诉你关键路径在哪。我见过太多人写完代码综合报错或时序违例后第一反应换更高端的芯片——但换个心态先打开综合报告把关键路径里的组合逻辑拆掉几级或者改成流水线可能根本不用换芯片就解决了。还有一点写Verilog之前先画草图。哪怕是在纸上画个松松垮垮的框图把信号流的方向标出来把寄存器标注清楚再动手写代码。我发现凡是这么做的人代码写出来基本一遍过而上来就写代码的人往往在调试上花的时间是前者的好几倍。Verilog的难点从来不在语法在于你是否真的想清楚了信号应该怎么流。希望这篇内容能帮你找到描述电路和描述信号流动的感觉。代码可以抄但电路思维必须自己搭起来。这条路没有捷径多写、多综合、多调试慢慢就通了。