在实际工业自动化项目里,HMI(Human-Machine Interface,人机界面)很容易被理解成一块触摸屏。真正接手过设备上位机开发或产线数字化改造的人会清楚,HMI 承担的远不止“显示数据”:它要在毫秒到秒级的周期内采集 PLC、仪表或传感器的数据,按工艺要求组织画面,在报警发生时把最需要处理的信息推给操作员,还要记录操作日志和趋势曲线。很多人第一次做 HMI 时都是从简单 Demo 开始的,比如把一组温度、压力数据用网页展示出来,再想办法让操作员能下发设定值。这类项目在网络社区里常以 “Show HN” 的方式分享,用来验证交互是否直观、刷新是否及时。本文会从概念到代码走完一遍:先理清 HMI 在自动化系统里的位置,再对比 Modbus、OPC UA、MQTT 等通信协议,随后用 Python + FastAPI + 浏览器搭建一个最小可运行 HMI 原型,最后给出画面设计、生产部署和常见故障的排查清单。
1. 先理解 HMI 在自动化系统里到底做什么
1.1 HMI 解决的是“人与过程”之间的信息缺口
机械设备本身不会告诉你它现在的状态。要判断一台泵是否正常运行,只看电流、压力、温度三个数已经不够,还要看它们的趋势、前后工序的联动关系以及报警记录。HMI 要解决的就是这个信息缺口:把 PLC 内存里的寄存器数值翻译成人能理解的工程单位,把离散的报警变成有优先级的事件,把操作员的手动操作转换成对控制器的写入指令。
通俗地说,PLC 负责“决策和执行”,HMI 负责“解释和交互”。如果一个现场没有 HMI,操作员要么盯着一排指示灯,要么拿编程软件和万用表现场查变量,效率和安全都无法保障。HMI 做得好的项目,操作员可以快速判断设备状态;做得不好,操作员会在满屏闪烁的报警里逐渐麻木,最后连真实故障也被忽略。
1.2 HMI、SCADA、上位机有什么不一样
在技术讨论里,HMI、SCADA、上位机经常被混用。它们确实高度重叠,但侧重点不同,下面这张表可以帮助快速区分:
| 名称 | 核心关注点 | 典型形态 | 常见范围 |
|---|---|---|---|
| HMI | 单机或单工序的人机交互 | 触摸屏、工控机界面 | 一台设备、一条产线局部 |
| SCADA | 区域级的采集、监控与调度 | 服务器、监控大屏、历史库 | 一个车间、一个厂区 |
| 上位机 | 控制系统里的上层软件,相对 PLC 而言 | PC 应用、Web 应用 | 与下位机通信的所有界面 |
如果你的项目只是一个水泵控制系统,一块触摸屏把启停、压力和故障显示清楚就够了,这属于 HMI。如果要把 50 台设备的运行数据汇总到中控室,还要做历史趋势和报表,这就进入了 SCADA 的范畴。刚开始做原型时,不用纠结边界,先按“采集、显示、操作、记录”四条主线和程序框架。
1.3 HMI 内部的数据链路
所有 HMI 不管界面多复杂,数据链路都可以画成同一条主线:
- 采集层:通过 Modbus、OPC UA、MQTT 等协议从控制器或传感器读取原始值。
- 处理层:做单位换算、质量戳判断、死区过滤、报警判定。
- 存储层:把过程数据写入内存缓存或历史数据库。
- 界面层:把处理后的数据渲染成画面,接收操作员的指令。
- 回写层:把设定值、启停命令写回控制器。
很多新手把 HMI 开发误解为纯前端工作,把大量时间花在画图、做动画上,结果数据源不稳定、地址映射错误、单位换算不全,界面再好看也白搭。后续所有章节都会围绕这条链路展开。
2. HMI 的技术底座:硬件、软件与通信协议
2.1 硬件形态怎么选
HMI 不一定是触摸屏。根据使用环境,常见形态有:
- 一体化触摸屏:适合单设备就地操作,防护等级高,但算力有限,第三方库支持少。
- 工业平板电脑:适合安装在控制柜或操作台上,运行 Windows/Linux,可以跑 Web HMI 或组态软件。
- 普通 PC 加服务器:适合集中监控,画面多,扩展性强。
- 边缘网关加 Web 浏览器:数据在边缘网关完成采集和协议转换,浏览器只负责渲染,适合改造老设备。
选型时先看环境:温度、振动、粉尘、防爆等级决定硬件的防护等级;再看采集点数、刷新频率和画面数量,决定算力;最后看是现场就地操作还是远程监控,决定是否需要 Web 化。原型阶段可以先不关心硬件,把软件链路跑通,再迁移到目标设备。
2.2 软件层的职责划分
一个可维护的 HMI 软件至少分成四层:
| 层次 | 职责 | 常见技术 |
|---|---|---|
| 设备通信层 | 与 PLC、仪表通信,统一读接口 | pymodbus、libplctag、OPC UA SDK |
| 数据处理层 | 类型转换、单位换算、质量码、报警 | 自研服务、规则引擎 |
| 存储层 | 实时库、历史库、事件库 | Redis、InfluxDB、SQLite |
| 展示与交互层 | 画面、报警、趋势、操作 | 组态软件、Web 前端 |
实际项目里见过不少反例:把通信代码直接写在界面控件的点击事件里,一个画面三五个按钮就重复连接十几次,程序一多就乱。正确做法是先保证数据服务独立于界面,界面只依赖数据服务提供的统一接口,这样后期换 PLC、换协议、换画面框架时都不会伤筋动骨。
2.3 Modbus、OPC UA、MQTT 怎么选
这三者是目前 HMI 集成里最常见的协议。先看一张对比表:
| 协议 | 典型场景 | 优点 | 局限 | 数据模型 |
|---|---|---|---|---|
| Modbus RTU | 串口总线,PLC 与仪表 | 简单、硬件成本低 | 速率低、无加密 | 线圈、寄存器 |
| Modbus TCP | 以太网 PLC | 实现简单、可跨平台 | 无加密、无订阅 | 线圈、寄存器 |
| OPC UA | 跨厂商互操作、复杂设备 | 信息模型强、安全、可订阅 | 实施复杂度高 | 节点、对象、方法 |
| MQTT | IIoT、云端、边缘 | 轻量、发布订阅、穿透性好 | 无统一信息模型 | Topic、Payload |
Modbus 是最通用的“最低公约数”,PLC、仪表、电表基本都支持,数据模型是线圈和寄存器,没有复杂对象,简单直接。OPC UA 适合跨厂商、需要复杂信息模型的场景,自带加密、证书和订阅机制,能把设备对象、方法、历史数据建模成一个信息空间。MQTT 适合边缘采集和云端传输,发布订阅模式让多端共享数据很方便,但协议本身不定义数据含义,需要自己设计主题和 Payload,否则维护成本很高。
实际项目中这三者经常混用。例如设备层用 Modbus 采集老仪表,边缘网关汇集数据后再通过 MQTT 转发到云端平台。选型时先回答一个问题:数据从哪里来,到哪里去,中间要过几道网关。链路越长,协议转换的测试工作量越大。
2.4 Tag 变量是 HMI 的灵魂
HMI 里面的每一个显示点,本质上是一个 Tag,也就是标签或变量。一个 Tag 至少包含这些属性:
| 属性 | 示例 | 说明 |
|---|---|---|
| 名称 | TANK1.LEVEL | 工程内唯一 |
| 来源 | 保持寄存器 0 | Modbus 地址、OPC 节点或 MQTT 主题 |
| 数据类型 | uint16 / int32 / float32 | 决定解析方式 |
| 缩放 | 0.1 | 原始值到工程值的换算 |
| 单位 | % | 界面显示单位 |
| 读写权限 | 只读 / 可写 | 决定能否下发 |
| 报警配置 | 上限 80% | 触发报警的阈值 |
设计 Tag 表是 HMI 开发里最枯燥也最重要的工作。后面排错时,大多数“数值不对”的问题都出在这张表没有对齐。原型阶段建议用 CSV 或数据库管理 Tag 表,不要把它写在代码注释里,否则点位一多必然失控。
3. 搭建一个最小可运行 HMI 原型
3.1 环境准备与依赖
下面的例子用 Python 完成,不依赖任何商业组态软件,适合在开发机或树莓派上先跑通链路。验证环境如下:
| 项目 | 要求 | 说明 |
|---|---|---|
| 操作系统 | Windows 10/11、Ubuntu 20.04+ | 示例代码不依赖平台特性 |
| Python | 3.9 以上 | 推荐 3.10 或 3.11 |
| 网络 | 本机回环即可 | 演示阶段不需要外部网络 |
| 浏览器 | Chrome、Edge | 前端使用原生 JavaScript |
先创建项目目录并安装依赖:
mkdir demo-hmi cd demo-hmi python -m venv venv # Windows venv\Scripts\activate # Linux / macOS source venv/bin/activate安装三个关键包:
pip install fastapi uvicorn pymodbus为了方便复现,把依赖写进 requirements.txt:
fastapi>=0.110 uvicorn>=0.29 pymodbus>=3.6注意 pymodbus 3.x 的 API 和 2.x 差别很大,下面代码按 3.x 写。如果环境中安装的是 2.x,先升级到 3.x 再继续。
3.2 用 Modbus TCP 模拟 PLC 数据源
没有真实 PLC 时,先用 pymodbus 自带的 Modbus TCP server 模拟一个数据源。创建 simulator.py,内容如下:
import random import threading import time from pymodbus.datastore import ModbusServerContext, ModbusSlaveContext from pymodbus.datastore.store import ModbusSequentialDataBlock from pymodbus.server import StartTcpServer # 初始化 4 类数据块,每个 100 个寄存器 store = ModbusSlaveContext( di=ModbusSequentialDataBlock(0, [0] * 100), co=ModbusSequentialDataBlock(0, [0] * 100), hr=ModbusSequentialDataBlock(0, [0] * 100), ir=ModbusSequentialDataBlock(0, [0] * 100), ) context = ModbusServerContext(slaves=store, single=True) def data_writer() -> None: """每隔 1 秒更新一次保持寄存器的值,模拟现场变化。""" while True: slave = context[0] values = [ random.randint(200, 350), # 0 温度原始值,除以 10 得到摄氏度 random.randint(200, 450), # 1 压力原始值 random.randint(30, 80), # 2 流量 random.randint(10, 90), # 3 液位 random.randint(0, 1500), # 4 转速 random.randint(0, 7), # 5 设备状态码 ] slave.setValues(3, 0, values) time.sleep(1) if __name__ == "__main__": t = threading.Thread(target=data_writer, daemon=True) t.start() StartTcpServer(context=context, address=("127.0.0.1", 5020))这里使用的是 5020 端口,而不是标准的 502。原因是在 Linux 下 1024 以下端口需要 root 权限,