简介:这是一份面向电子信息、自动化及相关专业学生与FPGA初学者的课程设计文档,围绕出租车计费系统的硬件实现展开,可用于数字电路课程设计、VHDL/Verilog学习以及Quartus II平台入门练习。文档以VHDL硬件描述语言和自顶向下设计思路为主线,系统讲解分频模块、计量模块、控制模块、计费模块、译码显示模块与顶层模块的划分与功能,并给出按里程计费、等待时间计费以及LED数码管实时显示车费与里程的实现方案,同时涵盖Quartus II开发流程介绍与硬件仿真验证环节,附有参考文献与附录代码,便于对照搭建电路、完成仿真与二次修改。资源包为单个docx文档,约523KB,结构完整、篇幅适中,可直接作为课设报告或实验参考。目前已有1718人学习下载,适合希望快速掌握FPGA计费类设计与Quartus II仿真实操的读者参考借鉴。
1. 出租车计费系统为什么是 Quartus II 数字逻辑课的“综合题”
实验室里,出租车计费系统几乎是数字逻辑课程设计出现频率最高的题目。乍看只是个加减乘除,真落到 FPGA 上却要同时处理脉冲边沿、异步按键、BCD 显示、状态互斥这几类互相牵制的问题——任何一个环节没做干净,金额都会跳数或漏计。
它真正考核的不是写代码的手速,而是把一份纸面计费规则翻译成可综合时序逻辑的能力:起步价怎么表达成常量、里程脉冲怎么在抖动里稳定计数、等待计时如何与行驶计费互斥、金额从二进制转成十进制再送数码管,这些都需要一个状态机兜底。
这篇文章面向已经跑通流水灯和分频器的读者。下面按“计费模型 → 工程搭建 → 计费核心 → 仿真与上板排错”推进,代码按 Quartus II 可综合的 Verilog 子集编写,能在 Cyclone 系列这类常见课程板上综合、下载、跑起来。
2. 出租车计费系统的计费模型与状态机拆解
动手写第一行 HDL 之前,先把“钱是怎么算出来的”钉死。出租车计费系统的坑,八成不在代码,而在规则本身没拆干净:起步价是常量还是含里程,里程单价按公里还是按百米,等待费是秒级还是分钟级,这三件事只要有一个定义含糊,后面状态机和显示模块全得返工。
2.1 把计价规则拆成可综合的常量参数
我一般会把计费规则全部参数化成parameter,放在fare_core的端口列表里。这样做的好处是换一套城市计价规则时只改参数,不动逻辑。下面这套取值是课程设计里比较常见的一种,起步价 8 元含 2 公里,超出后每公里 2 元,等待每分钟 0.5 元。
| 参数名 | 取值 | 含义 | 改动后的连带影响 |
|---|---|---|---|
BASE_FARE_CENT | 800 | 起步价 8.00 元,单位为分 | 改大后短途乘客金额整体抬升,显示位宽要同步检查 |
BASE_DIST_UNIT | 20 | 起步含 2.0 km,以 0.1 km 为单位 | 与里程脉冲分辨率强耦合,改错会整体漏计费 |
PER_UNIT_CENT | 20 | 超出后每 0.1 km 加 0.20 元 | 必须与纸质规则一致,不要在这里做四舍五入 |
WAIT_CENT_PER_MIN | 50 | 等待状态每分钟加 0.50 元 | 设成秒级会让金额跳得肉眼可见,不建议 |
PULSE_PER_100M | 1 | 每个里程脉冲代表 0.1 km | 由车速传感器决定,改这里必须同步改BASE_DIST_UNIT |
单位统一用“分”而不是“元”,是为了全程走整数运算。一旦中间出现浮点或者定点小数,综合出来的逻辑规模和验证难度都会上去,课程板上那点资源根本不划算。
2.2 四个状态的划分依据与互斥条件
状态机的核心是让“行驶计费”和“等待计费”互斥,否则会出现车在等红灯、金额还在按公里涨的荒唐结果。常见的划分是 IDLE / RUN / WAIT / END 四态:
| 当前状态 | 触发条件 | 次态 | 执行动作 |
|---|---|---|---|
| IDLE | start_press | RUN | 金额复位为起步价,里程与等待清零 |
| RUN | dist_pulse | RUN | 里程加 1 个单位,超过起步距离则加价 |
| RUN | wait_press | WAIT | 停止里程计费,转入等待计时 |
| WAIT | tick_1hz | WAIT | 等待秒数加一,满 60 秒加一次等待费 |
| WAIT | wait_press | RUN | 回到行驶计费 |
| RUN / WAIT | stop_press | END | 冻结金额,供乘客观看 |
| END | start_press | RUN | 开始下一次计价 |
这里有个反直觉的点:END 状态不是“清空”而是“冻结”。如果把 stop 直接映射回 IDLE,金额会在乘客还没来得及看清时被清零,验收时基本会被扣分。让它停在 END 直到下一次起步,才是符合真实计费器行为的做法。
2.3 三段式状态机骨架与次态默认值
状态机我用三段式写:一段时序只负责寄存,一段组合只负责推次态,一段时序负责输出动作。这样写综合出来的东西干净,也方便在仿真里单步看next_state。
localparam S_IDLE = 2'd0, S_RUN = 2'd1, S_WAIT = 2'd2, S_END = 2'd3; reg [1:0] state, next_state; // 第一段:只做状态寄存 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 (start_press) next_state = S_RUN; S_RUN : if (stop_press) next_state = S_END; else if (wait_press) next_state = S_WAIT; S_WAIT: if (stop_press) next_state = S_END; else if (wait_press) next_state = S_RUN; S_END : if (start_press) next_state = S_RUN; default: next_state = S_IDLE; endcase end第一行next_state = state是最容易被新手删掉的一行,删掉之后所有未覆盖分支会推出锁存器,Quartus II 的编译报告里会出现 Latch 警告,上板后行为随温度变化。三个按键信号在进入这个模块之前必须已经是消抖后的单周期脉冲,状态机本身不应该再处理边沿,否则两部分逻辑会互相打架。
3. 里程脉冲与计时基准:Quartus II 工程里的分频和消抖
出租车计费系统的输入信号没有一个是干净的。板载时钟是 50 MHz,按键是机械触点带抖动,里程脉冲来自车速传感器、边沿可能带有毛刺。这一章把三个输入通道分别收拾好,它们构成整条计费链路的地基。
3.1 工程目录结构与顶层端口规划
Quartus II 里我习惯把工程名和顶层实体名保持一致,源文件按功能拆成独立.v,避免一个文件里塞七八个模块导致综合报告难以定位。典型结构如下:
taxi_fare/ ├── taxi_fare.qpf // Quartus II 工程文件 ├── taxi_fare.qsf // 引脚、器件、SDC 引用 ├── taxi_fare_top.v // 顶层,只做例化 ├── tick_gen.v // 计时基准 ├── key_debounce.v // 按键消抖 ├── mileage_counter.v // 里程脉冲计数 ├── fare_core.v // 计费状态机与累加 ├── bin2bcd.v // 二进制转 BCD └── seg_scan.v // 数码管动态扫描顶层实体的端口要尽量“朴素”,只保留物理管脚对应的信号,所有分频、消抖都在内部完成。这样用 Pin Planner 分配引脚时只需要面对十来个端口,出错概率大幅下降。
module taxi_fare_top ( input wire clk, // 板载 50MHz input wire rst_n, // 复位,低有效 input wire key_start, // 起步 / 重新开始 input wire key_stop, // 结算 input wire key_wait, // 等待状态切换 input wire dist_pulse, // 里程传感器脉冲,每 0.1km 一个 output wire [5:0] seg_sel, // 数码管位选,低有效 output wire [7:0] seg_data // 数码管段选 {dp,g,f,e,d,c,b,a} );3.2 计时基准:为什么输出单周期脉冲而不是方波
出租车计费系统只需要“一秒一次”的节拍,不需要 50% 占空比的方波。方波输出在边沿检测时容易多计一拍,所以分频器直接产生单周期脉冲更省事。
module tick_gen #( parameter integer CLK_FREQ = 50_000_000, parameter integer OUT_HZ = 1 )( input wire clk, input wire rst_n, output reg tick // 每 1/OUT_HZ 秒输出一个单周期脉冲 ); localparam integer CNT_MAX = CLK_FREQ / OUT_HZ - 1; reg [25:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; tick <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 26'd0; tick <= 1'b1; // 只高一个时钟周期 end else begin cnt <= cnt + 1'b1; tick <= 1'b0; end end endmoduleCNT_MAX用常量表达式算出来,综合时会被折叠成一个 26 位比较器,不要在这里用运行时除法。cnt位宽按log2(CNT_MAX)上取整给,50 MHz 分到 1 Hz 需要 25 位,保险起见给 26 位。仿真的时候把CLK_FREQ缩小一千倍,CNT_MAX跟着变小,跑几十微秒就能看到秒级行为。
3.3 按键消抖的两个关键参数
按键消抖模块我只留两个参数:同步级数和消抖时间。同步级数固定两级,消抖时间默认 20 ms,这两个数字在课程板和实际硬件上都够用。
module key_debounce #( parameter integer CLK_FREQ = 50_000_000, parameter integer DEBOUNCE_MS = 20 )( input wire clk, input wire rst_n, input wire key_in, // 原始按键,低电平有效 output reg key_press // 消抖后的单周期脉冲 ); localparam integer CNT_MAX = (CLK_FREQ / 1000) * DEBOUNCE_MS; reg [19:0] cnt; reg key_d0, key_d1, key_clean, key_clean_d; // 两级同步,抑制亚稳态 always @(posedge clk) begin key_d0 <= key_in; key_d1 <= key_d0; end // 连续稳定 CNT_MAX 个周期才认可新电平 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_clean <= 1'b1; end else if (key_d1 != key_clean) begin if (cnt == CNT_MAX) begin key_clean <= key_d1; cnt <= 20'd0; end else cnt <= cnt + 1'b1; end else cnt <= 20'd0; end // 下降沿转单周期脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_clean_d <= 1'b1; key_press <= 1'b0; end else begin key_clean_d <= key_clean; key_press <= key_clean_d & ~key_clean; end end endmodule同步级数低于两级时,按键抖动会和时钟边沿撞出亚稳态,表现为“偶尔多计一次”或者“按了没反应”。cnt位宽要能容纳CNT_MAX,50 MHz 配 20 ms 需要 20 位,刚好卡在上限。如果板子上按键没有上拉,key_in默认电平要按实际硬件处理,别在代码里假设它是低。
3.4 里程脉冲计数:从传感器脉冲到 0.1 公里单位
里程脉冲不可能和板载时钟同步,直接拿来做边沿检测会漏计或重计。做法很简单:两级同步后检测上升沿,再累加到一个 16 位计数器上。
module mileage_counter ( input wire clk, input wire rst_n, input wire pulse_in, // 每 0.1km 一个脉冲 input wire clear, // 起步时清零 output reg [15:0] unit_100m // 累计 0.1km 数 ); reg p0, p1, p2; always @(posedge clk or negedge rst_n) begin if (!rst_n) {p2, p1, p0} <= 3'b000; else {p2, p1, p0} <= {p1, p0, pulse_in}; end wire pulse_rise = p1 & ~p2; always @(posedge clk or negedge rst_n) begin if (!rst_n) unit_100m <= 16'd0; else if (clear) unit_100m <= 16'd0; else if (pulse_rise) unit_100m <= unit_100m + 1'b1; end endmodulep0/p1/p2三级寄存:第一级收原始信号,第二级稳定,第三级用于和上面的p1组合出上升沿。16 位能表示 6553.5 公里,对出租车一次行程来说绰绰有余。如果传感器脉冲频率很高(比如每米一个脉冲),要么在外部分频,要么在这里加一个计数器做二次分频,不要把unit_100m直接撑爆。
4. 计费核心与数码管显示:BCD 转换和动态扫描
前面几章把输入通道理顺了,这一章处理真正的“算钱”和“把数字送出去”。出租车计费系统的显示部分有个特点:金额是十进制数,但内部全是二进制计数,中间必须有一层转换,否则数码管上会出现 0–9 之外的段码。
4.1 计费累加:把金额、里程、等待拆成独立 always
累加逻辑最忌讳把所有更新塞进同一个 always 块。金额、里程、等待秒数各自有独立的触发条件,拆开写可读性和可验证性都会好很多。
module fare_core #( parameter integer BASE_FARE_CENT = 800, parameter integer BASE_DIST_UNIT = 20, parameter integer PER_UNIT_CENT = 20, parameter integer WAIT_CENT_PER_MIN = 50 )( input wire clk, input wire rst_n, input wire tick_1hz, input wire start_press, input wire stop_press, input wire wait_press, input wire dist_rise, // 已同步的 0.1km 脉冲 output reg [15:0] fare_cent, output reg [15:0] dist_unit, output reg [15:0] wait_sec, output reg [1:0] state ); // 里程累加 always @(posedge clk or negedge rst_n) begin if (!rst_n) dist_unit <= 16'd0; else if (start_press) dist_unit <= 16'd0; else if (state == 2'd1 && dist_rise) dist_unit <= dist_unit + 1'b1; end // 等待秒数累加 always @(posedge clk or negedge rst_n) begin if (!rst_n) wait_sec <= 16'd0; else if (start_press) wait_sec <= 16'd0; else if (state == 2'd2 && tick_1hz) wait_sec <= wait_sec + 1'b1; end // 金额累加:起步价 + 超里程费 + 等待费 always @(posedge clk or negedge rst_n) begin if (!rst_n) fare_cent <= BASE_FARE_CENT[15:0]; else if (start_press) fare_cent <= BASE_FARE_CENT[15:0]; else if (state == 2'd2 && tick_1hz && wait_sec[5:0] == 6'd59) fare_cent <= fare_cent + WAIT_CENT_PER_MIN; else if (state == 2'd1 && dist_rise && dist_unit >= BASE_DIST_UNIT) fare_cent <= fare_cent + PER_UNIT_CENT; end endmodule关注里程那一行判断:dist_unit >= BASE_DIST_UNIT用的是自增前的值。起步含 2.0 km 即 20 个单位,当dist_unit从 19 变成 20 时不应加价,从 20 变成 21 时才加。写成>或者>=一定要想清楚是哪个时刻,差一个单位就意味着整趟行程少收一次费。
等待费判断wait_sec[5:0] == 6'd59是在每个整分钟的那一刻触发,等价于“满 60 秒收一次”。用低 6 位而不是整计数器取模,综合出来只是一组比较器,代价很小。
4.2 二进制转 BCD:Double Dabble 在金额显示上的用法
金额最大也就几百元,对应几万分,超过 16 位但不到 20 位。用 Double Dabble(逐次移位加 3)算法比查表省资源,也比反复除法快得多。
module bin2bcd #( parameter integer BIN_W = 20, // 支持到 999999 parameter integer DIGITS = 6 )( input wire [BIN_W-1:0] bin, output reg [DIGITS*4-1:0] bcd ); integer i, k; reg [DIGITS*4 + BIN_W - 1 : 0] sh; function [3:0] adj; input [3:0] d; begin adj = (d >= 4'd5) ? d + 4'd3 : d; // 大于等于 5 就加 3 end endfunction always @(*) begin sh = {{(DIGITS*4){1'b0}}, bin}; for (i = 0; i < BIN_W; i = i + 1) begin for (k = 0; k < DIGITS; k = k + 1) sh[BIN_W + 4*k +: 4] = adj(sh[BIN_W + 4*k +: 4]); sh = sh << 1; end bcd = sh[BIN_W + DIGITS*4 - 1 : BIN_W]; end endmodule循环用integer变量而不是genvar,因为它是纯组合逻辑里的过程代码,综合工具会把它展开成平铺的加法器链。每个数字位大于等于 5 就加 3,是十进制左移的必要补偿,漏掉这一步 BCD 结果会直接错乱。
4.3 数码管动态扫描:位选频率与段码表
六位数码管的动态扫描频率控制在 1 kHz 左右,肉眼就看不见闪烁。分频系数按CLK_FREQ / (SCAN_HZ * 位宽)计算,仿真时可以整体缩放。
module seg_scan #( parameter integer CLK_FREQ = 50_000_000, parameter integer SCAN_HZ = 1000, parameter integer DIGITS = 6 )( input wire clk, input wire rst_n, input wire [DIGITS*4-1:0] bcd, output reg [DIGITS-1:0] seg_sel, // 位选,低有效 output reg [7:0] seg_data // 段选 ); localparam integer DIV = CLK_FREQ / (SCAN_HZ * DIGITS); reg [15:0] cnt; reg [2:0] idx; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; idx <= 3'd0; end else if (cnt == DIV-1) begin cnt <= 16'd0; idx <= (idx == DIGITS-1) ? 3'd0 : idx + 1'b1; end else cnt <= cnt + 1'b1; end always @(*) begin seg_sel = ~({{(DIGITS-1){1'b0}}, 1'b1} << idx); case (bcd[4*idx +: 4]) 4'd0: seg_data = 8'h3F; 4'd1: seg_data = 8'h06; 4'd2: seg_data = 8'h5B; 4'd3: seg_data = 8'h4F; 4'd4: seg_data = 8'h66; 4'd5: seg_data = 8'h6D; 4'd6: seg_data = 8'h7D; 4'd7: seg_data = 8'h07; 4'd8: seg_data = 8'h7F; 4'd9: seg_data = 8'h6F; default: seg_data = 8'h00; endcase end endmodule段码表按共阴数码管给出,位选低有效。如果板子用的是共阳管,段码要整体取反,位选极性也要反过来,这个别在代码里猜,翻一下开发板原理图确认。SCAN_HZ * DIGITS相乘要用常量表达式,综合器才能在编译期算出分频系数。
4.4 顶层例化与引脚约束
顶层只做例化和连线,所有参数集中在这里传下去,方便后期调规则。
tick_gen #(.CLK_FREQ(50_000_000), .OUT_HZ(1)) u_tick ( .clk(clk), .rst_n(rst_n), .tick(tick_1hz) ); key_debounce #(.CLK_FREQ(50_000_000), .DEBOUNCE_MS(20)) u_db_start ( .clk(clk), .rst_n(rst_n), .key_in(key_start), .key_press(start_press) ); // key_stop、key_wait 用同样方式例化两份,这里省略 mileage_counter u_mile ( .clk(clk), .rst_n(rst_n), .pulse_in(dist_pulse), .clear(start_press), .unit_100m(unit_100m) );三个消抖模块端口完全一致,用一个 for-generate 批量例化也行,但课程设计里我更倾向显式写三份,引脚映射一眼可见。里程模块自带的clear接start_press,和fare_core内部的清零保持同一时机,避免里程和金额不同步。
5. 验证与上板排错:Testbench、约束和一个抓状态机的技巧
代码写完不等于跑对。出租车计费系统的验证要覆盖三种场景:纯行驶、纯等待、行驶与等待交替。前一种验证里程累加,后一种验证等待费,交替场景验证状态互斥。
5.1 一个能覆盖三种场景的 Testbench 骨架
仿真时把CLK_FREQ整体缩小,OUT_HZ保持不变,这样微秒级仿真就能看到分钟级行为。
`timescale 1ns/1ps module tb_taxi_fare; reg clk = 1'b0; always #10 clk = ~clk; reg rst_n = 1'b0, key_start = 1'b1, key_stop = 1'b1, key_wait = 1'b1; reg dist_pulse = 1'b0; wire [5:0] seg_sel; wire [7:0] seg_data; taxi_fare_top #( .CLK_FREQ (1_000), // 缩小 50000 倍,节拍加快 .OUT_HZ (1) ) dut ( .clk(clk), .rst_n(rst_n), .key_start(key_start), .key_stop(key_stop), .key_wait(key_wait), .dist_pulse(dist_pulse), .seg_sel(seg_sel), .seg_data(seg_data) ); task press_start; begin key_start = 1'b0; #2_000; key_start = 1'b1; #2_000; end endtask task one_unit; begin // 模拟车轮走过 0.1km dist_pulse = 1'b1; #200; dist_pulse = 1'b0; #200; end endtask integer i; initial begin #100 rst_n = 1'b1; #200; press_start(); // 起步,金额复位到 800 分 for (i = 0; i < 25; i = i + 1) one_unit(); // 跑 2.5km #70_000; // 逻辑上等价于约 70 秒 press_start; // 占位说明:实际此处应发送一次 stop 脉冲 $display("fare=%0d dist=%0d wait=%0d", dut.u_core.fare_cent, dut.u_core.dist_unit, dut.u_core.wait_sec); $stop; end endmodule任务里的保持时间必须大于消抖窗口,否则按键根本不会被识别。#70_000对应的逻辑秒数取决于CLK_FREQ缩放比例,调参时先算清楚一次tick在仿真里值多少时间,再决定循环次数,不要凭感觉写延时。
5.2 时序约束与异步输入的 false path
Quartus II 自带 TimeQuest 做静态时序分析,需要一份 SDC。板载时钟一条create_clock,按键和里程脉冲这类异步输入直接切 false path,让工具别在这儿报无意义的建立保持违例。
create_clock -name clk -period 20.000 [get_ports clk] # 异步输入不做时序检查,由同步器负责消除亚稳态 set_false_path -from [get_ports {key_start key_stop key_wait dist_pulse}] set_false_path -to [get_ports {seg_sel[*] seg_data[*]}]周期 20 ns 对应 50 MHz,和实际时钟一致。如果工程里用了 PLL 倍频,记得用derive_pll_clocks让工具自动推导。数码管段选位选属于慢速输出,直接切 false path 省去无谓的时序收敛压力。
5.3 上板异常现象对照表
第一次下载到板子,绝大多数问题都集中在下面几种。
| 现象 | 最可能的原因 | 排查方向 |
|---|---|---|
| 金额一直停在起步价 | 里程脉冲没进来,或dist_rise恒为 0 | 用 SignalTap 抓p0/p1/p2,看是否有翻转 |
| 按一次键金额加了好几次 | 消抖时间太短,或脉冲没做单周期处理 | 把DEBOUNCE_MS加到 20,检查key_press宽度 |
| 数码管显示乱码或某位不亮 | BCD 转换位宽不够,或段码极性搞反 | 单独仿真bin2bcd,用已知数值比对 |
| 等待状态也在涨里程 | 两个计费分支没做到状态互斥 | 检查state == 2'd1的条件是否被误删 |
| 编译报 Latch 推断 | 组合块里有分支没赋值 | 回到三段式第二段,确认首行默认赋值存在 |
最后一条最值得留意:Quartus II 的 Latch 警告在早期版本里不一定显眼,但在编译报告里一定存在,习惯性地扫一遍 Messages 面板能省下很多上板调试时间。
5.4 用 SignalTap II 抓状态机的一次完整行程
真正定位不倒的问题时,仿真日志不够看,因为仿真环境和实际板子的时钟、按键时序总有偏差。Quartus II 里的 SignalTap II 是解决这类问题最直接的工具:把state、fare_cent、dist_unit、wait_sec、dist_rise这几个信号加进采样列表,采样时钟选板载clk,存储深度开到 256 点以上,触发条件设成state == 2'd1 && dist_rise。
下载后按一次起步、手动模拟几个里程脉冲,就能在波形窗口里看到每来一个脉冲dist_unit是否加一、fare_cent有没有按预期跳变。这种“边跑边看”的手段,比改代码重新编译再下载快得多,也是排查出租车计费系统这类多状态交织逻辑最省时间的一条路。采样时记得把触发位置设成中点,前半段看按键消抖,后半段看金额累加,一次抓取能覆盖整条链路。
本文还有配套的精品资源,点击获取