
1. 项目概述从赛题到一台“电子秤”的完整实现第九届蓝桥杯嵌入式国赛的“电子秤”题目可以说是我参加过所有嵌入式竞赛中将基础外设、算法处理和工程实践结合得最紧密的一道题。它远不止是简单地读取一个称重传感器的数据然后显示出来。题目要求你从零开始基于指定的竞赛开发板通常是STM32G431或类似型号搭建一个具备称重、计价、超限报警、数据存储和查询功能的完整电子秤系统。这相当于在一个限时、高压的竞赛环境中完成一个迷你版的商业产品开发全流程。对于嵌入式开发者尤其是学生和初学者而言这个项目是一个绝佳的“练手”综合案例。它几乎覆盖了单片机应用的核心技能栈模拟信号采集ADC、人机交互按键、LCD、数据存储EEPROM、定时器应用、状态机编程以及简单的业务逻辑处理。通过复现这个项目你不仅能巩固STM32的HAL库或标准外设库编程更能深刻理解如何将零散的技术点串联成一个稳定、可靠的系统。我当年做这个项目时最大的收获不是某个外设用得有多熟而是学会了如何设计一个清晰、易维护的软件架构来应对复杂的多任务需求。2. 核心需求与系统设计拆解拿到题目第一步不是着急写代码而是把需求彻底吃透并转化为清晰的技术指标和模块划分。国赛题目的要求通常非常具体任何一点遗漏都可能导致严重失分。2.1 需求规格说明书自拟根据常见的赛题回忆和工程逻辑一个完整的竞赛级电子秤通常包含以下核心需求基本称重与显示实时采集称重传感器信号模拟电压经处理后转换为重量值单位克/g并实时在LCD屏幕上显示。单价设置与金额计算可以通过按键设置当前商品的单价元/千克系统根据金额 重量 * 单价 / 1000自动计算并显示金额。超重报警功能当重量超过预设的阈值例如2kg时触发声光报警LED闪烁、蜂鸣器鸣响并在LCD上给出提示。数据存储与查询称重记录存储每次完成一次称重例如按下“累计”键将当前时间时、分、重量、金额作为一个条目存储到板载EEPROM中。记录查询功能可以通过按键翻页查询历史存储的记录并在LCD上显示。去皮功能可以将当前重量值置为零用于扣除容器重量。累计功能在称重模式下多次称重可以累加金额并显示累计总价。系统设置通过按键进入设置模式可以设置单价、报警阈值、时间等参数。2.2 系统架构与模块划分基于以上需求我们可以将整个系统划分为硬件驱动层、业务逻辑层和人机交互层。清晰的模块化设计是保证代码在竞赛高压下仍能稳定运行的关键。硬件驱动层ADC模块负责以一定频率如100Hz采样称重传感器输出的模拟电压。这是数据的源头其稳定性和准确性直接决定整个系统的精度。LCD显示模块驱动液晶屏提供函数接口用于显示重量、金额、单价、时间、菜单、报警信息等。按键模块扫描矩阵键盘或独立按键识别短按、长按等事件并转化为统一的事件信号上报。EEPROM模块提供对板载EEPROM如M24C02的读写接口用于参数的保存和历史记录的存储与读取。定时器模块利用基本定时器如TIM6产生精确的时基如1ms中断为按键扫描、数据更新、报警判断提供时间基准。声光报警模块控制LED和蜂鸣器用于超限报警指示。业务逻辑层核心数据滤波与标定算法对ADC原始采样值进行软件滤波如滑动平均、中位值平均并将滤波后的值通过线性标定公式转换为实际重量值。重量(g) k * ADC值 b其中k和b需要通过实物标定得出。状态机管理这是整个系统的“大脑”。通常设计几个主要状态正常称重状态、设置单价状态、查询记录状态、系统设置状态。状态机根据按键事件和内部条件在不同状态间切换并调度相应的显示和逻辑处理。业务逻辑处理在称重状态下实时计算金额处理去皮、累计等操作判断超限并触发报警管理历史记录队列的存储与读取。人机交互层UI界面管理根据当前系统状态调用LCD模块的接口绘制不同的显示界面。例如称重界面、设置界面、查询列表界面。按键事件响应将底层按键模块上报的原始键值映射为当前状态下有意义的业务操作。例如在称重界面KEY1可能是“去皮”在设置界面KEY1可能是“数值增加”。设计心得在竞赛或产品开发中切忌将所有代码堆在main.c或中断服务函数里。务必坚持“高内聚、低耦合”的原则。例如ADC采集只管采集和滤波它不关心重量是多少重量计算模块只接收ADC滤波值输出重量UI模块只负责显示。这样调试时问题很容易被定位。当年我队友就因为按键处理和业务逻辑混在一起导致一个按键卡死引发整个系统显示异常调试了整整一个晚上。3. 核心模块实现细节与避坑指南3.1 称重核心ADC采样与数据处理这是整个电子秤的“感官系统”其稳定性是重中之重。硬件连接竞赛开发板上的称重传感器一般是电阻应变式半桥或全桥传感器输出毫伏级差分信号。通常会连接到一个高精度、低漂移的仪表放大器如板载的HX711专用芯片或者直接使用STM32内部的差分ADC通道。在蓝桥杯板子上通常直接使用MCU的ADC单端模式测量经过放大后的单端电压信号。软件实现要点ADC配置使用DMA循环采样模式是首选。以100Hz的频率连续采样多个点如64个点存入数组。DMA模式可以解放CPU避免因频繁进入中断而影响系统实时性。// 示例ADC1, DMA1, 规则通道连续转换DMA循环模式 HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_raw_buffer, BUFFER_SIZE);数字滤波ADC采样必然伴随噪声。滑动平均滤波简单有效非常适合这种实时性要求高的场景。#define FILTER_WINDOW 10 static uint32_t filter_buffer[FILTER_WINDOW] {0}; static uint8_t filter_index 0; uint32_t get_filtered_adc_value(uint32_t new_raw_value) { filter_buffer[filter_index] new_raw_value; filter_index (filter_index 1) % FILTER_WINDOW; uint32_t sum 0; for(int i 0; i FILTER_WINDOW; i) { sum filter_buffer[i]; } return sum / FILTER_WINDOW; }对于工频干扰50Hz如果采样频率设置不当可能会引入周期性噪声。可以采用中位值平均滤波法去极值后平均抗脉冲干扰能力更强。标定与换算这是将“数字”变为“重量”的关键一步。步骤一零点标定。在空载秤盘上无任何物品时读取滤波后的稳定ADC值记为AD_ZERO。步骤二满量程标定。放置一个已知精确重量的砝码如500g标准砝码读取稳定后的ADC值记为AD_WEIGHT。步骤三计算系数。根据两点确定一条直线的原理实际重量 K * (当前AD值 - AD_ZERO)其中K 标准重量 / (AD_WEIGHT - AD_ZERO)将K和AD_ZERO作为常数保存在代码或EEPROM中。避坑指南坑1采样频率与滤波窗口的权衡。频率太高如1kHz且滤波窗口太大会导致显示刷新慢感觉“迟钝”频率太低则无法滤除某些噪声。100Hz采样 10点滑动平均是一个不错的起点。坑2忽略温漂和时漂。传感器和放大电路的零点会随时间和温度变化。高要求的应用需要做自动零点跟踪在判断长时间空载且稳定时缓慢修正AD_ZERO值但竞赛中通常忽略或提供一个“零点校正”按键功能。坑3变量溢出。重量和金额计算涉及浮点数。STM32G431没有FPU浮点计算较慢。可以使用uint32_t类型以“厘克”0.01g和“分”0.01元为单位进行整数运算最后显示时再格式化。这能大幅提升性能并避免浮点精度问题。// 使用整数运算示例重量单位0.01g金额单位0.01元分 uint32_t weight_centigram 0; // 重量单位0.01g uint32_t unit_price_per_kg 1000; // 单价单位 元/千克。 1000表示10.00元/千克 uint32_t amount_cent 0; // 金额单位分 // 计算金额金额(分) 重量(g) * 单价(元/kg) / 1000 * 100 // 等价于金额(分) (重量(0.01g) / 100) * 单价(元/kg) / 1000 * 100 // 简化后金额(分) 重量(0.01g) * 单价(元/kg) / 10000 amount_cent weight_centigram * unit_price_per_kg / 10000;3.2 状态机设计系统流畅运行的灵魂电子秤需要响应多种按键并在不同界面间切换使用if-else堆砌会很快变得难以维护。状态机是优雅的解决方案。定义系统状态typedef enum { STATE_WEIGHING, // 正常称重状态 STATE_SET_PRICE, // 设置单价状态 STATE_QUERY_RECORD, // 查询记录状态 STATE_SYS_SETTING, // 系统设置阈值、时间 STATE_TARE, // 去皮确认状态可选 } SystemState_t; static SystemState_t current_state STATE_WEIGHING;状态机处理函数在主循环中调用void system_state_machine_handler(void) { KeyEvent_t key_event get_key_event(); // 获取按键事件 switch(current_state) { case STATE_WEIGHING: handle_weighing_state(key_event); break; case STATE_SET_PRICE: handle_set_price_state(key_event); break; case STATE_QUERY_RECORD: handle_query_state(key_event); break; // ... 其他状态处理 } }以称重状态处理函数为例void handle_weighing_state(KeyEvent_t event) { switch(event.key) { case KEY_SET: if(event.type SHORT_PRESS) { current_state STATE_SET_PRICE; // 切换到单价设置 lcd_show_set_price_screen(); // 更新界面 } break; case KEY_UP: if(event.type SHORT_PRESS) { enter_record_query_mode(); // 进入查询模式 current_state STATE_QUERY_RECORD; } break; case KEY_TARE: if(event.type SHORT_PRESS) { perform_tare(); // 执行去皮操作 } break; case KEY_ACCUM: if(event.type SHORT_PRESS) { save_current_transaction(); // 保存当前笔交易到EEPROM add_to_total_amount(); // 累计总金额 } break; default: // 其他按键或无按键事件 update_weight_display(); // 实时更新重量和金额显示 check_weight_alarm(); // 检查超限报警 break; } }设计心得状态机的引入使得每个状态下的逻辑变得非常清晰独立。调试时如果发现设置单价有问题你只需要关注handle_set_price_state这个函数大大缩小了问题范围。此外一定要处理好状态切换的“入口”和“出口”动作。例如从称重状态进入设置状态需要保存当前界面并绘制设置界面从设置状态退出时需要保存设置的值并恢复称重界面。3.3 数据存储EEPROM的可靠读写历史记录需要掉电保存EEPROMI2C接口是标准选择。这里的关键是存储结构设计和读写寿命管理。定义记录结构体#pragma pack(1) // 按1字节对齐节省空间 typedef struct { uint8_t hour; uint8_t minute; uint32_t weight_centigram; // 重量0.01g为单位 uint32_t amount_cent; // 金额分为单位 } TransactionRecord_t; #pragma pack()假设一条记录占13字节。如果EEPROM是2KB2048字节最多可存储2048 / 13 ≈ 157条记录。存储策略循环队列存储在EEPROM中固定一个区域作为记录存储区。用一个变量也存于EEPROM记录最后写入的地址索引。当写满时覆盖最老的记录。这避免了频繁擦写固定区域。关键参数存储单价、报警阈值、零点值等也需要存储。为它们分配固定的EEPROM地址。EEPROM读写注意事项写延迟每次写操作字节或页写后需要等待几毫秒的写入时间。在HAL_I2C_Mem_Write后调用HAL_Delay(5)是简单粗暴但有效的方法。跨页写一次写入的数据不能跨越物理页边界。M24C02的页大小是16字节。我们的记录是13字节所以单次写入是安全的。但如果要写入一个20字节的结构体就必须拆分成两次写操作。数据校验为防止数据异常可以在记录末尾增加一个校验和Checksum或CRC8字段。写入时计算并存储读取时验证。bool eeprom_write_record(uint16_t index, const TransactionRecord_t* record) { uint16_t addr RECORD_START_ADDR index * sizeof(TransactionRecord_t); uint8_t crc calculate_crc8((uint8_t*)record, sizeof(TransactionRecord_t)-1); // 将记录和CRC一起写入 // ... 写入操作注意地址是否跨页 return true; } bool eeprom_read_record(uint16_t index, TransactionRecord_t* record) { uint16_t addr RECORD_START_ADDR index * sizeof(TransactionRecord_t); // ... 读取操作 uint8_t crc_read ...; // 从存储中读出的CRC uint8_t crc_calc calculate_crc8((uint8_t*)record, sizeof(TransactionRecord_t)-1); return (crc_read crc_calc); }4. 系统整合与调试实战当各个模块都准备好后将它们整合到一个工程里并确保它们能协同工作这是最具挑战性也最有成就感的一步。4.1 主程序框架与任务调度在main.c中应避免使用HAL_Delay进行长时间阻塞。推荐采用基于定时器中断的“前后台”或简易时间片轮询架构。// 在1ms定时器中断中 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim-Instance TIM6_Instance) { system_1ms_tick; // 系统时钟滴答 key_scan_tick(); // 按键扫描计时 // 可以设置更多的软件定时器标志位 } } // 在主循环中 int main(void) { // HAL初始化、外设初始化、全局变量初始化 system_init(); lcd_show_welcome_screen(); HAL_Delay(1000); while (1) { // 1. 按键扫描与事件处理每10ms执行一次 if(system_1ms_tick - last_key_scan_tick 10) { last_key_scan_tick system_1ms_tick; key_scan_process(); // 此函数内部会调用状态机 } // 2. 重量显示与业务逻辑更新每100ms执行一次 if(system_1ms_tick - last_display_update_tick 100) { last_display_update_tick system_1ms_tick; // 只有称重状态才需要频繁更新显示 if(current_state STATE_WEIGHING) { update_weight_data(); // 读取ADC滤波换算重量/金额 refresh_weighing_display(); // 刷新LCD显示 check_alarm(); // 检查报警条件 } } // 3. 其他低优先级任务如EEPROM存储完成指示等 // ... } }4.2 调试技巧与问题排查在整合过程中你一定会遇到各种奇怪的问题。以下是我总结的排查清单现象可能原因排查方法重量显示跳变剧烈1. ADC采样频率或滤波参数不当。2. 传感器供电不稳或受干扰。3. 机械结构不稳如秤盘晃动。1. 用调试器或串口打印原始ADC值观察其波动范围。2. 检查电源电压尝试给传感器模拟部分加滤波电容。3. 固定好传感器和秤盘。按键反应迟钝或连击1. 按键消抖处理不当。2. 按键扫描周期太长。3. 在主循环中使用了阻塞式HAL_Delay。1. 确保消抖时间合理通常10-20ms。2. 将按键扫描放在定时中断或确保其周期稳定如10ms。3. 改用状态机和非阻塞延时。LCD显示乱码或闪烁1. 数据传输时序错误SPI/I2C。2. 刷新频率太高且未清屏直接覆盖绘制。3. 内存越界破坏了显示缓冲区。1. 用逻辑分析仪抓取通信波形。2. 局部刷新代替全屏刷新或使用双缓冲机制。3. 检查数组边界使用-fstack-usage等工具检查栈使用。EEPROM数据读写出错1. I2C上拉电阻未接或阻值不对。2. 读写时序未满足芯片要求如写周期等待。3. 地址计算错误导致写到了其他区域。1. 检查硬件连接I2C总线通常需要4.7k上拉。2. 在写操作后增加足够延时HAL_Delay(5)。3. 打印出每次读写的地址与数据手册对比。累计金额计算错误1. 浮点数精度丢失或计算溢出。2. 去皮逻辑有误影响了累计基准。3. 数据类型在传递过程中被意外转换。强烈建议将所有计算转换为整数运算。打印出计算过程中的每一个中间变量核对单位克、千克、元、分。一个高级调试技巧利用串口打印日志。即使比赛可能不要求串口功能在开发阶段也务必启用。将关键变量如原始ADC值、滤波后值、计算出的重量、当前状态定期打印出来是定位复杂逻辑问题最有效的手段。你可以通过条件编译来控制日志的开关这样在最终版本中关闭即可。#ifdef DEBUG_ENABLE printf([%lu] ADC:%lu, W:%lu.%02lug, State:%d\r\n, system_1ms_tick, raw_adc, weight_g, weight_centigram % 100, current_state); #endif5. 功能扩展与优化思路完成基本功能后如果想做得更出彩可以考虑以下优化和扩展这些在竞赛评分或实际项目中都是加分项动态滤波算法在重量稳定时增大滤波窗口以获得更稳的显示在重量快速变化时减小滤波窗口以提升响应速度。可以通过计算最近一段时间ADC值的方差来判断是否稳定。自动休眠与唤醒长时间无操作后关闭LCD背光、降低ADC采样率进入低功耗模式。通过按键或传感器重量变化需配置外部中断唤醒来唤醒系统。简单的数字键盘输入用于直接输入单价而不是简单的加减。这需要设计一个软键盘UI和输入法逻辑。数据统计功能在查询模式中不仅显示单条记录还可以统计当天的总交易笔数、总重量、总金额。更完善的报警除了超重还可以增加欠载报警重量为负值、传感器故障报警ADC值超量程等。使用RTOS如果学有余力可以尝试使用FreeRTOS。将按键扫描、显示刷新、业务逻辑、数据存储分别放在不同的任务中通过队列和信号量通信。这会让程序结构更清晰但对调试能力要求更高。复现“蓝桥杯嵌入式电子秤”项目的整个过程是一次从模块到系统的完整训练。它强迫你去思考硬件如何驱动、数据如何流动、状态如何管理、异常如何应对。当你最终看到LCD上稳定显示重量按下按键能流畅切换功能EEPROM里正确保存记录时那种对系统掌控感带来的满足是任何单纯学习外设无法比拟的。我的建议是不要只满足于“跑通”多问几个“为什么这样设计”和“如果坏了怎么办”你的收获会成倍增加。