Unity3d实时读取Modbus RTU数据

1.首先要了解Modbus RTU是什么?

Modbus 是一种通信协议,用于在电子设备之间传输数据,最早由 Modicon(现为施耐德电气的一部分) 在1979年提出。它非常简单、开放,并且广泛用于工业自动化系统。

RTU 是 Modbus 的一种传输模式,全称是 Remote Terminal Unit(远程终端单元)模式

  • 它使用 二进制形式(而非 ASCII 字符)传输数据。

  • 数据包紧凑,传输效率高。

  • 常用于串口通信(RS-232、RS-485)。

所以,Modbus RTU 就是 Modbus 协议的一种实现方式,通常用于工业设备之间通过串口进行高效通信。

大白话就是:工业版的普通话,各个工业设备想要交流要使用的一种语言。

2.Modbus RTU 功能码

一个标准的 Modbus RTU 数据帧包括以下部分:

部分 长度 说明
地址(Slave Address) 1 字节 指定从设备编号(1~247)
功能码(Function Code) 1 字节 表示操作类型(读/写寄存器/线圈)
数据(Data) N 字节 要读写的寄存器地址、数量及实际值
CRC 校验(CRC Check) 2 字节 错误检测,保证通信可靠性

例如:01 03 02 01 00 01 D5 CA

  • 01:设备地址 1

  • 03:功能码 3(读取保持寄存器)

  • 02 01:寄存器起始地址

  • 00 01:读取数量

  • D5 CA:CRC 校验

3.通信特点

主从模式:一个主机(Master)控制一个或多个从设备(Slave)。

半双工通信(RS-485):只能一端发送,另一端接收。

效率高:RTU 数据压缩,不像 ASCII 模式每个字节用两倍空间传输。

距离远:在 RS-485 上,可靠传输可达 1200 米左右。

说白了就是你需要主动发送数据请求,它才会回应数据,它不会主动回应给你数据的,比如你说现在温度是多少(发送::01 03 02 01 00 01 D4 72),它回答现在23℃(读取到数据::01 03 02 01 F0 B9 90即:(0x01F0,496)

它不会自己跑出来告诉你"我现在是 25.3℃",除非你问它。

如果你需要实时获取温度或者距离,你就要不停的发送,不停的读取。

一般频率是0.02s-2s看具体手册和个人需求。

4.Unity3d获取数据:

Player Settings -> Api Compatibility Level -> .NET Standard 2.0 或 .NET Framework

启用 Allow 'unsafe' Code(部分串口库需要)

仅支持 Windows Standalone(Android/iOS 需原生插件,WebGL 不支持)

完整脚本如下:根据自己的文档适当调整发送数据

cs 复制代码
using UnityEngine;
using UnityEngine.UI;
using System.IO.Ports;
using System;
using System.Text;
using System.Threading;

public class TestModbusRTU : MonoBehaviour
{
    // ========== INSPECTOR 配置区(关键参数全暴露)==========
    [Header("🔌 串口配置")]
    [Tooltip("设备管理器中查看的COM端口")]
    public string portName = "COM3";
    [Tooltip("波特率:9600/19200/38400/115200")]
    public int baudRate = 9600;
    [Tooltip("校验位:与设备说明书严格一致!")]
    public Parity parity = Parity.None;
    [Tooltip("停止位")]
    public StopBits stopBits = StopBits.One;
    [Tooltip("读取超时(毫秒),建议≥2000")]
    public int readTimeout = 3000;
    [Tooltip("RS485设备必开:控制收发方向")]
    public bool enableRTS = false;
    [Tooltip("部分设备需开启")]
    public bool enableDTR = true;

    [Header("🤖 Modbus 参数")]
    [Tooltip("从站地址(十进制)")]
    public byte slaveAddress = 1;
    [Tooltip("寄存器地址(十进制),示例:513=0x0201")]
    public ushort registerAddress = 513;
    [Tooltip("固定读1个寄存器(返回2字节)")]
    public ushort registerCount = 1;

    [Header("🖥️ UI 绑定")]
    public Text statusText;      // 状态提示
    public Text valueText;       // 数据显示
    public Text debugLogText;    // 诊断日志(可选)
    public Button readButton;    // 读取按钮

    // ========== 内部状态 ==========
    private SerialPort serialPort;
    private bool isReading = false;
    private StringBuilder logBuffer = new StringBuilder();
    private const int RESPONSE_LENGTH = 7; // 1+1+1+2+2

    // ========== UNITY LIFECYCLE ==========
    void Start()
    {
        InitializeUI();
        InitializeSerialPort();

        if (readButton != null)
            readButton.onClick.AddListener(OnReadButtonClicked);
        else
            Debug.LogWarning("⚠️ ReadButton 未绑定,需手动调用 StartRead()");
    }

    void OnDestroy()
    {
        CloseSerialPort();
    }

    // ========== 初始化 ==========
    void InitializeUI()
    {
        if (statusText != null) statusText.text = "⏳ 初始化中...";
        if (valueText != null) valueText.text = "N/A";
        if (debugLogText != null) debugLogText.text = "诊断日志:\n";
        AppendLog($"[SYS] 脚本启动 | 目标设备: {slaveAddress} | 寄存器: {registerAddress}");
    }

    void InitializeSerialPort()
    {
        try
        {
            // 关闭旧端口(避免重复打开)
            if (serialPort != null && serialPort.IsOpen) CloseSerialPort();

            serialPort = new SerialPort(
                portName,
                baudRate,
                parity,
                8, // 数据位固定为8(Modbus RTU标准)
                stopBits
            )
            {
                ReadTimeout = readTimeout,
                WriteTimeout = 2000,
                DtrEnable = enableDTR,
                RtsEnable = enableRTS,
                Handshake = Handshake.None
            };

            serialPort.Open();
            Thread.Sleep(100); // 等待驱动稳定

            // 清空缓冲区
            if (serialPort.IsOpen)
            {
                serialPort.DiscardInBuffer();
                serialPort.DiscardOutBuffer();
            }

            string configStr = $"波特率:{baudRate} | {parity} | 8数据位 | {stopBits} | DTR:{enableDTR} | RTS:{enableRTS}";
            UpdateStatus($"✅ 串口 {portName} 已打开\n{configStr}", Color.green);
            AppendLog($"[串口] {portName} 打开成功 | {configStr}");
            AppendLog($"[诊断] 初始缓冲区: In={serialPort.BytesToRead} Out={serialPort.BytesToWrite}");
        }
        catch (Exception e)
        {
            string errorMsg = $"❌ 串口打开失败: {e.Message}\n请检查:\n1. 端口是否被占用\n2. 驱动是否安装\n3. 以管理员身份运行Unity";
            UpdateStatus(errorMsg, Color.red);
            AppendLog($"[错误] {errorMsg}");
            Debug.LogError($"[串口初始化] {e}");
        }
    }

    void CloseSerialPort()
    {
        if (serialPort != null)
        {
            if (serialPort.IsOpen) serialPort.Close();
            serialPort.Dispose();
            serialPort = null;
            AppendLog("[串口] 已安全关闭");
        }
    }

    // ========== 外部触发 ==========
    public void OnReadButtonClicked()
    {
        if (isReading)
        {
            AppendLog("[警告] 读取中,请稍后再试");
            return;
        }

        if (serialPort == null || !serialPort.IsOpen)
        {
            UpdateStatus("⚠️ 串口未打开!点击重试或检查配置", Color.yellow);
            InitializeSerialPort(); // 尝试重连
            return;
        }

        StartCoroutine(ReadModbusData());
    }

    // ========== 核心通信流程(含超时修复)==========
    System.Collections.IEnumerator ReadModbusData()
    {
        isReading = true;
        UpdateStatus("📡 构建请求帧...", Color.cyan);
        AppendLog($"\n[请求] 地址:{slaveAddress} | 寄存器:{registerAddress} | 计数:{registerCount}");

        // 1. 清空缓冲区 + 帧间隔等待(Modbus RTU关键!)
        serialPort.DiscardInBuffer();
        serialPort.DiscardOutBuffer();
        yield return new WaitForSecondsRealtime(0.05f); // ≥3.5字符时间(9600波特率≈4ms)

        // 2. 构建请求帧
        byte[] requestFrame = BuildRequestFrame();
        if (requestFrame == null)
        {
            UpdateStatus("❌ 帧构建失败", Color.red);
            isReading = false;
            yield break;
        }
        LogFrame("📤 发送帧", requestFrame);

        // 3. 发送数据
        try
        {
            serialPort.Write(requestFrame, 0, requestFrame.Length);
            AppendLog($"[发送] 已写入 {requestFrame.Length} 字节");
        }
        catch (Exception e)
        {
            UpdateStatus($"❌ 发送失败: {e.Message}", Color.red);
            AppendLog($"[错误] 发送异常: {e.Message}");
            isReading = false;
            yield break;
        }

        // 4. ⚠️ 关键:等待设备响应时间(RTU要求)
        yield return new WaitForSecondsRealtime(0.15f); // 根据波特率调整:9600≈150ms

        // 5. 读取响应
        byte[] response = new byte[RESPONSE_LENGTH];
        int totalRead = 0;
        DateTime startTime = DateTime.Now;
        AppendLog($"[接收] 开始读取 (超时:{readTimeout}ms)");

        try
        {
            while (totalRead < RESPONSE_LENGTH &&
                   (DateTime.Now - startTime).TotalMilliseconds < readTimeout)
            {
                if (serialPort.BytesToRead > 0)
                {
                    int bytesRead = serialPort.Read(response, totalRead, RESPONSE_LENGTH - totalRead);
                    totalRead += bytesRead;
                    AppendLog($"[接收] 累计读取 {totalRead}/{RESPONSE_LENGTH} 字节");
                }
                //yield return null; // 释放主线程
            }

            if (totalRead != RESPONSE_LENGTH)
            {
                // 尝试读取残余数据用于诊断
                int remaining = serialPort.BytesToRead;
                if (remaining > 0)
                {
                    byte[] partial = new byte[remaining];
                    serialPort.Read(partial, 0, remaining);
                    LogFrame($"⚠️ 响应不完整! 仅收到 {totalRead}+{remaining} 字节", partial);
                }
                throw new TimeoutException($"期望{RESPONSE_LENGTH}字节,实际{totalRead}");
            }

            LogFrame("📥 完整响应", response);
        }
        catch (TimeoutException te)
        {
            string msg = $"⏱️ 读取超时! (收到 {totalRead}/{RESPONSE_LENGTH} 字节)";
            UpdateStatus(msg, Color.red);
            AppendLog($"[超时] {te.Message} | 缓冲区剩余: {serialPort.BytesToRead} 字节");
            AppendLog("[诊断建议]\n1. 检查设备地址/波特率/校验位\n2. 用Modbus Poll测试硬件\n3. RS485确认A/B线接反?");
            isReading = false;
            yield break;
        }
        catch (Exception e)
        {
            UpdateStatus($"❌ 读取异常: {e.Message}", Color.red);
            AppendLog($"[异常] {e.GetType().Name}: {e.Message}");
            isReading = false;
            yield break;
        }

        // 6. 验证响应
        if (!ValidateResponse(response))
        {
            isReading = false;
            yield break;
        }

        // 7. 解析数据(大端序)
        ushort value = (ushort)((response[3] << 8) | response[4]);
        valueText.text = $"{value} (0x{value:X4})";
        UpdateStatus($"✅ 读取成功 | 值: {value}", Color.green);
        AppendLog($"[解析] 寄存器[{registerAddress}] = {value} (十进制) | 0x{value:X4} (十六进制)");

        isReading = false;
    }

    // ========== 帧构建与验证 ==========
    byte[] BuildRequestFrame()
    {
        try
        {
            byte[] baseFrame = new byte[]
            {
                slaveAddress,
                0x03, // 功能码:读保持寄存器
                (byte)(registerAddress >> 8),
                (byte)(registerAddress & 0xFF),
                (byte)(registerCount >> 8),
                (byte)(registerCount & 0xFF)
            };

            ushort crc = CalcCRC16(baseFrame, baseFrame.Length);
            byte[] fullFrame = new byte[baseFrame.Length + 2];
            Buffer.BlockCopy(baseFrame, 0, fullFrame, 0, baseFrame.Length);
            fullFrame[baseFrame.Length] = (byte)(crc & 0xFF);   // 低字节
            fullFrame[baseFrame.Length + 1] = (byte)(crc >> 8); // 高字节
            return fullFrame;
        }
        catch (Exception e)
        {
            AppendLog($"[帧构建] 错误: {e.Message}");
            return null;
        }
    }

    bool ValidateResponse(byte[] resp)
    {
        // 检查长度
        if (resp.Length < RESPONSE_LENGTH)
        {
            AppendLog($"[验证] 响应长度错误: {resp.Length} < {RESPONSE_LENGTH}");
            return false;
        }

        // 检查从站地址
        if (resp[0] != slaveAddress)
        {
            AppendLog($"[验证] 从站地址不匹配! 期望:{slaveAddress:X2}, 实际:{resp[0]:X2}");
            return false;
        }

        // 检查功能码(异常响应:0x83)
        if (resp[1] == 0x83)
        {
            AppendLog($"[验证] 设备返回异常! 错误码: 0x{resp[2]:X2} (参考Modbus异常码)");
            return false;
        }
        if (resp[1] != 0x03)
        {
            AppendLog($"[验证] 功能码错误! 期望:03, 实际:{resp[1]:X2}");
            return false;
        }

        // 检查字节数
        if (resp[2] != registerCount * 2)
        {
            AppendLog($"[验证] 数据长度错误! 期望:{registerCount * 2}, 实际:{resp[2]}");
            return false;
        }

        // CRC校验
        if (!ValidateCRC(resp, RESPONSE_LENGTH))
        {
            AppendLog("[验证] CRC校验失败!");
            return false;
        }

        AppendLog("[验证] 响应帧校验通过 ✓");
        return true;
    }

    // ========== CRC16 (Modbus标准) ==========
    ushort CalcCRC16(byte[] data, int length)
    {
        ushort crc = 0xFFFF;
        for (int i = 0; i < length; i++)
        {
            crc ^= data[i];
            for (int j = 0; j < 8; j++)
            {
                if ((crc & 1) == 1)
                    crc = (ushort)((crc >> 1) ^ 0xA001);
                else
                    crc >>= 1;
            }
        }
        return crc;
    }

    bool ValidateCRC(byte[] data, int length)
    {
        if (length < 2) return false;
        ushort calc = CalcCRC16(data, length - 2);
        ushort recv = (ushort)((data[length - 1] << 8) | data[length - 2]);
        bool valid = calc == recv;
        if (!valid) AppendLog($"[CRC] 计算:0x{calc:X4} | 接收:0x{recv:X4}");
        return valid;
    }

    // ========== UI 与日志辅助 ==========
    void UpdateStatus(string message, Color color)
    {
        if (statusText != null)
        {
            statusText.text = message;
            // 如需动态改色,需确保Text组件支持(使用Outline或自定义Shader)
        }
    }

    void LogFrame(string prefix, byte[] data)
    {
        if (data == null || data.Length == 0) return;
        string hex = BitConverter.ToString(data).Replace("-", " ");
        string log = $"{prefix}: {hex}";
        AppendLog(log);
        Debug.Log($"[Modbus] {log}");
    }

    void AppendLog(string message)
    {
        logBuffer.AppendLine(message);
        if (debugLogText != null)
        {
            // 限制日志长度(防内存溢出)
            if (logBuffer.Length > 2000)
                logBuffer.Remove(0, logBuffer.Length - 2000);
            debugLogText.text = logBuffer.ToString();
        }
    }
}
5.这是本人使用的距离传感器获取数据经验,有需要的可以随时交流讨论
相关推荐
晓晓hh9 小时前
JavaSE学习——迭代器
java·开发语言·学习
Laurence9 小时前
C++ 引入第三方库(一):直接引入源文件
开发语言·c++·第三方库·添加·添加库·添加包·源文件
kyriewen119 小时前
你点的“刷新”是假刷新?前端路由的瞒天过海术
开发语言·前端·javascript·ecmascript·html5
014-code10 小时前
String.intern() 到底干了什么
java·开发语言·面试
421!10 小时前
GPIO工作原理以及核心
开发语言·单片机·嵌入式硬件·学习
摇滚侠10 小时前
JAVA 项目教程《苍穹外卖-12》,微信小程序项目,前后端分离,从开发到部署
java·开发语言·vue.js·node.js
@insist12310 小时前
网络工程师-生成树协议(STP/RSTP/MSTP)核心原理与应用
服务器·开发语言·网络工程师·软考·软件水平考试
野生技术架构师11 小时前
2026年牛客网最新Java面试题总结
java·开发语言
环黄金线HHJX.11 小时前
Tuan符号系统重塑智能开发
开发语言·人工智能·算法·编辑器
dog25011 小时前
对数的大脑应对指数的世界
开发语言·php