简介:这份文档面向电子信息、自动化等专业学习FPGA课程设计的学生,围绕“用10×8双口RAM实现10个8位计数器”这一典型课题,给出从任务定义到功能验证的完整方案。内容涵盖计数器、分频、RAM与显示四个模块的设计思路,1MHz主时钟分频至1Hz计数脉冲的处理方式,按键切换地址选择输出、七段数码管译码显示的实现,以及Modelsim前仿真、Quartus II综合布局布线与引脚分配等验证环节,并附源代码与仿真结果。资源包共1个doc文件,约165KB,以课程设计报告形式组织,包含技术规范、总体设计图、模块划分与验证项目,适合作为课设撰写与实验调试的参照。已有1485人学习,可帮助读者理清RAM读写控制、分频时序与显示译码的衔接关系,少走弯路。
1. 一块 10×8 双口 RAM 撑起的十个计数器:这个 FPGA 课设到底在练什么
先看一个具体场景:板子上电,数码管显示某个数,按一下按键,数字跳到另一个值,再按一下,又换一个——十个计数器各自独立跑着,初值从 1 到 10,每秒加一,暂停时把当前值写回 RAM,下次切回来接着数。这就是「采用 RAM 实现计数器及 FPGA 功能验证」这个题目的全部外在表现。它不复杂,但把 FPGA 设计里最容易翻车的几件事全串起来了:时钟分频、双口 RAM 读写时序、按键消抖与地址切换、七段译码显示。适合刚学完 Verilog、想找一个能把分频器、存储器、计数器、显示模块真正连成一条链的练手项目的人。整套设计用 Verilog_HDL 写,前仿真和后仿真走 Modelsim,综合与布局布线走 Quartus II,目标板是 Cyclone II 2C35 这一类带 M4K RAM 块和足够用户 I/O 的器件。下面按「它怎么搭起来 → 怎么跑起来 → 怎么改」的顺序拆。
2. 四模块架构拆解:分频、RAM、计数、显示怎么串成一条链
2.1 自顶向下的模块划分与信号流向
这个设计的骨架非常清晰,顶层一个模块把四个子模块用 wire 连起来,数据流是单向的:50MHz 板载时钟进分频模块,分出 1MHz 给 RAM 做读写节拍、分出 1Hz 给计数器做秒计时;RAM 模块按地址存取 8 位数据;计数模块从 RAM 读出初值、在 1Hz 节拍下累加、暂停时把值写回;显示模块把 8 位二进制拆成百位、十位、个位,查表译成七段码送数码管。地址切换键决定当前操作的是 10 个存储单元里的哪一个。
顶层例化的写法在原始代码里是这样的(这里按可综合、端口方向正确的写法整理过,原稿里wire和input混用、模块名拼写不一致的地方后面避坑章节会专门说):
module top_ram_counter( input clk, // 板载 50MHz input reset, // 低电平复位 input start, // 开始计数 input stop, // 暂停计数 input count_reset, // 计数初值重载 input wr, rd, // RAM 写/读使能 input [3:0] addr_in, // 10 个地址,用 4 位足够 output [7:0] hex_b, hex_s, hex_g // 百/十/个位数码管段码 ); wire clk_1hz, clk_1mhz; wire [7:0] dout_ram, din_cnt; // 分频:50MHz -> 1MHz / 1Hz fpq u_div( .clk(clk), .reset(reset), .clk_1hz(clk_1hz), .clk_1mhz(clk_1mhz) ); // 双口 RAM:10 x 8 ram_10x8 u_ram( .wr(wr), .rd(rd), .reset(reset), .clk_1mhz(clk_1mhz), .din(din_cnt), .addr_in(addr_in), .dout(dout_ram) ); // 计数器:读初值、累加、回写 jsq u_cnt( .dout(dout_ram), .din(din_cnt), .start(start), .stop(stop), .clk_1hz(clk_1hz), .count_reset(count_reset) ); // 七段译码显示 smg u_disp( .dout(dout_ram), .hex_b(hex_b), .hex_s(hex_s), .hex_g(hex_g) ); endmodule这段顶层代码的关键点在于:din_cnt是计数器模块的输出、RAM 模块的输入,dout_ram是 RAM 的输出、同时喂给计数器和显示模块。也就是说 RAM 既是初值来源,也是暂停时的落盘位置,计数器和显示都从它取数。参数上addr_in用 4 位而不是 1 位,是因为要寻址 10 个单元,原稿里写成input addr_in单比特是明显笔误,综合时会直接报位宽不匹配。
2.2 分频模块:50MHz 怎么切出 1MHz 和 1Hz
分频是整个系统的时间基准,切错了后面全乱。思路很朴素:对输入时钟计数,计到阈值翻转输出。1MHz 相对 50MHz 是 50 分频,计数到 24 翻转一次(25 个周期高、25 个周期低,合起来 50 个周期一个完整方波);1Hz 相对 50MHz 是 5000 万分频,计数到 2500 万翻转一次。
module fpq( input clk, reset, output reg clk_1hz, clk_1mhz ); reg [4:0] cnt1; // 1MHz 分频计数,0~24 reg [29:0] cnt; // 1Hz 分频计数,需容纳 2500 万 // 50MHz -> 1MHz:每 25 个周期翻转 always @(posedge clk or negedge reset) begin if (!reset) begin clk_1mhz <= 0; cnt1 <= 0; end else if (cnt1 == 5'd24) begin cnt1 <= 0; clk_1mhz <= ~clk_1mhz; end else cnt1 <= cnt1 + 1'b1; end // 50MHz -> 1Hz:每 2500 万个周期翻转 always @(posedge clk or negedge reset) begin if (!reset) begin clk_1hz <= 0; cnt <= 0; end else if (cnt == 30'd24_999_999) begin cnt <= 0; clk_1hz <= ~clk_1hz; end else cnt <= cnt + 1'b1; end endmodule参数说明:cnt1位宽 5 位刚好装下 0~31,阈值取 24 得到 50 分频;cnt位宽 30 位,最大约 10.7 亿,装 2500 万绰绰有余。原稿里 1Hz 的阈值写的是30'd2500_0000,那是 2500 万,翻转周期是 5000 万个时钟,正好 1 秒,逻辑没错,但要注意别和「计数到 2500 万就输出一个脉冲」的写法混淆——翻转法和脉冲法在后续计数模块的边沿选择上不一样,这里用的是翻转法,所以计数器模块必须用posedge clk_1hz才能每秒触发一次。
2.3 双口 RAM 模块:10 个 8 位单元的读写控制
RAM 模块承担两件事:复位时把 1~10 预置进 10 个单元;正常工作时按wr/rd做写回和读出。原稿用mem[10:1]这种 1 起始的索引,Verilog 允许,但地址 0 就浪费了,后面地址切换逻辑要跟着对齐,容易出错,我一般改成mem[0:9]。
module ram_10x8( input wr, rd, reset, clk_1mhz, input [3:0] addr_in, input [7:0] din, output reg [7:0] dout ); reg [7:0] mem [0:9]; // 10 个 8 位单元 integer i; always @(posedge clk_1mhz or negedge reset) begin if (!reset) begin // 预置初值 1~10 for (i = 0; i < 10; i = i + 1) mem[i] <= i + 1; end else if (wr && !rd) begin mem[addr_in] <= din; // 写回 end else if (rd && !wr) begin dout <= mem[addr_in]; // 读出 end end endmodule逻辑说明:复位分支用 for 循环一次性铺初值,比手写十行赋值干净;读写用wr && !rd和rd && !wr互斥判断,避免两个使能同时拉高时行为不确定。参数上addr_in是 4 位,实际只用 0~9,地址 10~15 落在未定义区,综合工具可能推断出额外存储,所以地址切换逻辑里必须做「到 9 回 0」的钳位。dout是寄存器输出,读操作有一拍延迟,这一点在仿真波形里看得很清楚,写测试平台时别按组合逻辑的预期去比对。
2.4 计数模块与显示模块:累加、暂停回写、七段译码
计数模块的职责是:count_reset有效时从 RAM 读出的值重载;start=1 且 stop=0时每个 1Hz 上升沿加一;到 255 回绕到 0;暂停时把当前值通过din送回 RAM。显示模块则把 8 位值拆成百、十、个三位,各查一张七段码表。
module jsq( input [7:0] dout, input start, stop, clk_1hz, count_reset, output reg [7:0] din ); always @(posedge clk_1hz) begin if (!count_reset) din <= dout; // 重载初值 else if (start && !stop) begin if (din == 8'd255) din <= 8'd0; else din <= din + 1'b1; end // stop=1 时保持,由外部写回 RAM end endmodule显示模块的核心是查表,共阴数码管 0~9 的段码是C0 F9 A4 B0 99 92 82 F8 80 90。原稿里百位用dout/100、十位用dout/10、个位用dout%10,十位那里少了一个%10,导致十位可能超过 9 落到 default 分支,显示成 0——这是仿真时数码管「十位永远是 0」的典型原因,后面避坑章节展开。
module smg( input [7:0] dout, output reg [7:0] hex_b, hex_s, hex_g ); reg [3:0] b, s, g; always @(*) begin b = dout / 100; s = (dout / 10) % 10; // 注意取模 g = dout % 10; hex_b = seg7(b); hex_s = seg7(s); hex_g = seg7(g); end function [7:0] seg7; input [3:0] v; case (v) 4'd0: seg7 = 8'hC0; 4'd1: seg7 = 8'hF9; 4'd2: seg7 = 8'hA4; 4'd3: seg7 = 8'hB0; 4'd4: seg7 = 8'h99; 4'd5: seg7 = 8'h92; 4'd6: seg7 = 8'h82; 4'd7: seg7 = 8'hF8; 4'd8: seg7 = 8'h80; 4'd9: seg7 = 8'h90; default: seg7 = 8'hC0; endcase endfunction endmodule用 function 把三张重复的 case 表收成一张,是这类课设里最值得做的一步重构——原稿三张表逐字重复,改一个段码要改三处,漏一处就是显示不一致。参数上dout最大 255,百位最大 2,所以百位其实只需要 2 位,但统一用 4 位不影响综合。
3. 从 Modelsim 前仿真到 Quartus II 综合:把工程跑起来的完整步骤
3.1 工程建立与文件组织
先建目录,把五个源文件分开放,别一股脑丢根目录:
ram_counter/ ├── rtl/ │ ├── fpq.v # 分频 │ ├── ram_10x8.v # 双口 RAM │ ├── jsq.v # 计数器 │ ├── smg.v # 七段译码 │ └── top_ram_counter.v ├── sim/ │ └── tb_count_ram.v # 激励 └── quartus/ └── ram_counter.qpfQuartus II 里新建工程,器件选 Cyclone II 系列的 2C35(封装按实际板子选 FineLine BGA 672 或对应封装),顶层实体填top_ram_counter,把rtl/下五个文件全部 Add。综合前先做一次 Analysis & Synthesis,看有没有位宽不匹配、多驱动、未声明 wire 这类错误——原稿顶层里wire [3:0] addr_in;又声明成input,这种重复声明在 Quartus 里会直接报错,必须先清掉。
3.2 引脚分配与时钟约束
引脚分配照板子原理图来,原稿给的那份分配表可以直接抄,但要注意几个关键脚:clk接 PIN_N2(50MHz 晶振)、reset接 PIN_G26、start/stop接 PIN_N26/PIN_P25、wr/rd接 PIN_AE14/PIN_N25,数码管段码hex_b/hex_s/hex_g各 7 位分别落到 AB23 那一组、AF10 那一组、V20 那一组。分配完在 Assignments → Device → Device and Pin Options 里把未用引脚设成 As input tri-stated,避免上电后误驱动。
时钟约束这块,50MHz 对应周期 20ns,在 TimeQuest 里加一条:
create_clock -name clk -period 20.000 [get_ports clk]不写这条约束,时序分析跑出来是空的,后仿真也就失去意义。1Hz 和 1MHz 是分频出来的派生时钟,不用单独约束,工具会沿时钟树推。
3.3 激励文件怎么写才能看到「切换地址—读—计数—写回」全过程
原稿的激励模块count_ram_test用#20 clk<=~clk产生 50MHz(周期 40ns,实际是 25MHz,但仿真看逻辑够了),然后按时间点拉高rd、wr、start、stop。这种写法能跑,但时间点全靠手调,改一个参数就得重排。更稳的写法是用任务封装一次完整操作:
`timescale 1ns/1ps module tb_count_ram; reg clk, reset, start, stop, count_reset, wr, rd; reg [3:0] addr_in; wire [7:0] hex_b, hex_s, hex_g; top_ram_counter dut( .clk(clk), .reset(reset), .start(start), .stop(stop), .count_reset(count_reset), .wr(wr), .rd(rd), .addr_in(addr_in), .hex_b(hex_b), .hex_s(hex_s), .hex_g(hex_g) ); always #10 clk = ~clk; // 50MHz task select_addr(input [3:0] a); begin addr_in = a; rd = 1; wr = 0; #100; rd = 0; end endtask initial begin clk = 0; reset = 0; start = 0; stop = 0; count_reset = 0; wr = 0; rd = 0; addr_in = 0; #100 reset = 1; // 释放复位,RAM 预置 1~10 #100 count_reset = 1; // 允许计数 select_addr(4'd0); // 选第 1 个计数器 start = 1; #2_000_000; // 跑两个 1Hz 周期 stop = 1; wr = 1; #100; // 暂停并写回 wr = 0; stop = 0; select_addr(4'd5); // 切到第 6 个 #2_000_000; $stop; end endmodule逻辑说明:select_addr任务把「设地址 + 拉读使能 + 保持 + 释放」封成一步,切十个地址就是十次调用,比手写时间点可靠。#2_000_000对应 2ms 仿真时间,在 1Hz 节拍下正好两个计数周期——注意这里 1Hz 是分频出来的,仿真里 50MHz 是理想时钟,所以 2ms 能看到两次clk_1hz上升沿。参数上timescale设成 1ns/1ps,和 Quartus 默认一致,后仿真对时间才有意义。
3.4 前仿真、综合、后仿真三步验证的检查点
前仿真在 Modelsim 里跑,重点看三处波形:clk_1hz的周期是不是 1 秒(仿真时间尺度下)、dout_ram在rd拉高后一拍是否出现对应地址的初值、din_cnt在start有效后是否逐拍加一。综合在 Quartus 里跑,看 Compilation Report 的 Resource Utilization:10×8 RAM 应该被推断成 M4K 块或寄存器阵列,逻辑单元占用在几百 LE 量级,如果突然飙到几千,多半是某个 always 块写成了组合逻辑大表。后仿真把综合后的网表加.sdo延时文件回灌 Modelsim,重点看建立/保持时间有没有违例,以及复位释放后 RAM 初值是否真的写进去了——这一步原稿作者没做到下载,只到后仿真,其实后仿真通过基本就能上板。
4. 避坑与排查:五个让这个课设卡住一整天的典型问题
4.1 数码管十位永远是 0
现象:仿真波形里hex_s一直是C0,不管计数值多少。原因:原稿显示模块十位写的是dout_s = dout/10,没有%10,当dout到 20 以上时dout_s变成 2、3……超过 9 就落进 case 的 default 分支输出C0。解决:改成(dout/10)%10,或者干脆用 BCD 拆分逻辑。这个坑很隐蔽,因为个位和百位都对,只有十位错,第一反应会去查段码表。
4.2 RAM 地址 0 读出来是 X
现象:切到第一个计数器时dout_ram是红色不定态。原因:原稿mem[10:1]从 1 开始索引,地址 0 从未被赋值,而地址切换键从 0 开始扫。解决:统一改成mem[0:9],复位时 for 循环铺 0~9;或者保留 1 起始但地址切换从 1 开始、到 10 回 1。两种都行,关键是索引范围和切换范围必须一致。
4.3 暂停后切回来数值归零
现象:按暂停、切到别的地址、再切回来,计数器从初值重新开始而不是接着数。原因:暂停时只拉了stop,没有真正把din写回 RAM,或者写回时wr和rd同时为高,RAM 模块的互斥判断把这次写丢了。解决:暂停动作要拆成两步——先stop=1冻结计数,再wr=1, rd=0保持至少一个clk_1mhz周期完成写回,最后释放。写回期间地址必须锁在当前计数器上,不能跟着切换键动。
4.4 综合报「multiple drivers」或位宽不匹配
现象:Quartus 综合直接失败,报addr_in多驱动或din位宽 1 位对 8 位。原因:原稿顶层把addr_in同时写成input [3:0]和wire [3:0],din在 RAM 模块里声明成单比特。解决:端口方向只声明一次,内部连线用wire,模块间位宽严格对齐——din/dout一律 8 位,addr_in一律 4 位。这类错误在写代码时看不出来,综合一跑全暴露,所以每加一个模块先单独综合一次。
4.5 上板后按键切换地址乱跳
现象:按一下切换键,地址跳好几个。原因:机械按键抖动,几十毫秒内产生多个边沿,地址计数器被多次触发。解决:加一个 20ms 消抖模块,用 1kHz 采样时钟对按键打两拍再取边沿;或者简单点,在地址切换逻辑里加一个「按键释放后才允许下次切换」的状态位。这个坑在仿真里完全看不到,只有上板才现形,属于典型的「仿真通过不等于板子能跑」。
5. 二次开发改造:把课设级代码推到能复用的程度
这套代码跑通之后,真正有价值的是把它改成能复用的模板。我一般做三件事。第一,把分频模块参数化,用parameter CLK_IN = 50_000_000和parameter FREQ_OUT = 1算出阈值,而不是写死 24 和 2500 万,换一块 27MHz 的板子只改一个参数:
module fpq #( parameter CLK_IN = 50_000_000, parameter FREQ_OUT = 1 )( input clk, reset, output reg clk_out ); localparam HALF = CLK_IN / (2 * FREQ_OUT); reg [31:0] cnt; always @(posedge clk or negedge reset) begin if (!reset) begin cnt <= 0; clk_out <= 0; end else if (cnt >= HALF - 1) begin cnt <= 0; clk_out <= ~clk_out; end else cnt <= cnt + 1; end endmodule第二,把 RAM 深度和位宽也参数化,ADDR_W和DATA_W两个参数一改,10×8 就能变成 16×8 或 10×16,计数器数量跟着地址位宽走。第三,把显示模块的段码表抽成function或独立的seg7.vh头文件,共阴共阳用一个parameter INVERT切换。改完拿一个 16×8 的配置跑一遍前仿真,看地址 15 能不能正常读写、显示能不能到 255,验证参数化没有引入新的位宽截断。
| 改造点 | 原写法 | 改后 | 验证方法 |
|---|---|---|---|
| 分频阈值 | 写死 24 / 2500 万 | parameter计算 | 换 27MHz 仿真看 1Hz 周期 |
| RAM 规格 | mem[10:1]固定 | ADDR_W/DATA_W参数 | 16×8 配置跑读写 |
| 段码表 | 三份重复 case | 单个 function | 改一个段码看三位同步 |
| 按键 | 无消抖 | 20ms 采样消抖 | 上板连按十次看地址 |
最后说个血泪经验:这个项目我最初也是仿真全绿就以为完事,结果上板数码管乱闪,查了半天是分频模块的cnt位宽不够、1Hz 阈值溢出回绕,导致实际输出频率是设计值的两倍。从那以后我每次改分频参数,都强制先用 Modelsim 量一遍输出周期再综合,不再靠「看着差不多」判断。希望帮到你。
本文还有配套的精品资源,点击获取