简介:这是一份面向FPGA初学者与数字电路设计者的Verilog时钟分频器工程资料,围绕如何将高频时钟转换为任意指定频率输出这一核心问题展开,适合需要掌握分频器原理、时序约束与综合流程的开发者参考。压缩包共155个文件,约971KB,包含Verilog源码、UCF管脚约束与时钟约束文件,以及综合、实现、仿真各阶段产生的日志、报告、网表与比特流等工程文件,完整保留了从代码到硬件的设计链路。资源中提供了可配置分频系数的计数器实现思路,读者可据此理解计数器翻转、参数化分频与输出时钟生成的具体做法,并借助约束文件学习I/O分配与时序优化方法。目前已有1731人学习下载,可作为模板迁移到其他数字系统项目中,帮助读者快速搭建并验证自己的分频电路。
1. 从一块“跑飞”的板子说起:为什么我建议你手写一遍时钟分频器
刚入行那会儿,我在一块 FPGA 开发板上调一个串口收发模块,波特率死活对不上,示波器一测,时钟频率差了整整一倍。查了一下午才发现,是分频器的使能逻辑写成了组合逻辑,毛刺直接把后级触发器打翻了。那次之后我才明白,时钟分频器看着简单,却是数字设计里最容易翻车的地方之一。这份基于 Verilog 的时钟分频器项目,就是围绕这个痛点展开的:它把偶数分频、奇数分频、半整数分频、任意整数分频几种典型场景都做成了可综合的模块,还配了 testbench 方便仿真验证。适合正在学 FPGA 或者数字 IC 设计、想搞懂时钟树底层逻辑的开发者,也适合手头项目里需要一个稳定分频模块、不想每次都重新造轮子的人。下面我按“能干什么、怎么跑、怎么改”的顺序,把这个项目拆开讲一遍。
2. 项目定位与技术选型:为什么是纯 Verilog 而不是 IP 核
2.1 这个项目到底解决了什么问题
很多初学者做分频,第一反应是调用厂商提供的时钟管理 IP 核,比如 PLL 或者 MMCM。这当然没问题,但 IP 核有两个局限:一是可移植性差,换一家厂商的器件就得重新配置;二是你很难真正理解分频的时序逻辑,遇到需要动态切换分频比、或者分频比不是 2 的幂次时,IP 核反而不好用。这个项目走的是纯 RTL 路线,所有分频逻辑都用 Verilog 手写,不依赖任何厂商原语。这意味着你把它拿到任何支持 Verilog-2001 的综合工具里都能跑,从仿真到上板一条龙。
项目覆盖的分频类型包括:偶数分频(2、4、8 分频)、奇数分频(3、5、7 分频)、半整数分频(如 2.5 分频)、以及参数化的任意整数分频。每种类型都单独成模块,顶层用一个多路选择器根据配置信号切换输出。这种结构的好处是,你可以单独把某个模块拎出来用,也可以整体例化,灵活性很高。
2.2 技术栈与目录结构
项目本身不依赖任何第三方库,工具链上推荐用 Icarus Verilog 做仿真、GTKWave 看波形,综合的话 Quartus 或 Vivado 都行。目录结构大致是这样的:
clk_divider/ ├── rtl/ │ ├── div_even.v // 偶数分频 │ ├── div_odd.v // 奇数分频 │ ├── div_half.v // 半整数分频 │ ├── div_any.v // 任意整数分频 │ └── clk_div_top.v // 顶层多路选择 ├── tb/ │ ├── tb_div_even.v │ ├── tb_div_odd.v │ ├── tb_div_half.v │ └── tb_div_any.v ├── sim/ │ └── run_iverilog.sh // 一键仿真脚本 └── doc/ └── timing_notes.md // 时序约束说明rtl目录放所有可综合的 RTL 代码,tb目录放对应的 testbench,sim里是一个 shell 脚本,封装了 iverilog 编译和 vvp 运行的命令。doc里有一份时序说明,记录了每种分频方式在综合时需要注意的约束,比如奇数分频要设置 false path 或者多周期路径。
提示:如果你用的是 Windows 环境,可以把
run_iverilog.sh里的命令手动敲进 CMD 或者 PowerShell,效果一样。
2.3 核心参数定义
顶层模块clk_div_top的接口定义如下:
module clk_div_top #( parameter DIV_TYPE = 0, // 0:偶数 1:奇数 2:半整数 3:任意 parameter DIV_NUM = 4 // 分频比,偶数时必须是偶数 )( input wire clk_in, // 输入时钟 input wire rst_n, // 低电平复位 output wire clk_out // 分频后时钟 );DIV_TYPE决定走哪条分频路径,DIV_NUM是分频比。这里有个细节:偶数分频时DIV_NUM必须是偶数,奇数分频时必须是奇数,半整数分频时DIV_NUM乘以 2 之后要是奇数。这些约束在综合时不会报错,但仿真结果会不对,所以 testbench 里专门加了断言检查。
3. 核心模块拆解:偶数、奇数、半整数分频的 RTL 实现
3.1 偶数分频:最简单也最容易写错
偶数分频的思路很直接:用一个计数器数到DIV_NUM/2 - 1,然后翻转输出。但这里有个坑,很多人会把计数器和输出用同一个 always 块里的阻塞赋值,导致综合出锁存器。正确的写法是用非阻塞赋值,并且把计数器和输出分开。
module div_even #( parameter DIV_NUM = 4 // 必须为偶数 )( input wire clk_in, input wire rst_n, output reg clk_out ); reg [15:0] cnt; always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; clk_out <= 1'b0; end else begin if (cnt == (DIV_NUM/2 - 1)) begin cnt <= 16'd0; clk_out <= ~clk_out; // 计数满一半就翻转 end else begin cnt <= cnt + 1'b1; end end end endmodule这段代码的逻辑是:计数器从 0 数到DIV_NUM/2 - 1,每数满一次就翻转clk_out。因为翻转发生在计数满的时刻,所以输出时钟的周期正好是输入时钟的DIV_NUM倍。参数DIV_NUM必须为偶数,否则DIV_NUM/2会截断,分频比就不对了。比如DIV_NUM=5时,5/2=2,实际分频比变成 4,这就是典型的“仿真对不上”问题。
3.2 奇数分频:用双边沿或者双计数器
奇数分频比偶数分频麻烦,因为不能简单地数一半翻转。常见做法有两种:一种是利用时钟的上升沿和下降沿分别计数,然后把两个输出相或;另一种是用两个计数器,一个在上升沿计数,一个在下降沿计数,最后把两个输出做逻辑运算。项目里用的是第二种,因为纯 RTL 综合更友好。
module div_odd #( parameter DIV_NUM = 3 // 必须为奇数 )( input wire clk_in, input wire rst_n, output wire clk_out ); reg [15:0] cnt_pos, cnt_neg; reg clk_pos, clk_neg; // 上升沿计数 always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt_pos <= 16'd0; clk_pos <= 1'b0; end else if (cnt_pos == DIV_NUM - 1) begin cnt_pos <= 16'd0; clk_pos <= ~clk_pos; end else begin cnt_pos <= cnt_pos + 1'b1; end end // 下降沿计数 always @(negedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt_neg <= 16'd0; clk_neg <= 1'b0; end else if (cnt_neg == DIV_NUM - 1) begin cnt_neg <= 16'd0; clk_neg <= ~clk_neg; end else begin cnt_neg <= cnt_neg + 1'b1; end end assign clk_out = clk_pos | clk_neg; // 两个输出相或 endmodule这里的关键是clk_pos和clk_neg的占空比都不是 50%,但它们的或运算结果正好是 50% 占空比、分频比为DIV_NUM的时钟。参数DIV_NUM必须是奇数,否则两个计数器的翻转点会重合,输出就乱了。仿真时如果看到输出时钟有毛刺,大概率是DIV_NUM设成了偶数。
3.3 半整数分频:2.5 分频是怎么实现的
半整数分频,比如 2.5 分频,意思是输出时钟周期是输入时钟的 2.5 倍。这听起来很玄学,但实现思路并不复杂:用两个计数器,一个在上升沿计数,一个在下降沿计数,计数上限分别是DIV_NUM的整数部分和小数部分组合。项目里用了一个参数化的写法,支持任意X.5分频。
module div_half #( parameter DIV_NUM = 5 // 实际分频比为 DIV_NUM/2,这里 5 表示 2.5 分频 )( input wire clk_in, input wire rst_n, output reg clk_out ); reg [15:0] cnt; reg clk_pos, clk_neg; // 上升沿计数,数到 (DIV_NUM-1)/2 翻转 always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; clk_pos <= 1'b0; end else if (cnt == (DIV_NUM-1)/2) begin cnt <= 16'd0; clk_pos <= ~clk_pos; end else begin cnt <= cnt + 1'b1; end end // 下降沿计数,数到 DIV_NUM-1 翻转 always @(negedge clk_in or negedge rst_n) begin if (!rst_n) begin clk_neg <= 1'b0; end else if (cnt == DIV_NUM - 1) begin clk_neg <= ~clk_neg; end end always @(*) begin clk_out = clk_pos ^ clk_neg; // 异或得到半整数分频 end endmodule这段代码里,DIV_NUM=5对应 2.5 分频。上升沿计数器数到(5-1)/2=2时翻转clk_pos,下降沿计数器在cnt数到 4 时翻转clk_neg,最后异或输出。注意clk_out用的是组合逻辑赋值,综合时可能会引入毛刺,所以实际项目中建议在输出端加一级寄存器打拍。参数DIV_NUM必须是奇数,因为半整数分频的本质是DIV_NUM/2,只有DIV_NUM为奇数时结果才是X.5。
3.4 任意整数分频:参数化设计的取舍
任意整数分频模块div_any其实是偶数分频和奇数分频的通用版本。它的思路是:如果DIV_NUM是偶数,就走偶数分频逻辑;如果是奇数,就走奇数分频逻辑。项目里用了一个generate语句在综合时根据参数选择不同的实现。
module div_any #( parameter DIV_NUM = 7 )( input wire clk_in, input wire rst_n, output wire clk_out ); generate if (DIV_NUM % 2 == 0) begin : gen_even div_even #(.DIV_NUM(DIV_NUM)) u_div ( .clk_in (clk_in), .rst_n (rst_n), .clk_out(clk_out) ); end else begin : gen_odd div_odd #(.DIV_NUM(DIV_NUM)) u_div ( .clk_in (clk_in), .rst_n (rst_n), .clk_out(clk_out) ); end endgenerate endmodulegenerate语句在综合时展开,所以不会引入额外的面积开销。参数DIV_NUM可以是任意正整数,但要注意:如果DIV_NUM=1,输出时钟等于输入时钟,这时候分频器其实没起作用,综合工具可能会优化掉整个模块。如果DIV_NUM=0,那就是非法参数,仿真时会报错。
4. 仿真与上板验证:从 Icarus Verilog 到实际波形
4.1 一键仿真脚本怎么用
项目里提供了一个run_iverilog.sh,内容如下:
#!/bin/bash # 编译 testbench 和 RTL iverilog -o sim.out tb/tb_div_even.v rtl/div_even.v # 运行仿真 vvp sim.out # 打开波形 gtkwave wave.vcd &这个脚本只针对偶数分频的 testbench,其他模块的仿真需要手动改文件名。我一般会把它改成接受命令行参数的形式,比如./run_iverilog.sh even就仿真偶数分频,./run_iverilog.sh odd就仿真奇数分频。这样批量跑回归测试的时候方便很多。
4.2 Testbench 里必须检查的几个点
以tb_div_even.v为例,testbench 的结构是这样的:
`timescale 1ns/1ps module tb_div_even; reg clk_in; reg rst_n; wire clk_out; // 例化被测模块 div_even #(.DIV_NUM(4)) u_div ( .clk_in (clk_in), .rst_n (rst_n), .clk_out(clk_out) ); // 生成输入时钟,周期 10ns initial begin clk_in = 0; forever #5 clk_in = ~clk_in; end // 复位和激励 initial begin rst_n = 0; #20 rst_n = 1; #200 $finish; end // 自动检查分频比 integer pos_cnt = 0; always @(posedge clk_out) pos_cnt = pos_cnt + 1; initial begin #200; if (pos_cnt != 5) $display("ERROR: expected 5, got %0d", pos_cnt); else $display("PASS: div_even works"); end // 波形输出 initial begin $dumpfile("wave.vcd"); $dumpvars(0, tb_div_even); end endmodule这里有几个关键点:第一,timescale要设对,否则仿真时间会乱;第二,复位信号要持续足够长,一般至少两个时钟周期;第三,自动检查逻辑要放在$finish之前,否则检查不到。我见过有人把$finish写在检查逻辑前面,结果仿真直接结束,什么也没打印。
4.3 上板验证时要注意的时序问题
仿真通过不代表上板就能跑。实际 FPGA 里,分频器的输出时钟如果驱动了其他模块,需要特别注意时钟约束。比如奇数分频的输出时钟占空比不是 50%,如果后级模块对占空比敏感,可能会出问题。常见做法是在分频器输出端加一个 BUFG,把时钟引到全局时钟网络上。
另外,如果分频比很大,比如 1000 分频,输出时钟频率很低,这时候用示波器或者逻辑分析仪抓波形时,要记得把采样率调够,否则看到的波形是混叠的。我一般会在输出端加一个计数器,把分频后的时钟再分频一次,用 LED 闪烁来肉眼判断频率对不对。
注意:综合工具可能会把分频器优化掉,如果输出时钟没有驱动任何负载。解决办法是在输出端加一个
(* keep = "true" *)属性,或者把输出接到一个实际的引脚上。
5. 避坑与常见问题:那些年我踩过的分频器坑
5.1 现象:仿真波形正确,上板后输出频率不对
原因:综合工具把分频器逻辑优化了,或者时钟约束没设对。常见情况是分频器的输出时钟被当成了普通信号,没有走全局时钟网络,导致偏斜过大。
解决:在综合脚本里给输出时钟加create_generated_clock约束,或者在代码里例化 BUFG。如果是 Quartus,可以在 Assignment Editor 里把输出引脚设为全局时钟。
5.2 现象:奇数分频输出有毛刺
原因:用了组合逻辑直接输出,或者两个计数器的翻转点没对齐。奇数分频的clk_pos | clk_neg如果直接输出,在翻转瞬间会有竞争冒险。
解决:在输出端加一级寄存器打拍,用clk_in的上升沿采样。这样虽然会引入一个周期的延迟,但输出干净很多。
5.3 现象:半整数分频的占空比不是 50%
原因:半整数分频的占空比天然就不是 50%,因为周期是X.5个输入时钟,不可能做到精确的 50%。如果后级模块要求 50% 占空比,半整数分频就不适用。
解决:如果必须用半整数分频且要求 50% 占空比,可以用 PLL 先倍频再分频,但那就不是纯 RTL 方案了。项目里的半整数分频模块在文档里明确标注了占空比范围,用之前先确认后级能不能接受。
5.4 现象:参数化分频器在综合时报错
原因:generate语句里的条件判断用了非恒定表达式,或者参数类型不匹配。比如DIV_NUM定义成了integer,但在generate里当成了wire用。
解决:确保generate里的条件在综合时是常量。DIV_NUM用parameter定义,不要用localparam或者integer。如果还是报错,把generate展开成两个独立的模块,用顶层多路选择器切换。
5.5 现象:仿真时$finish提前触发,检查逻辑没执行
原因:$finish的时间设得太早,或者检查逻辑的initial块和$finish在同一个时间步。
解决:把$finish的时间设得比检查逻辑晚,或者用$stop代替$finish,手动继续仿真。我一般会在检查逻辑里加$display,然后$finish放在最后。
6. 二次开发与改造:把分频器变成你自己的模块
6.1 动态切换分频比
项目里的分频比是参数化的,综合时固定。如果你需要运行时动态切换分频比,比如从 4 分频切到 8 分频,可以把DIV_NUM改成输入端口,然后在模块内部用寄存器锁存。但要注意:动态切换时,计数器的当前值可能会超过新的分频比,导致输出时钟出现一个异常周期。常见做法是加一个div_change信号,在切换时先复位计数器,等一个完整周期后再切换输出。
module div_dynamic #( parameter MAX_DIV = 16 )( input wire clk_in, input wire rst_n, input wire [15:0] div_num, // 动态分频比 input wire div_change, // 切换使能 output reg clk_out ); reg [15:0] cnt; reg [15:0] div_latched; always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; clk_out <= 1'b0; div_latched <= 16'd4; end else if (div_change) begin div_latched <= div_num; // 锁存新的分频比 cnt <= 16'd0; // 复位计数器 end else if (cnt == (div_latched/2 - 1)) begin cnt <= 16'd0; clk_out <= ~clk_out; end else begin cnt <= cnt + 1'b1; end end endmodule这个模块的div_num是运行时输入的,div_change拉高一个周期后,新的分频比生效。注意div_latched的初值设成了 4,避免上电后分频比不确定。
6.2 加一个使能信号,降低功耗
如果分频器不是一直工作,可以加一个enable信号。当enable为低时,计数器和输出都保持,功耗能降不少。这个改动很简单,在always块的敏感列表里加上enable,然后在条件判断里加一层if (enable)就行。
6.3 验证改造后的模块
改造完一定要跑回归测试。我一般会写一个脚本,把所有 testbench 跑一遍,对比输出波形的周期和占空比。如果改造涉及动态切换,还要专门写一个 testbench,模拟切换过程,检查切换瞬间有没有毛刺。
提示:改造后的模块如果要用在正式项目里,建议加一个
assert属性,在仿真时自动检查分频比是否正确。综合时assert会被忽略,不影响面积。
6.4 一个我常用的调试技巧
每次改完分频器,我都会在 testbench 里加一段自动测量代码,用$time记录输出时钟的上升沿时间,然后算周期。如果周期和预期不符,直接$display报错。这样比肉眼看波形快得多,尤其是分频比很大的时候。
从那以后,我每次写完分频器,都强制走一遍“仿真自动检查 + 上板 LED 验证”的流程,再也不敢只看波形就下结论了。希望这个项目的拆解能帮到你,少走一些我当年走过的弯路。
本文还有配套的精品资源,点击获取