简介:这是2018年山西省电子设计大赛电子秤赛题的完整代码包,面向全国大学生电子设计竞赛参赛者、电子类本科生及嵌入式爱好者。包内包含上位机Python程序、下位机Arduino程序、启动脚本和界面截图,共5个文件,压缩包大小9KB。其中2个py文件分别承担数据交互与图形界面功能,ino文件为Arduino端采集与处理逻辑,bat脚本可一键启动上位机,png截图便于快速了解界面效果。资源提供经过测试可直接运行的实战案例,可帮助备赛同学理解双机通信、串口解析、称重传感器标定与界面显示等关键模块的实现方式,节省搭建环境与调试时间。已有89人学习下载,适合计划参加电赛或正在准备相关课题的同学参考学习,也可作为课程设计与毕业设计的起点。
1. 电子秤上位机源码里藏着的上下位机通信骨架
2018年山西省电子设计大赛的电子秤题,技术难点不在称重电路本身,而在上下位机怎么配合。很多队伍HX711采样说得头头是道,一进联调就卡在串口数据上:上位机读到的重量偶尔跳一个大数、界面卡死无响应、Arduino一拔就找不到端口。这份源码的价值恰好是把Arduino下位机(dzc.ino)和Python上位机(dianZiCheng.py与dianZiChen_JieMian.py)放在一个工程里,再配套start.bat一键启动,完整呈现了从传感器采集到界面刷新的数据闭环。
对准备参加全国大学生电子设计竞赛(National Undergraduate Electronics Design Contest)的同学,这套代码可以整体抽出来当脚手架用。对做嵌入式上位机开发的工程师,它展示了串口帧格式设计、Python解析、GUI刷新频率和启动交付的实际取舍。下面按数据流方向,先拆下位机,再拆上位机,最后落到启动脚本、联调排错和移植方法。
2. Arduino下位机称重链路:HX711采样、去皮与串口帧设计
2.1 HX711与压力传感器的接线确认
dzc.ino对应的称重方案,最常用的是HX711模块搭配电阻应变式压力传感器。HX711是24位ADC,内部自带稳压和可编程增益放大器,厨房秤、小量程电子秤基本都是这个方案。接线时要先把模块的VCC接Arduino的5V,GND共地,DOUT和SCK各接一个数字引脚。dzc.ino里通常显式指定这两个引脚,而不是用库默认值,这个习惯在比赛环境下很实用——模块坏了换一块,引脚定义不用跟着改。
四个传感器引脚(红、黑、白、绿)按模块丝印对应E+、E-、A+、A-接入。接反的典型症状是空载读数为负,或者放上重物读数反而减小,这个在标定之前就要先排除。再确认一遍Arduino Uno的引脚分配:SCK接D2、DOUT接D3是常见做法,和HX711库的默认示例一致,照着接不会出错。
dzc.ino里称重主程序的典型结构是这个样子:
#include "HX711.h" #define DOUT_PIN 3 #define SCK_PIN 2 HX711 scale; void setup() { Serial.begin(9600); // 与上位机波特率保持一致 scale.begin(DOUT_PIN, SCK_PIN); scale.set_scale(2280.0f); // 先用1.0跑通,标定后改回真实值 scale.tare(10); // 上电自动去皮,10次采集取零点 } void loop() { float weight = scale.get_units(10); // 10次读数平均,滤掉小幅抖动 if (weight < 0) weight = 0; // 空载负漂直接归零 Serial.print("$WT,"); Serial.print(weight, 2); Serial.println("*"); delay(200); // 5Hz刷新,够称重场景用 }代码逻辑说明:scale.set_scale()设置的是原始ADC读数和实际克数之间的换算系数,每个传感器的机械结构和贴片电阻都存在差异,所以这个值不能跨传感器共用,必须单独标定。scale.tare(10)在上电阶段采集10次毛重取平均当作零点,目的是消除称盘自重和零点漂移,比只读一次更稳。循环里的get_units(10)是采集10次求平均后再除以scale,数值会比单次读法平滑很多。最后用Serial.print拼出的不是裸浮点数,而是带帧头和帧尾的文本帧,给上位机解析留了明确边界。
三个关键参数值得关注。波特率9600是电赛场景里最常见的上下位机默认值,Arduino IDE上传时要确认工具菜单里选的是Uno对应的端口;delay(200)对应5Hz的发送频率,秤的物理采样频率不是无限往上提的——HX711的DOUT引脚数据率由芯片自身决定,典型是10Hz或80Hz两档,循环设得再快也不会超过这个上限;Serial.print(weight, 2)里的2是保留两位小数,这个精度默认给到0.01g,界面显示和它会保持一致的量纲。
2.2 串口数据的帧格式:为什么是$WT,123.45*
很多参赛队图省事直接Serial.println(weight),上位机也读得出来,但一旦下位机在采集过程中夹带了调试信息,或者传感器偶发返回NaN,上位机解析就乱了。dzc.ino的做法是把数据包成固定语义的文本帧:$开头标记一帧开始,WT是消息类型标识,*结尾。上位机按这个模式匹配,就能把有效数据和噪声数据分开。
三种通信格式在工程上的取舍,用表列出来更直观:
| 通信格式 | 示例 | 可读性 | 抗干扰能力 | 适用场景 |
|---|---|---|---|---|
| 裸浮点数 | 123.45 | 好 | 差,帧边界一丢就错乱 | 纯粹调试阶段 |
| 文本帧+标识 | $WT,123.45* | 好 | 中,靠标识符定界 | 电赛、课程设计、快速原型 |
| 二进制帧+校验 | AA 55 01 00 00 F4 01 xx | 差 | 强,带校验和与序号 | 工业仪表、远程长时间运行 |
dzc.ino用的是中间这档文本帧。好处是Python端一个正则就能提取数值,坏处是如果下位机在同一个串口上既发数据又发调试日志,帧会被切碎。所以下位机进入发布状态后应当做到"这个串口只发数据帧,调试输出一律屏蔽",这是约定,也是上下位机联调时最容易忽视的一层纪律。
2.3 标定:没有砝码空谈不得
标定的本质是把scale因子求出来。常见做法是空称状态下先tare归零,放上标准砝码(500g或1000g),读一次原始ADC均值,然后用砝码实际重量除以该原始值得到换算系数。很多开源称重库支持在串口监视器里发t去皮、c标定的交互命令,dzc.ino如果没有自带交互,可以临时写一段一次性标定代码:
void calibration() { scale.set_scale(1.0); scale.tare(); // 空盘归零 float raw = scale.get_units(10); float known_weight = 1000.0; // 放上去的砝码克数 float new_scale = raw / known_weight; Serial.print("New scale: "); Serial.println(new_scale, 4); }这段代码不进正式流程,而是烧录后跑一次、记录数值、再写回正式程序的。逻辑说明:先把scale强制设为1,此时get_units返回的其实是原始ADC读数均值;用原始值除以砝码重量,得到"每克对应多少原始读数",这个商就是set_scale里要填的系数。我举例用的2280.0f只是量级示意,实际值从200到3000都有可能,一定要按自己手里的传感器实测,照抄别人的数字会造成几十克的误差。
标定完成的验证信号有两个:放砝码读数误差在±1%以内,拿起砝码后能回到零位附近、回程误差在±0.3g以内。这两个指标过了,下位机部分就可以交付给上位机联调。
3. Python上位机串口解析与Tkinter实时界面实现
3.1 pyserial读串口:从轮询到事件循环
dianZiCheng.py要解决的第一件事,是用pyserial把重量读进Python。串口读取最忌讳在主线程里写一个while True死循环去ser.read,一旦串口拔掉或阻塞,程序直接卡死,窗口显示"未响应"。实际工程里习惯把串口读取放进独立线程,用事件标志控制退出。
下面是适配$WT,123.45*帧格式的典型读取循环:
import serial import re from threading import Thread, Event FRAME_PATTERN = re.compile(r"\$WT,(\d+\.?\d*)\*") class WeightReader: def __init__(self, port: str = "COM3", baud: int = 9600): self.ser = serial.Serial(port, baud, timeout=0.2) self.pattern = FRAME_PATTERN self.stop_event = Event() self.weight = 0.0 def start(self): Thread(target=self._read_loop, daemon=True).start() def _read_loop(self): while not self.stop_event.is_set(): line = self.ser.readline().decode("utf-8", errors="ignore").strip() m = self.pattern.search(line) if m: self.weight = float(m.group(1))代码逻辑说明:serial.Serial的timeout=0.2让readline在无数据时最多阻塞0.2秒后返回空串,不会死等。errors="ignore"容忍串口偶发的半截字节——Arduino端如果混入非ASCII内容,默认的utf-8解码会抛异常,直接忽略错误字节比中断程序稳妥。正则用search而不是match,是因为读到的行前后可能残留空格或换行,search只要在行内找到完整帧就算匹配成功。读线程只负责往self.weight写最新值,不碰界面控件,这是避免Tkinter在后台线程操作控件导致崩溃的关键约束。
这里补充一个Python层面容易忽略的细节:self.weight = float(...)在CPython里是原子赋值,GIL保证读取线程和GUI线程同时访问这个变量不会出现数据竞争。但如果你在读线程里同时改两个带关联性的变量(比如重量和时间戳),就要考虑用lock或者干脆把数据打包成元组再赋,否则界面可能拿到一对不一致的值。单就电子秤这个场景,一个float变量就够了,不需要额外加锁。
3.2 Tkinter界面:用after回调做5Hz局部刷新
dianZiChen_JieMian.py解决的是第二部分:把重量实时显示到界面上。Tkinter是Python自带GUI库,电赛现场不用装额外依赖,启动快,交付也省事。刷新界面的关键不是time.sleep,而是root.after(ms, func)这种定时回调机制:
import tkinter as tk class ScaleApp: def __init__(self, root, reader): self.reader = reader self.root = root self.weight_var = tk.StringVar(value="0.00 g") tk.Label(root, textvariable=self.weight_var, font=("Consolas", 48)).pack(pady=20) self._update() def _update(self): new_val = f"{self.reader.weight:.2f} g" if self.weight_var.get() != new_val: self.weight_var.set(new_val) self.root.after(200, self._update)逻辑说明:after(200, ...)每200ms调用一次_update,在Tkinter主线程内部完成字符串更新,和Arduino端5Hz的发送频率对齐。这里特意加了"值不变就不更新"的判断,空载静止时界面不会反复重建字符串对象;Tkinter在高频文本更新下会有字体重绘开销,省掉这些无效更新,长时间运行不会出现卡顿。
font=("Consolas", 48)里的等宽字体值得单独说:电子秤数字显示时,等宽字体保证每一位数字宽度一致,重量从9.99变成10.00时不会出现个位与十位之间的跳动位移。很多队伍用系统默认字体,数字变化时屏幕边缘会抖,换成Consolas或Courier New就稳了。如果想在答辩演示时更有说服力,可以再加一个Label显示最近一次数据的接收时间戳time.strftime("%H:%M:%S"),代码量增加很少,但现场效果会好很多。
做上位机开发时经常拿C#上位机和LabVIEW控制界面来对比。C#的WinForms布局灵活、打包方便,但现场改协议要重新编译;LabVIEW上手图形化拖拽快,但数据解析和正则处理并不顺手。Python Tkinter方案的核心优势是把串口解析和界面放在同一门语言里,比赛现场改个正则、调个刷新频率、加个按钮,不需要在两个IDE之间来回切换。代价是目标机器上要有Python运行时,这也是源码里配套了start.bat的原因,下一章重点展开。
4. start.bat启动脚本与上下位机联调高频坑位排查
4.1 start.bat做了什么
start.bat是这套源码里容易被忽略、但实际价值很高的一个文件。比赛现场评委的电脑不一定装了Python,即使装了,环境变量也不一定配对。bat脚本把"打开终端、进入项目目录、运行Python程序"整个过程自动化,降级成双击一步:
@echo off cd /d %~dp0 where python >nul 2>&1 if errorlevel 1 ( echo [ERROR] python not found in PATH pause exit /b 1 ) start "" pythonw dianZiChen_JieMian.py逐行说明:cd /d %~dp0把工作目录切到bat文件所在目录,避免快捷方式从别的位置启动时找不到dianZiChen_JieMian.py;where python检查PATH里能否找到python解释器,找不到就明确提示而不是直接闪退;最后用start "" pythonw启动界面程序,pythonw不弹黑色控制台窗口,评委看到的就是一个干净的称重界面。如果目标机器上装了多个Python版本,可以把where python改成py -3 --version,用Windows的Python启动器指定3.x版本,兼容性更好。
注意pythonw只适合GUI程序。调试阶段想打印串口日志,还是要用python dianZiCheng.py把输出打到控制台。比赛现场还有一个常见操作:如果界面程序运行时Python报错闪退,临时改成python dianZiChen_JieMian.py能看到完整traceback,定位完再改回pythonw。
4.2 联调时高频出现的五个坑
电子秤上下位机联调,花的时间往往比写代码还多。把常见现象、原因和排查命令放到一张表里,现场对照着查最快:
| 现象 | 最常见原因 | 排查方式 |
|---|---|---|
| Arduino上传超时 | 选错板卡型号,或端口被占用 | 工具菜单选"Arduino/Genuino Uno",拔掉其他USB串口设备 |
| 上位机提示Serial port not found | 串口号变化,COM号对不上 | python -m serial.tools.list_ports列出全部COM口 |
| 上位机有数字但一直不动 | 波特率不一致 | 上下位机都确认是9600,改完Arduino重新上传 |
| 重量读数缓慢漂移 | 传感器旁强干扰或供电不稳 | Arduino改用外部稳定5V电源,不要只靠USB供电 |
| 数据断断续续 | 串口缓冲溢出或USB转串口线质量差 | 下位机发送间隔从200ms提到300ms,观察是否缓解 |
这里展开说前两条。python -m serial.tools.list_ports是pyserial自带的命令行工具,不用写任何代码就能列出当前机器的全部串口,输出类似COM3 - USB-SERIAL CH340 (COM3)。如果上位机支持从命令行参数传串口号(python dianZiCheng.py COM5),现场换口就不用改源码。Arduino上传超时在Windows上还有一个隐藏原因——上传前没关闭上位机对串口的占用,Python进程占着端口时,Arduino IDE的烧录器拿不到设备,先关掉上位机程序再上传即可。
关于串口号频繁变化,还有一个工程化处理办法:在Python端按USB设备ID自动匹配串口,而不是写死COM号。
import serial.tools.list_ports as ports def find_weight_scale_port(vid_pid: str = "1A86:7523") -> str: for p in ports.comports(): if p.vid and p.pid: if f"{p.vid:04X}:{p.pid:04X}" == vid_pid: return p.device return ""说明:comports()返回串口对象列表,每个对象带vid/pid属性,对应USB设备的厂商ID和产品ID。CH340芯片的vid:pid通常是1A86:7523,FTDI是0403:6001。按设备ID而不是COM口号找串口,插拔顺序变了也能自动对上,这个函数在答辩现场演示时很实用——上位机启动后自动选对秤的端口,不用每次手动改。
4.3 串口数据粘包与半包
用了$和*做帧边界,但第一次联调时还是可能遇到$WT,12.7*$WT,12.9*挤在一行里的情况。原因是Python端readline是按换行符切行的,而Arduino的Serial.println在帧末尾追加了\r\n,两者配合时正常。一旦某次串口缓冲里残留了上一轮的半截数据,readline就可能返回一行包含两帧的内容,这就是粘包。
在Python端解决粘包有两条路径:一是读取前用ser.reset_input_buffer()清掉残留字节,简单直接;二是不依赖readline,改用ser.read(ser.in_waiting)读出当前缓冲区所有字节,再按$和*手动切帧,这种方法更接近工业上字节流切割的思路。比赛场景用第一种就够了。
半包则是readline在timeout时间内没等到完整一行,拿到的是断成两截的行片段。正则search在这里的优势体现得很明显——搜不到完整的$WT,数字*就丢弃这一行,绝不用半截数据去渲染界面。这也解释了为什么上位机重量显示很少出现"突然从12跳到1200"这种症状:解析层已经把不合法帧拦掉了,显示层拿到的永远是完整包。
5. 把电子秤通信架构移植到其他传感器采集项目
5.1 抽离出来的三条契约
把这套源码拆散之后会发现,真正能复用的是三条约定。第一条:下位机只管往串口写"标识符,数值"的文本帧,上位机只在帧级别解析,不关心帧是怎么产生的,HX711换成其他传感器,上位机代码结构不用动。第二条:解析层和显示层用变量作为唯一接口,读线程往变量写,GUI线程从变量读,换掉Tkinter换PyQt,串口部分原样保留。第三条:入口永远保留一键启动脚本,哪怕目标机器已经装好完整IDE,双击bat也比敲命令行靠谱。保持这三条,把电子秤的HX711换成DHT11,就是一套环境监测站的基本骨架。
5.2 快速改造示例:DHT11温湿度下位机
把下位机换成DHT11,帧格式保持$TH,25.31,48.2*,前面是温度、后面是湿度。Arduino端改动集中在loop里的采集和拼帧,上位机端只需要替换正则为:
FRAME_PATTERN = re.compile(r"\$TH,(\d+\.?\d*),(\d+\.?\d*)\*") # m.group(1) 是温度,m.group(2) 是湿度逻辑说明:正则比重量版本多了一个分组,pyserial读取循环和Tkinter的after(200)刷新机制完全不用动。唯一要注意的是DHT11首次上电1秒内读取容易返回无效值,下位机侧需要连续读几次直到返回合法数据再拼帧,否则上位机会频繁收到空帧。对从没接触过串口协议的同学来说,拿温湿度练手比直接上重量更容易建立"帧格式设计"的感觉。
5.3 提升波特率时要连坐改动的参数
如果传感器数据量变大,需要同时上报重量、温度、角度三个值,常见做法是把波特率从9600提到115200。此时不是改两个Serial.begin就完事,连坐参数至少有三个:Python端serial.Serial的baud要同步修改;timeout要从0.2降下来——115200波特率下1字节大约87微秒,200ms足够吃下几十行完整帧,反而会造成读取延迟感变大;HX711或DHT11的物理采样周期要重新确认,串口发得再快,传感器本身的数据率上限就摆在那里,超发空转只会增加无效帧。
最后一个很实际的检查技巧:提升波特率之后,用python -m serial.tools.list_ports确认端口没变,再写一个最小脚本只读串口、把原始内容打印到控制台,观察一帧里有没有乱码字节。如果出现乱码,优先怀疑的是USB转串口芯片的驱动版本,而不是业务代码——CH340在Windows 10以上系统有时会因为驱动过旧在高波特率下丢字节,换到115200之前先更新驱动版本,这是最容易让新手白耗半天的坑。
本文还有配套的精品资源,点击获取