智能体重秤这个项目,我在几年前第一次接触时就觉得它是个特别适合练手的嵌入式综合案例。它不像单纯的流水灯或者串口收发那么单调,也不像四轴飞控或者高精度示波器那样门槛高得吓人。它刚好卡在一个很舒服的位置:涉及模拟信号采集、模数转换、数字滤波、状态机逻辑、人机交互、低功耗管理,甚至还能延伸到无线数据传输。可以说,把一个智能体重秤做透,嵌入式开发中常见的那些坑,你基本都能踩上一遍。
这次要聊的这个STM32智能体重秤开源项目,包含了完整的代码、原理图和仿真文件。对于正在学STM32但苦于没有完整项目练手的朋友,或者想了解传感器信号调理和嵌入式系统设计全貌的开发者来说,这是一个非常值得花时间啃下来的案例。我下面会从整体设计思路开始,把每个关键环节拆开来讲,包括为什么这么选型、参数怎么算、代码怎么写、坑怎么避。文章会比较长,但如果你能跟着走一遍,自己动手复现出来,对嵌入式系统的理解会上一个大台阶。
1. 项目整体设计与方案选型拆解
1.1 为什么选STM32和电阻应变式传感器
先说说这个项目的核心构成。一个智能体重秤,本质上要完成三件事:感知重量、处理数据、呈现结果。感知靠传感器,处理靠单片机,呈现靠显示或传输。这个开源项目选择的是STM32作为主控,配合电阻应变式压力传感器和HX711模数转换芯片,显示部分用OLED或者LCD,整体架构非常经典。
为什么是STM32?市面上做体重秤的芯片多得是,从几毛钱的专用ADC芯片到各种8位单片机都能干。但选STM32有几个很实际的考虑。第一,STM32的生态太成熟了,标准外设库、HAL库、LL库随便挑,网上资料铺天盖地,遇到问题搜一下基本都能找到方向。第二,这个项目不只是做个秤,它还要留出扩展空间,比如后面想加蓝牙传输、想加历史数据存储、想加多用户识别,STM32的资源和外设都能撑得住。第三,对于学习者来说,STM32的调试工具链完善,ST-Link便宜好用,Keil和CubeIDE都能快速上手。
传感器这边,电阻应变式压力传感器是体重秤领域最主流的选择。它的原理不复杂:金属弹性体上贴了应变片,受力变形时应变片电阻变化,通过惠斯通电桥把电阻变化转成电压变化。这个电压变化非常微弱,通常是毫伏级别,所以必须用专用芯片放大和采样。HX711就是干这个的,它内部集成了低噪声可编程增益放大器和一个24位ADC,专门为电子秤设计,价格便宜,精度足够。
注意:电阻应变式传感器有量程和灵敏度两个关键参数。常见的小量程传感器灵敏度标称1mV/V,配合HX711的128倍增益,满量程输出大概在几十毫伏。选传感器时量程要留出余量,比如做人体秤选50kg量程的传感器,实际使用不会超过150kg,但余量留足能保证线性度和寿命。
1.2 系统架构与信号链路设计
整个系统的信号链路是这样的:人体重量作用在传感器上,传感器输出差分电压信号,HX711对差分信号进行放大和ADC转换,通过串行接口把数字量传给STM32,STM32做滤波、标定、单位换算,最后驱动显示屏输出重量值。
这个链路里,HX711和STM32的接口是重点。HX711用的是类似SPI的两线接口,一根SCK时钟线,一根DOUT数据线。STM32这边用普通GPIO模拟时序就能读,不需要硬件SPI,这样引脚分配更灵活。读取时序是:DOUT变低表示数据准备好,然后SCK给25到27个脉冲,每个脉冲上升沿读出一位数据,24位数据加1到3位通道选择位。这个时序在代码里要精确控制,太快太慢都可能读错。
电源部分也值得说一下。HX711和传感器对电源噪声很敏感,如果直接用STM32的3.3V供电,数字电路的开关噪声会耦合到模拟部分,导致读数跳动。比较稳妥的做法是给HX711和传感器单独用一颗低压差线性稳压器供电,或者在电源引脚旁边加足够的去耦电容。这个项目在原理图上应该体现了这一点,仿真的时候也要注意电源网络的设置。
显示部分,OLED用I2C接口比较省引脚,LCD用并口或者SPI看具体型号。代码里需要把重量值格式化成字符串,然后调用显示驱动。如果要做低功耗,显示部分可以定时刷新,不用一直刷。
1.3 仿真方案的选择与价值
这个项目带了仿真文件,这点对初学者特别友好。仿真可以在没有实物硬件的情况下验证大部分逻辑,比如HX711的读取时序、重量计算算法、显示刷新逻辑。常用的仿真工具能模拟STM32的外设行为,配合虚拟终端或者图表显示,能看到重量值的变化。
仿真的价值在于快速迭代。你改一行滤波参数,不用重新烧录硬件,直接跑仿真就能看效果。但仿真也有局限,它没法完全模拟传感器的噪声特性、电源波动、机械安装误差这些真实世界的问题。所以我的建议是:仿真用来验证逻辑和算法,实物用来验证性能和稳定性,两者结合。
提示:做仿真时,HX711的模型可以用一个可调电压源加ADC来近似,或者直接用软件模拟时序。重点是把STM32端的读取逻辑和数据处理跑通,传感器端的物理特性可以简化。
2. 核心细节解析与实操要点
2.1 HX711读取时序的精确控制
HX711的读取时序是整个项目里最需要抠细节的地方。它的数据输出是24位补码格式,最高位是符号位。读取过程分几步:首先等待DOUT引脚变低,这表示转换完成;然后拉低SCK,延时一小段时间;再拉高SCK,延时;在上升沿之后读取DOUT的电平,这就是一位数据;重复25到27次。
这里有几个坑。第一个是延时时间。HX711的 datasheet 里写了,SCK高电平和低电平的最小时间都是0.2微秒,但实际用STM32的GPIO模拟时,因为函数调用开销,不加延时可能反而太快导致时序不满足。我一般会在拉高拉低之后各加一个微秒级的短延时,用循环实现,不用系统滴答,避免中断干扰。
第二个坑是通道选择。HX711有两个输入通道,A通道增益128或64,B通道增益32。第25、26、27个脉冲用来选择下一次转换的通道和增益。如果只用一个通道,发25个脉冲就行,但要注意第25个脉冲的上升沿之后DOUT的状态决定了下次的通道。这个细节如果搞错,会出现读数漂移或者通道错乱。
第三个坑是数据对齐。24位数据读进来后,要拼成一个32位有符号整数。因为HX711输出的是补码,如果最高位是1,表示负数,需要做符号扩展。常见做法是把24位数据左移8位,然后强制转换成int32_t,这样符号位就自动扩展了。这个操作在代码里就一行,但不理解原理的话很容易写错。
// HX711读取一个24位数据的典型实现 int32_t HX711_Read(void) { int32_t value = 0; uint8_t i; // 等待DOUT变低 while(HX711_DOUT_READ() == 1); for(i = 0; i < 24; i++) { HX711_SCK_HIGH(); delay_us(1); value = value << 1; HX711_SCK_LOW(); delay_us(1); if(HX711_DOUT_READ()) value++; } // 第25个脉冲,选择A通道增益128 HX711_SCK_HIGH(); delay_us(1); HX711_SCK_LOW(); delay_us(1); // 符号扩展 if(value & 0x00800000) value |= 0xFF000000; return value; }这段代码里,delay_us的实现很关键。如果用系统滴答做微秒延时,在中断里调用可能会有问题。简单可靠的做法是用一个空循环,根据CPU主频算循环次数。比如72MHz的STM32,一个循环大概几个时钟周期,循环十几次差不多就是1微秒。这个需要实际测一下,用示波器看SCK波形最直观。
2.2 数字滤波与重量计算
HX711读出来的原始值跳动比较大,直接显示根本没法看。必须做滤波。常用的滤波方法有几种:滑动平均、中值滤波、一阶低通滤波、卡尔曼滤波。体重秤这个场景,我推荐滑动平均加中值滤波的组合。
滑动平均就是取最近N个采样值求平均,N一般取8到16。这个算法简单,对周期性噪声抑制效果好,但响应速度会变慢。中值滤波是取最近N个值排序后取中间值,对脉冲噪声特别有效,比如有人碰了一下秤台产生的尖峰。两个结合起来,先中值再平均,效果比较稳。
滤波之后是标定。标定就是建立原始ADC值和实际重量之间的线性关系。理论上,重量和ADC值是线性的,所以只需要两个点:空载时的ADC值(零点)和已知重量时的ADC值(满度点)。实际标定的时候,我会用两个已知重量,比如0kg和20kg,分别记录ADC值,然后算斜率和截距。
// 标定参数 float zero_offset = 0.0f; // 零点ADC值 float scale_factor = 0.0f; // 每克对应的ADC值 // 计算重量 float get_weight(int32_t adc_value) { float weight = (adc_value - zero_offset) / scale_factor; return weight; }标定过程中有个细节:传感器安装后会有初始应力,零点会漂。所以每次开机或者长时间不用后,最好做一次自动零点跟踪。做法是在判断秤上没东西的时候,缓慢更新零点值。判断没东西可以用一个阈值,比如ADC值在零点附近波动不超过一定范围,持续几秒钟就认为空载。
注意:标定用的砝码或者已知重物,重量要尽量准确。如果用一袋米来标定,米袋上标的重量和实际重量可能差不少,标出来的秤就不准。有条件的话用标准砝码,没条件就用多个已知重量的物品交叉验证。
2.3 显示驱动与用户交互逻辑
显示部分,OLED用SSD1306或者SH1106驱动芯片的比较多,I2C接口,两根线搞定。代码里需要实现初始化序列、写命令、写数据、刷新显存这几个函数。初始化序列比较长,但都是固定的,照着数据手册写就行。显存一般是一块1024字节的缓冲区,对应128x64的点阵,刷新的时候整块写过去。
重量显示要处理几个状态:稳定显示、正在称重、超载、欠载。稳定判断可以用滤波后的值变化率,如果连续几次采样变化小于某个阈值,就认为稳定了,这时候锁定显示值,并且可以触发记录或者传输。正在称重的时候可以显示实时值,但加个提示。超载就是ADC值超过量程上限,显示错误提示。
用户交互如果只有显示,那就太单调了。可以加几个按键:一个去皮键,一个单位切换键,一个开关机键。去皮就是把当前重量作为新的零点,单位切换就是公斤和磅之间换。按键处理要用状态机,消抖可以用定时器扫描,不要用延时消抖,那样会阻塞主循环。
// 按键扫描状态机示例 typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_PRESSED, KEY_RELEASE } KeyState; void key_scan(void) { static KeyState state = KEY_IDLE; static uint16_t counter = 0; switch(state) { case KEY_IDLE: if(key_pressed()) { state = KEY_DEBOUNCE; counter = 0; } break; case KEY_DEBOUNCE: if(++counter > DEBOUNCE_TIME) { if(key_pressed()) state = KEY_PRESSED; else state = KEY_IDLE; } break; case KEY_PRESSED: if(!key_pressed()) { state = KEY_RELEASE; counter = 0; } break; case KEY_RELEASE: if(++counter > RELEASE_TIME) state = KEY_IDLE; break; } }这个状态机每毫秒调用一次,DEBOUNCE_TIME设成20左右,就能有效消抖。按键事件可以在状态切换的时候触发,比如从KEY_DEBOUNCE到KEY_PRESSED算一次按下,从KEY_PRESSED到KEY_RELEASE算一次释放。
3. 实操过程与核心环节实现
3.1 硬件搭建与原理图要点
拿到原理图之后,先别急着焊板子,把几个关键网络捋一遍。HX711的电源和地要单独走线,尽量远离数字信号线。传感器的四根线,激励正负和信号正负,要对应接到HX711的E+、E-、A+、A-。如果接反了,读数会反向,但不会坏,调换一下就行。
STM32的引脚分配要考虑周全。HX711的SCK和DOUT可以接任意GPIO,但最好选不在同一组相邻的,方便布线。I2C的SCL和SDA要接硬件I2C引脚,或者用软件I2C也行,但硬件I2C更稳。如果要用串口输出调试信息,留出UART引脚。电源部分,如果板子上有AMS1117这类LDO,注意输入输出电容要够,输出端加个磁珠或者电感隔离一下模拟部分。
PCB布局的时候,传感器接口和HX711尽量靠近,走线短而粗,减少干扰。晶振靠近STM32,底下不要走线。模拟地和数字地可以单点连接,或者用0欧电阻隔开。这些细节在原理图上可能看不出来,但实际画板子的时候要注意。
提示:如果不想画板子,用现成的STM32最小系统板加HX711模块和OLED模块,杜邦线连起来也能跑。但杜邦线长了容易引入干扰,读数跳动会大一些。调试阶段可以接受,最终成品还是建议做板。
3.2 软件框架与任务调度
软件框架我推荐用前后台架构,主循环里轮询几个任务,中断处理紧急事件。具体来说,定时器中断每1毫秒触发一次,负责按键扫描、状态机更新、超时计数。主循环里依次处理HX711读取、滤波计算、显示刷新、串口输出。HX711的读取频率不用太高,10Hz到80Hz都行,看具体型号,常见的80SPS就是每秒80次。
读取和滤波可以放在一起。每次读到新的ADC值,先做中值滤波,把最近5个值排序取中间,然后放入滑动平均的缓冲区,缓冲区满了就求平均。平均后的值再去做标定换算,得到重量。重量值再经过一个稳定判断,决定是否更新显示。
显示刷新不用太快,10Hz到20Hz就够了,太快了人眼也看不出来,还浪费CPU。可以用一个计数器,每50毫秒刷一次。刷新的时候,先把重量格式化成字符串,比如"12.34 kg",然后调用OLED的显示函数。
串口输出可以用来调试,把原始ADC值、滤波后的值、重量值都打出来,方便观察。但正式产品里可以关掉,省电。
// 主循环任务调度示例 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); MX_TIM2_Init(); HX711_Init(); OLED_Init(); Timer_Start(); while(1) { if(hx711_data_ready()) { int32_t raw = HX711_Read(); filter_add(raw); float weight = get_weight(filter_get()); update_display(weight); } if(display_tick >= 50) { display_tick = 0; OLED_Refresh(); } key_scan(); power_manage(); } }这个框架简单清晰,适合初学者。如果要做更复杂的任务,可以上FreeRTOS,把HX711读取、显示、按键分别做成任务。但体重秤这个应用,前后台足够了,上RTOS反而增加复杂度。
3.3 标定流程与参数存储
标定是体重秤能不能用的关键。我一般分两步:零点标定和满度标定。零点标定就是空载的时候读一批ADC值,求平均,存到zero_offset。满度标定就是放一个已知重量,读一批ADC值,求平均,然后算scale_factor = (adc_value - zero_offset) / known_weight。
标定参数要存到非易失存储器里,STM32内部有Flash,可以模拟EEPROM。存的时候要注意,Flash写之前要先擦除,擦除是按页擦的,所以参数最好集中放在一页里。写的时候加个校验和,读的时候验证一下,防止数据损坏。
// 参数存储结构 typedef struct { float zero_offset; float scale_factor; uint32_t magic; uint32_t checksum; } CalibParams; void save_calib(CalibParams *params) { params->magic = 0x5A5A5A5A; params->checksum = calculate_checksum(params, sizeof(CalibParams) - 4); FLASH_Unlock(); FLASH_ErasePage(CALIB_PAGE_ADDR); FLASH_ProgramWord(CALIB_PAGE_ADDR, *(uint32_t*)params); // ... 继续写其他字 FLASH_Lock(); }标定的时候有个实用技巧:多标几个点,用最小二乘法拟合直线,比两点标定更准。因为传感器的线性度不是完美的,特别是在量程两端。如果条件允许,用0kg、10kg、20kg、30kg四个点拟合,出来的参数更可靠。
注意:标定完成后,把参数读出来验证一下,放上已知重量看显示值对不对。如果偏差大,检查传感器安装有没有松动,或者HX711的增益设置对不对。有时候传感器受力不均匀也会导致读数不准,安装的时候要保证受力面平整。
4. 常见问题与排查技巧实录
4.1 读数跳动大或不归零
这是最常见的问题。表现是空载时读数在几十克甚至几百克范围内跳,或者放上重物后拿下来,读数不回到零。原因可能有几个:电源噪声、传感器安装应力、HX711时序问题、滤波参数不合适。
排查顺序我一般是这样的。先看电源,用示波器测HX711的VCC和AVDD,看纹波大不大。如果纹波超过几十毫伏,加电容或者换LDO。然后看传感器,空载时用手轻轻按传感器,看读数有没有响应,如果没有或者响应很奇怪,可能是接线问题或者传感器坏了。再检查HX711的时序,用逻辑分析仪抓SCK和DOUT的波形,看脉冲数对不对,时序间隔够不够。最后调滤波参数,把滑动平均的窗口加大,或者加一个死区,零点附近的小波动直接归零。
不归零的问题,很多时候是传感器的蠕变或者机械结构的迟滞。传感器受力后会有微小的永久变形,需要时间恢复。如果结构设计不合理,比如传感器安装面不平,也会导致不归零。软件上可以做自动零点跟踪,但根本解决还是要靠机械设计。
4.2 称重值线性度差
线性度差的表现是:小重量准,大重量偏大或偏小;或者中间某个区间特别不准。这个问题通常是标定方法或者传感器本身的问题。
先检查标定点。如果只用两个点标定,而且这两个点距离很近,那外推到大重量时误差就会放大。建议标定点覆盖整个量程,至少三个点。如果传感器本身线性度不好,那就没办法了,只能换传感器。电阻应变式传感器的线性度一般在满量程的0.05%到0.1%,好的能到0.02%。买的时候看规格书,别贪便宜买太差的。
还有一个容易被忽略的点:HX711的增益。A通道有128和64两档,128档的输入范围小,适合小信号;64档范围大,适合大信号。如果传感器灵敏度高,用128档;灵敏度低或者量程大,用64档。选错了会导致信号饱和或者分辨率不够。
4.3 显示闪烁或刷新慢
OLED显示闪烁,一般是刷新频率太低或者刷新方式不对。如果每次刷新都清屏再写,会看到明显的闪烁。正确做法是维护一个显存缓冲区,所有绘制操作在缓冲区里完成,最后一次性把缓冲区写到OLED。这样刷新的时候屏幕内容整体更新,不会闪。
刷新慢可能是I2C速度太低。I2C默认100kHz,可以提到400kHz,速度快四倍。但要注意OLED模块能不能支持400kHz,有些便宜的模块只能跑100kHz。另外,显存传输的数据量是1024字节,400kHz下大概20多毫秒,100kHz下要80多毫秒,差别很明显。
如果用了DMA传输I2C,刷新会更快,但代码复杂一些。对于体重秤来说,没必要,400kHz的I2C足够了。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 读数跳动大 | 电源噪声 | 示波器测VCC纹波 | 加去耦电容,换LDO |
| 读数不归零 | 传感器蠕变 | 空载观察长时间漂移 | 自动零点跟踪,改进机械结构 |
| 线性度差 | 标定点不足 | 多点标定验证 | 增加标定点,最小二乘拟合 |
| 显示闪烁 | 刷新方式不对 | 观察刷新时序 | 用显存缓冲区,整屏刷新 |
| 按键不灵敏 | 消抖参数不当 | 调整消抖时间 | 状态机消抖,20ms左右 |
| 超载不报警 | 阈值设置错误 | 检查ADC上限 | 根据量程计算阈值 |
| 功耗偏高 | 外设未关 | 测各模块电流 | 空闲时关显示,降主频 |
这个表里的问题,我基本都遇到过。特别是电源噪声和传感器蠕变,这两个是体重秤的顽疾。电源噪声靠硬件解决,传感器蠕变靠机械设计和软件补偿。没有一劳永逸的办法,只能不断调试优化。
提示:调试的时候,把原始ADC值、滤波值、重量值都通过串口打出来,用串口示波器或者简单的绘图工具看曲线。这样能直观地看到滤波效果和稳定过程,比盯着数字看强多了。
5. 项目扩展与进阶方向
5.1 低功耗设计与电池供电
如果想把体重秤做成电池供电的,低功耗就是必须考虑的问题。STM32本身有几种低功耗模式:睡眠、停止、待机。体重秤大部分时间没人用,可以进停止模式,靠按键或者HX711的中断唤醒。HX711也有省电模式,不转换的时候可以关掉。
具体做法是:主循环里判断如果一段时间没有称重活动,就关闭OLED显示,把STM32进停止模式。HX711的DOUT引脚在数据准备好时会变低,可以配置成外部中断唤醒。唤醒后重新初始化外设,读取数据,判断是否有人上秤。如果只是短暂干扰,继续睡;如果确实有人,就进入正常称重流程。
功耗估算一下:STM32停止模式大概几微安,HX711省电模式不到1微安,OLED关闭后几乎不耗电。整体待机电流可以做到10微安以下。用2000mAh的电池,理论上能待机好几年。当然实际会有各种漏电,但几个月到一年是没问题的。
5.2 数据记录与无线传输
加个蓝牙模块或者WiFi模块,就能把体重数据传到手机或者云端。蓝牙用HC-05或者BLE模块,串口透传,STM32这边只需要把重量值格式化后通过UART发出去。手机端可以做个简单的APP或者用现成的串口助手接收。
数据记录可以用STM32内部的Flash或者外挂一片EEPROM。每次称重后把重量和时间戳存下来,可以存几百条记录。需要的时候通过串口或者蓝牙读出来。时间戳可以用RTC,STM32内部有RTC外设,配个32.768kHz晶振就行。
如果要做多用户识别,可以加个按键或者RFID读卡器,每个用户有自己的编号,数据按用户存储。这个扩展就复杂一些了,涉及到用户管理和数据结构的设。
5.3 从仿真到实物的过渡经验
仿真跑通了不代表实物就能跑。我踩过的坑包括:仿真里HX711的时序是理想的,实物上因为GPIO速度、线路延迟,时序可能不满足;仿真里电源是干净的,实物上开关电源的噪声会耦合进来;仿真里传感器是线性的,实物上传感器有蠕变、迟滞、温度漂移。
过渡的时候,我建议分步验证。先把HX711和传感器接上,用最简单的代码读原始值,通过串口看数据。确认能读到数、数据有变化之后,再加滤波和标定。然后加显示,最后加按键和其他功能。每加一个模块,都单独测试通过再集成。这样出问题的时候容易定位。
还有,实物的机械结构很重要。传感器怎么安装、秤台怎么固定、受力点在哪里,这些都会影响读数。我见过有人传感器装歪了,导致四角重量不一致,同一个重量放在不同位置读数差很多。所以结构设计要保证受力均匀,传感器安装面要平整。
6. 代码结构与关键模块解析
6.1 工程目录组织
拿到开源代码后,先看目录结构。一个清晰的工程应该按功能模块分目录,比如Drivers放STM32的HAL库和CMSIS,Hardware放HX711、OLED、按键的驱动,App放主逻辑和算法,Utils放延时、滤波、存储这些通用工具。这样找代码方便,移植也方便。
我一般会这样组织:
Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f1xx_hal_conf.h │ │ └── ... │ └── Src/ │ ├── main.c │ ├── stm32f1xx_it.c │ └── ... ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── Hardware/ │ ├── hx711.c │ ├── hx711.h │ ├── oled.c │ ├── oled.h │ ├── key.c │ └── key.h ├── App/ │ ├── weight.c │ ├── weight.h │ ├── filter.c │ ├── filter.h │ ├── calib.c │ └── calib.h └── Utils/ ├── delay.c ├── delay.h ├── flash_eeprom.c └── flash_eeprom.h这样分下来,每个文件职责明确,改哪个模块就动哪个目录,不会牵一发动全身。
6.2 滤波模块的实现细节
滤波模块我单独拎出来说,因为它直接影响称重体验。前面提了中值加滑动平均,具体实现的时候,中值滤波用一个长度为5的数组,每次新数据进来,把最旧的挤出去,然后排序取中间。排序用冒泡就行,5个元素很快。滑动平均用一个长度为16的环形缓冲区,每次新数据进来替换最旧的,然后求和除以16。
这两个滤波器的参数可以调。中值长度5是经验值,太小了脉冲抑制不够,太大了响应慢。滑动平均长度16也是折中,想要更稳就加到32,想要更快就减到8。实际调试的时候,放上重物看读数稳定的时间,一般1到2秒内稳定下来比较合适。
// 中值滤波 #define MEDIAN_SIZE 5 static int32_t median_buf[MEDIAN_SIZE]; static uint8_t median_idx = 0; int32_t median_filter(int32_t new_val) { median_buf[median_idx] = new_val; median_idx = (median_idx + 1) % MEDIAN_SIZE; int32_t temp[MEDIAN_SIZE]; memcpy(temp, median_buf, sizeof(temp)); // 冒泡排序 for(int i = 0; i < MEDIAN_SIZE - 1; i++) for(int j = 0; j < MEDIAN_SIZE - 1 - i; j++) if(temp[j] > temp[j+1]) { int32_t t = temp[j]; temp[j] = temp[j+1]; temp[j+1] = t; } return temp[MEDIAN_SIZE / 2]; }滑动平均的环形缓冲区实现更简单,一个数组加一个索引,每次覆盖最旧的数据,然后求和。求和的时候可以用一个累加变量,每次减去被覆盖的值加上新值,这样不用每次都遍历数组,效率高。
6.3 状态机与主逻辑
主逻辑用一个状态机来管理,状态包括:初始化、空载监测、称重中、稳定显示、超载、错误。初始化状态做外设初始化和参数加载。空载监测状态判断秤上有没有东西,没有就更新零点。称重中状态读取数据、滤波、计算重量。稳定显示状态锁定重量值,等待用户操作或者自动回到空载监测。超载和错误状态显示提示,等待恢复。
状态切换的条件要明确。比如从空载监测到称重中,条件是ADC值超过空载阈值。从称重中到稳定显示,条件是连续N次采样变化小于稳定阈值。从稳定显示到空载监测,条件是ADC值回到空载阈值以下。这些阈值都要根据实际调试确定。
typedef enum { STATE_INIT, STATE_IDLE, STATE_WEIGHING, STATE_STABLE, STATE_OVERLOAD, STATE_ERROR } SystemState; void state_machine(void) { static SystemState state = STATE_INIT; static uint16_t stable_count = 0; switch(state) { case STATE_INIT: if(init_done()) state = STATE_IDLE; break; case STATE_IDLE: if(get_adc() > IDLE_THRESHOLD) { state = STATE_WEIGHING; stable_count = 0; } else { update_zero_offset(); } break; case STATE_WEIGHING: if(is_stable()) { stable_count++; if(stable_count > STABLE_COUNT) state = STATE_STABLE; } else { stable_count = 0; } if(get_adc() > OVERLOAD_THRESHOLD) state = STATE_OVERLOAD; break; case STATE_STABLE: if(get_adc() < IDLE_THRESHOLD) state = STATE_IDLE; break; case STATE_OVERLOAD: if(get_adc() < OVERLOAD_THRESHOLD) state = STATE_IDLE; break; case STATE_ERROR: if(error_cleared()) state = STATE_IDLE; break; } }这个状态机每10毫秒跑一次,配合滤波后的数据,逻辑清晰,不容易乱。实际项目中,状态机是控制复杂逻辑的好工具,比一堆if-else嵌套强多了。
7. 调试工具与实战技巧
7.1 必备调试工具清单
做这个项目,有几样工具能大幅提升效率。首先是ST-Link调试器,能单步、能看变量、能设断点,比串口打印强太多。其次是逻辑分析仪,抓HX711和I2C的波形,时序问题一目了然。再次是串口助手,看调试信息。最后是万用表和示波器,查电源和信号质量。
逻辑分析仪不用太贵的,几十块的那种八通道就够用。抓HX711的时候,设置SCK为上升沿触发,看DOUT在SCK上升沿前后的状态。抓I2C的时候,看SCL和SDA的时序,确认起始、地址、数据、应答都对。
串口打印我习惯用printf重定向,把原始ADC值、滤波值、重量值、状态机状态都打出来。格式用CSV,方便导入Excel画图。比如:
Raw,Filtered,Weight,State 123456,123450,0.00,IDLE 123460,123455,0.01,IDLE ...这样一眼就能看出滤波效果和状态切换。
7.2 时序调试的实战经验
HX711的时序调试,我踩过最深的坑是延时不够。一开始用HAL_Delay,最小单位是毫秒,根本不够。后来用空循环,但循环次数没算对,SCK频率太高,HX711不响应。后来用示波器量了SCK的周期,调整循环次数,才搞定。
正确的做法是:先确定SCK的频率。HX711的SCK最高频率是1MHz左右,但实际用100kHz到200kHz比较稳。周期就是5到10微秒。高电平和低电平各占一半,就是2.5到5微秒。用空循环实现的时候,先写一个大概的循环,然后用示波器量,再调整。
// 粗略的微秒延时,需要根据主频调整 void delay_us(uint32_t us) { uint32_t i; for(i = 0; i < us * 8; i++) // 8是经验系数,72MHz下大概1微秒 { __NOP(); } }这个系数不是固定的,不同主频、不同优化等级都不一样。所以最好用定时器做微秒延时,或者用DWT计数器。DWT是Cortex-M内核自带的,用起来很方便,精度也高。
7.3 标定过程的实操记录
标定的时候,我一般会记录一组数据,然后手动算一下。比如空载时读100个值,求平均得到zero_offset。放上10kg砝码,再读100个值,求平均得到adc_10kg。然后scale_factor = (adc_10kg - zero_offset) / 10000。单位是克,所以除以10000。
算完之后,放上5kg砝码验证,看显示值是不是5000克左右。如果偏差超过1%,就检查砝码准不准、传感器安装有没有问题。如果偏差在1%以内,基本可以接受。想要更准,就多标几个点,用最小二乘拟合。
标定参数存Flash的时候,我习惯存两份,一份在页首,一份在页尾,读的时候两份都读,对比一下,如果一致就用,不一致就用默认值。这样能防止Flash写入过程中断电导致参数丢失。
提示:标定完成后,把参数记下来,写在纸上或者存到电脑里。以后如果Flash被擦除了,可以手动恢复,不用重新标定。
8. 从项目中学到的嵌入式设计思维
8.1 模拟与数字的边界处理
这个项目让我对模拟和数字的边界有了更深的理解。传感器输出的是模拟信号,HX711把它变成数字信号,STM32处理数字信号。这个边界在哪里,怎么处理,直接决定了系统的性能。
模拟部分的关键是噪声和精度。电源要干净,走线要短,地要稳。数字部分的关键是时序和逻辑。时钟要准,状态要清,边界要明。两者交界的地方,比如HX711的ADC,既要考虑模拟输入的噪声,又要考虑数字输出的时序。
实际设计中,我学会了把模拟部分和数字部分分开布局,电源分开供电,地在一点连接。这些原则说起来简单,做起来需要经验。比如去耦电容放多远、地平面怎么分割、信号线怎么走,都是细节。
8.2 实时性与稳定性的权衡
体重秤这个应用,实时性和稳定性是矛盾的。滤波窗口越大,越稳定,但响应越慢。窗口越小,响应越快,但跳动越大。怎么权衡,取决于用户体验。
我的经验是,稳定时间在1到2秒比较合适。人站上秤,等1到2秒看到稳定读数,可以接受。如果等5秒,就太慢了。如果立刻显示,读数跳来跳去,用户会觉得秤不准。所以滤波参数要调到一个平衡点。
另外,稳定判断的阈值也要调。阈值太小,永远稳定不了;阈值太大,没稳定就锁定了。我一般设成满量程的0.1%左右,比如150kg量程,阈值设150g。连续5次采样变化小于这个值,就认为稳定。
8.3 从功能实现到产品化的距离
做完功能只是第一步,产品化还有很长的路。功能实现只要求能跑通,产品化要求稳定、可靠、一致、好用。比如,功能实现时传感器随便放,产品化时要考虑安装方式、受力均匀、长期稳定性。功能实现时显示能看就行,产品化时要考虑视角、亮度、功耗。
这个项目作为学习案例,功能实现已经足够。但如果想做成产品,还需要考虑外壳设计、批量标定、老化测试、EMC认证等等。这些离初学者比较远,但心里要有个数,知道从学习项目到产品还有哪些路要走。
我个人在实际操作中的体会是,嵌入式项目的难点往往不在代码本身,而在对物理世界的理解和处理。传感器会漂、电源会噪、温度会变、机械会变形,这些都不是代码能完全解决的。好的嵌入式工程师,既要懂软件,也要懂硬件,还要懂一点物理和材料。这个体重秤项目,就是一个很好的起点,让你把这些都摸一遍。