☰
51单片机出租车计价器:从霍尔传感器到数码管的完整实现
2026/10/13 23:17:49 网站建设 项目流程

简介:基于51单片机的出租车计价器课程设计资源,面向电子工程、嵌入式系统初学者及单片机课程设计学生,帮助理解单片机硬件控制、传感器输入与计费算法实现。压缩包共46个文件,主要以C源码、汇编启动文件、Keil工程文件、Proteus仿真工程及原理图文档为主,并包含hex烧录文件与LCD、EEPROM、DS1302等硬件驱动模块,便于直接编译、仿真与下载验证。包体约1.62MB,结构紧凑。目前已有1505人学习下载。资源内提供完整程序源码、Proteus仿真工程、设计文档和原理图,可对照学习里程采集、按键控制、实时时钟计费、液晶显示等模块化设计思路,适合用于课程设计参考、毕设预研或51单片机综合实训。

1. 出租车计价器:51课设里看着简单、做起来全是细节的硬项目

很多同学第一次打开“基于51单片机的出租车计价器”这个压缩包时,以为里面就是数码管加几个按键,跑起来显示个金额就算完事。实际上这个项目真正要处理的事情远比表面多:传感器脉冲怎么进单片机、空车和载客怎么切换状态、里程怎么累加不丢数、等待时间怎么计费,以及掉电之后单价能不能保住。它几乎把51单片机最常用的几块外设全部串起来了,所以才会在课程设计和毕业设计里反复出现。这篇笔记就把我从硬件接法、代码框架到实际踩过的坑按落地顺序讲一遍,新手可以照着接线抄代码,熟手可以直接跳到第5章看高频翻车点。

2. 硬件方案怎么定:霍尔传感器、数码管和51的接法

2.1 传感器选型:开关型霍尔为什么是默认选择

计价器的“车轮转一圈”必须要变成单片机认识的电信号。常见方案有干簧管、光电对射和开关型霍尔三种,我这些年看下来,开关型霍尔基本是首选。干簧管虽然便宜,但内部是机械触点,车一颠就会误触发,寿命也短;光电对射需要精心做码盘和对准光路,装在轮子上很容易被泥水挡光。开关型霍尔传感器没有触点,靠近磁铁就输出低电平、离开就恢复高电平,直接给出一路脉冲,和51单片机的P3.2脚天然匹配。

买元件的时候注意区分裸霍尔和三线霍尔模块。裸霍尔芯片(比如A3144这类)是开集电极输出,必须自己接一个10kΩ上拉电阻到5V,否则引脚会悬空,电平飘忽不定。如果是已经做成三线模块的,板子上一般自带比较器和上拉电阻,输出脚可以直接接单片机。我的习惯是:能用模块就别用裸芯片,接线少一半,排查问题也省事。传感器供电直接用单片机板的5V就可以,但要确保稳压输出干净,具体原因放到2.3节讲。

2.2 数码管动态扫描与按键接线

显示部分有两个方向:LCD1602和数码管。LCD1602接线少,代码也简单,适合图省事;但做“计价器”这个题目时,数码管的仪表感更强,而且动态扫描本身就是一个高频考点,所以我建议主显示用4位或6位共阳数码管,金额显示占4位,里程用2位单独一组。如果手里的开发板已经焊好了数码管,直接用板上IO即可;如果是面包板或者洞洞板自己搭,段选走P0口一定要加上拉电阻,否则段电流不够,显示会明显偏暗。

接线表我按最常见的51最小系统板来写,P0做段选,P2低四位做位选,P3.2接霍尔脉冲,P1低三位接按键,P1.3留个空位给蜂鸣器,做超速提示或者按键提示音都行。

功能信号单片机引脚备注
霍尔传感器输出OUTP3.2 / INT0外部中断0,下降沿触发
霍尔传感器供电VCC/GND5V/GND模块自带稳压则直接接
数码管段选a~dpP0.0~P0.7必须接上拉电阻
数码管位选位选1~4P2.0~P2.3经NPN三极管驱动
按键K1空车/载客切换P1.0接10k上拉到5V,按下接地
按键K2单价切换P1.1同上
按键K3本次清零P1.2同上

2.3 电源与抗干扰:别让计价器被自己干扰死

51单片机的计价器本身功耗不高,真正容易翻车的是供电质量。如果用USB线从电脑取电,电脑那边一旦有负载波动,5V电压就会抖动,霍尔传感器的阈值也会跟着漂,表现出来就是脉冲乱跳、金额疯涨。我一般建议用独立5V稳压电源供电,在单片机电源入口并一个100uF电解电容和一个104独石电容,如果传感器线比较长,再在霍尔模块的VCC和GND之间也并一个104电容,滤掉车轮转动带来的高频干扰。

这里有一个实际经验:做演示的时候,有人习惯把计价器和直流电机共用一个电源,电机一启动,数码管就开始闪。原因是电机换向时会在电源线上拉出很大的尖峰电压,甚至能直接把单片机复位。遇到这种情况,要么给电机单独供电,要么在电机两端加续流二极管和一个小电感,要么干脆用光耦把电机控制和单片机完全隔离。对于课程设计阶段的计价器,最省事的做法就是别共地、别共用同一路电源。

3. 计价逻辑的C代码:状态机、里程和金额怎么算

3.1 空车、载客、等待三段状态机

计价器最核心的逻辑不是“算乘法”,而是怎么用状态机把计价规则表达清楚。我习惯定义三个状态:IDLE空车,LOADED载客正常行驶,WAIT停车等待。空车时脉冲照常来但金额不计,载客时每过一个脉冲就累加里程,当车速为零且仍在载客状态时切换到WAIT,按分钟累加等待费。按键K1负责在IDLE和LOADED之间切换,切到LOADED的瞬间必须把本次里程、等待秒数、金额全部清零,否则会把上一单的数据带进下一单。

typedef enum { S_IDLE = 0, S_LOADED = 1, S_WAIT = 2 } TaxiState; volatile TaxiState state = S_IDLE; volatile unsigned char key_event = 0; void key1_switch(void) { if (state == S_IDLE) { state = S_LOADED; clear_trip_data(); // 清里程、清等待秒数、清金额 } else { state = S_IDLE; // 到达目的地,保留本次金额用于显示 } }

这段代码里关键一点是clear_trip_data()必须在状态切到LOADED之后立刻执行,不能在主循环里延迟处理。如果切状态和清数据之间夹了一次中断或者一次延时,第一个脉冲就可能已经记账了,起步价逻辑就会差一个脉冲。按键事件判断我用定时器扫描,具体见4.3节,这里先把状态流程立住。

3.2 脉冲计数与中断设计:中断里只做事,别算数

霍尔传感器每转一圈来一个或者多个脉冲,外部中断0负责计数。中断服务函数里只做一件事:给计数器加一,然后把控制权交回主循环。千万不要在中断里直接算金额、刷新数码管,51的中断优先级和主循环相互影响,一旦中断服务函数执行时间过长,下一个脉冲就可能丢,或者数码管刷新被卡住。

volatile unsigned long pulse_total = 0; void ext0_isr(void) interrupt 0 { if (state == S_LOADED || state == S_WAIT) { pulse_total++; } }

中断里的判断是有讲究的:空车状态不计数,这样不仅符合计价逻辑,还能避免误触发把数据弄脏。pulse_total必须声明为volatile,因为主循环和中断都在读写它,不加上这个修饰符,优化后的代码可能读到缓存里的旧值,这种问题非常难查。

里程换算不要在中断里做,而是放到定时器1的每秒回调里处理。常见的做法是设置PULSES_PER_KM这个宏,把每公里的脉冲数写死,比如轮胎周长2米、每圈1个脉冲,那么1公里就是500个脉冲。累加的时候不要用除法直接算“现在的总里程”,我习惯先把脉冲数剥出来,凑满0.1公里就加一个tenth_km,这样显示和计价都方便,下面代码表现了这种“分段剥皮”的写法。

#define PULSES_PER_KM 500u #define PULSES_PER_100M (PULSES_PER_KM / 10) volatile unsigned char tenth_km = 0; // 0.1km为单位 volatile unsigned long total_km = 0; // 整数公里 void timer1_1s_update(void) { while (pulse_total >= PULSES_PER_100M) { pulse_total -= PULSES_PER_100M; tenth_km++; if (tenth_km >= 10) { tenth_km = 0; total_km++; } } }

这里的while如果一次处理多个脉冲,说明车速很快,而51主循环里做完一次减法再循环判断完全来得及。用减法而不是用取余的好处是:不会因为取余把不足一格的脉冲尾部丢掉,累计误差会小很多。如果你想让计价器更接近真实出租车,可以把PULSES_PER_KM改成从EEPROM读取,方便现场标定,这个放到第4章讲。

3.3 金额计算:用“分”做定点运算,避开浮点

51单片机的浮点运算慢,而且Keil默认的浮点库在资源紧张时还容易把代码体积撑爆。我建议金额一律用整数“分”做单位:起步价8.00元存成800,每公里1.50元存成150,最后显示的时候手动加小数点。里程用tenth_km这个0.1公里单位来计算,3公里就是30。

#define START_KM 30u // 3.0km #define START_FARE 800u // 8.00元 #define PER_KM_FARE 150u // 1.50元/公里 #define PER_MIN_FARE 50u // 0.50元/分钟等待费 unsigned int fare_fen = 0; volatile unsigned char wait_min = 0; void calc_fare(void) { unsigned int charge_km = 0; if (tenth_km > START_KM) { // 超过起步里程的部分,向上取整到整公里 charge_km = (tenth_km - START_KM + 9) / 10; } fare_fen = START_FARE + charge_km * PER_KM_FARE; fare_fen += wait_min * PER_MIN_FARE; }

charge_km计算那里的(x + 9) / 10是正整数向上取整的经典写法,用来模拟“按整公里计费、不足一公里按一公里算”的规则。如果你们课程要求的是“四舍五入”,就把+9改成+5,但实际出租车行业绝大多数是上取整,这么做不会被老师挑毛病。等待分钟的累计放在定时器里,当状态为S_WAIT时每秒加一次,凑满60次就wait_min++。

3.4 掉电保护单价:要不要存EEPROM

很多同学做完计价器,断电再上电发现单价变回去了,只能重新按键设置。常见做法是给STC单片机用内部EEPROM存单价,或者外挂AT24C02。内部EEPROM的寄存器每个型号都不一样,直接抄代码容易踩坑,我一般建议在课程设计阶段用AT24C02,I2C协议是通用的,换单片机也不用改驱动。

void eeprom_write_byte(unsigned char addr, unsigned char dat) { // 典型I2C写流程:Start -> 设备地址+写 -> 寄存器地址 -> 数据 -> Stop // 设备地址一般为 0xA0 i2c_start(); i2c_send_byte(0xA0); // 写地址 i2c_wait_ack(); i2c_send_byte(addr); i2c_wait_ack(); i2c_send_byte(dat); i2c_wait_ack(); i2c_stop(); }

这里只展示了单字节写的框架,完整驱动还需要读、应答等待、延时函数,网上很多现成代码可以直接参考。要特别注意的是,不要在主循环里反复调用这个写函数,EEPROM有擦写寿命,通常是在单价按键切换时写一次就够了。上电后从EEPROM读出单价,校验一下数值范围,比如是否在800到999之间,防止读到垃圾值把计价器搞成天价。

4. 从脉冲到显示:标定、数码管刷新和按键消抖

4.1 脉冲标定:怎么把“圈数”变成“公里数”

PULSES_PER_KM这个参数直接决定计价器的准确性,但计算值永远只是起点,必须做实测标定。计算公式本身很简单:如果传感器装在车轮上、每圈产生1个脉冲,那么PULSES_PER_KM = 1000 / 轮胎周长(米)。轮胎周长用直径 * 3.14估算,比如直径0.64米的轮胎,周长约2.0米,每公里就是500个脉冲。

现实情况是轮胎气压、载重、磨损都会改变实际周长,算出来的系数能差到5%。所以我的标定流程是:把计价器装到车上,找一段100米的路面,推着车走一遍,记下单片机累计的脉冲数N,然后用PULSES_PER_KM = N * 10替换原值。这样100米的实测误差会直接缩放成每公里的参数,全程不需要额外仪器。

参数估算值实测标定值
轮胎直径0.64m用卷尺量实际直径
轮胎周长2.0m推车量一圈实际滚动距离
每公里脉冲数500实测100米脉冲数 * 10
起步里程3.0km按题目要求设置
起步价8.00元按题目要求设置

在Keil里,这些参数我会集中放在一个config.h文件里,改起来方便。如果做的是Proteus仿真项目,没有真实轮胎,就需要用信号发生器代替霍尔脉冲输出,这时候PULSES_PER_KM只是一个仿真假设值,按键切换频率就能模拟车速,但一定要记得把信号源的频率设置成和“车速”匹配,不然金额会疯涨。

4.2 数码管动态扫描:两毫秒一位,别贪快

数码管显示得当才能让老师觉得“这项目是活的”。动态扫描的原理是人眼视觉暂留,4位数码管轮流点亮,每位只亮一小段时间,刷新率超过50Hz就不会看到闪烁。我习惯用定时器0中断,每2ms切换一位,4位一圈8ms,等效刷新率125Hz,效果非常稳定。

unsigned char code seg_code[10] = { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, // 0~4 共阳段码 0x92, 0x82, 0xF8, 0x80, 0x90 // 5~9 }; unsigned char disp_buf[4]; // 待显示数字 void timer0_isr(void) interrupt 1 { static unsigned char pos = 0; P2 &= 0xF0; // 全部位选关闭,防止拖影 P0 = seg_code[disp_buf[3 - pos]]; // 段选 P0 |= 0x80; // 如果是6位带小数点,单独处理DP P2 |= (1 << pos); // 打开当前位 pos = (pos + 1) & 0x03; // 0~3循环 }

这段代码有个很关键的细节:切换位选之前必须先关掉所有位选,再更新段选,最后打开目标位。如果顺序反了,下一位的段码会短暂出现在上一位上,肉眼看到的就是数字边缘有残影。pos的循环用& 0x03而不是% 4,是因为51单片机做整数取模比较慢,与运算更快,这个属于代码层面的小优化。如果用的是共阴数码管,上面的段码表要全部取反,或者找个共阴专用的表填进去,这个在第5章还会单独讲。

4.3 按键消抖:用定时器扫描代替延时

按键是计价器最容易出现“按一下跳两个状态”的地方。机械按键按下和松开的瞬间会有几毫秒到十几毫秒的抖动,不做处理就会把一次按压识别成多次。常见做法是延时消抖:检测到低电平后延时10ms再读一次,两次都是低电平才算数。缺点是主循环会被延时卡住,中段期间又有脉冲进来,容易和计价逻辑抢时间。

我更喜欢把按键扫描放进10ms的定时器中断里,由中断判断“连续两次读到相同电平才算稳定”。这样主循环只要查标志位就能处理按键,不会阻塞脉冲累加。

volatile unsigned char key_stable = 0xFF; volatile unsigned char key_event = 0; void timer2_10ms_isr(void) interrupt 3 { static unsigned char last = 0xFF; unsigned char now = P1 & 0x07; if (now == last) { if (key_stable == now && (key_stable != 0x07)) { key_event = now; // 连续两次稳定且不是所有键都松开 } key_stable = now; } else { last = now; key_stable = 0xFF; // 状态还没稳定,等待下一次中断 } }

这里P1 & 0x07只取低三位,对应三个按键。按键按下接地,读到的值是0或1或2,全松开时是0x07。只有当两次中断读到的值完全一致,才认为信号稳定,然后把事件交给主循环。消抖周期就是定时器中断的10ms,这个值经验上比较合适,太小消不掉抖动,太大会让人觉得按键反应迟钝。主循环里拿到key_event后要清掉标志位,避免同一事件处理两次。

5. 计价器排坑:五个高频翻车现场与修复办法

5.1 现象一:霍尔信号不触发中断,里程数字完全不动

很多人接好线之后旋转车轮,数码管上的里程纹丝不动,第一反应是传感器坏了。其实大部分原因不是传感器,而是输出信号根本没被单片机认到。如果是裸霍尔芯片没接上拉电阻,输出引脚会在高电平和悬空之间飘,外部中断根本抓不到稳定的下降沿;如果是三线模块,则要检查模块上的指示灯是否随磁场变化,灯不亮说明磁铁极性放反或者距离太远。

原因排查下来,要么是上拉电阻缺失,要么是中断触发方式配错。解决方法是:在OUT和VCC之间接一个10kΩ电阻,用万用表量OUT对地电压,靠近磁铁时接近0V、离开时接近5V,说明传感器正常工作。然后确认代码里外部中断0初始化为下降沿触发,也就是IT0 = 1,再把EX0 = 1打开。如果依然不触发,可以用导线把P3.2手动碰一下GND模拟脉冲,能触发说明问题在传感器侧,不能触发则要查初始化代码。

5.2 现象二:轮子转一圈金额疯涨,好像计价器喝了假酒

有一个典型的翻车现场是:用霍尔模块接上之后,车轮慢慢转,金额却像秒表一样往上跳。原因往往有两个方向。一是霍尔模块的输出端没有滤波,轮子转动时的机械振动让信号在阈值附近反复横跳,一个物理脉冲被识别成几十个逻辑脉冲。二是代码里中断处理太慢,或者说主循环里做了一次耗时的除法或者数码管延时,中断里还没来得及处理,脉冲就已经堆积。

解决方法是给传感器输出端加一个RC低通滤波,常见取值为10kΩ电阻串联加104电容到地,或者直接在模块输出脚和地之间并一个104电容,然后让轮子转起来观察波形是否变干净。代码侧的处理是保证中断函数只做累加,不要在中断里做除法、取模,并且把主循环里所有“轮流等待”的写法改成定时器调度,确保主循环每圈都足够快。

5.3 现象三:数码管亮度低或者数字有残影

数码管显示暗,很多人第一反应是程序问题,其实先查电路。共阳数码管的段选接在P0口,P0是开漏结构,内部没有上拉,必须外接4.7kΩ到10kΩ的上拉排阻。如果没接上拉,段电流不够,数码管发暗是必然的。另一个常见问题是位选驱动方式,直接用P2引脚驱动一位甚至几位数码管,电流完全不够,正确的做法是通过NPN三极管扩流,基极串1kΩ电阻接到P2,集电极接5V,发射极接数码管公共端。

残影问题则百分之百是扫描时序问题。动态扫描切换时如果没有先关闭全部位选,相邻两位会互相串扰,看起来就是数字旁边有淡淡的影子。把第4章那段代码里的P2 &= 0xF0放到段选更新之前,按“先关位选、再给段码、最后开位选”的顺序执行,残影基本能解决。

5.4 现象四:等待计费不准,切回载客总会多算或少算一秒

状态机里从载客切到等待,再从等待切回载客,如果使用主循环里的软件延时判断,很容易出现时间基准被其他事情打断的情况。比如主循环里执行了一次金额计算,耗时几毫秒,如果用循环次数来凑秒,这一下就产生偏差,累积十分钟就是十几秒的误差。

正确做法是把1秒的时基彻底交给定时器。我在定时器1中断里维护一个节拍计数,每满20次(比如50ms一次)就给等待秒数加1,加1之前先判断当前状态是不是S_WAIT。按键切换状态时,只改状态变量,不碰节拍计数,这样下一次中断自然按新状态判断,不会出现因为切换而丢掉半个节拍的问题。如果你发现等待费比真实时间多了几秒,大概率是切换时把计数器清零了,试着把清零动作放到切换到等待状态的那一刻,而不是退出等待的时候。

5.5 现象五:断电后单价回到默认值,每次开机都要重新设置

单价存不住是最容易被扣分的小问题,本质是“写EEPROM”这件事根本没发生。很多代码在按键切换单价时只是改了RAM里的变量,断电自然丢。解决方法是先在配置菜单里把单价切成目标值,然后单独按“确认保存”键才调用EEPROM写操作,避免每次调价都频繁写入。

这里还要注意一点:EEPROM读出来之后,要检查值是否在合理区间,比如单价不能为0也不能大于999。因为全新的EEPROM默认是0xFF,第一次读出来的值可能完全不可信,如果不做校验,计价器可能按一个极其离谱的价格计费。我一般在上电初始化时加一条简单判断:读出的值小于100或大于999就认为无效,回写默认单价。

6. 让计价器更像真表:分段计价与定点四舍五入的小技巧

课程设计做到能跑、能算、能清零,已经能交差,但如果想让成品更接近真实出租车,我建议把“单一每公里单价”升级为分段计价。真实场景往往是10公里以内一个单价,超过10公里加收空驶费,夜里还有夜间价,这些规则用查表分段的写法最清晰。

unsigned int step_fee_by_km(unsigned int tenth) { unsigned int extra = 0; if (tenth > 100) { extra += (tenth - 100) * 22; // 10km以上每0.1km收2.20元 } if (tenth > 30) { extra += (tenth - 30) * 15; // 3~10km每0.1km收1.50元 } return START_FARE + extra; }

这段代码里tenth是以0.1km为单位的里程,所以每公里1.50元换算成每0.1公里就是15分,2.20元就是22分。分段判断从高区间往低区间写,每个区间只算自己多出来的部分,这样比if-else嵌套更不容易漏算边界。验证方法也简单:在Proteus里给脉冲源设一个固定频率,比如50Hz对应模拟车速10km/h左右,跑一段固定时间,用计算器算出理论里程和理论金额,对比数码管显示是否一致。偏差超过0.1元,优先检查PULSES_PER_KM标定系数而不是计价代码。

我自己的一个习惯是,每次改完计价逻辑都会在Keil里把优化等级从默认的0改成8再编译一遍,因为中断函数里的volatile变量在低优化等级下能跑,在高优化等级下却会出问题,提前排查能省掉很多“为什么我这板子一亮就死机”的迷惑时刻。计价器项目练的就是这套“中断、状态机、消除误差”的基本功,遇到玄学问题先怀疑上拉和电源,再怀疑代码逻辑,多半能快速定位。希望这篇笔记能帮你在搭计价器的时候少走几步弯路。

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

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

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

立即咨询