简介:围绕Quartus II与FPGA技术展开的出租车计费系统设计文档,面向电子信息、自动化、集成电路等专业的课程设计与毕业设计学习者,也适合刚接触VHDL与可编程逻辑的开发者参考。文档以自顶向下思路讲解计价器整体结构,逐项说明分频、计量、控制、计费、译码显示与顶层模块的职责及衔接方式,并覆盖起步价、按里程计费、等待超时计费等典型规则的实现逻辑与硬件仿真验证过程,可帮助读者理清从需求分析到仿真波形的完整链路。压缩包内共1个docx文件,约523KB,篇幅紧凑,便于打印或二次整理。目前已有1718人学习下载,适合用作课程设计对照方案、模块划分与仿真测试的参考,也能作为VHDL入门练习的实例范本。
1. 从计价规则到数字逻辑:出租车计费系统在 FPGA 里的落地姿势
出租车计费要处理的量其实只有两个:跑了多少公里、等了多长时间,可一旦要把它变成数码管上那串"元角分",中间要过的坎就多了——起步价怎么扣、超起步里程后单价怎么叠加、等待计时和行驶计时怎么并存、金额进位又怎么保证不串位。搬进 Quartus II 做,意味着整条链路是 Verilog 描述逻辑、仿真器跑波形、综合布局布线后落到 FPGA 上,再用数码管把单价、里程、总价同时显示出来。它跟单片机方案的差别在于,这里没有 CPU,也没有现成的乘法指令和浮点库,金额累加、BCD 转换、动态扫描全靠时钟节拍和状态机推着走。适合正在做数字电路课程设计、EDA 实训的人,也适合想把 Verilog 从"点灯"推进到"能跑的小系统"的嵌入式从业者。
2. Quartus II 工程建立与出租车计费系统的模块划分
2.1 新建工程时那几个必须选对的选项
Quartus II 的版本跨度很大,13.0sp1、17.1、18.1 至今都有人在用,但 New Project Wizard 的路径一致,真正容易埋雷的只有三处。第一处是器件选择:出租车计费系统的资源开销很小,几百个 LE、几十个寄存器就够,Cyclone IV E 的 EP4CE6、EP4CE10 这类入门板子完全吃得下;如果手上是 DE2-115 这级别的板子,直接按封装选 EP4CE115F29C7。器件一旦选错,后面 Pin Planner 里可用 IO 列表会完全不同。
第二处是 EDA 工具设置。仿真建议挂 ModelSim-Altera(13.0 之后叫 ModelSim-Altera Starter Edition),写 testbench 做自动比对比较顺手;只想快速看几个信号波形的话,直接跑 Quartus 自带的 University Program VWF 也够用。第三处是顶层实体命名,工程名和顶层模块名保持一致,能省掉后面在 Settings 里手动指定 Top-Level Entity 的麻烦。系统时钟按板子来,DE2 系列通常是 50MHz,这个数字会在第 4 章算扫描分频比时直接用到。
| 工程选项 | 建议值 | 说明 |
|---|---|---|
| 器件系列 | Cyclone IV E | 入门板主流,逻辑资源足够 |
| 顶层实体名 | fare_top | 与工程名一致,综合时自动识别 |
| 仿真工具 | ModelSim-Altera | 支持标准 Verilog testbench |
| 系统时钟 | 50 MHz | 后续所有分频参数的基准 |
| 默认 SDC | 不勾选 | 自己写约束,避免自动约束覆盖 |
提示:器件改过之后,Pin Planner 里原本分配好的引脚会大面积失效,务必重新走一遍全部分配再编译。
2.2 顶层模块与五个子模块的职责边界
把整个系统拆成"顶层只做连线、子模块各管一段"是这类设计最省心的做法。顶层fare_top不做任何运算,只负责把时钟、复位和按键信号分发下去,再把各模块输出汇总给数码管驱动。往下拆五个模块:clk_gen负责产生 1Hz 计时脉冲和扫描时钟,key_debounce处理上车、下车两个机械按键,fare_core是计价状态机,bin2bcd把二进制金额和里程转成 BCD,seg_driver做动态扫描输出。
这么拆的好处是每个模块的时钟域和复位策略都很单一,clk_gen之外全是同步逻辑,仿真时能一段一段单独验证,不用每次都跑整机波形。模块之间的接口尽量用单周期脉冲传递事件,比如km_pulse、min_pulse,避免跨模块用使能电平做握手。
| 模块 | 输入 | 输出 | 时钟域 |
|---|---|---|---|
| clk_gen | clk, rst_n | tick_1hz, scan_clk | 50 MHz |
| key_debounce | clk, rst_n, key_in | key_pulse | 50 MHz |
| fare_core | clk, rst_n, km_pulse, min_pulse | money, km, running | 50 MHz |
| bin2bcd | bin | bcd | 组合逻辑 |
| seg_driver | clk, rst_n, 6 位 BCD | seg_sel, seg_code | 50 MHz |
2.3 分频与按键消抖:最小可跑通的代码骨架
分频器不要写成"输出一个 1Hz 方波",而是输出一个只维持一个时钟周期的使能脉冲,这样后级计数器直接拿它当条件写if (tick_1hz)就行,不必再关心电平长短。按键消抖同理,机械按键的抖动在几百微秒到几毫秒之间,用 50MHz 采样的话,20 位计数器大约对应 21ms,足够把抖动滤干净。
// 产生 1Hz 单周期使能脉冲,50MHz 输入 module clk_gen #( parameter CLK_FREQ = 50_000_000 )( input wire clk, input wire rst_n, output reg tick_1hz ); localparam CNT_MAX = CLK_FREQ - 1; // 1 秒对应的时钟个数 reg [25:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; tick_1hz <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 26'd0; tick_1hz <= 1'b1; // 只高一个周期 end else begin cnt <= cnt + 26'd1; tick_1hz <= 1'b0; end end endmoduleCNT_MAX取CLK_FREQ - 1,是因为计数器从 0 开始数,数到 49 999 999 正好是 50 000 000 个周期即 1 秒。落到板子上如果发现计时偏快或偏慢,先核对板子实际晶振,有些开发板焊的是 50MHz,有的标称 27MHz,这个参数不匹配的话,后面所有时间相关的量都会跟着错。
按键消抖用移位寄存器采样法,把输入连续打几拍,全部相同才认账:
module key_debounce #( parameter CNT_MAX = 20'd1_000_000 // 50MHz 下约 20ms )( input wire clk, input wire rst_n, input wire key_in, output reg key_pulse ); reg [19:0] cnt; reg key_sync, key_last; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_sync <= 1'b1; key_last <= 1'b1; key_pulse <= 1'b0; end else begin key_last <= key_sync; if (key_sync == 1'b0) begin // 低电平有效,按下时开始计时 if (cnt == CNT_MAX) begin cnt <= cnt; if (key_last == 1'b1) key_pulse <= 1'b1; // 只在边沿出脉冲 end else cnt <= cnt + 20'd1; end else begin cnt <= 20'd0; key_pulse <= 1'b0; end end end // 实际工程里 key_sync 由两级触发器同步外部输入得到,此处省略 endmoduleCNT_MAX决定消抖窗口,20ms 能覆盖绝大多数轻触按键,如果用的是手感偏硬的工业按键可以加到 50ms。key_pulse只在按住期间输出一个周期的高电平,后级状态机直接拿它做跳转条件,就不需要再做边沿检测。
3. 计价核心:起步价、里程单价与等待费的计数器实现
3.1 把计价规则写成整数参数
计价规则最忌讳用浮点,Verilog 里real类型综合工具基本不支持。稳妥做法是统一把金额放大十倍,用"角"作为最小单位,所有参数都是整数。起步价 8 元记作 80,超程单价每秒…每公里 2 元记作 20,等待每分钟 0.5 元记作 5。这样一来整个计价过程只有加法和比较,没有一处乘除,综合出来的电路就是几个加法器和寄存器,资源占用极低。
| 计价项 | 规则值 | 内部整数(单位 0.1 元) | 触发条件 |
|---|---|---|---|
| 起步价 | 8.0 元 | 80 | 上车后首次进入计费状态 |
| 起步含里程 | 3 km | 3 | 仅作比较,不参与金额 |
| 超程单价 | 2.0 元/km | 20 | km_pulse 上升沿 |
| 等待费 | 0.5 元/min | 5 | min_pulse 且 waiting 有效 |
注意:起步里程是个"包含"关系,不是"额外赠送"。也就是说第 4 公里开始才叠加单价,前 3 公里不论跑没跑满都不退钱,代码里必须写成
km + 1 > BASE_KM而不是km >= BASE_KM,差一个数就是一块钱的误差。
3.2 用车费状态机管住上车、行驶、等待、下车
计价过程天然对应四个状态:空车待客、载客行驶、到达停车、重新开始。用三段式状态机写,组合逻辑算次态,时序逻辑管数据通路,避免在同一个 always 里既做跳转又做运算导致综合出锁存器。
module fare_core #( parameter [15:0] BASE_FARE = 16'd80, // 起步价 8.0 元 parameter [7:0] BASE_KM = 8'd3, // 起步含 3 公里 parameter [15:0] PER_KM_FARE = 16'd20, // 每公里 2.0 元 parameter [15:0] PER_MIN_WAIT = 16'd5 // 等待每分钟 0.5 元 )( input wire clk, input wire rst_n, input wire car_start, // 上车脉冲 input wire car_stop, // 下车脉冲 input wire km_pulse, // 每 1 公里一个脉冲 input wire min_pulse, // 每 1 分钟一个脉冲 input wire waiting, // 等待标志 output reg [15:0] money, // 总金额,单位 0.1 元 output reg [7:0] km, // 累计里程,单位 1 公里 output reg running ); localparam S_IDLE = 2'd0, S_RUN = 2'd1, S_STOP = 2'd2; reg [1:0] state, next_state; reg base_paid; // 起步价是否已计入 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S_IDLE; else state <= next_state; end always @(*) begin next_state = state; case (state) S_IDLE : if (car_start) next_state = S_RUN; S_RUN : if (car_stop) next_state = S_STOP; S_STOP : if (car_start) next_state = S_RUN; // 直接接下一单 default: next_state = S_IDLE; endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin money <= 16'd0; km <= 8'd0; running <= 1'b0; base_paid <= 1'b0; end else if (state == S_IDLE) begin money <= 16'd0; km <= 8'd0; running <= 1'b0; base_paid <= 1'b0; end else if (state == S_RUN) begin running <= 1'b1; if (!base_paid) begin // 上车先扣起步价 money <= BASE_FARE; base_paid <= 1'b1; end else if (km_pulse) begin km <= km + 8'd1; if (km + 8'd1 > BASE_KM) // 超出起步里程才加价 money <= money + PER_KM_FARE; end else if (waiting && min_pulse) begin money <= money + PER_MIN_WAIT; // 等待时长独立累加 end end else begin // S_STOP 锁存金额与里程 running <= 1'b0; base_paid <= 1'b0; // 为下一单做准备 end end endmodulebase_paid这个标志是关键。如果不用它,而是在state == S_RUN每个时钟周期都去判km == 0就赋起步价,一旦第一个km_pulse和状态切换同拍到来,非阻塞赋值会让起步价被单价覆盖,少收 8 块钱。参数化写法还有个好处:换成夜间费率或者长途费率时,只改顶层例化的参数值,模块内部一行不动。
3.3 二进制金额转 BCD 的双 dabble 实现
金额和里程在寄存器里都是二进制,要送数码管必须先转 BCD。综合工具对除法取余的处理代价很大,money / 10 % 10这种写法会综合出一大坨组合逻辑。常用做法是双 dabble 算法,把二进制串从高位往低位一位一位移进 BCD 区,每移一位之前检查各 BCD 位是否大于等于 5,是就加 3。
module bin2bcd #(parameter BIN_W = 16)( input wire [BIN_W-1:0] bin, output reg [19:0] bcd // 5 位 BCD,最大显示 9999 ); integer i; reg [BIN_W+20-1:0] shift; always @(*) begin shift = {(20'd0), bin}; for (i = 0; i < BIN_W; i = i + 1) begin if (shift[BIN_W+3 : BIN_W ] >= 4'd5) shift[BIN_W+3 : BIN_W ] = shift[BIN_W+3 : BIN_W ] + 4'd3; if (shift[BIN_W+7 : BIN_W+4 ] >= 4'd5) shift[BIN_W+7 : BIN_W+4 ] = shift[BIN_W+7 : BIN_W+4 ] + 4'd3; if (shift[BIN_W+11 : BIN_W+8 ] >= 4'd5) shift[BIN_W+11 : BIN_W+8 ] = shift[BIN_W+11 : BIN_W+8 ] + 4'd3; if (shift[BIN_W+15 : BIN_W+12] >= 4'd5) shift[BIN_W+15 : BIN_W+12] = shift[BIN_W+15 : BIN_W+12] + 4'd3; if (shift[BIN_W+19 : BIN_W+16] >= 4'd5) shift[BIN_W+19 : BIN_W+16] = shift[BIN_W+19 : BIN_W+16] + 4'd3; shift = shift << 1; end bcd = shift[BIN_W+19 : BIN_W]; end endmoduleBIN_W是输入二进制位宽,金额用 16 位就够表示到 6553.5 元;bcd高 20 位每 4 位一个十进制数字,从高到低依次是千、百、十、个位。这个模块是纯组合逻辑,转换一次大约 16 级组合延迟,放在 50MHz 下完全够用。要注意的是bcd输出比输入宽,别在顶层例化时接错位宽,Quartus 编译会给位宽不匹配的警告,看到就别放过。
4. 数码管动态扫描与整机联合仿真的参数怎么设
4.1 扫描频率、分频比与亮度之间的关系
动态扫描的本质是用人眼视觉暂留骗过眼睛,把 6 位数码管按顺序轮流点亮,每位数码管每次亮一小段。刷新率太低会看出抖动,太高则每位点亮的平均电流被摊薄,亮度下降。经验值是整个面板刷新率达到 50Hz 以上就不闪,单管刷新率取 500Hz~1kHz 比较稳。分频计数的上限用CLK_FREQ / (SCAN_HZ × 位数)算。
| 系统时钟 | 单管刷新率 | 分频计数上限 | 实测观感 |
|---|---|---|---|
| 50 MHz | 200 Hz | 41666 | 边缘有可见抖动 |
| 50 MHz | 500 Hz | 16666 | 基本无感 |
| 50 MHz | 1 kHz | 8333 | 稳定,亮度略降 |
| 50 MHz | 2 kHz | 4166 | 亮度明显下降 |
提示:如果板子上的数码管是共阳极,位选低电平有效,且位选线通常经三极管驱动,直接在 FPGA 引脚上拉 6 位数码管的段电流会超规格,长时间跑容易损坏 IO。
4.2 段码表与位选译码的写法
段码表建议用case硬编码,避免用for循环拼位再查表,综合器对case的优化更彻底。共阳极段码{dp,g,f,e,d,c,b,a},0 对应8'hC0,逐个数下来就是固定的九个数。
module seg_driver #( parameter CLK_FREQ = 50_000_000, parameter SCAN_HZ = 1000 )( input wire clk, input wire rst_n, input wire [3:0] d0, d1, d2, d3, d4, d5, // 6 位 BCD 输入 output reg [5:0] seg_sel, // 位选,低有效 output reg [7:0] seg_code // 段选,低有效 ); localparam CNT_MAX = CLK_FREQ / (SCAN_HZ * 6); reg [15:0] div_cnt; reg [2:0] idx; reg [3:0] cur; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 16'd0; idx <= 3'd0; end else if (div_cnt >= CNT_MAX - 1) begin div_cnt <= 16'd0; idx <= (idx == 3'd5) ? 3'd0 : idx + 3'd1; end else div_cnt <= div_cnt + 16'd1; end always @(*) begin case (idx) 3'd0: begin cur = d0; seg_sel = 6'b111110; end 3'd1: begin cur = d1; seg_sel = 6'b111101; end 3'd2: begin cur = d2; seg_sel = 6'b111011; end 3'd3: begin cur = d3; seg_sel = 6'b110111; end 3'd4: begin cur = d4; seg_sel = 6'b101111; end default: begin cur = d5; seg_sel = 6'b011111; end endcase end always @(*) begin case (cur) 4'd0: seg_code = 8'hC0; 4'd1: seg_code = 8'hF9; 4'd2: seg_code = 8'hA4; 4'd3: seg_code = 8'hB0; 4'd4: seg_code = 8'h99; 4'd5: seg_code = 8'h92; 4'd6: seg_code = 8'h82; 4'd7: seg_code = 8'hF8; 4'd8: seg_code = 8'h80; default: seg_code = 8'h90; endcase end endmoduleCNT_MAX用整数除法算出来,编译时就会被常量折叠成一个具体数字,不占用运行时资源。idx用 3 位寄存器但只用到 0~5,多出来的两个状态用default兜底,防止跑飞时锁死。
4.3 Testbench 里如何造出 1 km 和 1 分钟脉冲
整机仿真的难点在于真实的一分钟在仿真里要等太久,所以 testbench 里不要等真实的tick_1hz,直接把km_pulse和min_pulse当成外部激励源驱动,用 task 封装备忘,想跑几公里就跑几公里。
`timescale 1ns/1ps module tb_fare_top; reg clk = 1'b0; reg rst_n = 1'b0; reg car_start = 0, car_stop = 0, waiting = 0; reg km_pulse = 0, min_pulse = 0; wire [5:0] seg_sel; wire [7:0] seg_code; always #10 clk = ~clk; // 20ns 周期,即 50MHz fare_top dut ( .clk(clk), .rst_n(rst_n), .car_start(car_start), .car_stop(car_stop), .km_pulse(km_pulse), .min_pulse(min_pulse), .waiting(waiting), .seg_sel(seg_sel), .seg_code(seg_code) ); task drive_km(input integer n); // 连续产生 n 个里程脉冲 integer i; begin for (i = 0; i < n; i = i + 1) begin @(posedge clk); km_pulse = 1'b1; @(posedge clk); km_pulse = 1'b0; repeat (2) @(posedge clk); end end endtask initial begin repeat (10) @(posedge clk); rst_n = 1'b1; @(posedge clk); car_start = 1'b1; // 上车 @(posedge clk); car_start = 1'b0; drive_km(5); // 行驶 5 公里 waiting = 1'b1; repeat (3) begin // 等待 3 分钟 @(posedge clk); min_pulse = 1'b1; @(posedge clk); min_pulse = 1'b0; repeat (5) @(posedge clk); end waiting = 1'b0; @(posedge clk); car_stop = 1'b1; // 下车 @(posedge clk); car_stop = 1'b0; repeat (20) @(posedge clk); $stop; end endmodule按这套激励,理论结果是起步 8 元,加超程 2 公里 × 2 元 = 4 元,加等待 3 分钟 × 0.5 元 = 1.5 元,合计 13.5 元,对应内部money应该是 135。仿真跑完直接看dut.money是不是 135,不对就顺着base_paid、km三个信号往回查,比盯着数码管段码猜要快得多。
5. 从编译报错到上板验证:时序约束、SignalTap 与计价精度校准
5.1 引脚约束与时序约束怎么写
引脚分配两种方式,Pin Planner 图形界面适合初次分配,.qsf文本适合复用和版本管理:
set_location_assignment PIN_23 -to clk set_location_assignment PIN_25 -to rst_n set_location_assignment PIN_64 -to car_start set_location_assignment PIN_65 -to car_stop set_location_assignment PIN_84 -to km_pulse set_location_assignment PIN_85 -to min_pulse set_location_assignment PIN_86 -to waiting时序约束至少要有主时钟定义,否则 TimeQuest 拿不到分析基准:
create_clock -name sys_clk -period 20.000 [get_ports clk] derive_clock_uncertainty set_false_path -from [get_ports {car_start car_stop km_pulse min_pulse waiting}]-period 20.000对应 50MHz。按键和脉冲这几根输入脚标成set_false_path,是因为它们相对系统时钟是异步的,强行做建立保持时间分析只会得到一堆假违例,实际靠前面的两级同步器处理亚稳态就够了。
5.2 SignalTap 抓什么信号
上板以后如果发现金额跳变不对,SignalTap 是最直接的排查手段。采样时钟选系统 50MHz,采样深度 1024 够看几秒的窗口,触发条件设成km_pulse上升沿,触发位置放在窗口前 1/4 处,这样能看到脉冲到来前后的完整状态变化。要抓的信号不用多,state、km、money、base_paid四个就够定位绝大多数问题。注意 SignalTap 会消耗额外的 RAM 块,调试完成后记得在 Quartus 里关掉再重新编译,否则会白白占资源。
5.3 计价精度校准的几个技巧
第一,等待计费的触发不要用真实的 1Hz 计数,而是用分钟脉冲min_pulse,在顶层把clk_gen的分频比改成CLK_FREQ × 60,这样等待一分钟才出一个脉冲,避免秒级抖动累积成分钟级误差。第二,里程脉冲如果来自外部传感器,一定要在进入 FPGA 前过一道消抖,否则轮子颠一下可能被当成好几公里,这个误差在计价系统里是直接扣钱的。第三,显示锁定比实时刷新更符合实际,乘客下车后金额应该停在屏幕上而不是马上归零,所以在S_STOP状态里让money和km保持不变,下一次car_start才清零,这一点在很多课程设计里被忽略,却直接影响演示效果。第四,如果发现金额末位偶尔跳变,先检查bin2bcd的输出是不是接了纯组合逻辑直接驱动数码管,改成过一级寄存器打一拍,能消掉绝大部分毛刺。
本文还有配套的精品资源,点击获取