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.这是本人使用的距离传感器获取数据经验,有需要的可以随时交流讨论
