从0到1:EC942边缘计算机用Python实现Modbus TCP采集+MQTT上云全记录(附踩坑实录)

前言

最近在搞一个工业物联网的小项目:用映翰通 EC942 边缘计算机采集现场设备的数据,再通过 MQTT 把数据传到云端。EC942 自带了 DeviceSupervisor Agent(DSA),配置化点几下鼠标就能采集上云,但我这次想要更灵活的协议处理和数据加工能力,所以选择了自己写采集程序 这条路------用 Python 实现一个 Modbus TCP 主站,采集完再用 paho-mqtt 发布到云端 Broker。

手头没有真实 PLC 怎么办?用 Modbus Slave 模拟器在 PC 上模拟一个 Modbus TCP 从站,通过网线直连 EC942,先把整套流程跑通,之后再接真实设备。

今天上午从环境搭建到最终跑通,中间踩了好几个坑,每一个都挺有代表性,整理出来希望能帮后面遇到同样问题的人少走弯路。先上一张整体架构图,看清楚今天到底在打通哪几段链路:

实线是"采集上云"的主链路,虚线是后面加的"云端反向控制"链路。下面按今天实际操作的顺序,一步步还原整个过程。

环境准备

EC942 跑的是 Debian 10 系统,默认 ETH2 网口地址是 192.168.4.100/24。我把 PC 网卡配成同网段的静态 IP 192.168.4.11,网线直连 EC942 的 ETH2 口------现代网卡都支持 Auto-MDIX,直通线交叉线都能用,不需要交换机。

PC 上打开 Modbus Slave 模拟器,切到 **Modbus TCP Server(从站/服务端)**模式,监听默认的 502 端口,然后在保持寄存器 40001~40010 里填了一组测试值:1,2,3,4,5,6,7,77,8,9。中间故意插了个 77 当"特征值",后面验证解析对不对的时候特别好用------一眼就能看出来有没有对上。

这里有个坑提前说一下:Modbus 协议里的寄存器地址是从0开始的,但模拟器界面显示的是 4xxxx 这种"传统显示地址" ,两者相差1。也就是说界面上的 40001 对应协议报文里的 addr=040008(也就是我们的特征值77)对应 addr=7。这个"差1"的换算贯穿了后面写代码的整个过程,一定要记住。

踩坑①:ping通了,端口却连不上

万事俱备,先验证一下网络通不通。PC 上 ping 192.168.4.100 没问题,那反过来在 EC942 上验证到 PC 的连通性总该没问题吧?结果一顿骚操作:

复制代码
nc -zv 192.168.4.11 502

bash: nc: command not found

换 telnet:

复制代码
bash: telnet: command not found

那装个 mbpoll 总行了吧:

复制代码
sudo apt install mbpoll

E: Unable to locate package mbpoll

三连败。原因其实很简单:EC942 跑的是精简版 Debian 镜像,压根没预装这些网络调试工具;而 apt install 报"找不到包",是因为 Debian 10 (buster) 已经 EOL(生命周期结束) ,默认的软件源早就下线了,装什么都得先把 /etc/apt/sources.list 改成指向 archive.debian.org 才行。

这里还有个概念要理清楚:ping 通只能证明网络三层(ICMP)是通的,完全不能说明 502 端口有没有程序在监听。ping 和端口连通性根本是两回事,别被"ping都通了怎么还连不上"这种直觉误导。

工具不好使不代表没法测。EC942 上现成有 Python3,标准库自带的 socket 模块就能干这个活,不用装任何东西:

复制代码
python3 -c "
import socket
try:
    socket.create_connection(('192.168.4.11', 502), timeout=3)
    print('端口可达')
except OSError as e:
    print('连接失败:', e)
"

这一招后面帮了大忙,记下来备用。

动手写 Modbus TCP 采集代码

工具的坑先放一放,开始写正经代码。Modbus TCP 相比 RTU(串口版本)少了 CRC16 校验(TCP 自身保证可靠传输),但多了一个 MBAP 头

复制代码
事务标识符(2字节) + 协议标识符(2字节,恒为0) + 长度(2字节) + 单元标识符(1字节) + PDU(功能码+数据)

核心是一个 TcpMaster 类,内部维护长连接、断线自动重连:

复制代码
class TcpMaster:
    def __init__(self, host, port=502, timeout=2.0):
        self.host = host
        self.port = port
        self.timeout = timeout
        self._sock = None
        self._txn_id = 0
        self._lock = threading.Lock()

    def _transact(self, unit_id: int, pdu: bytes) -> bytes:
        with self._lock:
            try:
                self._ensure_connected()
                self._txn_id = (self._txn_id + 1) & 0xFFFF
                mbap = struct.pack(">HHHB", self._txn_id, 0,
                                   len(pdu) + 1, unit_id)
                self._sock.sendall(mbap + pdu)

                header = self._recv_exact(7)
                txn_id, _proto_id, length, _unit_id = struct.unpack(
                    ">HHHB", header)
                if txn_id != self._txn_id:
                    raise ModbusError(f"事务号不匹配")
                body = self._recv_exact(length - 1)
            except (OSError, socket.timeout) as exc:
                self.close()  # 出错即断开,下次自动重连
                raise ModbusError(f"TCP 通信异常: {exc}") from exc

        if body[0] & 0x80:
            raise ModbusError(f"从站异常码 {body[1] if len(body) > 1 else '未知'}")
        return body

    def read_registers(self, unit_id, fc, addr, qty):
        """fc=3 保持寄存器, fc=4 输入寄存器"""
        pdu = struct.pack(">BHH", fc, addr, qty)
        body = self._transact(unit_id, pdu)
        count = body[1]
        return list(struct.unpack(f">{count // 2}H", body[2:2 + count]))

点表配置化,不把地址硬编码在代码里,对应上面提到的"40001→addr=0"换算规则:

复制代码
"points": [
  {"name": "reg_40001", "unit_id": 1, "fc": 3, "addr": 0, "qty": 1, "dtype": "uint16"},
  {"name": "reg_40008", "unit_id": 1, "fc": 3, "addr": 7, "qty": 1, "dtype": "uint16"}
]

主循环里读点表、拼 JSON、发 MQTT,逻辑很直白:

复制代码
def poll_once(master, points):
    values, errors = {}, {}
    for pt in points:
        try:
            regs = master.read_registers(pt["unit_id"], pt["fc"], pt["addr"], pt["qty"])
            values[pt["name"]] = round(decode(regs, pt["dtype"], pt.get("scale", 1.0)), 4)
        except ModbusError as exc:
            errors[pt["name"]] = str(exc)
    return values, errors

代码写完了,装好依赖,准备跑起来。

踩坑②:paho-mqtt和Python 3.7八字不合

复制代码
pip install paho-mqtt
python3 main.py config.json

一运行就崩:

复制代码
File ".../paho/mqtt/client.py", line 49, in <module>
    from typing import Literal
ImportError: cannot import name 'Literal' from 'typing'

During handling of the above exception, another exception occurred:
...
ModuleNotFoundError: No module named 'typing_extensions'

排查下来发现:EC942 的 Debian 10 自带 Python 3.7 ,而 pip install paho-mqtt 默认装的是最新的 2.1.0 。paho-mqtt 从 2.x 开始用到了 typing.Literal,这是 Python 3.8 才有的特性,Python 3.7 下会尝试从 typing_extensions 这个补充包里兜底导入,但环境里压根没装这个包,两条路都走不通,直接报错崩溃。

而且就算装上 typing_extensions 解决了这个报错,2.x 版本还改了 mqtt.Client() 的构造函数签名,要求显式传入 callback_api_version 参数,不传就直接报错------相当于换个坑接着踩。所以最省事的办法是直接把版本锁定到兼容 Python 3.7 的 1.6.1,API 跟老代码完全匹配,不用改一行业务代码:

复制代码
pip uninstall paho-mqtt -y
pip install "paho-mqtt==1.6.1"

这个坑的教训是:装依赖库不能无脑装最新版,尤其是在这种系统 EOL、Python 版本偏老的嵌入式设备上,遇到诡异的 ImportError 先看看是不是库和运行环境的版本对不上。

踩坑③:一个数字引发的连接失败

依赖装好了,重新运行,这次导入没问题了,但是:

复制代码
WARNING 采集点 reg_40001 失败: TCP 通信异常: [Errno 111] Connection refused
WARNING 采集点 reg_40002 失败: TCP 通信异常: [Errno 111] Connection refused
...(十个点位全部失败,每5秒循环一次)

Connection refused(Errno 111)这个报错的含义很明确:TCP 握手包已经送到了目标主机,但对方在那个端口上没有程序监听,直接给拒绝了。这跟网络通不通是两码事------网络层没问题,问题出在"包送去的地方不对,或者对方没在听"。

排查思路很简单,先怀疑配置,再怀疑对端:

  1. 检查 config.jsontcp.host 填的什么 ------ 一看,填的是 192.168.4.1,而 PC 实际的 IP 是 192.168.4.11少打了最后一个 1
  2. 改成正确的 192.168.4.11 之后重新跑。

一个数字的差别,让程序执着地往一个根本不存在(或者存在但没监听 502 端口)的地址上死磕,报错信息其实已经把线索摆得明明白白,只是当时没往这个方向想。这提醒我一个通用排查顺序:遇到 Connection refused,先把配置文件里的 IP/端口原样打印出来跟实际环境核对一遍,比怀疑代码逻辑更快定位问题

曙光初现:采集成功

改完 IP,重新运行:

复制代码
INFO 上报 {'reg_40001': 1.0, 'reg_40002': 2.0, 'reg_40003': 3.0, 'reg_40004': 4.0, 'reg_40005': 5.0, 'reg_40006': 6.0, 'reg_40007': 7.0, 'reg_40008': 77.0, 'reg_40009': 8.0, 'reg_40010': 9.0} rc=0

和模拟器里填的 1,2,3,4,5,6,7,77,8,9 完全对上,尤其是 reg_40008: 77.0 这个特征值精确匹配------这就是前面特意埋一个"跳出规律的值"的用处,一眼就能确认地址映射和字节解析全部正确,不用逐个数值去比对。

数值显示成 77.0 而不是 77 是正常的,不是bug:解码函数里 scale 默认值是浮点数 1.0,整数乘以浮点数结果自然变成浮点型,不影响数据本身的正确性。

踩坑④:MQTT订阅看不到数据

Modbus 这条链路验证完了,接下来验证数据是不是真的传到了云端 MQTT Broker。用 MQTT.fx 连上 broker,订阅框里填了设备的 topic 前缀 factory/line1/ec942-0001,点了 Subscribe:

复制代码
0 msg/s
No content in table

设备端日志明明显示 rc=0(发布调用成功),订阅端却啥也收不到,第一反应是不是broker连错了、账号密码不对。仔细一看订阅框里填的主题是:

复制代码
factory/line1/ec942-0001

而程序实际发布的两条消息在它的子主题下:

复制代码
factory/line1/ec942-0001/status
factory/line1/ec942-0001/telemetry

MQTT 的主题匹配是精确匹配,订阅父级主题不会自动收到子主题的消息,除非用通配符。把订阅改成:

复制代码
factory/line1/ec942-0001/#

# 是多级通配符,能匹配这个前缀下所有层级的子主题。改完立刻就收到了 telemetrystatus(带 online: true 的保留消息)两条数据,跟设备端日志的内容完全一致。

这个坑顺带纠正了我对 rc=0 的一个误解:client.publish() 返回的 rc=0 只表示消息成功放进了本地发送队列,不代表消息已经真正送达远端 Broker。要确认真正送达,必须站在接收方的角度去订阅验证,不能只看发送方日志"没报错"就认为万事大吉。

进阶:让云端也能反向控制设备

采集上云跑通之后,又加了一个功能:云端通过 MQTT 下发指令,反向控制 Modbus 从站的寄存器/线圈。这意味着程序要从单纯的"采集→上报"升级成双向通信:订阅一个命令主题,收到指令后用 Modbus 的写功能码下发,再回一条执行结果的 ack 消息。

先给 TcpMaster 加上写方法(FC05写线圈、FC06写单寄存器、FC16写多寄存器),复用已有的加锁 _transact,读写操作天然线程安全:

复制代码
def write_register(self, unit_id, addr, value):
    """fc=6 写单个保持寄存器"""
    pdu = struct.pack(">BHH", 6, addr, value & 0xFFFF)
    self._transact(unit_id, pdu)

然后是命令处理回调,收到 {"point": "reg_40001", "value": 100} 这样的 JSON,查点表、按功能码分发、执行完回 ack:

复制代码
def make_command_handler(master, control_index, ack_topic):
    def _on_message(client, _userdata, msg):
        point_name = None
        try:
            cmd = json.loads(msg.payload.decode("utf-8"))
            point_name = cmd["point"]
            value = cmd["value"]
            pt = control_index[point_name]   # 白名单:不在点表里的点位名直接 KeyError
            fc = pt["fc"]

            if fc == 6:
                master.write_register(pt["unit_id"], pt["addr"], int(value))
            # ... fc==5写线圈、fc==16写多寄存器同理

            ack = {"point": point_name, "value": value, "success": True}
        except (KeyError, ValueError, TypeError, ModbusError) as exc:
            ack = {"point": point_name, "success": False, "error": str(exc)}
        client.publish(ack_topic, json.dumps(ack), qos=1)
    return _on_message

测试的时候直接复用了现有环境:用 MQTT.fx 往 factory/line1/ec942-0001/cmd 发布 {"point": "reg_40001", "value": 100},PC 上 Modbus Slave 窗口里 40001 立刻变成了 100,下一轮采集的 telemetry 里也同步更新------写入和读回形成了完整闭环。

这里必须多说一句安全上的考虑:写操作比读操作危险得多 ,读错了顶多是数据不对,写错了是真的会影响现场设备。所以代码里做了两层防护:一是点位白名单,control_index 只认 control_points 里显式列出的点位,其他点位名直接被拒绝;二是所有写操作不管成功失败都会记日志,方便事后审计。生产环境上线前,MQTT Broker 那边的账号权限也得单独收紧,别让所有客户端账号都能往控制指令主题发消息。

总结:今天的踩坑清单

现象 根因 解决方案
ping通了,nc/telnet/mbpoll全部找不到,apt install也装不上 精简镜像没装网络调试工具;Debian 10已EOL默认源失效 不装工具,直接用Python内置socket.create_connection()测端口
ImportError: cannot import name 'Literal' / ModuleNotFoundError: No module named 'typing_extensions' paho-mqtt 2.x依赖typing.Literal(需Python 3.8+),而设备是Python 3.7 降级安装 paho-mqtt==1.6.1
[Errno 111] Connection refused 循环报错 config.json里IP填错了一位(.1写成想要的.11 核对配置文件里的IP和实际环境是否一致
MQTT.fx订阅无内容,但设备端日志显示rc=0 订阅主题是精确匹配,没加通配符#,收不到子主题消息 订阅改成 factory/line1/ec942-0001/#

从踩坑的分布能看出来,今天遇到的坑大多不是"协议本身难",而是环境细节:一个精简系统缺个工具、一个库版本不兼容、一个IP多打少打一位、一个MQTT主题没加通配符。这类问题排查起来比写代码逻辑本身更磨人,也更容易被忽略,希望这篇记录能帮到同样在踩坑路上的朋友。

完整代码结构

复制代码
├── config.json      # 点表配置(读点位 + 写点位)+ MQTT连接参数
├── modbus_tcp.py    # Modbus TCP 主站实现(读写寄存器/线圈)
└── main.py          # 主循环:采集轮询 + MQTT发布 + 控制指令订阅

如果这篇文章对你有帮助,欢迎点赞收藏,后续如果接入真实PLC或者对接私有协议,我会继续更新踩坑记录

相关推荐
donoot1 小时前
双层 PDF 体积暴涨(18MB 原图 → 200MB PDF)完整原因剖析 + 针对性优化方案
python·pymupdf·paddleocr·双层pdf
发量惊人的中年网工3 小时前
自建清洗中心VS托管安全服务:2026年企业DDoS防护ROI深度测算
网络·ddos
RSABLOCKCHAIN9 小时前
AI Agents in LangGraph-2
人工智能·python
WA内核拾荒者10 小时前
WhatsApp 账号异常检测的自动化告警系统设计
数据库·python·自动化
码流怪侠11 小时前
【GitHub】Bend:让 GPU 并行编程像写 Python 一样简单
python·github
Yang961111 小时前
光缆故障快速排查实操技巧,鼎讯信通 Smart-S3 OTDR 一线使用心得
网络
2401_8949155312 小时前
GEO 搜索优化完整源码从零部署:环境配置、集群搭建全流程
开发语言·python·tcp/ip·算法·unity
LDZKKJ12 小时前
OpenAI模型“越狱“入侵Hugging Face——AI安全史上的至暗时刻
网络·人工智能·安全