
简介面向嵌入式开发学习者与交通监测类课程设计需求这套基于STM32的公路四路超重检测系统工程资料围绕多路称重采集、阈值判断、声光报警及联网上传等环节展开可用于毕业设计、课程实践或原型验证。压缩包共306个文件约10.31MB包含57个c源文件和63个h头文件以及o、crf、d等编译中间文件另有uvprojx工程、sct分散加载、map与lst映射清单等便于直接打开Keil工程复现和二次开发。已有333人学习下载。资料保留较完整的工程结构与外设驱动代码涵盖ADC、定时器、串口通信及物联网协议相关实现读者可据此梳理四路检测的数据流、报警逻辑和联网监控思路也能参考工程配置与编译产物进行排错和功能扩展。1. 去年帮人看一个公路治超点的老设备问题不是秤不准去年帮人看一个公路治超点的老设备问题不是秤不准而是四条车道的读数互相干扰一号道刚过完一辆六轴车三号道的显示还没回零值班的人只能凭经验猜哪条是真的。这套基于 STM32 的公路四路超重检测系统要解决的就是这种“多路同时在线”的现场问题——四路称重通道各自独立采集、独立标定、独立判定超限再由主控统一打包通过联网模块把车重、时间、车道号一起送到后台。它适合正在做公路超重检测方向毕设或课设、需要一套能从采集跑通到上报的完整方案的人也适合要把样机装到路边实测的工程场景。下面按需求、选型、架构、实现、避坑、验收的顺序把这套系统拆开讲。2. 需求拆解与器件选型四路通道、量程和采样率怎么定2.1 先确认“四路”指什么四条车道还是同一秤台的四个角这是动手前必须问清楚的一件事。两种理解对应的软件结构完全不同四条车道是四个互不相干的独立通道各自有零点、灵敏度系数和超重阈值同一秤台四个角则是四个分量需要先求和得到总重再用四角差值判断车辆是否偏载。我在方案里把两种模式都留了口子用一个配置字节ch_mode切换0 表示独立通道1 表示四点合成这样后面换场景不用改代码结构只改配置。除了通道定义还要把性能边界写死否则答辩现场一问就露怯。公路治超点常见两种工况一种是引导进站的低速通过车速 5 到 15 公里每小时另一种是完全静态停车称重。低速工况下一辆车压在 1.5 米长的秤板上有效信号窗口大概 0.3 到 0.4 秒如果采样率只有 10Hz窗口里只能拿到三四个点峰值根本抓不准。所以这套方案定位在低速与静态称重动态精度按满量程 ±3% 到 ±5% 验收静态精度可以做到 ±0.5%。想做成高速不停车称重必须上专用的动态称重传感器和更高的采样率那是另一个量级的预算不在本方案范围内。功能清单我一般按下面几条锁死后面所有模块都围绕它展开四路独立采集单路断线不影响其余三路车辆到位触发、离开结束一次通过生成一条完整记录按轴重和总重双阈值判定超限本地声光报警并输出一路继电器信号本地至少存一万条记录联网上报断网期间不丢数据现场可标定标定参数掉电不丢还要能防止误改。工作温度按零下 20 到 60 摄氏度设计传感器接线做防雷和屏蔽。2.2 主控与 ADC 选型为什么是四片 HX711而不是片内 12 位 ADC先说结论主控用 STM32F103RCT6称重前端用四片 HX711每片对应一路。这个组合不是最先进的但在四路低速称重场景下是最省心、最容易复现的。对比项STM32F103RCT6STM32F407VET6主频72MHz168MHzFlash / RAM256KB / 48KB512KB / 192KB串口数量56片上 ADC12 位12 位适合场景裸机时间片调度四路 IO 模拟时序需要网络协议栈、多任务或更强滤波算力F103 的 48KB 内存跑一个裸机时间片调度器加一个环形缓冲队列绰绰有余省下来的成本可以贴到传感器和防护上。真正要谨慎的是 ADC 这一环很多同学图省事直接用片内 12 位 ADC 接应变桥结果量程 30 吨时最小分辨力大概 7 公斤空载时噪声就有十几公斤零点一直跳。这不是代码问题是分辨率不够。方案分辨率有效通道输出速率MCU 开销说明HX711 × 424 位410Hz / 80Hz低IO 模拟时序通道间物理隔离无串扰成本低ADS125624 位8 路差分 4 路最高 30kSPS中SPI 加 DRDY 中断速率高但要外加基准和模拟前端片内 12 位 ADC12 位多路快低分辨力不足只适合做粗略检测HX711 的短板是速率80Hz 输出时内部增益和滤波切换会带来更大噪声实测零点抖动比 10Hz 时明显。所以我的做法是静态称重走 10Hz低速动态走 80Hz由配置项决定标定时按实际工况选不要一套参数打天下。2.3 触发、执行与联网部件把外围清单一次列全触发用一对地感线圈加检测器检测器输出干接点经光耦隔离后进 GPIO这样线圈那边的干扰不会直接打到主控。线圈负责“车来了”红外对射负责“车走了”两个信号配合比单靠重量阈值判断车辆离开靠谱得多——重车停在秤上不动时光看重量是判断不出它走没走的。执行部分包括一路继电器输出可接道闸或外部报警器、一个有源蜂鸣器和四个车道状态指示灯。本地显示用 0.96 寸 OLED只显示四路当前重量和状态不做过多的界面现场人员看得懂就行。联网这块有两种常见做法WiFi 透传模块走 AT 指令适合有现成热点的实验室和校园场景4G Cat.1 模块走串口 AT 或 PPP适合真正装在路边的场景。两者对主控来说都是串口所以底层驱动写成同一套串口收发加状态机换模块只改初始化指令表不动上层协议。存储用 W25Q64 存历史记录AT24C02 存标定参数做双备份多花两块钱能省掉很多“参数丢了要重新标定”的麻烦。3. 总体架构与通信协议设计四层链路和数据帧怎么落地3.1 四层架构与各模块的职责边界整套系统按感知层、控制层、传输层、应用层划分这个划分不是为了好看是为了让每个人接手时知道改哪一层不会牵连别处。层级主要模块职责对外接口感知层称重传感器、地感线圈、红外对射把重量和车辆位置变成电信号模拟毫伏信号、开关量控制层STM32 主控、HX711、OLED、报警输出采集、滤波、标定、判定、本地显示串口、GPIO、SPI、I2C传输层WiFi / 4G 模块把记录打包上传接收下行参数UART AT 指令应用层后台服务与数据库入库、查询、统计、告警推送MQTT / HTTP控制层内部再分成驱动、业务、应用三段。驱动层只管时序比如 HX711 读数、Flash 读写、串口收发业务层管滤波、标定换算、超重判定状态机应用层管调度用 SysTick 做 1ms 基准主循环里按时间片轮询各个任务。这里没有上 RTOS原因是任务数量少、实时性要求不高裸机时间片更容易调试出问题的时候一眼能看出是哪个任务超时。调度周期我这样分配HX711 采集任务每 10ms 跑一次四路轮询滤波与判定任务每 50ms 跑一次OLED 刷新 200ms联网心跳 30 秒记录落盘在判定完成后立即触发。这套节奏在 F103 上跑下来 CPU 占用不到三成留足了余量。3.2 数据帧格式与 CRC16 校验上下行都用同一套壳本地模块之间、设备与后台之间我习惯用同一套帧结构调试时用串口助手就能看不用两套解析逻辑。偏移长度字段说明02帧头固定 0xAA 0x5521协议版本当前 0x01方便以后扩展31命令字0x01 实时数据0x02 记录补传0x10 参数下发0x11 参数回读41通道掩码bit0 到 bit3 对应第 1 到第 4 路52负载长度小端最大 512 字节7N负载TLV 结构重量用 int32 定点单位 0.01kg7N2CRC16MODBUS 多项式 0xA001低字节在前重量为什么要用定点而不用 float因为浮点在串口传输和存储时字节序容易出岔子int32 定点表示 0.01kg30 吨就是 3000000离溢出还远得很解析也简单。CRC 校验代码如下初值 0xFFFF多项式 0xA001算完低字节先发。/* MODBUS CRC16初值 0xFFFF多项式 0xA0010x8005 反转 */ static uint16_t crc16_modbus(const uint8_t *p, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *p; for (uint8_t i 0; i 8; i) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; /* 发送时先发低字节再发高字节 */ }这个函数在接收端要再算一遍和帧尾的两个字节比对不一致就整帧丢弃并累加错误计数。错误计数超过阈值时我一般会触发一次模块重连因为连续 CRC 错通常意味着波特率漂了或者线路接触不良而不是偶发干扰。3.3 本地存储分区与掉电保护参数双备份记录走环形区W25Q64 有 8MB按下面这样分区边界留出余量以后加字段不用重排。起始地址大小用途校验方式0x0000004KB参数备份 A魔数 0x5A5A CRC160x0010004KB参数备份 B同上0x002000512KB称重记录环形区每条 32 字节共 16384 条每条末尾 CRC80x0820004KB运行日志与异常计数无参数区做双备份的意义在于写参数时先擦 B 区写入新值并校验成功后再写 A 区。如果写到一半掉电下次上电读到 A 区校验失败就回退到 B 区两个都坏才用默认值。这个逻辑看着简单但真到了现场一次意外断电就能把标定系数写成 0xFF重新标定要封路半小时代价比多存 4KB 高得多。记录区用环形写入写指针存在参数区里每写一条就更新一次指针。环形区满了就覆盖最老的记录同时置一个溢出标志。记录结构里带序号和时间戳补传时后台按序号去重避免网络重发导致重复入库。4. 下位机实现四路采集、动态滤波与超重判定的代码骨架4.1 四路 HX711 的时序驱动与轮询调度HX711 的时序不复杂但有几个参数不能错DOUT 变低表示数据就绪SCK 需要给 25 个脉冲前 24 个移出数据补码形式第 25 个脉冲决定下一次的通道和增益。四路就是四组 DOUT 和 SCK共占用 8 个 IO。#define HX711_CH_NUM 4 #define HX711_TIMEOUT 30 /* ms超过判定该路断线 */ typedef struct { GPIO_TypeDef *port_dout; /* 数据脚输入 */ uint16_t pin_dout; GPIO_TypeDef *port_sck; /* 时钟脚输出 */ uint16_t pin_sck; int32_t offset; /* 空载零点原始值 */ float scale; /* 每公斤对应的原始计数 */ int32_t raw; /* 最近一次原始读数 */ float weight_kg; /* 换算后的重量 */ uint8_t online; /* 1 在线0 断线 */ } Hx711Ch_t; /* 读取一路原始值返回 INT32_MIN 表示超时断线 */ static int32_t hx711_read_raw(Hx711Ch_t *ch) { uint32_t t0 HAL_GetTick(); /* 等待 DOUT 拉低说明转换完成 */ while (HAL_GPIO_ReadPin(ch-port_dout, ch-pin_dout) GPIO_PIN_SET) { if (HAL_GetTick() - t0 HX711_TIMEOUT) { ch-online 0; return INT32_MIN; } } int32_t v 0; for (uint8_t i 0; i 24; i) { HAL_GPIO_WritePin(ch-port_sck, ch-pin_sck, GPIO_PIN_SET); delay_us(1); /* SCK 高电平需保持 0.2~0.5us */ v (v 1) | (HAL_GPIO_ReadPin(ch-port_dout, ch-pin_dout) GPIO_PIN_SET); HAL_GPIO_WritePin(ch-port_sck, ch-pin_sck, GPIO_PIN_RESET); delay_us(1); } /* 第 25 个脉冲选择下一次为 A 通道增益 128 */ HAL_GPIO_WritePin(ch-port_sck, ch-pin_sck, GPIO_PIN_SET); delay_us(1); HAL_GPIO_WritePin(ch-port_sck, ch-pin_sck, GPIO_PIN_RESET); if (v 0x00800000) { /* 24 位负数补码符号扩展 */ v | 0xFF000000; } ch-online 1; return v; }这段代码有两个地方值得多说一句。一是delay_us(1)不能省成几个__NOP()72MHz 下一个 NOP 才十几纳秒凑不够 HX711 要求的 0.2 微秒高电平读出来的数据会整体偏移甚至全 0这个坑我在第一版上踩过示波器一量才发现 SCK 是尖脉冲。二是超时返回必须区分于正常读数我用 INT32_MIN 做哨兵值上层看到它就跳过这一路绝不能让断线的通道参与平均值计算否则零点会被拉偏。轮询调度放在 10ms 的时间片里一次只读一路四路轮完是 40ms。HX711 在 80Hz 模式下单次转换 12.5ms40ms 轮一圈刚好跟得上不会丢数据。如果开 10Hz 模式单次 100ms轮询周期要相应拉长到 400ms 以上这时候HX711_TIMEOUT也得跟着放大否则会误判断线。4.2 动态称重的滤波与峰值提取原始读数不能直接用空载时会有几十个 counts 的抖动重车压上去又叠加了车辆振动。我的处理链是三段先做 5 点中值滤波去掉脉冲干扰再做一阶低通平滑最后在触发窗口内取峰值。#define MED_N 5 #define LPF_ALPHA 0.25f /* 越小越平滑但响应变慢 */ typedef struct { int32_t med_buf[MED_N]; uint8_t med_idx; float lpf; /* 低通输出 */ int32_t peak; /* 本次窗口峰值 */ uint16_t peak_hold; /* 峰值附近保持计数 */ } Filter_t; /* 中值滤波5 点排序取中间值抗脉冲干扰 */ static int32_t median5(int32_t *buf) { int32_t t[MED_N]; for (uint8_t i 0; i MED_N; i) t[i] buf[i]; for (uint8_t i 0; i MED_N - 1; i) { for (uint8_t j i 1; j MED_N; j) { if (t[j] t[i]) { int32_t tmp t[i]; t[i] t[j]; t[j] tmp; } } } return t[MED_N / 2]; } /* 每 50ms 调用一次输入换算后的公斤值 */ static void filter_update(Filter_t *f, float kg, uint8_t vehicle_in) { f-med_buf[f-med_idx] (int32_t)(kg * 100.0f); /* 转成 0.01kg 整数 */ f-med_idx (f-med_idx 1) % MED_N; float med median5(f-med_buf) / 100.0f; f-lpf LPF_ALPHA * (med - f-lpf); if (vehicle_in) { if ((int32_t)(f-lpf * 100) f-peak) { f-peak (int32_t)(f-lpf * 100); f-peak_hold 0; } else if (f-peak_hold 20) { f-peak_hold; /* 峰值后 1 秒内不更新防止抖动误判 */ } } }中值滤波的窗口取 5 是折中取 3 点去不掉连续的干扰取 7 点会把真实的上升沿削平。低通系数 0.25 对应的时间常数大约 3 个采样周期在 50ms 周期下是 150ms跟得上车辆压上来的速度又不至于把振动放大。峰值保持段加了 1 秒的锁定时间原因是车辆在秤上轻微晃动时重量会在峰值附近反复波动不加锁定的话峰值会一直往上爬判出来的重量比实际偏高。峰值取的是“轴重”还是“总重”取决于秤板长度和车辆轴距。秤板短于轴距时一次只压一根轴峰值就是单轴重多轴车要按轴依次累加秤板长于整车时峰值直接就是总重。这两种情况的判定逻辑不一样我在配置里加了weigh_mode答辩时如果老师问“六轴车怎么算总重”这就是回答的落点。4.3 超重判定状态机与报警输出判定不能只看一个瞬时值要用状态机把“车来了、正在称、称完了、判超限、上报”这几步串起来否则一辆车会被重复判定好几次后台收到的记录数对不上实际车流量。typedef enum { ST_IDLE 0, /* 等待车辆触发 */ ST_SAMPLING, /* 车辆在秤采集峰值 */ ST_SETTLE, /* 车辆离开等待重量回落 */ ST_JUDGE, /* 判定超限 */ ST_REPORT /* 生成记录并入队 */ } WeighState_e; typedef struct { uint32_t limit_kg; /* 超重阈值单位 kg */ uint16_t deadband_pct; /* 死区百分比默认 5防止临界抖动 */ uint8_t confirm_cnt; /* 连续超限次数确认默认 3 */ } Threshold_t; static void judge_task(void) { static WeighState_e st ST_IDLE; static uint8_t over_cnt 0; Threshold_t *th g_threshold; switch (st) { case ST_IDLE: if (g_vehicle_in) { g_filter.peak 0; st ST_SAMPLING; } break; case ST_SAMPLING: if (!g_vehicle_in) { st ST_SETTLE; g_settle_ms 0; } break; case ST_SETTLE: g_settle_ms 50; if (g_settle_ms 500) { st ST_JUDGE; } break; case ST_JUDGE: { uint32_t peak_kg g_filter.peak / 100; /* 只有超过阈值加上死区才累加超限计数 */ if (peak_kg th-limit_kg * (100 th-deadband_pct) / 100) { if (over_cnt th-confirm_cnt) { g_alarm_on 1; /* 声光报警 继电器输出 */ g_record.over_flag 1; } } else { over_cnt 0; g_record.over_flag 0; } st ST_REPORT; break; } case ST_REPORT: record_push(g_record); /* 写入环形队列 */ over_cnt 0; st ST_IDLE; break; default: st ST_IDLE; break; } }死区和连续确认这两层是为了对付临界车。一辆限重 30 吨的车实际称出来 30.2 吨如果直接判超限司机和值班员都会来找你。加 5% 死区和连续 3 次确认之后判出来的超限基本都在 31.5 吨以上争议少很多。报警输出用g_alarm_on控制持续 5 秒后自动关闭防止蜂鸣器一直响。4.4 上报队列与断网补传上报队列放在 RAM 里做一个 32 条的环形缓冲每条记录生成后先入队联网任务从队头取。发送成功才出队失败就留在队里等下一轮。队列满的时候最老的一条转存到 Flash 记录区保证不丢。#define QUEUE_SIZE 32 typedef struct { uint32_t seq; /* 记录序号后台按此去重 */ uint32_t ts; /* Unix 时间戳 */ uint8_t ch; /* 通道号 1~4 */ int32_t weight01kg; /* 重量单位 0.01kg */ uint8_t over_flag; /* 1 超限 */ } Record_t; static Record_t q_buf[QUEUE_SIZE]; static uint8_t q_head, q_tail, q_count; static uint8_t record_push(const Record_t *r) { if (q_count QUEUE_SIZE) { record_save_flash(q_buf[q_tail]); /* 队列满落盘保底 */ q_tail (q_tail 1) % QUEUE_SIZE; q_count--; } q_buf[q_head] *r; q_head (q_head 1) % QUEUE_SIZE; q_count; return 1; }补传逻辑放在联网任务里连上后台之后先读 Flash 里未确认的记录按序号从小到大发每发一条等一次 ACKACK 里的序号对得上才把 Flash 里该条标记为已上传。这里有个细节标记已上传不要擦整块用一个 32 位的位图记录哪些条已确认位图满了再统一整理这样能少擦很多次 Flash。5. 常见问题与避坑五个最容易在现场翻车的点5.1 零点漂移上电读数正常跑两小时重量越飘越高现象是空载时显示从 0 慢慢涨到几十公斤重启后又归零。原因通常有两个一是 HX711 的供电和主控共用一路 3.3V数字部分电流波动通过地线串到模拟端二是软件上只在开机时取了零点之后一直用固定值减。解决的办法是硬件上给 HX711 的 AVDD 单独走 LC 滤波地线单点汇到电源入口软件上加自动零点跟踪车辆离开后如果连续 30 秒重量都在满量程 2% 以内就缓慢修正零点每次只修一个很小的步长避免把真实的小重量也修掉。跟踪步长我一般设成每 10 秒修正 0.5kg慢慢纠不激进。5.2 四路串扰压一号道三号道跟着跳数现象很吓人以为是软件读了别的通道。实际排查下来九成是四路传感器的屏蔽线拧在一起走线或者四片 HX711 共用一根 SCK 时钟。SCK 是方波频率虽然不高但走线长、回路大很容易耦合到相邻通道的模拟输入上。解决分三步每路传感器用独立屏蔽双绞线屏蔽层单端接地四片 HX711 的 SCK 各走各的绝不在板上并联成一根总线在 HX711 的输入端并 0.1uF 电容做差模滤波。改完之后再压单路其余三路的跳数应该在满量程 0.1% 以内。5.3 联网模块假在线心跳正常数据死活发不出去现象是串口每 30 秒都能收到模块返回的“已连接”但上报的数据后台一条也收不到。原因是 AT 指令的返回只代表模块和基站或路由建立了链路不代表上层协议通。这种情况我一般是三层排查先看模块返回里有没有分配到 IP再看能不能 ping 通后台地址最后看协议层有没有收到后台的心跳应答。软件上不能只信模块的“连接成功”提示必须自己发一条心跳帧并等待后台回包连续三次没回包就判定链路失效主动重连。这个超时重连逻辑写进代码里比在现场盯着串口助手看要省事得多。5.4 看门狗复位正好把刚采到的数据写没了现象是偶尔丢一条记录日志里能看到复位标志。原因是写 Flash 需要几十毫秒这段时间喂狗没跟上。解决不是简单地把看门狗时间拉长而是把写 Flash 的动作拆开先写 RAM 队列由低优先级的任务在空闲时批量落盘落盘前先喂一次狗参数区写入时用双缓冲先写 B 区校验通过再写 A 区任何一步被复位打断下次上电都有可用的一份。另外中断里不要做 Flash 操作F103 在擦写 Flash 时取指会暂停中断响应会被拖住反过来又影响喂狗这是个连环坑。5.5 标定参数被误改现场手一抖整段路的数据全废现象是换了个操作员之后所有重量都偏大一倍或偏小一半。原因是标定入口藏在按键长按里误触就进了标定模式随便按两下把灵敏度系数改了。解决是三道锁进入标定必须同时按住两个指定按键并保持 5 秒进入后要求输入一个四位数口令口令存在参数区里默认值在首次上电时强制修改标定完成写入前回读一次并算出校验重量和输入的标准重量偏差超过 2% 就拒绝保存。参数区同时保留上一版参数我在参数结构里留了个rollback标志现场出问题时能一键回到上一版。6. 验收与答辩怎么证明这套系统真的能用验收要给出可重复的测试方法而不是“我试了一下没问题”。我一般按下面这张表走一遍每一项都要留下数据。测试项测试方法判定标准静态精度每路单独加标准砝码从 20% 到满量程取 5 个点误差不超过满量程 ±0.5%四路一致性同一载荷依次压四路比较读数四路之间偏差不超过 ±1%超重判定用可调载荷逐步逼近阈值死区内不报警超过死区连续 3 次后报警断网补传拔掉联网模块制造 10 条记录后恢复恢复后 60 秒内全部补传序号无重复掉电保护记录写入过程中断电重复 10 次参数不丢记录最多丢最后 1 条长时间运行连续通电 72 小时零点漂移不超过满量程 0.3%答辩现场最容易出问题的环节是演示称重因为不可能把砝码搬到教室里。我的做法是提前准备一个数据回放模式把现场采到的一路原始数据存成数组通过串口注入系统照常走滤波、判定、上报的完整流程屏幕上能看到重量曲线跳动、超限报警亮起、后台收到记录。这样演示不依赖实物也不怕现场环境干扰。要强调的是回放模式只走业务层不替代真实采集的验证验收表里那几项还是得用砝码实测。还有一个小习惯把所有可以调的量集中到一个结构体里阈值、死区、确认次数、上报周期、标定系数全放进去做成一个参数页答辩时老师问“这个阈值能不能改”当场改给他看比嘴上说“可以改”有说服力得多。从那以后我每次打样之前都会先把标定流程和断网补传这两条路在台面上跑通再焊板子、再装壳。血泪经验是硬件问题能靠换件解决流程没走通到了现场就是干瞪眼。希望这套结构能帮到你少走几段弯路。本文还有配套的精品资源点击获取