☰
STM32+HX711电子秤实战:从驱动时序到标定滤波的完整实现
2026/10/2 8:25:14 网站建设 项目流程

1. 为什么选择 STM32 + HX711 这套组合做称重

1.1 从需求倒推方案:称重项目的核心诉求是什么

做称重类项目,绕不开三个硬指标:精度、稳定性、响应速度。你拿一个厨房秤的方案去做工业配料秤,精度差两个数量级;反过来用 24 位工业级 ADC 去做一个只需要判断"有没有东西放上去"的检测装置,成本又浪费得离谱。所以在动手之前,先想清楚你的量程和精度要求。

HX711 这颗芯片本质上是一个专为电子秤设计的 24 位模数转换器,内部集成了稳压电源、片内时钟振荡器,以及最重要的——两路差分输入通道,其中通道 A 支持 128 倍或 64 倍增益,通道 B 固定 32 倍增益。这意味着你不需要额外搭仪表放大器,电阻应变片输出的毫伏级差分信号直接进 HX711 就能被放大到 ADC 能识别的范围。

STM32 在这里扮演的角色是"大脑":读取 HX711 转换结果、做数字滤波、去皮标定、驱动 OLED 显示、处理按键交互。为什么不用 51 单片机?因为称重项目里滤波算法、多任务调度、浮点运算对算力有要求,STM32F103C8T6 这类 Cortex-M3 内核跑 72MHz,做滑动平均加中值滤波毫无压力,而且 HAL 库生态成熟,OLED 驱动、定时器、串口这些外设配置起来很快。

1.2 硬件选型的关键参数对比

先把几个核心器件的选型逻辑理清楚,这部分直接决定项目能不能跑起来。

器件常见型号关键参数选型理由
主控STM32F103C8T672MHz, 64KB Flash, 20KB RAM性价比高,资料多,够用
称重 ADCHX71124位, 增益128/64/32专为称重设计,内置放大
显示屏SSD1306 0.96寸 OLED128x64, I2C/SPI自发光,对比度高,驱动简单
传感器电阻应变式称重传感器1kg/5kg/20kg 可选输出 1mV/V 左右差分信号

这里有个新手最容易踩的坑:称重传感器的量程和 HX711 的增益要匹配。传感器输出灵敏度通常是 1mV/V,假设激励电压 5V,满量程输出就是 5mV。HX711 通道 A 在 128 增益下,输入满量程约 ±20mV,刚好覆盖。如果你选了 64 增益,满量程变成 ±40mV,虽然不会溢出,但有效位数会浪费。所以默认用通道 A 128 增益,除非你的传感器灵敏度特别高。

1.3 系统整体架构拆解

整个项目的信号链路是这样的:

称重传感器(应变片电桥)→ HX711(差分放大 + 24位ADC)→ STM32(GPIO模拟时序读取)→ 数字滤波 → 标定换算 → OLED显示

HX711 和 STM32 之间用的是自定义两线串行接口,不是标准 I2C 也不是 SPI,就两根线:PD_SCK(时钟)和 DOUT(数据)。STM32 通过拉高拉低 PD_SCK 来产生时钟脉冲,HX711 在 DOUT 上逐位输出 24 位数据。这个时序必须严格遵守,否则读出来的数据全是乱的。

OLED 这边,0.96 寸 SSD1306 用 I2C 接口只需要两根线(SCL、SDA),和 HX711 加起来一共占用 4 个 GPIO,资源占用非常小。如果你用的是 SPI 版本 OLED,速度会更快,但接线多两根,看个人取舍。

2. HX711 驱动时序与采样率深度解析

2.1 HX711 通信时序:为什么时序错了数据就废了

HX711 的数据读取时序是整个项目最核心也最容易出错的地方。它的工作流程是这样的:

  1. 当 DOUT 为高电平时,表示转换数据还没准备好,此时不能读
  2. 当 DOUT 变为低电平,表示数据就绪
  3. 给 PD_SCK 发送 25~27 个时钟脉冲
  4. 前 24 个脉冲,每个上升沿从 DOUT 读出 1 位数据(MSB 先行)
  5. 第 25 个脉冲用于选择下一次转换的通道和增益

关键点在于第 25 个脉冲的数量决定下一次的通道和增益:

脉冲总数下次通道增益
25A128
26B32
27A64

所以如果你只发 24 个脉冲就拉低 PD_SCK,HX711 会一直等待,DOUT 保持状态,下次读取就会错位。我见过太多人卡在这里,读出来的数据要么全是 0xFFFFFF,要么跳变剧烈,根本原因就是脉冲数不对。

// HX711 读取一个 24 位有符号数(通道A,增益128) long HX711_Read(void) { unsigned long count = 0; unsigned char i; HX711_DOUT_READ(); // 配置 DOUT 为输入 while(HX711_DOUT_IN()); // 等待 DOUT 变低,数据就绪 delay_us(1); for(i = 0; i < 24; i++) { HX711_SCK_H(); delay_us(1); count = count << 1; HX711_SCK_L(); delay_us(1); if(HX711_DOUT_IN()) count++; } // 第25个脉冲,选择下次通道A增益128 HX711_SCK_H(); delay_us(1); HX711_SCK_L(); delay_us(1); // 24位有符号数转32位有符号 if(count & 0x800000) count |= 0xFF000000; return (long)count; }

这段代码里delay_us(1)不能省。HX711 手册要求 PD_SCK 高电平持续时间 0.2~50us,低电平同样。虽然 STM32 跑 72MHz 时 GPIO 翻转本身就够慢,但为了保险,加个 1us 延时最稳。实测下来,不加延时在部分批次 HX711 上会偶发读错。

2.2 采样率 10Hz 还是 80Hz:这个选择影响你的滤波策略

HX711 有两个采样率档位:10Hz 和 80Hz,通过 RATE 引脚控制。RATE 接低电平(默认)是 10Hz,接高电平是 80Hz。

很多人一上来就想用 80Hz,觉得"采样越快越好"。但这里有个权衡:

  • 10Hz 模式:内部滤波更强,噪声更低,适合静态称重(比如电子秤、配料秤)
  • 80Hz 模式:响应快,但噪声明显增大,适合动态称重(比如皮带秤、分拣线)

我实测过同一套硬件,10Hz 模式下读数波动在 ±5 个 ADC 码以内,80Hz 模式下波动能到 ±30 个码。对于需要显示稳定重量的场景,10Hz 是更优解。如果你确实需要快速响应,可以在 80Hz 下配合更强的数字滤波,但要注意滤波会引入延迟。

提示:RATE 引脚在模块上通常有焊盘可以改,但如果你买的是成品 HX711 模块,默认就是 10Hz,不用动。

2.3 数字滤波:滑动平均 + 中值滤波的组合拳

原始 ADC 数据一定带噪声,直接显示会跳得没法看。我的做法是先中值滤波去脉冲干扰,再滑动平均平滑。

中值滤波窗口取 5,滑动平均窗口取 10。为什么是这个组合?中值滤波能干掉突发的尖峰噪声(比如电源波动、机械振动),滑动平均则让数据曲线更平滑。窗口太大响应慢,太小滤波效果差,5 和 10 是我反复试出来的平衡点。

#define MEDIAN_WINDOW 5 #define AVERAGE_WINDOW 10 long median_filter(long new_val) { static long buf[MEDIAN_WINDOW] = {0}; static int idx = 0; long temp[MEDIAN_WINDOW]; int i, j; buf[idx++] = new_val; if(idx >= MEDIAN_WINDOW) idx = 0; for(i = 0; i < MEDIAN_WINDOW; i++) temp[i] = buf[i]; // 冒泡排序 for(i = 0; i < MEDIAN_WINDOW - 1; i++) for(j = 0; j < MEDIAN_WINDOW - 1 - i; j++) if(temp[j] > temp[j+1]) { long t = temp[j]; temp[j] = temp[j+1]; temp[j+1] = t; } return temp[MEDIAN_WINDOW / 2]; } long average_filter(long new_val) { static long buf[AVERAGE_WINDOW] = {0}; static int idx = 0; static long sum = 0; long avg; sum -= buf[idx]; buf[idx++] = new_val; sum += new_val; if(idx >= AVERAGE_WINDOW) idx = 0; avg = sum / AVERAGE_WINDOW; return avg; }

这两个函数串起来用:average_filter(median_filter(raw))。注意滑动平均的 sum 要用 long 类型,10 个 24 位数据加起来可能超过 int 范围。

3. 标定与去皮:让读数变成真实重量

3.1 两点标定法的数学原理

HX711 读出来的是 ADC 码值,不是重量。要换算成克或千克,需要标定。最常用的是两点标定法:

  1. 空载时读一个值raw_zero
  2. 放上已知重量W_ref的砝码,读一个值raw_ref
  3. 计算比例系数scale = (raw_ref - raw_zero) / W_ref
  4. 实际重量weight = (raw_current - raw_zero) / scale

这个公式假设传感器线性度良好。实际上电阻应变式传感器在满量程 60% 以内线性度非常好,所以标定砝码选满量程的 50%~80%最准。

有个细节:raw_zero和raw_ref都应该是滤波后的稳定值,不能拿单次读数。我的做法是空载时连续读 20 次取平均作为零点,放砝码后等读数稳定(连续 10 次波动小于阈值)再取平均。

3.2 去皮功能的实现逻辑

去皮(Tare)本质上是把当前重量当作新的零点。实现很简单:

float tare_offset = 0; void tare(void) { tare_offset = current_weight; // 当前重量记为皮重 } float get_weight(void) { return current_weight - tare_offset; }

但这里有个坑:去皮后如果传感器漂移,零点会慢慢偏。我的经验是,去皮操作后如果长时间不用,最好重新标定零点。另外,去皮值不要存到 Flash 里反复擦写,STM32F103 的 Flash 擦写寿命只有 1 万次左右,频繁存皮重会把扇区写坏。如果确实需要掉电保存,用 EEPROM 或者加个铁电存储器。

3.3 温漂补偿的实用技巧

称重传感器对温度敏感,尤其是廉价传感器,温漂可能达到满量程的 0.05%/℃。如果你的项目工作在温差大的环境,不做补偿的话,早上标定好,中午读数就偏了。

简单的补偿方法:在传感器附近加一个 NTC 热敏电阻或 DS18B20,测温度,然后做线性补偿。具体做法是:

  1. 在 20℃ 和 40℃ 两个温度点分别标定,得到两个 scale 值
  2. 计算温度系数k = (scale_40 - scale_20) / 20
  3. 实际使用时scale_compensated = scale_20 + k * (T - 20)

这个方法虽然粗糙,但能把温漂影响降低 70% 以上。如果要求更高,就得做多点标定加查表了。

4. OLED 显示驱动与界面设计

4.1 SSD1306 I2C 驱动要点

0.96 寸 OLED 用 SSD1306 驱动芯片,I2C 地址通常是 0x78(写)或 0x7A。注意有些模块地址是 0x3C 或 0x3D,这是 7 位地址,左移一位后就是 0x78 和 0x7A。如果你用 HAL 库的HAL_I2C_Master_Transmit,传的是 8 位地址,所以用 0x78。

初始化序列比较长,但核心就几步:关显示、设时钟分频、设多路复用比、设显示偏移、设起始行、设电荷泵、设内存寻址模式、开显示。这些寄存器配置在 SSD1306 手册里都有,网上也有现成的初始化数组,直接抄就行。

// OLED 写命令 void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; // 0x00 表示命令 HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 2, 100); } // OLED 写数据 void OLED_WriteData(uint8_t data) { uint8_t buf[2] = {0x40, data}; // 0x40 表示数据 HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 2, 100); }

这里0x00和0x40是控制字节,告诉 SSD1306 接下来的是命令还是数据。很多人第一次写 OLED 不亮,就是忘了加这个控制字节。

4.2 显示汉字和自定义图形

SSD1306 本身不带字库,显示英文和数字可以用内置的 6x8 或 8x16 点阵,但显示汉字必须自己取模。常用的取模软件是 PCtoLCD2002,设置成阴码、逐列式、顺向,生成 16x16 的汉字点阵数组。

显示汉字的函数逻辑是:先设置页地址和列地址,然后连续写 32 个字节(16x16 点阵占两页,每页 16 字节)。

void OLED_ShowChinese(uint8_t x, uint8_t page, const uint8_t *hz) { uint8_t i; OLED_SetPos(x, page); for(i = 0; i < 16; i++) OLED_WriteData(hz[i]); OLED_SetPos(x, page + 1); for(i = 0; i < 16; i++) OLED_WriteData(hz[i + 16]); }

如果你要显示图片,比如开机 Logo,也是同样的原理,用取模软件生成 128x64 的数组,然后逐页写入。注意图片取模时选阴码,因为 OLED 是 1 亮 0 灭,阴码正好对应。

4.3 界面布局与刷新策略

称重项目的界面不需要花哨,核心信息就三个:当前重量、单位、状态指示。我的布局是:

  • 第一行:标题"电子秤"或项目名
  • 中间大字:重量数值,用 16x32 的大字体
  • 右下角:单位"g"或"kg"
  • 左下角:状态("稳定"或"波动")

刷新策略上,不要每次循环都全屏刷新,I2C 速率有限,全屏刷新一次要几十毫秒,会拖慢主循环。我的做法是只刷新重量数值区域,其他静态内容只在初始化时写一次。另外,重量数值变化小于最小显示单位时不刷新,避免屏幕闪烁。

注意:0.9 寸 OLED 和 0.96 寸的 I2C 兼容性有差异,部分 0.9 寸模块需要上拉电阻更强(4.7k 甚至 2.2k),否则通信不稳定。如果你遇到花屏或偶尔不亮,先检查上拉电阻。

5. 完整实操流程与关键代码实现

5.1 硬件接线与供电注意事项

先把接线理清楚,这是最容易出问题的地方。

HX711 侧:

  • VCC → STM32 3.3V(注意:HX711 模块有的标 5V,但 3.3V 也能工作,且和 STM32 电平匹配)
  • GND → STM32 GND
  • DT (DOUT) → PA0
  • SCK → PA1
  • E+ / E- → 传感器激励正负
  • A+ / A- → 传感器信号正负

OLED 侧:

  • VCC → 3.3V
  • GND → GND
  • SCL → PB6 (I2C1_SCL)
  • SDA → PB7 (I2C1_SDA)

供电上有个大坑:HX711 和 OLED 不要共用一根细杜邦线供电。OLED 全亮时电流能到 20mA,HX711 工作时也有几毫安,共用细线会导致电压跌落,HX711 读数跳动。我的做法是电源线单独走粗线,或者在 HX711 的 VCC 和 GND 之间并一个 100uF 电解电容加 0.1uF 陶瓷电容。

传感器接线更要小心:E+ 和 E- 接反不会烧,但 A+ 和 A- 接反会导致读数反向。如果你发现放上砝码读数变小,把 A+ 和 A- 对调即可。

5.2 主循环任务调度

主循环不能写成"读 HX711 → 显示 → 读 HX711 → 显示"这种阻塞式,否则按键响应会卡。我的做法是用定时器中断做时间片调度:

  • 10ms 时间片:读 HX711(10Hz 采样率,100ms 读一次,这里做计数分频)
  • 50ms 时间片:更新滤波、计算重量
  • 100ms 时间片:刷新 OLED
  • 20ms 时间片:扫描按键
volatile uint32_t tick_10ms = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim == &htim2) tick_10ms++; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_TIM2_Init(); OLED_Init(); OLED_Clear(); HX711_Init(); HAL_TIM_Base_Start_IT(&htim2); uint32_t last_read = 0, last_display = 0, last_key = 0; while(1) { uint32_t now = tick_10ms; if(now - last_read >= 10) // 100ms 读一次 { last_read = now; long raw = HX711_Read(); long filtered = average_filter(median_filter(raw)); current_weight = (filtered - raw_zero) / scale; } if(now - last_display >= 10) // 100ms 刷新一次 { last_display = now; OLED_ShowWeight(current_weight); } if(now - last_key >= 2) // 20ms 扫描按键 { last_key = now; Key_Scan(); } } }

这种写法下,每个任务都不会互相阻塞,按键响应灵敏,显示也不闪。

5.3 标定流程的代码实现

标定我做成一个独立模式,通过按键进入。流程是:

  1. 长按按键 3 秒进入标定模式
  2. OLED 提示"请清空秤台",等待 2 秒
  3. 连续读 20 次 HX711,取平均作为raw_zero
  4. OLED 提示"请放上砝码",等待用户放好并按键确认
  5. 连续读 20 次,取平均作为raw_ref
  6. 用户通过按键输入砝码重量(或者代码里写死 500g)
  7. 计算scale = (raw_ref - raw_zero) / 500.0
  8. 保存到 Flash(可选)
void Calibration(void) { long sum = 0; OLED_ShowString(0, 0, "Calibrating..."); OLED_ShowString(0, 2, "Empty the scale"); HAL_Delay(2000); for(int i = 0; i < 20; i++) { sum += HX711_Read(); HAL_Delay(100); } raw_zero = sum / 20; OLED_Clear(); OLED_ShowString(0, 0, "Put 500g weight"); OLED_ShowString(0, 2, "Press key..."); while(!Key_Pressed()); sum = 0; for(int i = 0; i < 20; i++) { sum += HX711_Read(); HAL_Delay(100); } raw_ref = sum / 20; scale = (float)(raw_ref - raw_zero) / 500.0f; OLED_Clear(); OLED_ShowString(0, 0, "Calibration OK"); HAL_Delay(1000); }

标定完成后,scale和raw_zero就是你的核心参数。如果每次上电都要重新标定太麻烦,可以把这两个值存到 Flash 的某个扇区,上电时读出来。

6. 常见问题排查与避坑经验

6.1 读数不稳定、跳变剧烈的排查思路

这是问得最多的问题。按以下顺序排查:

现象可能原因解决方法
读数在几百个码内跳电源噪声加滤波电容,单独供电
读数周期性跳变50Hz 工频干扰传感器线远离电源线,加屏蔽
读数缓慢漂移温漂做温度补偿,或等热稳定
读数偶尔突变时序错误检查 25 个脉冲,加延时
读数一直是 0xFFFFFFDOUT 没接或损坏检查接线,换模块

我遇到过一个案例:客户反映读数每隔几秒跳一次大的。查了半天发现是 HX711 的 SCK 线和电机的 PWM 线捆在一起了,电机一转就干扰。把线分开走,问题消失。所以布线时信号线一定要远离功率线。

6.2 OLED 不亮或花屏的排查

OLED 不亮,先量 VCC 和 GND 之间电压是不是 3.3V。如果电压正常,检查 I2C 地址对不对。用逻辑分析仪或者示波器看 SCL 和 SDA 有没有波形。如果没有波形,说明 I2C 初始化失败,检查 GPIO 配置和时钟使能。

花屏通常是初始化序列不完整或者上拉电阻不合适。0.96 寸模块一般自带 4.7k 上拉,但有些廉价模块上拉是 10k,在 400kHz 速率下波形上升沿太慢,导致数据错位。解决办法是把 I2C 速率降到 100kHz,或者外接 2.2k 上拉电阻。

还有一个隐蔽问题:OLED 的复位引脚。有些模块有 RES 引脚,必须在上电后给一个低脉冲复位,否则不工作。如果你买的模块只有 4 个引脚(VCC、GND、SCL、SDA),那复位是内部完成的,不用管。

6.3 HX711 采样率切换的实操细节

前面说了 10Hz 和 80Hz 的选择,这里补充一个实操细节:切换 RATE 引脚后,需要等下一次转换完成才生效。也就是说,你不能在读取过程中动态切换,否则会读到错误数据。正确做法是:切换 RATE 后,丢弃接下来的一次读数,从第二次开始才是新速率的数据。

另外,80Hz 模式下,HX711 的转换时间是 12.5ms,但如果你用 10ms 的定时器去读,会读到还没转换完的数据(DOUT 还是高),程序会卡在while(HX711_DOUT_IN())里。所以 80Hz 模式下,读取间隔要大于 12.5ms,我一般用 15ms。

6.4 按键抖动与长按短按识别

机械按键抖动是必然的,不处理的话按一次会触发多次。硬件上可以并 0.1uF 电容,但软件消抖更灵活。我的做法是状态机 + 计时:

typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_PRESSED, KEY_LONG } KeyState; KeyState key_state = KEY_IDLE; uint32_t key_tick = 0; void Key_Scan(void) { uint8_t pin = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_2); switch(key_state) { case KEY_IDLE: if(pin == 0) { key_state = KEY_DEBOUNCE; key_tick = tick_10ms; } break; case KEY_DEBOUNCE: if(tick_10ms - key_tick >= 2) // 20ms 消抖 { if(pin == 0) { key_state = KEY_PRESSED; key_tick = tick_10ms; } else key_state = KEY_IDLE; } break; case KEY_PRESSED: if(pin == 1) { key_state = KEY_IDLE; Key_ShortPress(); } else if(tick_10ms - key_tick >= 300) { key_state = KEY_LONG; Key_LongPress(); } break; case KEY_LONG: if(pin == 1) key_state = KEY_IDLE; break; } }

这样短按和长按都能识别,而且不会重复触发。长按 3 秒进入标定模式,短按去皮,逻辑清晰。

6.5 Flash 保存参数的注意事项

STM32F103C8T6 的 Flash 是 64KB,分成 64 个 1KB 的页。擦除最小单位是页,写入最小单位是半字(16 位)。保存标定参数时,不要每次标定都擦写同一页,因为擦写寿命只有 1 万次。我的做法是:

  1. 在 Flash 末尾划一页专门存参数
  2. 每次保存时,先读出整页到 RAM
  3. 修改对应位置
  4. 擦除整页
  5. 写回

如果参数更新频繁,可以用双页交替的方式:A 页写满了写 B 页,B 页写满了擦 A 页,这样寿命翻倍。

#define PARAM_ADDR 0x0800FC00 // 最后一页起始地址 typedef struct { long raw_zero; float scale; uint32_t magic; // 用于判断是否已初始化 } Params; void Save_Params(Params *p) { HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageAddress = PARAM_ADDR; erase.NbPages = 1; uint32_t page_err; HAL_FLASHEx_Erase(&erase, &page_err); uint16_t *src = (uint16_t *)p; for(int i = 0; i < sizeof(Params) / 2; i++) HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, PARAM_ADDR + i * 2, src[i]); HAL_FLASH_Lock(); }

注意magic字段,上电时先读这个值,如果是特定值(比如 0x5A5A),说明参数有效,否则用默认值。这样避免第一次上电读到全 0xFF 导致除零错误。

7. 项目扩展方向与个人实操体会

这套 STM32 + HX711 + OLED 的框架搭好之后,扩展性其实很强。比如加一个继电器模块就能做定量控制——重量到了就断开;加一个SD 卡模块就能记录称重数据;加一个蓝牙模块就能把数据传到手机。我甚至见过有人用这套方案做智能药盒,称药片重量来判断有没有按时吃药。

如果你要做多路称重,比如四个角各一个传感器,可以用多个 HX711,每个占用两个 GPIO,STM32F103C8T6 的 IO 完全够用。读取时轮流读,注意每个 HX711 的 DOUT 要分开接,SCK 可以共用。

我个人在实际操作中的体会是:称重项目 80% 的问题出在硬件和时序上,软件算法只占 20%。很多人一上来就研究卡尔曼滤波,结果连 HX711 的 25 个脉冲都没搞对。先把基础时序调通,读数稳定了,再考虑高级滤波。另外,标定用的砝码一定要准,你拿一个标称 500g 实际 480g 的砝码去标定,后面所有读数都是错的。条件允许的话,用标准砝码或者去菜市场找公平秤校准一下。

最后分享一个小技巧:调试阶段可以在 OLED 上同时显示原始 ADC 值和换算后的重量,这样一眼就能看出是 ADC 读数问题还是换算问题。等调通了再把原始值隐藏掉。这个习惯帮我省了很多排查时间。

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

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

立即咨询