1. 奇数分频:一个看似简单却暗藏玄机的设计
在数字电路设计,尤其是FPGA和ASIC开发中,时钟分频是最基础也是最频繁遇到的操作之一。偶数分频很简单,一个计数器在时钟上升沿计数,计满翻转输出即可。但当你需要得到一个占空比为50%的奇数分频时钟时,比如5分频、7分频,问题就变得有趣起来了。直接使用单边沿计数器,你只能得到占空比非50%的波形,这在很多对时钟质量要求严格的场景下是无法接受的。今天,我们就来深入拆解一种经典且高效的奇数分频实现方法——基于双边沿触发的“或逻辑”法,并以5分频为例,从原理、代码到仿真,把每一个细节和可能踩的坑都讲清楚。
这个方法的核心思想很巧妙:既然一个触发器只能在时钟的上升沿或下降沿动作,那我们何不用两个触发器,一个用上升沿,一个用下降沿,分别产生两个相位有特定关系的信号,再把它们组合起来呢?最终,我们将得到一个占空比严格为50%的奇数分频时钟。这个方法不仅适用于FPGA,在CPLD乃至ASIC设计中都是通用的思路。无论你是正在学习Verilog的在校学生,还是需要在实际项目中实现特定时钟需求的工程师,理解这个设计的来龙去脉都大有裨益。
2. 核心原理:为什么双边沿触发是奇数分频的关键
要理解这个设计,我们得先抛开代码,从时钟波形的本质入手。一个理想的50%占空比方波时钟,其高电平和低电平的时间是相等的。对于N分频(N为奇数),输出时钟的一个完整周期,需要覆盖输入时钟的N个周期。我们的目标是,让这个输出周期的高电平时间,精确等于(N/2)*Tclk(这里N/2不是整除,例如5分频就是2.5个输入时钟周期),低电平时间也同样如此。
单靠一个上升沿触发的计数器是做不到这一点的。因为计数器的翻转动作只能发生在时钟边沿的离散时刻。例如,要实现5分频,你可能会让计数器在计到0和2时翻转,但这样得到的输出信号,其高电平持续2个Tclk,低电平持续3个Tclk(或反之),占空比是40%/60%,而不是50%。
双边沿触发法的精妙之处在于“相位合成”。我们分别用输入时钟的上升沿和下降沿驱动两个独立的计数器。这两个计数器都产生一个占空比非50%的中间信号(通常称为DIV0和DIV1),但关键是,这两个中间信号的相位是错开的,错开的时间正好是半个输入时钟周期(因为一个用上升沿,一个用下降沿)。当我们把这两个相位错开的信号进行“或”操作时,它们的高电平部分就会在时间上拼接起来,最终形成一个高、低电平持续时间完全对称的波形。
以5分频(N=5)为例,(N-1)/2 = 2。我们让每个计数器在计数值为0和1时输出高电平,在计数值为2、3、4时输出低电平。这样,DIV0和DIV1各自都是占空比40%的波形。但由于DIV1由下降沿触发,它的波形相对于DIV0延迟了半个MCLK周期。将DIV0和DIV1做逻辑或,它们的高电平区域就会部分重叠、部分衔接,最终形成的DIV5_CLK波形,其高电平持续时间恰好是2.5个MCLK周期,低电平也是2.5个,完美实现50%占空比。
注意:这里
(N-1)/2必须是一个整数,这也是为什么这个方法天然适用于奇数分频。对于偶数分频,(N-1)/2不是整数,这个计数策略就不适用了。
3. 代码实现与关键细节解析
理解了原理,我们来看Verilog代码实现。代码是设计思想的直接体现,每一行都有其目的。
3.1 模块定义与参数化设计
首先,一个良好的设计应该是参数化的,便于复用。我们使用parameter来定义分频系数N和中间值M。
`timescale 1ns / 1ps module div_odd #( parameter N = 5, // 分频系数,必须为奇数 parameter CNT_WIDTH = 3 // 计数器位宽,根据N大小调整,需满足 2^CNT_WIDTH > N ) ( input wire MCLK, // 主时钟输入 output wire DIV_CLK, // N分频时钟输出 output wire DIV0, // 上升沿路径中间信号(调试用) output wire DIV1 // 下降沿路径中间信号(调试用) ); localparam M = (N-1)/2; // 高电平计数阈值这里我做了几点优化和说明:
- 参数化:
N和计数器位宽CNT_WIDTH都作为参数,使得模块可以轻松改为3分频、7分频等。M通过localparam自动计算,避免手动修改出错。 - 位宽定义:显式定义了计数器位宽。对于5分频,计数器需计到4,3位宽足够。如果N很大,需要相应增加
CNT_WIDTH。 - 输出信号:
DIV0和DIV1作为输出端口,在调试时非常有用,可以直观看到两个中间信号的波形,便于验证设计。在实际产品中,如果不需要观察,可以去掉。
3.2 核心计数器与信号生成逻辑
这是整个设计的核心部分,包含了两个分别由上升沿和下降沿触发的always块。
reg [CNT_WIDTH-1:0] count0, count1; reg div0_reg, div1_reg; // 上升沿计数器与DIV0生成 always @(posedge MCLK) begin if (count0 == M-1) begin // 计数到M-1时,下一个时钟沿DIV0变低 div0_reg <= 1'b0; count0 <= count0 + 1; end else if (count0 == N-1) begin // 计数到N-1(一个分频周期结束)时,计数器归零,DIV0变高 div0_reg <= 1'b1; count0 <= 0; end else begin // 其他情况,计数器递增 count0 <= count0 + 1; end end // 下降沿计数器与DIV1生成 always @(negedge MCLK) begin if (count1 == M-1) begin div1_reg <= 1'b0; count1 <= count1 + 1; end else if (count1 == N-1) begin div1_reg <= 1'b1; count1 <= 0; end else begin count1 <= count1 + 1; end end逻辑要点解析:
- 计数范围:计数器从0计数到
N-1(对于5分频是0~4),这是一个完整的输入时钟周期数。 - 翻转点:信号在计数到
M-1(对于5分频是1)时拉低,在计数到N-1(对于5分频是4)时拉高并复位计数器。这意味着DIV0/DIV1的高电平持续了M个计数周期(0和1),低电平持续了N-M个计数周期(2,3,4)。这正是产生占空比为M/N(即2/5)中间波形的关键。 - 阻塞与非阻塞赋值:这里全部使用了非阻塞赋值(
<=)。这是描述时序逻辑的标准做法,能正确模拟寄存器行为,避免仿真与综合结果不一致的“陷阱”。(关于这个“坑”,后面会详细展开)。
3.3 最终输出组合逻辑
两个中间信号生成后,通过一个简单的组合逻辑“或门”产生最终的分频时钟。
// 将寄存器输出连接到端口 assign DIV0 = div0_reg; assign DIV1 = div1_reg; // 关键步骤:通过或操作合成最终50%占空比时钟 assign DIV_CLK = div0_reg | div1_reg; endmodule这一行assign DIV_CLK = div0_reg | div1_reg;是整个设计的“画龙点睛”之笔。它利用数字逻辑中最基本的或门,将两个相位错开半个周期的波形“缝合”起来,生成了我们需要的完美时钟。在FPGA中,这个操作会由查找表(LUT)实现,速度极快。
4. 深入仿真:波形分析与设计验证
理论分析和代码编写完成后,必须通过仿真来验证。我们使用ModelSim、Vivado Simulator或任何支持Verilog的仿真工具进行测试。
4.1 正确的仿真波形
我们为测试平台提供一個100MHz(周期10ns)的MCLK,观察5分频后的DIV_CLK波形。
`timescale 1ns/1ps module tb_div_odd(); reg clk; wire div_clk, div0, div1; div_odd #(.N(5)) uut ( .MCLK(clk), .DIV_CLK(div_clk), .DIV0(div0), .DIV1(div1) ); initial begin clk = 0; forever #5 clk = ~clk; // 生成100MHz时钟 end initial begin #200; // 仿真运行一段时间 $finish; end endmodule仿真得到的波形应该是这样的:
MCLK: 周期10ns的方波。DIV0: 以MCLK上升沿为基准。在count0为0和1时高电平,持续20ns;在count0为2、3、4时低电平,持续30ns。周期为50ns(5个MCLK周期)。DIV1: 波形与DIV0完全相同,但每个边沿都相对于DIV0延迟了5ns(半个MCLK周期)。DIV_CLK: 是DIV0和DIV1的“或”。你会发现它的高电平期和低电平期都精确地持续了25ns,周期为50ns,实现了50%占空比的5分频(20MHz)。
通过测量DIV_CLK的周期和占空比,可以定量验证设计是否正确。这是硬件设计中最有说服力的一环。
4.2 一个致命的陷阱:阻塞与非阻塞赋值
在提供的原始资料中,作者提到了一个关键错误:错误地使用了非阻塞赋值。让我们重现这个错误,并理解为什么它会导致失败。
错误代码示例:
always@(posedge MCLK) begin if(COUNT0==M) // M=2 begin DIV0<=0; end else if(COUNT0==N) // N=5 begin COUNT0<=0; DIV0<=1; end COUNT0<=COUNT0+1; // 问题在这里! end错误分析:在同一个always块中,所有非阻塞赋值语句在时钟沿到来时是“并发”执行的。仿真器会先读取所有等式右边的值(旧值),然后在时间步结束时统一更新左边的寄存器。
- 当
COUNT0旧值等于4时,下一个时钟上升沿到来。 - 仿真器评估:
COUNT0==5吗?不成立(旧值是4)。COUNT0==2吗?不成立。所以两个if都不执行。 - 仿真器评估:
COUNT0<=COUNT0+1;右边是4+1=5。 - 时间步结束,
COUNT0被更新为5,DIV0保持不变。 - 关键点来了:
COUNT0是在这个时钟沿之后才变成5的,所以COUNT0==5这个条件在这个时钟沿永远不会被触发。计数器会一直累加下去,永远不会清零,DIV0也永远不会被置1,整个状态机就“跑飞”了。
正确的理解是:在描述时序逻辑(always @(posedge clk))时,如果你想根据计数器当前值(即本次时钟沿到来时的值)来决定其下一个值,那么对计数器的判断和更新必须在逻辑上连贯。通常有两种正确写法:
- 使用阻塞赋值的顺序执行(较少见,需谨慎):如原始资料中第一个正确示例,
COUNT0 = COUNT0 + 1;是阻塞赋值,它会立刻更新COUNT0的值,使得后续的if(COUNT0==5)能判断到。 - 使用非阻塞赋值的标准写法(推荐):如我在3.2节给出的代码。将计数器加1和条件判断基于同一个
count0的旧值。在count0 == N-1时,我们将其下一个值设为0,否则设为count0 + 1。这样写清晰、标准,综合工具也能很好地处理。
实操心得:在Verilog中,牢记“时序逻辑用非阻塞(<=),组合逻辑用阻塞(=)”这条黄金法则,可以避免90%以上的仿真与综合不一致问题。对于计数器,在同一个always块内,其“下一个状态”应完全由“当前状态”决定,所有对它的赋值都应使用非阻塞,并且赋值来源是它的旧值。
5. 工程设计考量与优化技巧
将代码成功仿真只是第一步,要将其变成可靠硬件,还需要考虑更多实际问题。
5.1 时钟约束与时序分析
这是该方法最重要的一点。DIV_CLK = DIV0 | DIV1是一个组合逻辑路径。DIV0和DIV1由不同的时钟边沿产生,到达或门的时间可能有微小差异。
- 问题:如果
DIV0或DIV1到DIV_CLK的路径延迟过大,会导致DIV_CLK输出出现毛刺(glitch)。一个短暂的毛刺对于将DIV_CLK用作时钟信号是灾难性的,可能触发错误的逻辑。 - 解决方案:
- 添加约束:在综合和布局布线工具(如Vivado、Quartus)中,需要对
DIV0和DIV1到DIV_CLK的路径设置最大延迟约束。告诉工具这条路径必须非常快,通常要小于目标时钟周期的几分之一(例如,对于100MHz的DIV_CLK,这条路径延迟要小于1-2ns)。 - 寄存器输出:最稳健的方法是使用一个额外的寄存器来采样
DIV_CLK。虽然这会引入一个时钟周期的延迟,但能消除所有毛刺,得到干净稳定的时钟输出。这对于高速系统至关重要。
- 添加约束:在综合和布局布线工具(如Vivado、Quartus)中,需要对
// 增加一级寄存器输出以消除毛刺 reg div_clk_reg; always @(posedge MCLK or negedge MCLK) begin // 注意:这里用了双沿触发器,有些FPGA原生支持 div_clk_reg <= div0_reg | div1_reg; end assign DIV_CLK = div_clk_reg;实际上,更常见的做法是选择MCLK或DIV_CLK的一个边沿来寄存。这需要仔细分析时序关系。
5.2 高频应用下的注意事项
原始资料提到“MCLK频率要求较高,尽量不要出现窄脉冲”。这里指的是什么? 当DIV0和DIV1的边沿非常接近时,它们通过或门产生的DIV_CLK脉冲宽度可能会非常窄。如果这个窄脉冲的宽度小于后续电路(如其他触发器)的建立/保持时间要求,就会导致功能异常。
- 根源:
DIV0和DIV1的跳变沿本身是由MCLK的上升沿和下降沿产生的,理论上间隔Tclk/2。但如果两条路径的延迟不匹配(由于布局布线导致),这个间隔可能缩小,产生窄脉冲。 - 应对策略:
- 平衡布局:在综合约束中,可以将产生
DIV0和DIV1的触发器放在彼此靠近的位置,并使用相同的布线资源,以最小化路径延迟差异。 - 使用全局时钟网络:如果
DIV_CLK需要驱动较大负载或长距离线路,应将其通过FPGA的全局时钟缓冲器(BUFG)后再输出。全局时钟网络具有低偏移、高驱动能力的特性。 - 降低源时钟频率:如果可能,这是最直接的方法。或者考虑使用其他奇数分频架构,例如基于状态机的设计。
- 平衡布局:在综合约束中,可以将产生
5.3 资源与性能权衡
- COUNT1的必要性:原始资料提到“COUNT1可有可无”。在低速情况下,
DIV1的计数器可以简化,甚至可以用DIV0反相后延迟得到。但在高速或高可靠性设计中,使用独立的下降沿计数器是最规范的做法,它能保证两个路径的对称性和可约束性。 - 通用性修改:本文的代码是参数化的,要改为7分频,只需例化时修改参数:
div_odd #(.N(7), .CNT_WIDTH(3)) uut (...);。注意CNT_WIDTH要能覆盖计数值(7分频需计到6,3位宽足够)。
6. 常见问题与调试技巧实录
在实际实现过程中,你可能会遇到以下问题:
问题1:仿真波形正确,但下载到FPGA后功能不正常。
- 可能原因:最常见的元凶就是时序违例,特别是
DIV_CLK组合路径上的毛刺。 - 排查步骤:
- 检查综合和布局布线报告中的“时序总结”(Timing Summary),看是否有建立时间(Setup Time)或保持时间(Hold Time)违例。
- 使用集成逻辑分析仪(如Vivado的ILA、Quartus的SignalTap)抓取实际FPGA内部的
DIV0、DIV1和DIV_CLK信号。观察DIV_CLK上是否有毛刺。 - 如果发现毛刺,按照5.1节的方法,添加输出寄存器或加强时序约束。
问题2:分频比不是预期的5,而是3或其他值。
- 可能原因:计数器判断条件写错。例如把
if (count0 == N-1)误写为if (count0 == N),会导致计数器多计一个数。 - 排查步骤:
- 仔细检查
always块中的判断条件。牢记计数器是从0开始计数。 - 在仿真中观察
count0和count1的数值变化,看它们是否在正确的数值复位。
- 仔细检查
问题3:占空比不是精确的50%。
- 可能原因:
DIV0和DIV1的高电平计数周期M计算错误。M必须等于(N-1)/2。 - 排查步骤:
- 确认参数
N是奇数。 - 确认
M的计算是正确的整数。例如7分频时,(7-1)/2=3,高电平应持续3个MCLK周期。 - 仿真测量
DIV0和DIV1的高电平时间是否正确。
- 确认参数
调试技巧:充分利用中间信号在设计时像本例一样输出DIV0和DIV1,是调试此类分频电路的利器。在仿真和ILA中同时观察这三个信号,可以迅速定位问题是出在计数器部分(看DIV0/DIV1波形是否正确),还是出在组合逻辑部分(看DIV_CLK是否由DIV0/DIV1正确合成)。
7. 方法对比与扩展思考
除了这种双边沿“或逻辑”法,实现奇数分频还有其他方法,各有优劣:
- 状态机法:使用一个状态机,状态数为N,每个状态对应一个时钟周期,直接控制输出时钟的高低。这种方法逻辑清晰,占空比精确,且没有组合逻辑毛刺问题。但资源消耗相对较大,特别是N较大时。
- 小数分频与DDS:对于更复杂的分频需求(如小数分频),通常会采用基于累加器的直接数字频率合成(DDS)技术,通过控制相位累加器的溢出和输出波形表来产生任意频率的时钟,精度非常高,但设计也更复杂。
如何选择?
- 对于简单的、固定的低阶奇数分频(如3、5、7),本文介绍的双边沿“或逻辑”法非常简洁高效。
- 对于需要动态改变分频比,或者分频系数较大的情况,状态机法可能更易于管理和验证。
- 对于需要高精度、任意频率(包括小数)时钟生成的场景,DDS是标准选择。
最后,我想强调的是,硬件设计不仅仅是写出能仿真的代码,更要考虑它在硅上的真实行为。时序约束、时钟质量、资源利用,这些都是在项目实践中需要反复权衡的。这个奇数分频的设计虽然小巧,但它完美地体现了数字逻辑设计中“用巧劲”的思想。理解并掌握它,对你构建更复杂、更可靠的数字系统大有帮助。在实际项目中,如果对时钟质量要求极高,我通常会倾向于在FPGA内部使用专用的时钟管理模块(如PLL或MMCM)来产生所需时钟,它们能提供更优的抖动和占空比性能。但当资源紧张或需要动态控制时,这种纯逻辑的分频方法依然是不可或缺的利器。