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.这是本人使用的距离传感器获取数据经验,有需要的可以随时交流讨论
相关推荐
echome8882 小时前
Python 装饰器详解:从入门到精通的实用指南
开发语言·python
重生之后端学习2 小时前
62. 不同路径
开发语言·数据结构·算法·leetcode·职场和发展·深度优先
栗子~~2 小时前
hardhat 单元测试时如何观察gas消耗情况
开发语言·单元测试·区块链·智能合约
The hopes of the whole village2 小时前
Matlab FFT分析
开发语言·matlab
兰文彬2 小时前
n8n 2.x版本没有内嵌Python环境
开发语言·python
yiyaozjk2 小时前
Go基础之环境搭建
开发语言·后端·golang
谁动了我的代码?2 小时前
VNC中使用QT的GDB调试,触发断点时与界面窗口交互导致整个VNC冻结
开发语言·qt·svn
We་ct3 小时前
LeetCode 212. 单词搜索 II:Trie+DFS 高效解法
开发语言·算法·leetcode·typescript·深度优先·图搜索算法·图搜索
OxyTheCrack3 小时前
【C++】简述main函数中的argc与argv
开发语言·c++