ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Verilog延时全解析:从#延时到SDF门级仿真的六种实现

Verilog延时全解析:从#延时到SDF门级仿真的六种实现 Verilog里的“延时”听起来是特别基础的东西但真要细抠你会发现它横跨仿真、综合和后仿真三个完全不同的世界。我见过不少刚入门的朋友在代码里写#5想着到板子上信号能慢5ns输出结果综合完功能直接错乱也有人在UART或者I2C模块里用计数器做延时却搞不清到底要数多少个时钟周期。这篇文章主要把我工作里最常用的6种Verilog延时方式整理一遍从行为级#延时、连续赋值延时到同步打拍、计数器延时、移位寄存器/FIFO延时再到后仿真专用的SDF门级延时。适合正在学Verilog语法、准备IC秋招、或者做FPGA项目时被“延时”这个概念坑过的同学参考。1. 先把话说清Verilog里的延时到底有哪几种1.1 三种“世界”里的不同延时我习惯把Verilog的延时分成三个世界仿真世界、可综合硬件世界、门级时序世界。仿真世界里#10的意思是“让仿真时间前进10个时间单位”它只影响仿真器中事件的先后顺序不映射任何真实电路。写testbench生成时钟、构造激励时序用的就是这种延时。可综合硬件世界里真实电路只有“逻辑门传播延迟”和“时钟沿触发的寄存行为”。我们平时写代码说“把这个数据延后两个时钟周期”真正靠的是寄存器打拍、计数器、FIFO这些时序逻辑而不是#。门级时序世界里ASIC库或者FPGA的底层单元会有真实的上升/下降延迟、建立保持时间这些通过specify块和SDF文件注入门级仿真。很多“仿真和综合不一致”的问题根源就是把第一世界里的延时写法错误地搬到了第二世界。想清楚当前代码将来要跑到哪个世界比记住语法本身更重要。1.2 六种延时怎么归类我准备按实际用途把延时拆成6种这样最好记行为级#延时testbench、行为模型里最常用。连续赋值/门原语延时assign #5和and #2这种给组合逻辑做延迟建模。寄存器打拍延时可综合设计里最基本的“晚N拍输出”。计数器延时状态机里常见的“等多少微秒再继续”。移位寄存器/FIFO延时数据流层面做排队、缓冲、跨时钟域延时。SDF标注与门级时序延时综合后仿真里用来验证时序违例。前两种基本只在仿真里有效第三到第五种是可综合硬件实现第六种是后仿真专用。下面一个个展开重点讲语法、使用场景和踩过的坑。2. 第一种延时行为级#延时仿真激励和时间控制2.1 基础语法timescale和#的写法行为级#延时写法很简单timescale 1ns/1ps module tb; reg a; initial begin a 1b0; #10; // 等待10ns a 1b1; #5 a 1b0; // 5ns后再把a拉低 #50; $finish; end endmodule文件顶部的timescale 1ns/1ps很关键前一个数字是时间单位后一个是时间精度。1ns/1ps表示#10就是10ns仿真器的最小时间刻度可以到1ps。如果没有写timescale不同仿真器各有各的默认值很容易出现同一个#5一个工具解释成5ns另一个解释成5个单位时间然后两个工具的波形对不上。#后面不一定只能跟常量参数和表达式都可以parameter HALF_CLK 5; always #HALF_CLK clk ~clk;所以我一般把时钟半周期定义成参数改频率的时候只改一处。2.2 时钟生成的最典型写法testbench里生成50MHz、周期20ns的时钟标准写法是timescale 1ns/1ps module tb; reg clk 1b0; always #10 clk ~clk; // 每10ns翻转一次周期就是20ns initial begin #1000; $finish; end endmodule注意always #10会一直跑下去所以通常配合$finish结束仿真。如果时钟需要带复位、使能也可以写成reg clk_en 1b1; always #10 begin if (clk_en) clk ~clk; end这种延时只存在于仿真器的“时间调度队列”里综合器看到#10根本不会生成任何硬件。2.3 最容易翻车的坑可综合模块里写#延时我见过最典型的错误是这样的// 仿真里看着没问题时钟上升沿后5nsq才更新 always (posedge clk) #5 q d; // 综合后真实行为q就是一个普通DFF沿到就更新RTL仿真时q在clk沿之后5ns才变化看起来像是实现了“延时”但综合器会直接忽略#5最后网表里的q在clk上升沿立刻采样d。于是前仿、后仿、上板三种结果全对不上查半天查不出来。我的原则很简单可综合模块里出现任何一个#就直接打回重写。#延时只允许出现在testbench、行为模型、仿真辅助代码里。2.4 #0的妙用#0不推进时间它只在当前仿真时间槽的“末尾”排一个事件常用来处理零延时竞争。比如两个initial块同时对某个信号赋值谁先谁后看工具心情容易出问题initial a 1b1; initial a 1b0;如果想让其中一条赋值最后生效可以加#0initial a 1b1; initial #0 a 1b0;这样第二个块的事件排在当前时间槽末尾最终a会变成0。需要注意#0同样属于行为级延时可综合代码里不要写。3. 第二种延时连续赋值和门原语延时给组合逻辑建模3.1 assign #5 out in 到底是什么含义连续赋值可以带延时门原语也可以带延时assign #5 y a b; and #2 g1 (y, a, b);它的仿真语义是当a或b变化新的组合运算结果要等5ns后才出现在y上。这是为了给纯组合逻辑模型加上近似传播延迟让行为仿真更接近真实门电路或者用来在testbench里构造信号之间的时间错位。这类#延时同样是仿真行为综合时会被忽略。工具只保留y a b的逻辑功能不会生成一个“延迟5ns”的硬件。所以它只适合建模不适合做功能实现。3.2 惯性延时会把短脉冲“吃掉”实际门电路有一个特点如果输入脉冲宽度比门延迟还短输出可能完全反应不过来脉冲直接消失。Verilog连续赋值默认采用“惯性延时”模型模拟的就是这个特性。看一个例子timescale 1ns/1ps module inert_test; reg a, b; wire y; assign #5 y a b; initial begin a 1; b 1; #2 b 0; // 脉冲只维持了2ns #2 b 1; #20; end endmodule输入b出现了一个2ns的负脉冲而assign的延时是5ns。由于这个脉冲宽度小于惯性延时y很可能完全不会出现一次完整的跳变。这正是真实组合门电路里常见的窄脉冲过滤现象。如果要建模“传输延时”也就是信号形状整体平移、不受脉宽过滤影响Verilog默认不支持像VHDL里那么直接的transport模型。工程上一般用移位寄存器或者specify路径延时配合门级SDF来近似这个在第六种延时里再展开。3.3 testbench里用#构造建立保持时间连续赋值和行为级#经常配合着做时序检查激励。比如要给DUT的输入din构造一个时钟上升沿前1ns变化、时钟沿后2ns变化的行为initial begin (posedge clk) #1 din pattern; // clk沿之后1ns变化可用于保持时间测试 #1; (posedge clk) #2 din other; // clk沿之后2ns变化模拟较差的保持裕量 end这类写法的重点不是功能延时而是故意制造边界时序验证设计能否满足建立保持时间。4. 第三种延时寄存器打拍可综合设计的基本功4.1 打拍延时的标准写法如果想把一个信号延后一个时钟周期输出最经典的两级同步器写法如下module sync_2stage ( input wire clk, input wire rst_n, input wire din, output wire dout ); reg din_meta; reg din_sync; always (posedge clk or negedge rst_n) begin if (!rst_n) begin din_meta 1b0; din_sync 1b0; end else begin din_meta din; din_sync din_meta; end end assign dout din_sync; endmodule这里的核心是两级寄存器串联第一级采到din下一个时钟沿传给第二级所以dout比din晚两个时钟周期。对于单个位信号做跨时钟域同步标准做法就是打两拍“晚两拍”正是两级寄存器天然产生的结果。为什么用非阻塞赋值因为打拍电路里两个寄存器是并行更新的。如果写成阻塞赋值在同一个always块里级联时虽然仿真也能得到看似一样的结果但可综合风格不推荐而且在更复杂电路里容易引发非预期竞争。记住一条黄金规则时序逻辑里非阻塞赋值组合逻辑里阻塞赋值。4.2 参数化实现“延时N拍”打成N拍也很简单用数组或者generate实现module delay_n #( parameter N 3, parameter W 8 )( input wire clk, input wire rst_n, input wire [W-1:0] din, output wire [W-1:0] dout ); reg [W-1:0] shift_reg [0:N-1]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i N; i i 1) shift_reg[i] {W{1b0}}; end else begin for (i N-1; i 0; i i - 1) shift_reg[i] shift_reg[i-1]; shift_reg[0] din; end end assign dout shift_reg[N-1]; endmodule这种“多级寄存器移位链”就是打拍延时的通用形态。实际应用很广跨时钟域同步器、信号对齐、边沿检测前的稳定化、滑动窗口滤波。比如“滑动窗口滤波”里经常要取连续三拍的数据做平均reg [7:0] d0, d1, d2; always (posedge clk) begin d0 din; d1 d0; d2 d1; end wire [9:0] sum d0 d1 d2;这里的d0、d1、d2就是三个拍延时后的窗口采样值。4.3 打拍延时的局限打拍延时只能延后整数个时钟周期。如果你需要延后半拍或者需要纳秒级精确延时靠打拍是做不到的。真实硬件上那种精确到几百皮秒的延迟一般要用PLL调整时钟相位、ODDR/IDELAY这类专用资源实现通用逻辑里的打拍只解决“周期级”延时。另外要注意打拍延时依赖时钟。如果目标时钟没起来或者复位期间有抖动整条链路的输出也不可靠。跨时钟域场景里两级触发器归根结底是降低亚稳态传播概率并不能保证100%消除亚稳态。5. 第四种延时计数器延时毫秒级等待就靠它5.1 一个能直接用的计数器延时模块很多场景需要“等一段较长的时间”比如等待外设就绪、产生一个固定脉宽、状态机里停留若干个时钟周期。这是计数器延时的主场。timescale 1ns/1ps module delay_cnt #( parameter CLK_FREQ_HZ 50_000_000, parameter DELAY_US 10 )( input wire clk, input wire rst_n, input wire start, output reg done ); localparam DELAY_CLKS CLK_FREQ_HZ / 1_000_000 * DELAY_US - 1; localparam CNT_WIDTH $clog2(DELAY_CLKS 1); reg [CNT_WIDTH-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt {CNT_WIDTH{1b0}}; done 1b0; end else if (!start) begin cnt {CNT_WIDTH{1b0}}; done 1b0; end else if (cnt DELAY_CLKS) begin done 1b1; cnt {CNT_WIDTH{1b0}}; end else begin cnt cnt 1b1; done 1b0; end end endmodule这个模块的接口很直观start拉高开始计时延时结束后done拉高一个周期。DELAY_CLKS由时钟频率和延时目标算出来$clog2自动决定计数器位宽。5.2 延时周期数到底怎么算很多人在这里犯迷糊。核心公式是延时周期数 时钟频率(Hz) × 延时时间(秒)例如50MHz时钟、延时10us那就是50_000_000 × 10 / 1_000_000 500个周期。但计数器从0开始计数所以要数完500个周期计数终值应该是500 - 1 499。上面的代码里DELAY_CLKS CLK_FREQ_HZ / 1_000_000 * DELAY_US - 1这个减1很容易被漏掉。我习惯在模块参数下面直接写注释防止以后改参数时算错// 50MHz下延时10us需要50*10500个时钟周期 // 计数器从0计到499正好500拍 localparam DELAY_CLKS 500 - 1;改时钟频率时只改CLK_FREQ_HZ和DELAY_US总线宽度和计数终值都会自动跟着变。5.3 状态机里怎么用计数器延时状态机里“延时等待”是很常见的需求比如UART发送一个字节的位时间或者I2C里SCL高低电平的脉宽控制。典型结构如下reg [2:0] state; localparam IDLE 3d0; localparam WAIT_CNT 3d1; localparam NEXT 3d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; end else begin case (state) IDLE: if (start) state WAIT_CNT; WAIT_CNT: if (cnt_done) state NEXT; NEXT: state IDLE; endcase end endcnt_done就是上面计数器模块的done信号。实际项目里不一定需要单独建一个模块可以把计数器直接写进状态机里但逻辑完全一样状态机进入WAIT、开始计数、cnt_done拉高后跳出。有人喜欢把延时封装成task比如task wait_us(input integer us); // 注意这里不能写#延时综合工具不认 // 要用循环计数实现 endtask可综合task里的循环计数器没问题但切记不要在task里写#。5.4 计数器延时的三个常见坑一是计数边界问题如果写成cnt DELAY_CLKS - 1实际只延了499个周期凭空少了1拍。二是清零时机问题不要在cnt DELAY_CLKS时用组合逻辑去清零而要在同一个时钟沿把cnt更新为0否则容易产生毛刺。三是done信号长度问题done最好只在cnt到达上限的那个周期拉高其他时间保持低电平否则状态机可能一直卡在等待状态。6. 第五种延时移位寄存器和FIFO数据流排队延时6.1 移位寄存器实现固定周期数据延时当数据是连续流式输入需要保存最近N拍的历史值时不能用简单打拍了要上移位寄存器链。比如8位数据延后2拍输出module shift_delay #(parameter W 8) ( input wire clk, input wire rst_n, input wire [W-1:0] din, output wire [W-1:0] dout ); reg [W-1:0] line1, line2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin line1 {W{1b0}}; line2 {W{1b0}}; end else begin line1 din; line2 line1; end end assign dout line2; endmodule和前面的delay_n参数化模块相比这个更贴近“数据流”场景。视频图像处理里说的行缓存line buffer本质就是移位存储把一行数据存进RAM延迟一个行周期再输出。原理和这个完全一致只是存储深度大了变成RAM实现。用移位寄存器做延时最大的优点是延时周期固定、可预期每一拍数据严格滑动一位输出一定是输入的固定延迟版本。6.2 FIFO延时与跨时钟域缓冲如果数据流的读写速率不同或者需要跨时钟域搬运数据移位寄存器就不够用了得用FIFO。同步FIFO的端口大致是这样module sync_fifo #( parameter DATA_WIDTH 8, parameter DEPTH 16 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, input wire rd_en, output wire [DATA_WIDTH-1:0] dout, output wire full, output wire empty );FIFO里的数据“先写先读”从写入到读出的时间差本质也是一种延时。但它和移位寄存器延时有一个重要区别FIFO的延迟不是固定的取决于写时序和读时序。写满再读和边写边读数据经过FIFO所花的时间完全不同。所以选型的时候要想清楚需要严格固定N拍延迟用移位寄存器或者RAM地址交替控制需要异步缓冲、跨时钟域、突发数据平滑用FIFO。实际项目里的一个典型场景是uart多字节收发接收侧每来一个字节都写入FIFOCPU侧有空再按自己的节奏读取。这样写侧和读侧时钟、速率都可以不同FIFO承担的就是弹性缓冲不是固定延时。i2c读写eeprom这类带地址和数据的连续读写也经常先用FIFO或RAM把整包数据排好队再按I2C时序逐字节发送。6.3 移位/FIFO延时的性能权衡移位寄存器实现简单、延迟固定但深度大时寄存器数量暴涨。延时深度超过几十拍建议用RAM实现环形缓冲而不是无脑堆移位寄存器。用异步FIFO做跨时钟域时还要关注格雷码指针同步、空满信号产生等细节这部分内容量很大我这里先提个醒异步FIFO的空满判断是核心难点不要随便在网上抄一段就当功能验证完。7. 第六种延时specify和SDF门级延时后仿真专用7.1 specify块里的路径延时真正到ASIC后仿真或者FPGA时序仿真阶段我们会遇到门级单元库的延迟。Verilog通过specify块描述路径延迟和时序检查例如module my_and (y, a, b); input a, b; output y; and (y, a, b); specify (a y) (1.2, 2.3); (b y) (1.0, 1.8); $setup(d, posedge clk, 1.5); $hold(posedge clk, d, 0.8); endspecify endmodule(a y) (1.2, 2.3)表示a到y的上升沿延时1.2个时间单位、下降沿延时2.3个时间单位。$setup和$hold是建立时间、保持时间检查。这些延时信息平时在RTL仿真里看不到只有综合、布局布线后生成门级网表再配合SDF文件才能体现出来。7.2 SDF反标怎么加到仿真里SDFStandard Delay Format就是存放标准单元延迟、线延迟、时序检查信息的文件。综合工具和布局布线工具会输出top.sdf测试平台里可以用系统任务直接反标initial begin $sdf_annotate(netlist/top.sdf, dut); end不同的仿真器也支持命令行反标比如ModelSim/Questa的vsim有-sdfmin/-sdfmax选项Vivado的xsim在后仿真时也有对应设置。关键是SDF文件必须和当前网表对应并且SDF版本、时间单位要和仿真环境匹配。7.3 后仿真里的延时相关坑最常见的两个问题一是没加SDF门级仿真直接跑出大量X态。这不一定代表设计功能错可能是网表里某些寄存器没有初始化而后仿环境没做全局复位。建议在所有时序逻辑里都加上异步复位并且testbench一开始先给复位一段足够长的时间。二是SDF单位与timescale不匹配。SDF文件里的延时单位可能是100ps而测试平台的timescale是1ns如果反标时解释错了所有延迟都会差10倍时序违例的判断就完全失真。发现后仿所有路径都偏大或偏小先检查SDF单位再检查timescale。另外门级后仿速度比RTL仿真慢很多尽量别把全量回归都跑到后仿里。我一般只选几条关键用例寄存器配置、跨时钟域握手、边界时序最差的场景。8. 六种延时速查选型和常见问题一次看完8.1 一分钟区分用哪种延时延时类型典型语法主要用途能否综合注意事项行为级#延时#5; #5 qd; #0testbench激励、时钟生成、事件等待不能可综合模块里禁用连续赋值/门原语延时assign #5 y...; and #2组合逻辑延迟建模、激励对齐延时不能综合功能保留默认惯性延时短脉冲会被过滤寄存器打拍延时always (posedge clk) qd跨时钟域同步、流水线延迟能只能整数个时钟周期计数器延时计数器计数到N-1状态机等待、定时、毫秒级延时能注意计数边界和done脉宽移位寄存器/FIFO延时数组移位链、同步/异步FIFO数据排队、行缓存、跨时钟域缓冲能FIFO延时不一定固定SDF门级延时specify块、$sdf_annotate综合后仿真时序验证仿真专用注意SDF单位和timescale匹配8.2 我踩过几次坑之后的选型经验每次写延时相关代码前我都会先问自己一句这个延时是给仿真看的还是给硬件看的给仿真看放心用#给硬件看彻底忘掉#从打拍、计数器、移位寄存器/FIFO里挑一个。第二个经验是把延时参数化。我一般会把时钟频率和延时目标写成parameter再用localparam算出计数终值注释里写清楚“当前参数下需要N个周期计数从0到N-1”。这样以后改频率不用逐个数字去猜。第三个经验是不要迷信网上随手贴过来的FIFO代码。同步FIFO功能简单真正容易出问题的是空满信号和跨时钟域异步FIFO的格雷码指针同步这部分一定要做完整仿真验证最好跑一跑写满、读空、同时读写这些边界情况。最后仿真和综合结果对不上的时候先全局搜索代码里的#十有八九就是行为级延时悄悄混进了可综合模块。养成这个排查习惯之后我调试时序问题的速度快了不少也希望这个经验能帮你少走几天弯路。
返回列表