2023-03-02
通讯
00
请注意,本文编写于 367 天前,最后修改于 353 天前,其中某些信息可能已经过时。

目录

协议概述
协议要点
异常应答
寄存器和功能码
具体实现
功能测试及报文解析
测试1
测试2

Modbus已经成为工业领域通信协议的业界标准(De facto),并且现在是工业电子设备之间常用的连接方式。 所以这也是我们工控领域软件开发的所必懂的通讯协议。

协议概述

  1. Modbus协议是应用于控制器上的一种通用语言,实现控制器之间,控制器通过网络和其他设备之间的通信,支持传统RS232/RS422/RS485和以太网设备,它已经成为一种通用的工业标准,有了它不同厂商生产的控制设备可以连成工业网络,进行集中控制,此协议定义了一个控制器能认识使用的消息结构

  2. 如果按照国际 ISO/OSI 的 7 层网络模型来说,标准 MODBUS 协议定义了通信物理层、链路层及应用层;

    - 物理层:定义了基于 RS232 和 RS485 的异步串行通信规范; - 链路层:规定了基于站号识别、主 / 从方式的介质访问控制; - 应用层:规定了信息规范(或报文格式)及通信服务功能;

    1630815-20190507090234157-1654633850.png

协议要点

  1. MODBUS 是主 / 从通信协议。主站主动发送报文 , 只有与主站发送报文中呼叫地址相同的从站才向主站发送回答报文。

  2. 报文以 0 地址发送时为广播模式,无需从站应答,可作为广播报文发送,包括:

    - 修改线圈状态; - 修改寄存器内容; - 强置多线圈; - 预置多寄存器; - 询问诊断;
  3. MODBUS 规定了 2 种字符传输模式: ASCII 模式、 RTU (二进制)模式;两种传输模式不能混用;

    1630815-20190507090627915-142906941.png

  4. 传输错误校验

  传输错误校验有奇偶校验、冗余校验检验。

  当校验出错时,报文处理停止,从机不再继续通信,不对此报文产生应答;

  通信错误一旦发生,报文便被视为不可靠; MODBUS 主机在一定时间过后仍未收到从站应答,即作出“通信错误已发生”的判断。

  1. 报文级(字符级)采用 CRC-16 (循环冗余错误校验)

  2. MODBUS 报文 RTU 格式

    1630815-20190507090837012-148058738.png

异常应答

  1. 从机接收到的主机报文,没有传输错误,但从机无法正确执行主机命令或无法作出正确应答,从机将以“异常应答”回答之。

  2. 异常应答报文格式

例:主机发请求报文,功能码 01 :读 1 个 04A1 线圈值

1630815-20190507091113789-1134737933.png

  由于从机最高线圈地址为 0400 ,则 04A1 超地址上限,从机作出异常应答如下(注意:功能码最高位置 1 ):

1630815-20190507091235070-524738869.png

  1. 异常应答码 1630815-20190507091630602-1951043065.png

寄存器和功能码

modbus的功能码很多,且不同功能码对应的报文也不一致,后续博客我会借用开源库实现一个modbus master 测试功能码 解析报文

下边我用表格总结一下寄存器,功能码,报文格式

1630815-20190507092620262-992826312.png

  1. 报文中的所有字节均为16进制

  2. 由上图我们总结出不同的功能码的报文(无论询问报文还是响应报文)前8个字节都是一致的 都是2字节消息号+2字节ModBus标识+2字节长度+1字节站号+1字节功能码 后边根据功能码不同而不同

  3. 报文中,指定线圈通断标志 FF00 置线圈为ON 0000置线圈为OFF

具体实现

接下来我们使用开源库NModbus库,来实现一个Modbus master

  1. 创建工程,从NuGet管理器安装NModbusu

1630815-20190507100513586-1397266336.png 先简单介绍一下NModbus中的几个重要方法

1630815-20190507110230268-56828543.png

  1. 具体实现
csharp
using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows.Forms; using NModbus; using System.Net.Sockets; using System.Threading; namespace ModbusTcp { public partial class Form1 : Form { private static ModbusFactory modbusFactory; private static IModbusMaster master; //写线圈或写寄存器数组 bool[] coilsBuffer; ushort[] registerBuffer; //功能码 string functionCode; //参数(分别为站号,起始地址,长度) byte slaveAddress; ushort startAddress; ushort numberOfPoints; public Form1() { InitializeComponent(); } private void Form1_Load(object sender, EventArgs e) { //初始化modbusmaster modbusFactory = new ModbusFactory(); //在本地测试 所以使用回环地址,modbus协议规定端口号 502 master = modbusFactory.CreateMaster(new TcpClient("127.0.0.1", 502)); //设置读取超时时间 master.Transport.ReadTimeout = 2000; master.Transport.Retries = 2000; groupBox1.Enabled = false; groupBox2.Enabled = false; } /// <summary> /// 读/写 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void button1_Click(object sender, EventArgs e) { ExecuteFunction(); } private async void ExecuteFunction() { try { //重新实例化是为了 modbus slave更换连接时不报错 master = modbusFactory.CreateMaster(new TcpClient("127.0.0.1", 502)); if (functionCode != null) { switch (functionCode) { case "01 Read Coils"://读取单个线圈 SetReadParameters(); coilsBuffer = master.ReadCoils(slaveAddress, startAddress, numberOfPoints); for (int i = 0; i < coilsBuffer.Length; i++) { SetMsg(coilsBuffer[i] + ""); } break; case "02 Read DisCrete Inputs"://读取输入线圈/离散量线圈 SetReadParameters(); coilsBuffer = master.ReadInputs(slaveAddress, startAddress, numberOfPoints); for (int i = 0; i < coilsBuffer.Length; i++) { SetMsg(coilsBuffer[i] + ""); } break; case "03 Read Holding Registers"://读取保持寄存器 SetReadParameters(); registerBuffer = master.ReadHoldingRegisters(slaveAddress, startAddress, numberOfPoints); for (int i = 0; i < registerBuffer.Length; i++) { SetMsg(registerBuffer[i] + ""); } break; case "04 Read Input Registers"://读取输入寄存器 SetReadParameters(); registerBuffer = master.ReadInputRegisters(slaveAddress, startAddress, numberOfPoints); for (int i = 0; i < registerBuffer.Length; i++) { SetMsg(registerBuffer[i] + ""); } break; case "05 Write Single Coil"://写单个线圈 SetWriteParametes(); await master.WriteSingleCoilAsync(slaveAddress, startAddress, coilsBuffer[0]); break; case "06 Write Single Registers"://写单个输入线圈/离散量线圈 SetWriteParametes(); await master.WriteSingleRegisterAsync(slaveAddress, startAddress, registerBuffer[0]); break; case "0F Write Multiple Coils"://写一组线圈 SetWriteParametes(); await master.WriteMultipleCoilsAsync(slaveAddress, startAddress, coilsBuffer); break; case "10 Write Multiple Registers"://写一组保持寄存器 SetWriteParametes(); await master.WriteMultipleRegistersAsync(slaveAddress, startAddress, registerBuffer); break; default: break; } } else { MessageBox.Show("请选择功能码!"); } master.Dispose(); } catch (Exception ex) { MessageBox.Show(ex.Message); } } private void comboBox1_SelectedIndexChanged(object sender, EventArgs e) { if (comboBox1.SelectedIndex >= 4) { groupBox2.Enabled = true; groupBox1.Enabled = false; } else { groupBox1.Enabled = true; groupBox2.Enabled = false; } comboBox1.Invoke(new Action(() => { functionCode = comboBox1.SelectedItem.ToString(); })); } /// <summary> /// 初始化读参数 /// </summary> private void SetReadParameters() { if (txt_startAddr1.Text == "" || txt_slave1.Text == "" || txt_length.Text == "") { MessageBox.Show("请填写读参数!"); } else { slaveAddress = byte.Parse(txt_slave1.Text); startAddress = ushort.Parse(txt_startAddr1.Text); numberOfPoints = ushort.Parse(txt_length.Text); } } /// <summary> /// 初始化写参数 /// </summary> private void SetWriteParametes() { if (txt_startAddr2.Text == "" || txt_slave2.Text == "" || txt_data.Text == "") { MessageBox.Show("请填写写参数!"); } else { slaveAddress = byte.Parse(txt_slave2.Text); startAddress = ushort.Parse(txt_startAddr2.Text); //判断是否写线圈 if (comboBox1.SelectedIndex == 4 || comboBox1.SelectedIndex == 6) { string[] strarr = txt_data.Text.Split(' '); coilsBuffer = new bool[strarr.Length]; //转化为bool数组 for (int i = 0; i < strarr.Length; i++) { // strarr[i] == "0" ? coilsBuffer[i] = true : coilsBuffer[i] = false; if (strarr[i] == "0") { coilsBuffer[i] = false; } else { coilsBuffer[i] = true; } } } else { //转化ushort数组 string[] strarr = txt_data.Text.Split(' '); registerBuffer = new ushort[strarr.Length]; for (int i = 0; i < strarr.Length; i++) { registerBuffer[i] = ushort.Parse(strarr[i]); } } } } /// <summary> /// 清除文本 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void button2_Click(object sender, EventArgs e) { richTextBox1.Clear(); } /// <summary> /// SetMessage /// </summary> /// <param name="msg"></param> public void SetMsg(string msg) { richTextBox1.Invoke(new Action(() => { richTextBox1.AppendText(msg + "\r\n"); })); } } }
  1. 界面布局

1630815-20190507100846269-1019229877.png

功能测试及报文解析

这里功能测试我们需要借助测试工具 Modbus Slave(Modbus从站客户端)

链接:https://pan.baidu.com/s/1Z3bET3l_2a4e6cu_p250tg

提取码:hq1r

简单说明一下,这里我实现了常用的几个功能码

  • 0x01 读一组线圈

  • 0x02 读一组输入线圈/离散量线圈

  • 0x03 读一组保持寄存器

  • 0x04 读一组输入寄存器

  • 0x05 写单个线圈

  • 0x06 写单个保持寄存器

  • 0x0F 写多个线圈

  • 0x10 写多个保持寄存器

简单说一下Modbus Slave 的操作, 打开连接建立连接选择连接方式为Tcp/Ip设置Ip和端口号

1630815-20190507102812290-1380847960.png

选择线圈或寄存器

点击Setup->Slave Definition,这里的Function我们需要读/写什么线圈或寄存器就对应选择

1630815-20190507103343126-1540445246.png

测试1

功能码0x01

这里我们所有的测试从站都使用站号1 起始地址0 长度10

功能码0x01 读取线圈 Modbus Slave的Function选择 01 Coil Status(0x)

测试结果:

1630815-20190507104711371-1878251500.png

点击 Display->Communication 可以截取报文,不知道为什么他报文字体那么小😱

1630815-20190507105025044-2105621186.png

000000-Rx:00 01 00 00 00 06 01 01 00 00 00 05 000001-Tx:00 01 00 00 00 04 01 01 01 06

测试2

功能码0x10

功能码0x10 写入一组数据到保持寄存器 Modbus Slave的Function选择03 Holding Register(4x) (说明一下 线圈和保持寄存器才有写操作)

测试结果

1630815-20190507111554655-1365408869.png

报文

000070-Rx:00 01 00 00 00 11 01 10 00 00 00 05 0A 00 0C 00 22 00 38 00 4E 00 5A 000071-Tx:00 01 00 00 00 06 01 10 00 00 00 05

上文测试了一个读操作和一个写操作,其他功能码的测试与上文一致,有兴趣的可以自行测试。

本文作者:Peter.Pan

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!