
简介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开发板主控芯片是STC15F2K60S251内核。板载外设有独立按键、共阴数码管、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工作在方式116位定时器配合12MHz晶振生成1ms基准中断。初始值算下来是65536-100064536也就是0xFC18。但要注意这是按“一个机器周期等于 12 个时钟周期”的传统51单片机得到的值如果用的是STC15系列它可以配置为1T模式此时初值计算方式完全不同在1T模式下定时1ms的初值是65536-1200053536也就是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_IDLE0、STATE_RUNNING1、STATE_SETTLE2在头文件里用宏或者枚举统一管理避免魔法数字散落在代码里。这种写法在赛场上看起来特别整洁调试时加断点也非常直观。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就能直观看出定时是否准确计费完成时让蜂鸣器响一声就能确认状态切换是否成功触发。最后再分享一个小技巧赛前一定要养成在草稿纸上画状态迁移图和计算示例的习惯。停车计费这道题的成本并不高费率和时长的边界组合却不少画清楚图再写代码能省下大量反复修改的时间。这道题做完后你完全可以把这套思路迁移到其它模拟场景里比如“电子秤计价”“出租车计费”“电费阶梯计费”等等换汤不换药核心逻辑都是状态机加定时器加显示。扎实吃透这题对你后续所有综合题的帮助都会非常大。本文还有配套的精品资源点击获取