1. 项目概述与核心需求拆解
“蓝桥杯嵌入式第九届国赛程序——电子秤”,这个标题一出来,相信很多参加过蓝桥杯或者正在备赛的嵌入式开发者都会会心一笑。这不仅仅是一个简单的课程设计,它浓缩了当时国赛对选手在实时操作系统应用、高精度数据采集、复杂人机交互以及稳定系统架构设计上的综合考察。我当年作为参赛者和后来的指导者,反复研究过这个赛题,它就像一块试金石,能清晰检验出一个嵌入式开发者是否具备了从“会写代码”到“能做好项目”的跨越能力。
简单来说,这个项目要求我们基于指定的竞赛平台(通常是STM32G431或类似系列微控制器),设计并实现一个具备称重、计价、存储、查询等功能的电子秤系统。它远不止是接个压力传感器读个数那么简单。核心需求隐藏在几个关键词背后:“国赛级别”意味着对功能的完整性、运行的稳定性以及代码的优雅性有极高要求;“电子秤”则点明了其工业控制类应用的属性,对测量的准确性、实时性和人机交互的友好性提出了挑战。这个项目非常适合有一定STM32和RTOS基础,想挑战综合项目实战、深入理解嵌入式系统设计思想的开发者。通过复现它,你不仅能巩固传感器、显示屏、按键、EEPROM等外设的驱动能力,更能学会如何在一个多任务环境中,让它们协同、稳定、高效地工作。
2. 系统整体设计与RTOS任务规划
面对这样一个功能复杂的系统,采用裸机前后台编程模式会很快陷入状态机混乱、响应迟缓的泥潭。因此,使用实时操作系统进行任务划分是必然选择。回顾第九届国赛的典型要求,系统通常需要实现称重测量、价格计算、商品信息管理、交易记录存储与查询、用户界面交互等功能。我的设计思路是,根据功能的内聚性和实时性要求,将系统分解为以下几个独立的任务,并为它们分配合理的优先级。
2.1 核心任务分解与优先级设定
一个稳健的任务结构是系统流畅运行的基石。以下是经过实践验证的任务划分方案:
传感器数据采集任务:这是系统的“感官”,优先级最高。它需要以固定的频率(例如100Hz)读取压力传感器(如HX711)的数据,并进行初步的滤波(如滑动平均或卡尔曼滤波)。其输出是经过处理的、稳定的重量原始值。高优先级确保了数据采集的及时性,为后续计算提供可靠输入。
业务逻辑处理任务:这是系统的“大脑”,优先级次高。它接收采集任务送来的重量数据,结合当前选中的商品单价,进行实时计价计算。同时,它管理着商品列表(商品编号、名称、单价)和交易流水(时间、商品、重量、金额)的数据结构。当用户确认交易时,它负责生成一条完整的交易记录。这个任务处理了核心的业务状态转移。
人机交互任务:这是系统的“脸面”,优先级中等。它负责管理LCD显示屏的刷新和矩阵键盘的扫描。显示内容需要根据系统状态(如待机、称重、设置、查询)动态更新。键盘扫描需要及时响应用户的按键输入,并将其转化为系统事件(如“按键1按下”、“确认键按下”)发送给业务逻辑任务。为了不阻塞其他任务,显示刷新可以采用条件刷新的策略,只有数据变化时才更新特定区域。
数据存储任务:这是系统的“记忆”,优先级较低。它负责将业务逻辑任务生成的交易记录,异步地写入到非易失存储器中。国赛平台通常提供EEPROM模拟区域。由于EEPROM写入速度慢、寿命有限,此任务必须谨慎设计。我通常采用一个队列(Queue)来接收待存储的记录,任务平时处于阻塞状态。当队列中有数据时,被唤醒,执行写入操作。写入时,需要处理好磨损均衡和掉电保护的问题。
系统监控与看门狗任务:这是系统的“保镖”,优先级可配置为最低但需定期执行。它负责监控其他任务的生命信号(通过任务通知或信号量),并定时“喂”独立看门狗。一旦发现某个任务长时间无响应,可以执行系统复位或错误处理流程,确保系统能从异常中恢复。
注意:优先级设置需要避免“优先级反转”。例如,数据存储任务在写EEPROM时,如果关闭了中断或占用了过长时间,可能会阻塞更高优先级的任务。解决方案是使用RTOS提供的信号量或事件组进行同步,并将耗时的操作分段执行。
2.2 任务间通信机制选型
任务划分好了,如何让它们高效、安全地对话?FreeRTOS提供了多种通信机制,我的选型原则是:简单场景用通知,单向数据用队列,同步互斥用信号量。
- 重量数据传递:从采集任务到业务逻辑任务,是单向、高频、连续的数据流。这里使用队列(Queue)是最合适的。采集任务将滤波后的重量值放入队列,业务逻辑任务从队列中取出。队列的缓冲特性可以平滑两个任务执行速度的微小差异。
- 用户事件传递:从人机交互任务到业务逻辑任务,是单向、低频、事件型的消息。例如“按键按下”、“确认交易”。使用任务通知(Task Notification)或队列均可。任务通知更轻量,适合简单事件;如果需要传递更复杂的事件结构体,则使用小队列。
- 交易记录传递:从业务逻辑任务到数据存储任务,是单向、低频、数据块的传递。由于一条交易记录包含多个字段,使用队列传递结构体指针是最佳实践。业务逻辑任务在堆上分配一个记录结构体内存,填充数据后,将指针发送给存储任务的队列。存储任务在处理完毕后,必须负责释放这块内存,防止内存泄漏。
- 资源保护:商品列表、当前交易状态等全局数据结构,会被多个任务访问(如业务逻辑任务修改,显示任务读取)。必须使用互斥信号量(Mutex)进行保护,确保数据的一致性。
3. 核心模块实现与关键技术细节
有了顶层设计,我们深入各个模块,看看具体实现中的“魔鬼细节”。
3.1 高精度称重传感器数据采集与处理
称重的核心是HX711模块,它是一个24位A/D转换器,专门用于称重传感器。驱动它不难,但要做好却需要功夫。
驱动与读取:标准的GPIO模拟时序读取数据即可。关键在于,HX711有通道A增益128(用于称重传感器)和通道B增益32。我们必须使用通道A。每次读取后,数据是24位有符号补码,需要转换为一个int32_t类型的原始值。
数字滤波算法:传感器的原始数据噪声很大,直接显示会跳动不止。必须滤波。
- 滑动平均滤波:最简单有效。维护一个固定长度的数组,每次新数据覆盖最旧的数据,然后计算平均值。长度选择10-20点,在平滑度和响应速度间折衷。代码实现简单,但会引入一定的滞后。
#define FILTER_LEN 10 static int32_t weight_buffer[FILTER_LEN] = {0}; static uint8_t buffer_index = 0; int32_t HX711_Get_Filtered_Value(void) { int32_t raw_val = HX711_Read(); // 假设的读取函数 weight_buffer[buffer_index] = raw_val; buffer_index = (buffer_index + 1) % FILTER_LEN; int64_t sum = 0; for(int i=0; i<FILTER_LEN; i++) { sum += weight_buffer[i]; } return (int32_t)(sum / FILTER_LEN); } - 一阶滞后滤波(低通滤波):
Y(n) = α * X(n) + (1-α) * Y(n-1)。其中α是滤波系数(0<α<1),X是新值,Y是滤波输出。α越小,越平滑,滞后越大。这种方法计算量小,适合在资源受限的MCU上运行。static int32_t filtered_weight = 0; #define ALPHA 0.2f // 滤波系数,可调整 int32_t HX711_Get_LPF_Value(void) { int32_t raw_val = HX711_Read(); filtered_weight = (int32_t)(ALPHA * raw_val + (1-ALPHA) * filtered_weight); return filtered_weight; }
标定与换算:这是将原始AD值转换为实际重量(克)的关键步骤。
- 零点标定:秤盘空载时,读取一个稳定的滤波后原始值,记为
AD_ZERO。 - 砝码标定:放上一个已知重量的标准砝码(如500g),读取稳定的原始值,记为
AD_WEIGHT。 - 计算系数:系数
K = (AD_WEIGHT - AD_ZERO) / 砝码实际重量(500.0)。这个K值就是每克对应的AD值变化量。 - 实时换算:
实际重量(克) = (当前AD值 - AD_ZERO) / K。
实操心得:标定过程最好做一个专门的“标定模式”在系统里,将计算出的
AD_ZERO和K值保存到EEPROM。上电时读取。标定时,务必确保秤台稳定、无风、传感器预热几分钟,这样得到的参数才准确。另外,K值建议用float类型存储和计算,以保证精度。
3.2 基于FreeRTOS的稳定业务逻辑实现
业务逻辑任务是一个状态机,它根据当前重量、用户输入和内部状态来决定系统的行为。
状态定义:典型的系统状态包括:SYS_IDLE(待机,显示零点)、SYS_WEIGHING(称重中,动态显示重量和金额)、SYS_SETTING(设置商品单价)、SYS_QUERY(查询历史记录)。
事件处理:任务主体在一个无限循环中,通常使用xQueueReceive或ulTaskNotifyTake等待事件到来。事件可能来自按键(切换商品、确认、查询、设置)或来自重量数据的显著变化(例如重量从0变为大于阈值)。
关键代码片段示意:
void BusinessLogic_Task(void *pvParameters) { SystemState_t currState = SYS_IDLE; Product_t currProduct = {0}; Transaction_t pendingTrans = {0}; Event_t receivedEvent; // 从EEPROM加载商品列表和标定参数 Load_System_Data(); while(1) { // 等待事件,超时时间可以设为一个节拍,用于处理无事件时的自动逻辑(如超时返回待机) if(xQueueReceive(eventQueue, &receivedEvent, pdMS_TO_TICKS(100)) == pdPASS) { switch(currState) { case SYS_IDLE: if(receivedEvent.type == EVT_KEY && receivedEvent.key == KEY_SET) { Enter_Setting_Mode(); currState = SYS_SETTING; } else if(receivedEvent.type == EVT_WEIGHT_CHANGE && receivedEvent.weight > WEIGHT_THRESHOLD) { // 检测到放上物品,进入称重状态 currState = SYS_WEIGHING; pendingTrans.weight = receivedEvent.weight; pendingTrans.product = Get_Default_Product(); // 获取默认或上次商品 Update_Display_Weighing(pendingTrans); // 刷新显示 } break; case SYS_WEIGHING: if(receivedEvent.type == EVT_KEY) { // 处理切换商品、确认交易等 Handle_Weighing_Key(receivedEvent.key, &pendingTrans, &currState); } else if(receivedEvent.type == EVT_WEIGHT_CHANGE) { // 重量更新,重新计算金额并刷新显示 pendingTrans.weight = receivedEvent.weight; pendingTrans.total = pendingTrans.weight * pendingTrans.product.price / 1000.0; // 假设单价是分/克 Update_Display_Weighing(pendingTrans); } break; // ... 处理其他状态 } } else { // 超时处理,例如称重状态下长时间无操作,判断为交易完成或取消 if(currState == SYS_WEIGHING) { // 可以添加超时自动保存交易或返回待机的逻辑 } } // 其他周期性处理,例如检查是否需要自动保存 } }3.3 掉电保护与EEPROM数据存储策略
国赛平台Flash模拟的EEPROM写入次数有限(通常10万次),且写入速度慢。粗暴地频繁写入会很快损坏存储单元。
交易记录存储设计:
- 结构体定义:一条记录包含时间戳、商品ID、重量、总价等。
- 存储区规划:在EEPROM中划出一块连续区域作为“循环队列”存储区。定义一个
写指针,指向下一个可写的位置。再定义一个记录总数。 - 写入流程:
- 业务逻辑任务生成一条完整的交易记录。
- 将该记录结构体的指针发送到存储任务的队列。
- 存储任务被唤醒,将记录按
写指针位置写入EEPROM。 - 写入成功后,
写指针和记录总数更新,并将这两个关键索引值本身也写入EEPROM中一个固定的头部区域。 - 关键技巧:头部索引的写入应该放在最后,并且使用一个“双备份”或“标志位”机制。例如,先写索引A区,再写索引B区。上电初始化时,读取A区和B区,通过校验和或序列号判断哪个是最后成功写入的。这可以防止在写入索引过程中掉电导致索引损坏、全部记录丢失的灾难性后果。
- 读取流程:上电时,从EEPROM头部读取有效的
写指针和记录总数。查询功能时,根据指针和总数,逆序或顺序读取记录并显示。
磨损均衡的简单实现:由于是循环队列,写指针会在存储区内循环移动,自然实现了对整个存储区域的均匀磨损,避免了固定位置反复擦写。
注意事项:EEPROM单次写入的数据量是有限的(如1字节、2字节、4字节)。写入一个结构体时,需要将其拆分为多个符合对齐要求的写入操作。务必查阅你所用MCU的Flash模拟EEPROM的编程手册,遵守其写入粒度和等待时间的要求。在写入期间,根据RTOS和驱动设计,可能需要挂起调度或关闭中断,但时间必须极短。
4. 人机交互界面与用户体验优化
界面的友好程度直接决定了产品的“质感”。在资源受限的MCU上,我们需要用代码精心雕琢。
4.1 LCD显示界面分层与动态刷新
国赛用的LCD通常是128x64或类似的点阵屏,驱动库已提供。我们的工作是组织内容。
界面分层:对应系统的每个状态,设计一个独立的显示函数。如Draw_Idle_Screen(),Draw_Weighing_Screen(Transaction_t* trans),Draw_Setting_Screen(Product_t* prod)等。
动态刷新策略:避免全屏刷新导致的闪烁。只刷新变化的部分。
- 称重界面:重量和总价是频繁变化的数字。可以单独记录它们上一次显示的值,只有在本次计算出的值与上次不同时,才调用局部清空和重绘函数。静态的标题、单位等元素只在进入该界面时绘制一次。
- 菜单界面:在商品列表或记录列表中滚动时,采用“整页刷新”或“光标移动”策略。如果一屏显示5条,翻页时全刷;如果是光标在固定位置列表内移动,可以只反显光标所在行。
抗闪烁技巧:在修改屏幕某一区域前,如果该区域原有内容复杂,可以先画一个背景色的矩形块覆盖它,再绘制新内容,这比直接绘制新内容覆盖旧内容有时更可靠,能避免残影。
4.2 矩阵键盘扫描与软件去抖
矩阵键盘的扫描通常放在一个定时器中断或一个高优先级的任务中。这里讲更稳健的软件去抖。
经典状态机去抖:为每个按键定义一个状态机(通常4个状态:IDLE->PRESS_DETECT->PRESS_CONFIRMED->RELEASE_DETECT)。
IDLE:默认状态。扫描到按键按下,进入PRESS_DETECT,记录时间戳。PRESS_DETECT:等待约10-20ms(消抖时间)。再次扫描,如果按键仍按下,则确认为有效按下,进入PRESS_CONFIRMED状态,并产生一个“按键按下”事件发送给业务逻辑任务。如果发现按键已释放,则回到IDLE(认为是抖动)。PRESS_CONFIRMED:等待按键释放。RELEASE_DETECT:检测到按键释放,等待10-20ms消抖。确认释放后,回到IDLE状态,并可选择是否产生一个“按键释放”事件。
这种方法彻底消除了抖动带来的多次触发问题,并且可以轻松支持长按检测(在PRESS_CONFIRMED状态计时,超过某个阈值则产生长按事件)。
5. 系统调试、测试与性能优化实录
程序写完了,怎么确保它稳定可靠?以下是我踩过坑后总结的流程。
5.1 分模块集成测试与系统联调
不要一次性写完所有代码再调试。应遵循“驱动层->任务层->业务层”自底向上的集成顺序。
- 传感器驱动测试:先写一个简单的裸机程序,确保能正确读取HX711数据,并验证滤波和标定算法在PC端模拟或简单环境下是否工作。
- 单个任务测试:创建人机交互任务,测试LCD显示和键盘扫描是否正常,事件能否正确产生。创建数据采集任务,测试能否以固定频率发布重量数据到队列。
- 双任务联调:让业务逻辑任务运行,订阅重量队列和按键事件。测试放上物品能否显示重量,按键能否切换状态。
- 加入存储任务:测试交易确认后,记录能否被正确加入队列,并最终写入EEPROM。这里要重点测试边界情况:队列满时怎么办?EEPROM写入失败怎么办?我建议在存储任务中加入重试机制和错误日志(可以输出到串口)。
- 压力与稳定性测试:
- 快速连续按键:测试事件队列是否溢出,系统是否死锁。
- 频繁放取重物:测试重量更新是否流畅,业务逻辑能否跟上。
- 长时间运行:让系统连续运行数小时,观察是否有内存泄漏(通过FreeRTOS的内存统计功能查看),任务栈是否溢出(利用FreeRTOS的栈溢出检测钩子函数)。
- 模拟异常:在调试时,手动“拔出”传感器(模拟断线),看系统是否会进入一个合理的错误状态(如显示“传感器错误”),而不是死机。
5.2 常见问题排查速查表
下表列出了开发过程中最可能遇到的典型问题及排查思路:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 重量显示数值乱跳或为0 | 1. HX711时序错误 2. 电源或传感器连接不稳 3. 滤波算法参数不当或未生效 | 1. 用逻辑分析仪或示波器检查SCK和DOUT引脚时序,对照HX711手册。 2. 检查VCC、GND,确保传感器供电稳定。测量传感器桥压是否正常。 3. 输出原始AD值观察,调整滤波算法参数。确认滤波函数被正确调用。 |
| 按键反应迟钝或失灵 | 1. 键盘扫描任务优先级过低或阻塞 2. 去抖算法有bug 3. GPIO配置错误(如上拉未开启) | 1. 提高键盘扫描任务的优先级,确保其执行周期稳定(如10ms一次)。 2. 简化程序,去掉去抖逻辑,测试原始扫描是否灵敏,逐步定位问题。 3. 检查硬件原理图和代码初始化,确认按键引脚配置为输入上拉模式。 |
| 系统运行一段时间后死机 | 1. 任务栈溢出 2. 堆内存耗尽 3. 中断服务程序(ISR)处理不当 4. 看门狗未及时喂食 | 1. 启用FreeRTOS的栈溢出检测(configCHECK_FOR_STACK_OVERFLOW)。2. 使用 xPortGetFreeHeapSize()监控堆空间,检查是否有内存泄漏。3. 检查ISR中是否调用了不可重入函数或进行了过长时间操作。 4. 确认看门狗任务在运行,且喂狗间隔小于看门狗超时时间。 |
| EEPROM存储的数据丢失或错乱 | 1. 写入过程中系统复位或掉电 2. 磨损均衡或索引管理逻辑错误 3. 写入地址越界 | 1. 强化掉电保护机制,采用“双备份索引”法。 2. 仔细调试存储逻辑,添加串口打印,跟踪每一次写入和指针更新。 3. 在写入前增加地址范围校验断言。 |
| LCD显示局部花屏或残影 | 1. 局部刷新逻辑错误,未清除旧内容 2. 显存(GRAM)更新与屏刷新不同步 3. SPI/I2C通信受干扰 | 1. 确保在更新变数字区域前,先用背景色填充该区域矩形。 2. 查阅LCD驱动芯片手册,确认是否需要发送“更新显示”命令。 3. 检查通信线路,在SCL/SDA线上增加上拉电阻,缩短走线。 |
5.3 资源优化与性能提升技巧
在资源紧张的MCU上,每一字节内存和每一微秒CPU时间都值得珍惜。
- 栈空间分配:不要盲目给任务分配大栈。通过测试,观察每个任务运行时的最大栈使用量(FreeRTOS的
uxTaskGetStackHighWaterMark函数),在此基础上增加20%-30%的安全余量即可。人机交互和业务逻辑任务通常需要较多栈空间。 - 队列深度设置:队列不是越大越好。重量数据队列深度设为5-10足矣;事件队列深度设为3-5;存储队列深度设为2-3。过深的队列会浪费内存并增加延迟。
- 使用
Tickless Idle模式:如果系统在无操作时大部分时间处于空闲,可以启用FreeRTOS的Tickless Idle模式。这会在空闲时停止SysTick定时器,让MCU进入低功耗睡眠模式,显著降低功耗。这对于电池供电的电子秤原型很有意义。 - 避免在任务中忙等待:任何需要等待的地方,都应使用RTOS的阻塞机制(
vTaskDelay,xQueueReceive,ulTaskNotifyTake等),让出CPU给其他任务,提高系统整体效率。 - 浮点数运算:STM32G4有硬件FPU,使用
float类型进行单价、总价计算没有问题。如果是在没有FPU的芯片上,应考虑使用定点数运算库来提升速度。