简介:基于Verilog的出租车计价系统,是一套完整的数字逻辑设计实践工程,参照上海市出租车计价规则,实现起步价、公里费与低速等候费的自动计算。工程覆盖Verilog模块化设计、仿真验证与FPGA实现流程,适合正在学习硬件描述语言及数字系统设计、FPGA开发的读者参考。压缩包共416个文件,约10.95MB,核心包含10个Verilog源程序文件、78个文本设计文件,同时提供项目工程文件、仿真波形文件、编译报告以及FPGA配置文件等,可完整支撑从源码编写、功能仿真到综合配置的每个环节。目前已有655人浏览学习。通过该工程,读者可直接在Quartus环境中打开项目,研读里程计数、计时器、费用计算等子模块的编写思路,理解不同行驶状态下的计费切换逻辑,并通过仿真波形观察验证结果;工程内另外保留了多个备份文件,便于对照调试和二次开发。这是一个兼具教学演示与设计参考价值的实用资源。 出租车计价系统,几乎是每个玩Verilog的人都会碰到的“标配级”练手项目。很多教程喜欢把它包装成一个简单的计数器加显示模块,实际上你把需求掰开揉碎之后会发现,起步价怎么判定、等待时间怎么计费、里程脉冲怎么和费用累加关联、数码管动态扫描怎么才能不闪不串位,每一处都比表面上复杂。这篇博文我打算从一个实际做过的FPGA课设出发,把计价系统的完整设计思路、关键代码、仿真验证和上板调试踩过的坑全部过一遍。不管是正在写课设、准备电子竞赛,还是第一次接触状态机和分频器的读者,都可以把这篇当成一份可复用的工程笔记。
1. 计价规则拆解:先把“怎么收钱”变成可测的逻辑
1.1 起步价、里程单价、等待费:三项计费参数的界定
做任何系统,第一件事不是写代码,而是把业务规则说清楚。出租车计价看起来简单,但如果没有明确的参数,代码根本没法落地。我这里用的是一套典型规则,你可以根据题目要求自行修改:
- 起步价:3公里内收费10元,起步里程内含8分钟的免费等待时间。
- 里程费:超过起步里程后,每公里收费2元。
- 等待费:行驶途中停车等待,超过8分钟后按每2分钟1元计费。
在这套规则里,“起步价”不是简单的一次性加价,而是一个分段判断逻辑:在起步里程内,不管怎么跑,费用都保持10元;一旦超过3公里,超出的部分开始按里程累加。等待费类似,它有一个免费额度,免费时间内不跳字,超过后才开始计入费用。
这些细节看起来不起眼,但恰恰是课程设计里最容易丢分的点。很多人的计价器只是“按脉冲数乘单价”,完全没有考虑起步价包含的免费等待时间,导致仿真波形和实际出租车行为对不上。所以第一步我建议你把规则画成一张表格,把每个参数的取值范围、单位、触发条件写清楚,再去定状态机和计数器。
1.2 起步价截止线:为什么状态机比“连续计数”更能表达真实业务
有了规则,下一个问题是:计价逻辑到底怎么组织?我见过有人用一个全局计数器从头数到尾,在某个计数值到达时切换费用算法。这种做法在简单场景下能跑,但一旦加入“停车等待”和“继续行驶”的切换,代码会变得非常混乱,因为你要用一堆if-else去判断当前到底处于什么业务阶段。
更贴近真实业务的做法,是用状态机把计价过程分成几个明确的阶段:空闲(空车待客)、行驶(载客运行)、等待(中途停车)。状态机的核心价值在于,它把“系统当前处于什么业务场景”这个问题显式化了,后续的费用计算、里程计数、等待计时,都只需要关心当前状态是什么,再决定要不要加钱,逻辑会清晰很多。
实际设计时,状态机可以用一段式、两段式或三段式。我习惯用两段式——第一段时序逻辑做状态跳转,第二段组合逻辑做下一状态判断,这样代码容易维护,仿真排查问题也更直观。
localparam IDLE = 2'd0; localparam RUN = 2'd1; localparam WAIT = 2'd2; reg [1:0] current_state; reg [1:0] next_state; always @(posedge clk or negedge rst_n) begin if (!rst_n) current_state <= IDLE; else current_state <= next_state; end always @(*) begin next_state = current_state; case (current_state) IDLE: begin if (start_signal) next_state = RUN; end RUN: begin if (wait_signal) next_state = WAIT; else if (stop_signal) next_state = IDLE; end WAIT: begin if (!wait_signal) next_state = RUN; else if (stop_signal) next_state = IDLE; end default: next_state = IDLE; endcase end这套状态机里,wait_signal来自车辆怠速检测信号,stop_signal来自“结束计费”按键。状态切换时同步清理对应的计时标志,后续计费逻辑就会非常顺。
1.3 一条规则表:把计价策略统一成参数化配置
状态机解决了“处在什么阶段”的问题,但费用计算本身还需要一套参数化配置。我用parameter把计价规则集中定义在模块顶部,这样换一套计价规则,不需要到处改代码。
| 参数名 | 含义 | 默认值 |
|---|---|---|
| START_FARE | 起步价 | 10(元) |
| START_DIST_PULSE | 起步里程对应的脉冲数 | 3000(个脉冲) |
| FARE_PER_KM | 超起步里程后每公里单价 | 2(元/公里) |
| FREE_WAIT_TIME | 起步价内含等待秒数 | 8(秒) |
| WAIT_FARE_PER_MIN | 等待计费周期 | 120(秒/元) |
这里的里程单位我建议直接用“脉冲数”来表示,不要在FPGA内部做浮点除法。因为真实的出租车计价器通过轮速传感器产生脉冲,一个脉冲对应固定的距离,脉冲计数在Verilog里是天然适配的。你只需要在写需求时换算好“多少个脉冲等于一公里”,然后把价格表里的公里数全部换成脉冲数。
这套参数表还有一个好处——写报告和答辩的时候,可以直接把它当作系统设计方案的一部分,老师一眼就能看出你做了需求分析,而不是拿到题目就闷头写代码。
2. 模块划分与状态机落地:从脉冲到费用的完整链路
2.1 顶层结构:分频、计数、计价、显示四个模块的分工
很多初学者喜欢把全部逻辑写在一个always块里,仿真跑通就完事,但上板之后问题非常多。模块化设计不只是为了好看,它能让每个独立的时钟域和逻辑边界变得可控。我的做法是把系统拆成四个模块:
- 时钟分频模块:把板载50MHz时钟分频成1秒脉冲,作为等待计时的基准。
- 里程计数模块:采集轮速脉冲,做消抖和计数。
- 计价控制模块:状态机加费用累加,是整个系统的核心。
- 显示扫描模块:把费用和里程的二进制数转换成BCD码,再动态扫描到数码管。
顶层模块只做例化,把各个模块的端口连起来。这样做的好处是,某个模块出问题时,你能直接定位到波形文件里对应的那一层信号,不用在几百行的代码里翻来找去。
2.2 里程脉冲捕获与计数器的实现细节
里程脉冲是计价系统里最关键的输入信号。在实际装置中它来自霍尔传感器或编码器,在课设里我们通常用一个按键来模拟脉冲。但按键和真实脉冲有个重要区别——按键有机械抖动,直接接进计数器会导致一次按下被计数多次。
所以里程计数模块首先要解决“输入信号的有效性”问题。通常我在采集脉冲前加一个两级同步器,把异步输入同步到系统时钟域,再做边沿检测和消抖处理。同步器的本质是打两拍,第一拍消除亚稳态,第二拍得到稳定值。
reg pulse_d1; reg pulse_d2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin pulse_d1 <= 1'b0; pulse_d2 <= 1'b0; end else begin pulse_d1 <= pulse_in; pulse_d2 <= pulse_d1; end end wire pulse_pos = pulse_d1 & !pulse_d2; // 上升沿有效有了这个边沿信号,里程计数器就可以安全地累加了。这里还有一个容易忽略的点:计数器到达起步里程脉冲数之后,超出的部分才开始计里程费,所以需要把总脉冲数和起步脉冲数做一次减法,而不是把总脉冲数直接乘以单价。
2.3 费用累加逻辑:一个always块里处理多通道加价
费用累加是计价模块的核心。我的做法是用一个大位宽寄存器保存当前总费用,每次秒脉冲或里程脉冲到来时,根据当前状态决定是否需要自增。
一个比较稳妥的设计思路是:在同一个always块里,用当前状态作为判断条件,分别处理“里程计费”和“等待计费”两个通道。这两个通道互斥,因为同一时刻要么在行驶,要么在等待,不会同时加钱。
always @(posedge clk or negedge rst_n) begin if (!rst_n) total_fare <= 0; else begin case (current_state) RUN: begin if (dist_pulse_pos && dist_count > START_DIST_PULSE) total_fare <= total_fare + FARE_PER_KM; end WAIT: begin if (wait_time > FREE_WAIT_TIME && wait_pulse_pos) total_fare <= total_fare + WAIT_FARE_STEP; end default: ; // IDLE不增加费用 endcase end end这里注意一个细节:FARE_PER_KM和WAIT_FARE_STEP的数值不一定是1。如果你用BCD码保存费用,可以让每次累加的单位是“角”或“分”,显示的时候再做小数点定位。这样计价精度更高,也不会因为浮点运算产生误差。
3. 动态扫描显示与仿真验证:板子上的每一分钱都要看得清
3.1 时钟分频:从板载时钟得到1秒基准的计算过程
FPGA开发板通常自带50MHz或100MHz的有源晶振,这个频率太快,直接用来做计时没有任何意义。我们需要先分频得到1秒的时基。
分频的本质是数脉冲。50MHz时钟意味着每秒有50,000,000个上升沿,设计一个计数器从0数到49,999,999,每数满一个周期就翻转一次分频输出,就能得到一个周期为1秒的方波信号。
parameter CLK_FREQ = 50_000_000; parameter CNT_1S = CLK_FREQ - 1; reg [31:0] cnt; reg clk_1s; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; clk_1s <= 1'b0; end else if (cnt == CNT_1S) begin cnt <= 0; clk_1s <= ~clk_1s; end else begin cnt <= cnt + 1'b1; end end很多人在写这个分频器时会忽略计数器位宽。50MHz的计数最大值接近5000万,二进制表示需要26位,如果只声明成16位,计数器会溢出,分频结果完全不对。所以这里我直接用reg [31:0],宁可多给几个位,也不在这上面翻车。
如果你不希望得到50%占空比的方波,而是一次性的1秒脉冲,也可以把clk_1s改成“计数到最大值时拉高一个周期”。对于等待计费来说,这种单脉冲时基更好用,可以直接作为wait_time的计数使能信号。
3.2 数码管动态扫描的刷新率与毛刺回避
费用和里程数据最终要显示在数码管上。如果每个数码管单独占一组I/O,那开发板引脚不够用,所以几乎都是动态扫描方案——一位一位地轮流点亮,靠视觉暂留让所有位看起来同时亮。
动态扫描的关键是刷新率。人眼舒适不闪烁的刷新率一般在50Hz以上,如果你用4位数码管,每位显示时间取4ms,整个扫描周期16ms,刷新率约60Hz,实际效果稳定。刷新率太低会看到明显的闪烁,太高又会造成某些位亮度不均匀。
动态扫描还有一个隐藏的毛刺问题:如果你在扫描位选切换的同时更新段码数据,数码管可能短暂串位显示上一位的数字,肉眼看起来就是数字“飘”了一下。解决办法是让段码更新和位选切换错开一个时钟周期,或者对段码输出做一级寄存器锁存,确保位选切换时段码数据已经稳定。
always @(posedge clk) begin seg_data_reg <= seg_data; // 段码寄存器,锁定数据 seg_sel <= seg_sel_next; // 位选切换 end这个方法成本极低,但能显著提升显示效果。上板测试时,你会发现数字切换干净利落,不再有那种“拖影”感。
3.3 用Icarus Verilog做波形验证:仿真方案怎么写
写FPGA项目最忌讳的就是直接综合下载,仿真验证这一步无论如何不能省。Icarus Verilog搭配GTKWave是一个非常轻量的仿真方案——免费、无需完整IDE、脚本化运行,特别适合课程设计场景。
仿真时需要写一个testbench,我一般这样设计激励:
- 生成周期20ns的半周期周期时钟,模拟50MHz时钟。
- 给出异步复位信号,拉低后再释放。
- 模拟start_signal按键,启动计价。
- 连续产生若干次pulse_in脉冲,模拟车辆行驶。
- 再拉高wait_signal一段时间,模拟中途停车。
initial begin clk = 1'b0; forever #10 clk = ~clk; end initial begin rst_n = 1'b0; #100 rst_n = 1'b1; #100 start_signal = 1'b1; #20 start_signal = 1'b0; repeat (50) begin #40 pulse_in = 1'b1; #40 pulse_in = 1'b0; end // 添加等待信号 #100 wait_signal = 1'b1; #2000 wait_signal = 1'b0; #100 $finish; end运行完仿真后,把波形文件导入GTKWave,重点观察current_state、dist_count、total_fare、wait_time这几个信号。如果total_fare在起步里程内没有变化,超过起步里程后按预期增加,等待超过免费时间后才开始计费,那基本就说明核心逻辑正确,可以上板了。
4. 上板调试高频问题与自检清单
4.1 “仿真过了上板乱跑”的根源排查
这是所有FPGA初学者都会碰到的问题:testbench里波形完美,下载到开发板之后数码管乱跳、计费不稳定。根源通常出在输入信号处理上。仿真里的按键是理想的零抖动电平变化,真实按键是机械触点抖动,一次按下会产生几十毫秒的连续电平跳变,直接进状态机就等于按了好几次。
解决办法是在顶层对start_signal、stop_signal、wait_signal这些外部输入统一做“同步加消抖”。先打两拍同步到系统时钟域,再用计数器消抖,确认按键电平稳定超过10ms才认为是有效按键。消抖计数器本身很简单,但它是上板稳定的关键。
4.2 按键消抖、异步同步与复位设计
我设计了一个通用按键消抖模块,思路是:检测到按键电平变化后,启动一个20ms左右的计数器,计数结束后重新采样电平,如果和前一次采样一致,就输出一个单脉冲。
reg [19:0] debounce_cnt; reg key_sync1, key_sync2; reg key_stable; always @(posedge clk) begin key_sync1 <= key_in; key_sync2 <= key_sync1; end always @(posedge clk) begin if (key_sync2 != key_stable) begin debounce_cnt <= 0; key_stable <= key_sync2; end end复位设计同样不能马虎。建议使用异步复位、同步释放的方式,避免复位信号释放时产生亚稳态。也就是rst_n直接进always块的敏感列表,但在内部先用两级触发器同步复位信号再释放。
4.3 位宽、溢出与边界情况的自检清单
上板前,我习惯对照这份清单自检一遍,很多问题能在综合前就被发现:
- 费用寄存器的位宽是否足够?如果计价上限是999.9元,用二进制需要至少14位,用BCD码需要16位。位宽不够的直接后果是费用跳变清零。
- 分频计数器的位宽是否满足最大值?50MHz分频至少要26位,建议直接声明32位。
- 起步里程脉冲数和计数器位宽是否匹配?如果计数器只有8位,而起步里程是3000个脉冲,计数会直接溢出。
- 等待时间的计时基准是否正确?是用分频后的1秒信号使能,还是直接数时钟周期,两者代码差别很大。
- 状态机的default分支写了吗?如果状态变量受到干扰进入未定义状态,系统会卡死。
- 显示模块里,费用、里程的BCD转换结果和位选是否同步?不同步会导致显示串位。
这些项目里,最隐蔽的是位宽问题。我自己第一次调试时,费用计数到一定值就回零,检查了半天才发现是一个位宽不够的中间信号在累加过程中发生了溢出。如果你也遇到类似症状,第一反应应该是检查所有计数器的位宽,而不是去怀疑显示模块。
最后分享一个我后来一直沿用的习惯:把所有通用模块,比如按键消抖、时钟分频、数码管扫描,都整理成独立文件存好。计价系统做完之后,这些模块在后面的串口通信、信号采集项目里都能直接复用。调试这种设计类项目,最花时间的从来不是写代码,而是定位那些“差一点就对了”的边界问题。把一个系统完整调通之后,你对状态机的理解会比看十遍教程都深刻。
本文还有配套的精品资源,点击获取