Quartus II FPGA出租车计费系统:状态机与数码管设计
2026/9/19 1:11:07 网站建设 项目流程

简介:这是一份面向电子信息、自动化及相关专业学生与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_CENT800起步价 8.00 元,单位为分改大后短途乘客金额整体抬升,显示位宽要同步检查
BASE_DIST_UNIT20起步含 2.0 km,以 0.1 km 为单位与里程脉冲分辨率强耦合,改错会整体漏计费
PER_UNIT_CENT20超出后每 0.1 km 加 0.20 元必须与纸质规则一致,不要在这里做四舍五入
WAIT_CENT_PER_MIN50等待状态每分钟加 0.50 元设成秒级会让金额跳得肉眼可见,不建议
PULSE_PER_100M1每个里程脉冲代表 0.1 km由车速传感器决定,改这里必须同步改BASE_DIST_UNIT

单位统一用“分”而不是“元”,是为了全程走整数运算。一旦中间出现浮点或者定点小数,综合出来的逻辑规模和验证难度都会上去,课程板上那点资源根本不划算。

2.2 四个状态的划分依据与互斥条件

状态机的核心是让“行驶计费”和“等待计费”互斥,否则会出现车在等红灯、金额还在按公里涨的荒唐结果。常见的划分是 IDLE / RUN / WAIT / END 四态:

当前状态触发条件次态执行动作
IDLEstart_pressRUN金额复位为起步价,里程与等待清零
RUNdist_pulseRUN里程加 1 个单位,超过起步距离则加价
RUNwait_pressWAIT停止里程计费,转入等待计时
WAITtick_1hzWAIT等待秒数加一,满 60 秒加一次等待费
WAITwait_pressRUN回到行驶计费
RUN / WAITstop_pressEND冻结金额,供乘客观看
ENDstart_pressRUN开始下一次计价

这里有个反直觉的点: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 endmodule

CNT_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 endmodule

p0/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 批量例化也行,但课程设计里我更倾向显式写三份,引脚映射一眼可见。里程模块自带的clearstart_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 是解决这类问题最直接的工具:把statefare_centdist_unitwait_secdist_rise这几个信号加进采样列表,采样时钟选板载clk,存储深度开到 256 点以上,触发条件设成state == 2'd1 && dist_rise

下载后按一次起步、手动模拟几个里程脉冲,就能在波形窗口里看到每来一个脉冲dist_unit是否加一、fare_cent有没有按预期跳变。这种“边跑边看”的手段,比改代码重新编译再下载快得多,也是排查出租车计费系统这类多状态交织逻辑最省时间的一条路。采样时记得把触发位置设成中点,前半段看按键消抖,后半段看金额累加,一次抓取能覆盖整条链路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询