你是不是也干过这事儿拿到一个功能需求文档都还没翻完就打开编辑器开始敲 Verilog敲着敲着发现接口定义不对、时钟域没想清楚、复位信号不知道挂哪然后开始大片大片地改代码——一天的时间先废一半。这不是新手才犯的毛病我在 FPGA 项目里见过太多人把“FPGA设计流程”和“写Verilog”直接划等号代码写得很嗨但一到综合、跑仿真就开始原地打转。其实Verilog 输入只是整个流程中很小的一段而且是所有下游环节的地基地基没扎实后面全是坑。这篇文章就是针对 FPGA 设计流程里的“Verilog 输入”阶段写的一份实操笔记。不讲玄乎的理论只讲你动手写 RTL 之前、之中、之后到底该做什么、怎么想、怎么查我会把验证流程、综合、时序收敛阶段对 RTL 输入的隐含要求也一并说清楚。适合刚入门想建立系统认知的新人也适合写了几年代码但全靠试错、没有建立条理的老手对照着查缺补漏。1. FPGA全流程里Verilog输入到底占哪一段1.1 把完整流程先铺开看一遍很多教程一上来就是“FPGA设计流程包括设计输入、综合、实现、下载……”把流程画成一条直线仿佛你写完代码它就自动变成芯片了。但实际上一个正经项目里的 FPGA 开发流程更接近一个迭代循环每一个阶段都会反过来影响你的 Verilog 写法。我自己习惯把流程拆成这样需求分析与方案设计搞清楚功能、性能指标和接口确定用哪颗芯片、哪些 IP、多少资源然后画模块框图。模块划分与接口定义这就是为写代码做施工图每个模块的端口信号、位宽、时序约定这个阶段定得越细后面返工越少。RTL 编码也就是你写 Verilog 的阶段。这不仅是“把设计想法翻译成代码”还需要同步考虑可综合性、可验证性、可维护性。功能仿真用 testbench 验证逻辑行为是否符合设计文档。逻辑综合把 RTL 映射到目标 FPGA 的查找表、触发器和 BRAM 等资源上综合工具会检查语法合法性并给出资源、频率预估。布局布线实现把综合后的网表放到实际 FPGA 布局布线会引入实际延迟。时序分析检查所有路径是否满足建立时间和保持时间不满足就得回到 RTL 或约束去改。板级调试与验证烧到片子上用逻辑分析仪抓波形、用在线逻辑调试工具观察内部信号。Verilog 输入在中间的“RTL 编码”环节但它的质量直接影响从功能仿真到板级调试的所有步骤。同样的功能写法讲究和不讲究综合出来的频率能差 50MHz排错时间能差两三倍。1.2 为什么说 RTL 编码是“设计输入”而不只是“写代码”在工程语境里Verilog 输入的价值不只是交付编译器能通过的文本而是把人的设计意图准确、完整、可验证地表达出来。对下游工具而言RTL 是综合、布线的输入对同事和后续维护者而言RTL 是最高频的设计文档对你自己的项目周期而言RTL 的编码阶段往往是隐藏成本最高的环节——因为思路一乱改代码的时间会几何级上涨。所以在开始编码前你得先接受一个定位写 Verilog 的人不是“程序员”而是“电路设计者”。同样的语句软件工程师关心它在 CPU 上怎么一步步执行而你关心它综合出来后对应什么硬件结构reg 是触发器还是寄存器组always 块描述的是组合逻辑还是时序逻辑case 会不会生成优先编码器还是译码器。2. 动手敲Verilog之前先逼自己做完这三件事2.1 把需求翻译成一张信号接口表不夸张地说我至少见过三次返工项目做到一半发现某个模块的输出位宽当初拍脑袋定的现在算法那边要求 16 位精度你只留了 8 位所有下游模块全要改。这种事说白了就是动手前没把接口定义清楚。所以在写任何一行 Verilog 之前先做一张接口表。我一般会在项目文档里按模块画一张表格信号名方向位宽时钟域有效电平/时序要求说明clk_sysinput1全局上升沿有效系统地 100MHzrst_ninput1异步低电平复位异步复位需要同步释放data_ininput8clk_sys与 clk_sys 对齐高电平有效信号data_valid_ininput1clk_sys高电平持续一拍表示 data_in 有效data_outoutput16clk_sys寄存器输出延迟一拍输出busyoutput1clk_sys高电平表示忙模块忙时忽略输入这张表是你和算法同事、上层模块设计者之间最基础的语言。特别要明确时钟域和有效电平不然别人接你的模块时序图根本画不对。接口表你花半天列好后面编码、仿真、极级调试都能省大把时间。信号命名这件事我也强调过很多次别再用 a、b、c、temp、temp1 这种名字。真实项目中信号一眼要能看出“干什么用、哪来的”。一个实用的小规范时钟用 clk_xxx复位用 rst_n带 _n 表示低有效有效指示信号用 xxx_valid数据用 xxx_data。位宽能用多少位就写多少位不要偷懒写 32哪怕只有两个取值应该用localparam或直接写 1 位。2.2 画模块框图哪怕只在草稿纸上画人脑对纯文字的抽象不够所以哪怕是很简单的功能我也建议画一张模块框图。画的时候你可能发现数据通路中某两个模块之间需要握手信号但你之前根本没定义或者你发现一个模块里混了两个时钟域的信号根本没法在一个 always 块里处理必须拆开。模块框图不需要画得多专业。画清楚这么几件事输入输出信号的走向每级之间的数据流有没有跨时钟域的边界哪些地方用 FIFO哪些地方用寄存器打拍关键状态机放在哪个模块。这张图画完你的 Verilog 文件结构基本就出来了一个模块一个顶层或者一个文件。2.3 确定复位策略和时钟方案复位和时钟是 RTL 编码里最容易被轻视、又最影响成败的两个点。很多初学代码把复位信号随便接到某个逻辑门上这样做在仿真里也许“看起来正常”但实际上板卡一上电系统各处复位撤销的时间不一致很容易出现亚稳态问题。复位策略业界最常用的是异步复位、同步释放。也就是 rst_n 作为异步信号直接复位触发器但释放时必须经过一个同步器让所有触发器在同一个时钟沿之后一起离开复位状态。在 Verilog 里最简单的实现可以这样写reg rst_n_sync1; reg rst_n_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rst_n_sync1 1b0; rst_n_sync2 1b0; end else begin rst_n_sync1 1b1; rst_n_sync2 rst_n_sync1; end end assign rst_n_internal rst_n_sync2;这段代码综合出来就是两级同步触发器它的意义在于保证整个设计中所有使用 rst_n_internal 的模块能在一个时钟周期内同步撤销复位。时钟方案呢尽量保持单时钟。能用一个时钟域解决问题就不要自己造两个时钟出来。如果确实有不同速率的接口该用异步 FIFO 或者时钟域转换模块就要用不要在 always 块里直接用另一个时钟的信号。你无法保证两个时钟的相位关系组合逻辑很容易出现时序违例。3. RTL不只是“程序”可综合Verilog的思维切换点3.1 从“顺序执行”切到“并行发生”写惯了 C、Python 的人刚开始写 Verilog 的时候最容易犯一个思维错误觉得代码是从上往下按顺序执行的。但在硬件描述里所有的 always 块和 assign 语句在仿真时都并行执行综合出来也是并行电路。每一行代码不是“一条指令”而是“描述了一部分硬件结构”。举个例子你写几个 always 块分别处理 data_a 和 data_b即使代码里 data_b 的 always 块写在 data_a 之后综合出来它们也同时工作。时序逻辑靠时钟沿统一触发组合逻辑靠输入信号变化触发。如果你脑子里带着软件执行顺序你会写出指望“前面的代码先跑完后面的代码再用结果”的电路那就不可能综合出正确结构。3.2 阻塞赋值和非阻塞赋值的“一个规则”阻塞赋值和非阻塞赋值的区别是 RTL 编码中被讲烂、也被错误使用最多的地方。我见过有人为了图省事把所有信号都写成非阻塞或者都写成阻塞仿真也能“跑通”但综合出的电路不一定是你想要的。工程上最稳的一条规则照抄就可以时序逻辑always (posedge clk)用非阻塞赋值组合逻辑always (*)用阻塞赋值原理上非阻塞赋值在仿真时是“先统一计算右边的值再在时钟沿同时更新左边的变量”这正好对应触发器的并行更新行为。而阻塞赋值是立即赋值适合用来描述组合电路中的信号传递。如果把时序逻辑里写成阻塞赋值比如这样always (posedge clk) begin a data_in; b a; end综合工具也能给出寄存器链但仿真行为里 b 拿到的是 a 更新后的值和实际硬件中“a 和 b 在同一个时钟沿才更新”的行为不一致仿真和硬件行为对不上后期排错会很痛苦。还有一点在同一个 always 块里同一个变量不要在多个分支里赋值。比如用 always (*) 写组合逻辑时如果某个条件下你没给输出赋值综合工具就会推断出一个锁存器而且通常不是你想要的。检查方法很简单条件分支写全case 写 default组合逻辑里的所有输入路径都要对输出赋值。3.3 敏感列表与 always 块描述的电路结构敏感列表是另一个基础但关键的点。时序逻辑的敏感列表一般写(posedge clk)组合逻辑建议写(*)代表块内被读取的所有信号。老代码里会看到把组合逻辑敏感列表手写成一长串信号的比如always (a or b or c or en) begin // 组合逻辑 end这种写法非常容易漏信号漏掉一个仿真行为就会出错综合工具也会产生“推导但未使用”的警告。现在都写(*)方便又安全。综合出来的结构对应到脑海里应该是这样时序逻辑的 always 块里每个非阻塞赋值的变量都对应一个 D 触发器。组合逻辑的 always 块对应一堆门电路。module 的每个端口对应芯片引脚或模块接口。当你能把 Verilog 语句翻译成这些结构你已经从“写代码的人”升级成“设计电路的人”了。3.4 状态机最常见的RTL类型也是最多bug的来源有限状态机FSM几乎出现在所有控制逻辑里但它的写法风格差异很大。推荐写法是“一段式、两段式、三段式”里的三段式第一段时序逻辑状态寄存器更新。第二段组合逻辑根据当前状态和输入决定下一状态。第三段时序逻辑或组合逻辑根据当前状态产生输出。三段式的好处在于结构清晰便于维护输出寄存器化后不会出现毛刺。最怕的是把所有逻辑堆在一个 always 块里一个状态机的 state 变量、next_state 变量、输出逻辑全混在一起。刚开始写的时候是这样“很爽”但二周后你自己回来看都会觉得头大。一个典型的状态机模板我放在项目里反复用parameter IDLE 3d0, SEND 3d1, WAIT 3d2, DONE 3d3; reg [2: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 ? SEND : IDLE; SEND: next_state WAIT; WAIT: next_state timeout ? DONE : WAIT; DONE: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑建议寄存器输出 always (posedge clk or negedge rst_n) begin if (!rst_n) done_reg 1b0; else if (state DONE) done_reg 1b1; else done_reg 1b0; end仔细看这个模板default 分支是必须的不然综合工具可能推断出锁存器输出用寄存器打一拍避免组合输出毛刺影响下游。事实上我在时序验证阶段反复遇到的问题有一半以上都能通过这种“给输出加一级寄存器”解决。4. 用IDE跑通一次Verilog输入与RTL检查4.1 新建工程时把环境和文件组织先理顺讲到具体操作我现在主要用的工具链是 Vivado 和 Quartus 两套不同项目环境不一样但流程核心是一致的。以 Vivado 为例你新建工程时它会一路问你要不要加约束文件、要不要选芯片型号很多新手一路 Next 点到底后面折腾半天发现工程里压根没约束文件。正确的做法是创建工程时先给工程取有意义的名字最好包含项目和版本比如uart_top_v1_2。芯片型号提前确认好别等到要板级验证了才发现选错了封装。创建完再手动添加 RTL 源文件并让人口 top module 名字和顶层文件名保持一致。文件组织方面我在项目里基本保持这样一个目录结构rtl/所有 Verilog 设计源文件sim/所有 testbench 和仿真脚本constr/约束文件 xdcdoc/设计文档、接口表、原理图别小看这种习惯。当你的工程有几十个 .v 文件你还能三秒定位出自己的顶层模块在哪这本身就是效率。4.2 RTL Analysis 到底分析的是什么写完代码后在 Vivado 里点一把Run RTL Analysis不要直接进入综合。这一步会让工具做语法检查、模块例化关系检查、RTL 级网表分析还会报出很多你可能忽略的错误比如模块没有实例化、端口位宽不匹配、没有主时钟约束、未连接的引脚等。这一步对 Verilog 输入的帮助特别大因为它已经在“逻辑网表”的层面理解你的代码了。它能看到你例化的子模块是不是全部存在、有没有多驱动、有没有未连接的信号。很多问题你靠眼睛检查不出来工具一眼就能看到。我建议每次写完一个功能模块就点一次RTL Analysis而不是把所有代码写完再一次性检查。这里的经验是小步迭代错误定位成本低。人的注意力是有限的两百行代码里找 bug 比二十行里找 bug 难十倍。4.3 仿真验证不能靠“肉眼盯着波形图”仿真验证是确认 RTL 行为正确的主要手段。最简单的验证途径就是用 testbench 给被测模块喂激励然后看波形、看打印信息。但这里面有大量的人为疏忽空间。我就见过有同事“看起来波形对”就把代码交了结果稍微换一组输入数据状态机就走飞了。更可靠的做法是写“自检查式 testbench”用$display打印关键节点的数据用assert或条件判断自动比对输出结果与期望值用$finish在出错时结束仿真。简单地说仿真结束你希望能看到一串 PASS/FAIL 的打印记录而不是人类去肉眼数波形。随着设计复杂度上升还能引入定向测试、随机测试、覆盖率统计这些方法但这已经是另外一个主题了。对于初版 RTLtestbench 至少需要覆盖这几类场景正常功能给一组预期数据检查输出边界值比如字节深度、位元位宽的最大最小值时序异常给定数据有效的时间变短、握手信号中途无效复位上电复位后模块处于 IDLE且所有输出为安全的初始值4.4 看懂综合报告里的资源、频率估计综合工具会把 RTL 映射到 FPGA 资源上这个“映射”过程会告诉你两件事用了多少资源大概能跑多快的时钟频率。Vivado 的 Synthesis 报告里主要关注这几项Slice / LUT / FF / BRAM / DSP 的数量和预估资源对比如果超出预期说明 RTL 可能有冗余逻辑或者某个 IP 占用过高。最差建立时间违例 WNSWorst Negative Slack负数就说明路径太慢不满足时钟约束。最差保持时间违例 WHS负数说明路径太短通常改约束或插延迟解决。如果你发现综合结果频率上不去优先回头检查 RTL而不是盲目在约束里加时钟周期。最常见的原因就三个组合逻辑链路过长导致关键路径延迟过大某个 always 块里塞了一个很大的乘法器或比较器复位信号异步路径没有做同步释放。综合工具给出的 WNS 可能比极级真实时序乐观一些因为布局布线前的路径估算没包含实际走线的延迟但在 RTL 阶段它足以帮你提前发现设计瓶颈。5. 自查清单我每次交付RTL前都会扫一遍的内容5.1 信号和位宽最容易栽的隐性坑我给自己长期用的自查清单第一条永远是“位宽匹配检查”。人眼检查位宽很难工具也不一定报错——因为 Verilog 允许隐式截断和扩展仿真可能跑得通但综合出来后数据已经“悄悄变了”。最典型的就是加法器的进位被丢掉。比如两个 8 位信号相加赋值给 8 位信号硬件上实际是一个 8 位加法器进位被丢弃。如果你的算法期望保留溢出那要懂得给结果扩展一位wire [7:0] a, b; wire [8:0] sum; assign sum a b;还有一个隐蔽的坑在比较器与 sign 扩展。把wire [7:0] data跟负数比较仿真结果有时候会把你吓一跳原因就是 8 位数据一扩展成了 32 位有符号数符号位不同结果完全不同。建议位宽和符号性在任何表达式中显式标注能避免 80% 的数据路径 bug。5.2 组合逻辑中无意的锁存器、多驱动、循环检查 RTL 时务必要把综合报告的所有 Warning 都读一遍特别是这三类inferring latch锁存器被推断出来了通常是组合逻辑里少写了分支。multiple driver同一个信号在两个 always 块里被赋值硬件级别这会造成短路。combinatorial loop组合逻辑的输出信号反馈到自己的输入路径通常是笔误把赋值方向写反了。这三类问题在仿真阶段可能完全看不出来但只要出现极级几乎必炸。所以每写完一个模块把综合警告清零当作一个验收指标这种做法能避免大量回头排查。5.3 跨时钟域信号有没有处理如果你的设计里确实存在两个时钟域或者外部输入信号和你的时钟没关系跨时钟域处理就在 RTL 编码阶段必须考虑进去。最基本的处理是打两拍同步。对单 bit 控制信号用两个触发器链把异步信号同步到本地时钟域可以大幅降低亚稳态传入后续逻辑的概率reg sync1_reg, sync2_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync1_reg 1b0; sync2_reg 1b0; end else begin sync1_reg async_signal; sync2_reg sync1_reg; end end wire synced_signal sync2_reg;但注意打两拍只能用于单 bit 信号同步。如果是多 bit 数据总线跨时钟域绝对不能“每人打两拍”就完事——因为两拍之后各个 bit 同步的先后顺序不一致数据可能变成乱码。这种情况要借助异步 FIFO 或握手协议处理。5.4 命名规范和注释写给三天后的自己看代码是写给人看的顺便让机器跑。很多 RTL 工程师有个误区觉得注释不重要。我自己吃过亏一个五年前的旧模块里面全是// step1、// 处理一下改的时候根本不敢动因为不知道每一段逻辑背后的设计意图。我现在的做法文件头注释写明模块功能、接口定义、时钟频率、作者、修改日期。每个 always 块上方用两三句话说明这个块描述的是什么电路结构。状态机的状态定义写清每个状态的进入条件、退出条件。命名使用全小写下划线风格常量用大写。所有端口按接口表顺序排列位宽和方向一眼能看清。这不仅是礼貌更是降低项目维护成本的实打实的手段。FPGA 设计团队里代码可读性和功能正确性基本等值。5.5 划一条交付基准线最后再分享一个实用做法把“综合报告中 0 Error、0 WARNING或经确认可忽略的 warning”作为 RTL 交付的室温阀。不管你最后是用 Vivado、Quartus 还是开源工具这个标准都可以用。如果 warning 里出现了 latch、multi-driver、未连接引脚、时钟域未约束一律当场解决不要拖到极级调试试。按这个标准执行我不敢说你的板卡一次就能跑通但调试时间的期望值至少降低一个数量级。我这些年用下来最明显的变化是大部分设计在仿真阶段已经把问题暴露完极级调试往往变成了确认功能、看接口时序而不再是满板子找信号。6. 我在实际项目里最常给新人看的一段“反面教材”有时候给一段“错误示范”比给模板更管用。下面这段代码是我从一个早期版本里简化出来的里面糅合了好几个典型问题module bad_example( input clk, input rst_n, input en, input [7:0] a, input [7:0] b, output reg [7:0] result ); reg [7:0] temp; reg [2:0] state; always (posedge clk) begin if (rst_n 1b0) result 8d0; if (en 1b1) temp a b; result temp; end endmodule你能看出几个问题我帮你列一下第一always (posedge clk)里用了阻塞赋值描述时序逻辑仿真行为会和电路不匹配。第二temp在模块里没有定义完整用途如果把它去掉、直接用result a b更清晰。第三if 语句后面如果没有 begin/end第二行result temp会无条件执行——几乎没有语法错误能帮你拦住这种逻辑错误。第四没有 default 或 else 时部分条件下 result 保持原值可能被综合成锁存行为。我让新人修改这段代码重点不是改出“正确版本”而是让他解释出每一步的描述他预计对应什么电路结构阻塞非阻塞的差异是什么组合逻辑和时序逻辑的边界在哪。这个练习做完绝大多数人对 RTL 的理解会上一个台阶。代码改成工业级的风格大致是module good_example( input clk, input rst_n, input en, input [7:0] a, input [7:0] b, output reg [7:0] result ); always (posedge clk or negedge rst_n) begin if (!rst_n) result 8d0; else if (en) result a b; else result result; end endmodule注意result result这行可以删掉因为 else 不赋值也保持寄存器原值。写不写它是个风格问题但很多 Lint 规则会鼓励你显式写出“不使能时保持”的状态看起来更明白。7. 关于Verilog输入我想给你的最后一句话经常有人问我“FPGA 设计里最难学的到底是什么”我觉得不是语法也不是某个工具按钮而是你脑子里是否形成了“写代码就是画电路”的意识。Verilog 输入作为整个设计流程的第一步决定了你的设计能否顺利通过仿真、综合、时序收敛和极级验证。每写一行代码就问自己一句如果把它综合成电路长什么样然后你会发现那些抽象的语法问题都会变成直观的电路结构问题。从流程角度看Verilog 输入还牵涉到接口文档、时序图、约束文件和 testbench 设计。它从来不是孤立的一步而是一个需要全局视角的入口。你花几个小时在这篇博文里不如动手去新建一个工程、写一个简单的计数器模块、跑一遍仿真和综合检查把流程走通一次。纸上谈兵和亲手走一遍的差别真的很大。