news 2026/9/2 2:24:12

蓝桥杯停车计费项目实战:状态机与定时器驱动单片机外设协作

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
蓝桥杯停车计费项目实战:状态机与定时器驱动单片机外设协作

简介:2021年第12届蓝桥杯第一场停车计费赛题资源包,是一份基于STM32平台的嵌入式竞赛项目资料,面向蓝桥杯参赛者、嵌入式开发初学者及高校备赛师生。赛题要求构建停车场自动计费系统,涵盖车辆进出管理、停车时长计算、分时计费规则、输入输出交互及异常处理,综合锻炼数据结构设计、时间算法与固件开发能力。包内共341个文件,以143个h头文件和97个c源文件为核心代码,另有编译产生的o/d/crf中间文件、Keil工程配置文件(uvprojx/uvoptx)、可烧录的hex文件及axf调试文件,压缩包整体约13.81MB,目录结构清晰便于查阅。目前已有472人学习下载。内容预览显示,资源包含STM32G4系列HAL库驱动(如定时器、I2C、UART、ADC、SPI等)和BSP用户自定义配置,可帮助读者快速搭建开发环境,理解定时计时、外设通信等关键实现,并支持在现有框架上进行功能扩展或代码移植,对提升编程能力与嵌入式岗位竞争力亦有帮助。

1. 赛题拆解:这题考的不是停车,是状态管理

2021年第十二届蓝桥杯单片机方向的第一场,出了一道“停车计费”题目。很多第一次看到这题的选手会觉得它是个纯应用题——停车嘛,无非就是算个钱而已。但实际动手做的时候就会发现,它真正考的东西和停车几乎没有关系,核心其实是三个词:状态机、定时器、外设协作。

题目给了一个非常贴近生活的场景:停车场入口有一个计费终端,用户需要通过按键选择车辆类型,系统自动计时,车辆离开时根据停车时长和对应车型费率计算出费用并显示。听起来很简单,真正写代码时你要同时处理好按键输入、计时累加、费用换算、数码管显示刷新,还要解决“在计时过程中按键不能失灵”“显示不能闪烁”“结算数据不能错位”这类细节问题。

这道题特别适合正在备赛蓝桥杯单片机组或嵌入式组的同学拿来练手,它是非常典型的“外设组合题”——把独立按键、定时器中断、数码管、数据运算全部串在一条逻辑线上。如果你能把这个题完整拿下,省赛里大部分涉及状态切换、计时计费、按键交互的题目你都会觉得轻松很多。这篇文章我就按自己实际做这题的过程,把拆题思路、代码框架、踩坑记录全部摊开来说。

2. 核心需求与设计思路:先想清楚再动手,别上来就写代码

2.1 需求还原与常见的评分维度

我拿到这类赛题的第一反应不是打开Keil,而是先把题目里所有功能描述整理成行为列表。就“停车计费”这个场景来说,通常包含这些关键行为:

  • 上电后系统进入待机状态,有明确的界面提示
  • 操作者通过按键选择车型(小车、货车、客车等,不同车型费率不同)
  • 车辆进入后开始计时,计时期间实时显示当前停车时长和费用
  • 车辆离开时按键结算,系统显示最终费用,并支持手动复位或者自动归零
  • 整个过程中显示内容要随状态切换,不能出现逻辑混乱

竞赛测评时,考官不会只看功能是否实现,还会看边界条件处理的完整性。比如停车超过一个计费周期后费用是否正常进位、按下按键瞬间有没有接触抖动导致重复切换、计时过程中显示刷新是否卡顿。这些点基本决定了你是拿基础分还是拿高分。

2.2 计费规则与数据模型设计

“停车计费”最核心的数据模型是:车型、单价、停车时间、应收费用。我建议在程序里用一组结构体来管理这些数据。以常见的计费规则为例——小车5元/小时,货车8元/小时,客车10元/小时,停车不足1小时按1小时计费——可以这样组织:

typedef struct { unsigned char carType; // 0-小车 1-货车 2-客车 unsigned char unitPrice; // 单价(元/小时) unsigned int totalMinutes; // 累计停车分钟数 unsigned int totalFee; // 累计费用(元) unsigned char isRunning; // 是否正在计时 } ParkSystem;

这里有一个关键设计决策:计费用分钟数累加,不要用浮点数计时。有些同学喜欢定义一个float类型变量存“小时数”,然后单价乘以小时数得到费用。思路没错,但在单片机上做浮点运算明显更慢,而且在显示格式化时需要做小数拆分,容易出错。我的做法是底层用一个unsigned int变量累计停车“秒数”,每满60秒就给“分钟数”加1,计费时全部用整数运算。要显示“小时”时再拿分钟数除以60取商和余数,费用也拆成“元”和“角”来送显示缓冲区。

之所以强调这点,是因为测评系统对显示数据的要求往往非常严格。曾经有选手用浮点算完费用后直接乘以10再取整送显示,结果由于单片机浮点运算精度截断,在部分计费组合下出现最后一位差1的情况,像这种问题排查起来非常隐蔽。

2.3 为什么必须用状态机来组织逻辑

很多初学者写这类题目时喜欢用“一个大循环从头到尾顺序执行”的思路,结果就是代码里到处是if-else的嵌套,一旦加了新功能,整个逻辑就乱套了。我在实际做题时强烈建议用状态机来组织整个程序。

停车计费系统天然存在几个稳定状态:空闲待机、停车计时、结算展示。每个状态只响应特定按键事件,并且只执行当前状态允许的动作。

  • 空闲状态下,KEY1负责切换车型,KEY2才开始计时,KEY3无意义
  • 计时状态下,KEY1不能再切换车型,KEY2记录暂停或结束,KEY3才用来结算
  • 结算状态下,所有按键都需重新初始化,等待下一次业务开始

用状态机的好处是逻辑边界清晰,每个状态独立处理自己的事情,不会出现“停车计时中按了车型切换键导致费率变化、费用全部重算”这种低级的逻辑漏洞。我自己写代码的习惯是:先画出状态迁移图,再喂给定时器中断一个时间节拍,主循环只做按键扫描、状态处理和显示刷新。这样写出来的代码,结构清晰,调试时也能很快定位到是哪一层出了问题。

3. 实操过程与关键代码实现

3.1 硬件平台与资源分配

我平时的训练平台是蓝桥杯单片机竞赛中常见的国信长天CT107D开发板,主控芯片是STC15F2K60S2(51内核)。板载外设有独立按键、共阴数码管、LED灯、矩阵键盘等。比赛现场使用的硬件基本与此一致,只是引脚分布在不同的跳线帽下。

在写代码之前,我先确定每个外设占用的引脚和功能:

  • 数码管段码和位码通过锁存器控制,占用P0和P2口部分引脚
  • 独立按键接在P3.0、P3.1、P3.2上,我分别定义为KEY1、KEY2、KEY3
  • 蜂鸣器接在P0.6,需要计费完成提示音时使用

如果你用的是STC15系列的开发板,强烈建议先看一遍原理图,确认锁存器的控制逻辑,特别是P2口的高位控制位。CT107D上用P2.2、P2.3、P2.4分别控制段码锁存器、位码锁存器和功能引脚锁存器,操作顺序错了会出现“写段码时位码被修改”“写位码时段码被清除”这种奇怪的问题。

3.2 定时器与计时逻辑

计时部分我采用的是定时器0,工作在方式1(16位定时器),配合12MHz晶振生成1ms基准中断。初始值算下来是65536-1000=64536,也就是0xFC18。但要注意,这是按“一个机器周期等于 12 个时钟周期”的传统51单片机得到的值,如果用的是STC15系列,它可以配置为1T模式,此时初值计算方式完全不同,在1T模式下定时1ms的初值是65536-12000=53536,也就是0xD120。这属于典型的“选手看教程有用但没看芯片手册”的坑。所以写代码前先确认开发板/芯片的工作模式,再用示波器或者状态灯验证一下时基是否准确。

void Timer0_Init(void) { TMOD |= 0x01; // 定时器0,方式1 TH0 = 0xFC; // 12T模式下1ms初值(12MHz晶振) TL0 = 0x18; ET0 = 1; // 开启定时器0中断 EA = 1; // 开总中断 TR0 = 1; // 启动定时器0 } unsigned int sysTick_ms = 0; void Timer0_ISR(void) interrupt 1 { TH0 = 0xFC; // 重装初值,关键!漏了这行时间会漂移 TL0 = 0x18; sysTick_ms++; // 每1000ms触发一次秒计数 if (sysTick_ms >= 1000) { sysTick_ms = 0; parkSystem.totalSeconds++; } }

这里有个非常重要的点:定时器进入中断后,硬件会自动将TF0清零,但初值不会自动重装,必须手动在原函数里再次给TH0和TL0赋值。如果在中断里忘了重装初值,定时时间会越来越长,体现在数码管上就是计费秒数走得比实际时钟慢,而且误差随时间累积越来越大。我也遇到过一些教材号称“自动重装入”的方式,那是对应STC15的定时器模式配置,而不是传统51的16位定时器方式,两者搞混了代码就会行为异常。

3.3 按键处理与防止抖动的细节

按键部分的处理逻辑决定了整个系统的操作体验。我在这个项目里采用“扫描检测 + 下降沿触发 + 软件延时消抖”的组合方案。

具体做法是:每隔10ms检测一次按键电平,连续两次检测到稳定的低电平才认为按键按下,同时用一组标志位记录当前按键状态,只有当按键从“未按下”变为“按下”时才执行对应操作。这样可以有效防止按下一次按键触发了多次操作的问题。

unsigned char Key_Scan(void) { unsigned char key = 0; if (KEY1 == 0) { Delay_10ms(); // 常用软件消抖 if (KEY1 == 0) { key = 1; while (KEY1 == 0); // 等待释放,避免重复触发 } } // KEY2、KEY3同理 return key; }

这里我要提一个具体问题:如果在主循环里同时做数码管动态扫描和按键等待释放,会出现按下按键后数码管停住不刷新,拖慢了整个系统的响应。最好的做法是“先读取按键状态,记录下来,立即返回”,不阻塞等待释放。我把按键扫描改成“读取缓冲区”的思路:定时器中每10ms采集一次原始电平,主循环里读到的已经是消抖后的稳定值,再配合边沿检测标志位,整个按键处理就不会阻塞显示刷新了。

void Key_Scan_Handler(void) { static unsigned char keyBuff = 0xFF; unsigned char keyState = (KEY1 << 2) | (KEY2 << 1) | KEY3; // 简单消抖:只记录连续两次相同的电平 if (keyState != keyBuff) { keyBuff = keyState; return; } keyStable = keyState; }

很多同学会觉得这小题的按键太简单,不值得花这么多心思,实际上一旦按下按键后出现误触发或者漏触发,整个测评环节都会扣分,这部分的工作量绝对值得投入。

3.4 显示逻辑与费用换算

显示部分我使用的是4位数码管,格式采用“小时-分钟”和“费用”两种模式切换。比如停车状态下显示当前停车时间和当前费用,结算状态下显示最终费用并闪烁提示。

为了让显示刷新稳定,我在代码里维护一个显示缓冲区buffer[4],每个位段对应的段码先通过查表转换成段码,再送去锁存器和位选口。这种做法的最大好处是:上层业务逻辑只负责修改buffer里的数据,完全不用关心数码管什么时候被点亮,刷新节奏统一由定时器中断控制。

unsigned char code SegTable[] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F, 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71 }; void Display_Refresh(void) { // 动态扫描,每2ms切换一位 static unsigned char pos = 0; Seg_Output(0); // 消隐 Bit_Output(0x01 << pos); Seg_Output(SegTable[buffer[pos]]); pos = (pos + 1) % 4; }

费用换算逻辑要仔细。假如小车单价5元/小时,停车总分钟数是87分钟,不足1小时按1小时计,那么计费小时数应该是87/60向上取整,即2小时,费用为10元。

unsigned int Calc_Fee(unsigned int minutes, unsigned char price) { unsigned int hour = minutes / 60; unsigned int remainder = minutes % 60; if (remainder != 0) { hour++; // 向上取整 } return hour * price; }

这个函数是整道题的核心算法,逻辑非常简单但极容易出错。最容易犯的错误是:直接用分钟数乘以单价再除以60,相当于把不足1小时的部分直接抹掉,这在计费规则不符合实际业务要求。比赛题面如果写“不足1小时按1小时计算”,就必须采用向上取整的写法;如果写“按分钟计费”,则要乘完单价再除以60,还要考虑怎么显示到角和分。所以,写计费逻辑之前,把题面字字读清楚,比写代码更重要。

3.5 状态切换的完整流程示例

我把主循环的框架写出来,方便直接套用。

void main(void) { System_Init(); // 时钟、定时器、IO初始化 parkSystem.carType = 0; parkSystem.unitPrice = 5; parkSystem.totalMinutes = 0; parkSystem.totalFee = 0; parkSystem.isRunning = 0; while (1) { unsigned char key = keyStableValue; switch (systemState) { case STATE_IDLE: if ((key & KEY1_MASK) == 0) { SwitchCarType(); // 切换车型 } if ((key & KEY2_MASK) == 0) { systemState = STATE_RUNNING; parkSystem.isRunning = 1; } break; case STATE_RUNNING: if ((key & KEY3_MASK) == 0) { parkSystem.totalFee = Calc_Fee( parkSystem.totalMinutes, parkSystem.unitPrice); systemState = STATE_SETTLE; } break; case STATE_SETTLE: if ((key & KEY1_MASK) == 0) { ResetSystem(); // 复位,进入下一辆车 systemState = STATE_IDLE; } break; } UpdateDisplay(); // 根据状态刷新显示缓冲区 } }

我把State定义成一个枚举类型或宏常量,STATE_IDLE=0、STATE_RUNNING=1、STATE_SETTLE=2,在头文件里用宏或者枚举统一管理,避免魔法数字散落在代码里。这种写法在赛场上看起来特别整洁,调试时加断点也非常直观。

4. 常见问题与排查技巧实录

4.1 定时时间偏慢或偏快

这是我做停车计费题时遇到的第一个问题。现象是:数码管显示的倒计时秒数比实际秒表慢,或者全部计时行为运行时间都不对。排查思路是从芯片工作模式开始。如果MCU配置为1T模式,但代码里使用12T模式的初值,定时周期会被拉长到12倍;反之,如果使用12T模式但代码里按1T算初值,定时周期会缩短到原来的1/12。解决方法是先看头文件里有没有默认的时钟分频配置寄存器,比如STC15系列的状态寄存器CLK_DIV,确保与定时器初值计算口径一致。我第一次做这个题就是没注意STC15默认是1T,结果烧录后时间慢了很多,排查了很久。

4.2 数码管在切换状态时出现乱码

乱码问题在动态扫描程序里很容易遇到,最常见的原因是修改显示缓冲区时,定时器中断正好在扫描中途,读到了buffer中两个不同时刻混合的数据,导致某一位亮度闪烁或显示一个“鬼影”。解决思路是:在修改buffer前临时关掉显示扫描,或者把整个显示缓冲区更新在一个原子操作里完成,比如用一个更新标志位,主循环里普通修改,刷新函数发现标志置位后一次性把所有位段数据复制到扫描缓冲区。这个技巧在复杂界面切换(比如从计时状态切到结算状态)时特别有效。

4.3 按键按下后灵异触发,比如按一下KEY2直接跳过KEY1的逻辑

这类问题大部分是消抖没做好。我看到很多新手的代码是“检测到低电平→延迟20ms→再次检测→执行功能”,看起来没问题,但如果delay期间使用了占空比固定的函数,或者delay里碰巧长了,用户按下的波形就可能被截取成两次高电平变化,导致一次物理按键被当作两次操作。这也是我在实际项目中把按键处理全部搬进定时中断修改为读取稳定值的原因。另一个做法是:检测到按下后先执行功能函数,再延迟等待释放,这样即使按下过程中产生了抖动,也不会被执行第二次。

4.4 费用显示位数不够

停车计费里费用数据可能会超过4位数。比如客车10元/小时,停一天就是240元,三位数还能显示,如果停5天就是1200元,4位数已经接近上限了。在比赛条件下,车辆不可能停好几天,但在扩展功能时会遇到。我的处理策略是:显示费用时隐藏掉末尾的“0”,用“元”为单位,超过999元时切换显示单位“千元”或增加小数位处理,保证任何情况下数据不会溢出。这类边界处理虽然不是题目必须要求,但如果你想让设计完整,提前考虑总没有坏处。

5. 参赛备赛心得与扩展方向

做完整道“停车计费”题,我最深的体会是:蓝桥杯单片机组真正难的不是某一个外设的驱动,而是怎么把多个外设在同一个逻辑框架里协调起来。按键要响应、定时器要计数、数码管要刷新、数据要换算,这些任务杂糅在一起,如果不用清晰的状态机加定时器节拍来驱动,很快就会陷入“改了这里那边又坏了”的泥潭。

给正在备赛的同学几条实操建议:

  • 第一,所有模块先单独验证。调通按键扫描、数码管显示、定时器时基之后,再组合起来,不要一上来就写完整逻辑,否则一旦出问题你根本不知道是谁的锅。
  • 第二,刻意训练“按状态拆分代码”的能力。每次做题都在头文件里规划好状态枚举,主循环只做状态分发,每个状态单独写一个函数,这样方便逐段调试。
  • 第三,善用开发板上的LED灯和蜂鸣器做调试辅助。比如定时器中断里翻转一个LED,就能直观看出定时是否准确;计费完成时让蜂鸣器响一声,就能确认状态切换是否成功触发。

最后再分享一个小技巧:赛前一定要养成在草稿纸上画状态迁移图和计算示例的习惯。停车计费这道题的成本并不高,费率和时长的边界组合却不少,画清楚图再写代码,能省下大量反复修改的时间。这道题做完后,你完全可以把这套思路迁移到其它模拟场景里,比如“电子秤计价”“出租车计费”“电费阶梯计费”等等,换汤不换药,核心逻辑都是状态机加定时器加显示。扎实吃透这题,对你后续所有综合题的帮助都会非常大。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 2:23:43

模型层工程实践:掌控AI自主性的关键

模型层是 AI 自主性的核心。很多人做 AI 应用时&#xff0c;把精力放在提示词、工作流、前端界面上&#xff0c;结果发现智能体总是“看起来聪明&#xff0c;落地就翻车”。问题往往不在模型本身&#xff0c;而在于你根本没掌控模型层。所谓掌控&#xff0c;不是会调一个 API&a…

作者头像 李华
网站建设 2026/9/2 2:21:43

EVTX解析器实战:将Windows事件日志高效转换为结构化数据

简介&#xff1a;这是一份用 Rust 语言实现的 Windows EVTX 事件日志解析器源码包&#xff0c;主要面向安全分析、应急取证和日志处理开发者&#xff0c;解决 EVTX 二进制格式难以快速读取与转换的问题。解析器以 100% 安全 Rust 编写&#xff0c;完全不使用不安全代码&#xf…

作者头像 李华
网站建设 2026/9/2 2:20:58

AI视觉生成工作流:用ComfyUI还原征服者对阵马克诺兰名场面

这次我们不聊空泛的战力排名&#xff0c;而是把“征服者到了第四季力量还是可以稳压马克诺兰父子二人吗”这个动漫IP话题&#xff0c;拆成一个可以实际操作的AI视觉生成与批量对比流程。先说结论&#xff1a;从漫画和动画目前给出的战斗表现看&#xff0c;征服者属于维特鲁姆帝…

作者头像 李华
网站建设 2026/9/2 2:18:28

Spring Boot + MyBatis-Plus 实战避坑指南:从版本选型到事务与性能优化

简介&#xff1a;面向Spring Boot与MyBatis-Plus整合开发的Java后端工程师&#xff0c;这份资料包围绕企业级应用常见场景&#xff0c;展示从自动配置、起步依赖&#xff0c;到增强CRUD、分页、乐观锁、逻辑删除等能力的落地方式&#xff0c;适合希望快速搭建数据访问层&#x…

作者头像 李华
网站建设 2026/9/2 2:15:22

Nacos 2.2.3 zip包部署全指南:从单机到集群的避坑实践

简介&#xff1a;Nacos 2.2.3是阿里开源的稳定版服务发现与配置中心组件&#xff0c;适合在Windows环境搭建微服务基础设施的开发者使用&#xff0c;可解决服务注册、动态配置、健康检查等常见问题。压缩包共16个文件&#xff0c;大小142.02MB&#xff0c;包含启动/关闭脚本&am…

作者头像 李华