☰
基于STM32的电子秤系统设计:从ADC采样到状态机与EEPROM存储实战
2026/11/1 6:51:38 网站建设 项目流程

1. 项目概述:从赛题到一台“电子秤”的完整实现

第九届蓝桥杯嵌入式国赛的“电子秤”题目,可以说是我参加过所有嵌入式竞赛中,将基础外设、算法处理和工程实践结合得最紧密的一道题。它远不止是简单地读取一个称重传感器的数据然后显示出来。题目要求你从零开始,基于指定的竞赛开发板(通常是STM32G431或类似型号),搭建一个具备称重、计价、超限报警、数据存储和查询功能的完整电子秤系统。这相当于在一个限时、高压的竞赛环境中,完成一个迷你版的商业产品开发全流程。

对于嵌入式开发者,尤其是学生和初学者而言,这个项目是一个绝佳的“练手”综合案例。它几乎覆盖了单片机应用的核心技能栈:模拟信号采集(ADC)、人机交互(按键、LCD)、数据存储(EEPROM)、定时器应用、状态机编程以及简单的业务逻辑处理。通过复现这个项目,你不仅能巩固STM32的HAL库或标准外设库编程,更能深刻理解如何将零散的技术点串联成一个稳定、可靠的系统。我当年做这个项目时,最大的收获不是某个外设用得有多熟,而是学会了如何设计一个清晰、易维护的软件架构来应对复杂的多任务需求。

2. 核心需求与系统设计拆解

拿到题目,第一步不是着急写代码,而是把需求彻底吃透,并转化为清晰的技术指标和模块划分。国赛题目的要求通常非常具体,任何一点遗漏都可能导致严重失分。

2.1 需求规格说明书(自拟)

根据常见的赛题回忆和工程逻辑,一个完整的竞赛级电子秤通常包含以下核心需求:

  1. 基本称重与显示:实时采集称重传感器信号(模拟电压),经处理后转换为重量值(单位:克/g),并实时在LCD屏幕上显示。
  2. 单价设置与金额计算:可以通过按键设置当前商品的单价(元/千克),系统根据金额 = 重量 * 单价 / 1000自动计算并显示金额。
  3. 超重报警功能:当重量超过预设的阈值(例如2kg)时,触发声光报警(LED闪烁、蜂鸣器鸣响),并在LCD上给出提示。
  4. 数据存储与查询:
    • 称重记录存储:每次完成一次称重(例如按下“累计”键),将当前时间(时、分)、重量、金额作为一个条目,存储到板载EEPROM中。
    • 记录查询功能:可以通过按键翻页查询历史存储的记录,并在LCD上显示。
  5. 去皮功能:可以将当前重量值置为零,用于扣除容器重量。
  6. 累计功能:在称重模式下,多次称重可以累加金额,并显示累计总价。
  7. 系统设置:通过按键进入设置模式,可以设置单价、报警阈值、时间等参数。

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单端模式,测量经过放大后的单端电压信号。

软件实现要点:

  1. ADC配置:使用DMA循环采样模式是首选。以100Hz的频率,连续采样多个点(如64个点)存入数组。DMA模式可以解放CPU,避免因频繁进入中断而影响系统实时性。

    // 示例:ADC1, DMA1, 规则通道,连续转换,DMA循环模式 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_raw_buffer, BUFFER_SIZE);
  2. 数字滤波: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),如果采样频率设置不当,可能会引入周期性噪声。可以采用中位值平均滤波法(去极值后平均),抗脉冲干扰能力更强。

  3. 标定与换算:这是将“数字”变为“重量”的关键一步。

    • 步骤一:零点标定。在空载(秤盘上无任何物品)时,读取滤波后的稳定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的可靠读写

历史记录需要掉电保存,EEPROM(I2C接口)是标准选择。这里的关键是存储结构设计和读写寿命管理。

定义记录结构体:

#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是2KB(2048字节),最多可存储2048 / 13 ≈ 157条记录。

存储策略:

  1. 循环队列存储:在EEPROM中固定一个区域作为记录存储区。用一个变量(也存于EEPROM)记录最后写入的地址索引。当写满时,覆盖最老的记录。这避免了频繁擦写固定区域。
  2. 关键参数存储:单价、报警阈值、零点值等也需要存储。为它们分配固定的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); #endif

5. 功能扩展与优化思路

完成基本功能后,如果想做得更出彩,可以考虑以下优化和扩展,这些在竞赛评分或实际项目中都是加分项:

  1. 动态滤波算法:在重量稳定时,增大滤波窗口以获得更稳的显示;在重量快速变化时,减小滤波窗口以提升响应速度。可以通过计算最近一段时间ADC值的方差来判断是否稳定。
  2. 自动休眠与唤醒:长时间无操作后,关闭LCD背光、降低ADC采样率进入低功耗模式。通过按键或传感器重量变化(需配置外部中断唤醒)来唤醒系统。
  3. 简单的数字键盘输入:用于直接输入单价,而不是简单的加减。这需要设计一个软键盘UI和输入法逻辑。
  4. 数据统计功能:在查询模式中,不仅显示单条记录,还可以统计当天的总交易笔数、总重量、总金额。
  5. 更完善的报警:除了超重,还可以增加欠载报警(重量为负值)、传感器故障报警(ADC值超量程)等。
  6. 使用RTOS:如果学有余力,可以尝试使用FreeRTOS。将按键扫描、显示刷新、业务逻辑、数据存储分别放在不同的任务中,通过队列和信号量通信。这会让程序结构更清晰,但对调试能力要求更高。

复现“蓝桥杯嵌入式电子秤”项目的整个过程,是一次从模块到系统的完整训练。它强迫你去思考硬件如何驱动、数据如何流动、状态如何管理、异常如何应对。当你最终看到LCD上稳定显示重量,按下按键能流畅切换功能,EEPROM里正确保存记录时,那种对系统掌控感带来的满足,是任何单纯学习外设无法比拟的。我的建议是,不要只满足于“跑通”,多问几个“为什么这样设计”和“如果坏了怎么办”,你的收获会成倍增加。

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

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

立即咨询