本文讲解C#串口通信数据接收不完整解决方案。
目录
一、概述
二、Modbus RTU介绍
三、解决思路
四、实例
一、概述
串口处理接收数据是串口程序编写的关键,在实际应用中基本是哪个采用异步通信的方式,所以接收数据就需要考虑接收数据的完整性,同时需要考虑数据分包,粘包,数据包的错误的情况。
有些场合尤其是全自动化设备指令收发时,数据完整性十分必要。
首先想到在串口接收事件里面添加延时,这种方案能解决分包发送的情况,对其他情况帮助不大,同时对时效要求高的情况下,显得鸡肋,不建议使用。
常规的正确的做法使用缓存的编写方式,当然这种依赖协议的完整性,本文以接收modbus协议数据为例。
二、Modbus RTU介绍
数据帧格式
首先我们要知道一帧正常的MODBUS数据帧包含的内容有:地址域 + 功能码 + 数据 + 差错校验,再者无论是上述哪种协议版本,Modbus帧格式都是一样的:
其中地址域:一个字节,理论上代表256个设备,实际可用的只有247个,地址0是广播地址(0~247)
功能码:一个字节,具体含义有规范要求。
数据:N个字节,具体格式和大小跟功能码有关。Modbus信息帧所允许的最大长度为256个字节,所以数据最多252个字节。
差错校验:2个字节,低字节在前,高字节在后。RTU采用16位CRC校验,是从开头一直校验到此之前。在每个RTU数据帧之前和之后有不少于3.5个字符位作为帧的分隔。
Modbus RTU通讯协议在数据通讯上采用主从应答的方式进行。只能由主机(PC,HMI等)通过唯一从机地址发起请求,从机(终端设备)根据主机请求进行响应,即半双工通讯。该协议只允许主机发起请求,从机进行被动响应,因此从机不会主动占用通讯线路造成数据冲突。
类似Modbus RTU协议的主从应答协议还有西门子的PPI、电表常用的DL/T645-2007等协议。
如果想进一步学习可以使用仿真软件进行调试学习
Modbus Poll则可以仿真出ModbusRTU中的主站。
Modbus Slave 可以仿真出ModbusRTU中的从站。
下载地址;https://www.modbustools.com/download/ModbusSlaveSetup64Bit-822.exe
当然可以下载破解版
1、协议格式
信息传输为异步方式,使用16进制进行通讯,信息帧格式:
地址码 | 功能码 | 数据区 | CRC校验码 |
1字节 | 1字节 | N字节 | 2字节 |
地址码
地址码是每个通讯信息帧的第一个字节,一般支持1到247,部分设备也支持0地址,用于接收主机的广播数据,每个从机在总线上地址必须唯一,只有与主机发送的地址码相符的从机才能响应返回数据。
功能码
功能码是每个通讯信息帧的第二个字节。主机发送,通过功能码告知从机设备应当执行何种操作。
常见的八种功能码:
功能码 | 定义 | 操作 |
01H | 读取线圈 | 读取一个或多个连续线圈状态 |
05H | 写单个线圈 | 操作指定位置的线圈状态 |
0FH | 写多个线圈 | 操作多个连续线圈状态 |
02H | 读取离散量输入 | 读取一个或多个连续离散输入状态 |
04H | 读取输入寄存器 | 读取一个或多个连续输入寄存器数据 |
03H | 读保持寄存器 | 读取一个或多个保持寄存器数据 |
06H | 写单个保持寄存器 | 把两个十六进制数据写入对应位置 |
10H | 写多个保持寄存器 | 把4*N个十六进制数据写入N个连续保持寄存器 |
数据区
数据区随功能码以及数据方向的不同而不同,这些数据可以是“寄存器首地址+读取寄存器数量”、“寄存器地址+操作数据”、“寄存器首地址+操作寄存数量+数据长度+数据”等不同的组合,在“功能码分析”详解不同功能码的数据区。
Modbus CRC校验
Modbus RTU协议常用与工业现场对数据传输的稳定性和正确性有较高的要求,因此通过CRC校验保证数据传输的正确性和完整性。
2、错误反馈
地址与CRC校验错误并不会收到从机的数据反馈,其他错误将向主机返回错误码。数据帧的第二位加上0X80表示请求发生错误(非法功能码、非法数据值等),错误数据帧如下:
地址码 | 功能码 | 错误码 | CRC校验码 |
1字节 | 1字节 | 1字节 | 2字节 |
常见错误码如下:
值 | 名称 | 说明 |
01H | 非法的功能码 | 不支持该功能码操作寄存器 |
02H | 非法的寄存器地址 | 访问设备禁止访问的寄存器 |
03H | 非法的数据值 | 写入不支持的参数值 |
04H | 从机故障 | 设备工作异常 |
3、通讯信息传输过程
通讯命令由主机发送从机时,与主机发送的地址码相符的从机接收通讯命令,如果CRC校验无误,则执行相应的操作,然后把执行结果(数据)返回给主机。返回信息中包含地址码、功能码、执行后的数据以及CRC校验码。如果地址不匹配或者CRC校验出错就不返回任何信息。
功能码分析功能码01H:读线圈
例如:主机要读取从机地址为01H,起始线圈地址为00H的1个线圈状态,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 01 | |
起始线圈地址 | 高字节 | 00 |
低字节 | 00 | |
线圈数量 | 高字节 | 00 |
低字节 | 01 | |
CRC校验 | 低字节 | FD |
高字节 | CA |
如果从机寄存器00H线圈闭合,从机返回:
从机返回 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 01 | |
字节数 | 01 | |
线圈状态 | 01 | |
CRC校验码 | 低字节 | 90 |
高字节 | 48 |
功能码05H:写单个线圈
例如:主机要控制从机地址为01H,线圈地址为0000H的线圈状态,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 01 | |
线圈地址 | 高字节 | 00 |
低字节 | 00 | |
控制方式 | 高字节 | 00(断开)、FF(闭合) |
低字节 | 01 | |
CRC校验 | 低字节 | XX |
高字节 | XX |
从机返回与主机请求相同;
功能码0FH:写多个线圈
例如:主机要控制从机地址为01H,起始线圈地址为00H的4个线圈状态,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 0F | |
起始线圈地址 | 高字节 | 00 |
低字节 | 00 | |
线圈数量 | 高字节 | 00 |
低字节 | 04 | |
写入字节数 | 01 | |
控制方式 | 00(全部断开)、0F(全部闭合) | |
CRC校验 | 低字节 | XX |
高字节 | XX |
功能码0FH操作,从机返回:
从机返回 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 0F | |
起始线圈地址 | 高字节 | 00 |
低字节 | 00 | |
线圈数量 | 高字节 | 00 |
低字节 | 04 | |
CRC校验 | 低字节 | 54 |
高字节 | 08 |
功能码02H:读离散输入
例如:主机要读取从机地址为01H,起始离散量地址为00H的4个输入状态,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 02 | |
起始离散量地址 | 高字节 | 00 |
低字节 | 00 | |
读取数量 | 高字节 | 00 |
低字节 | 04 | |
CRC校验 | 低字节 | 79 |
高字节 | C9 |
如果从机首地址00H开始的4离散输入全部检测到输入,从机返回:
从机返回 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 02 | |
字节数 | 01 | |
离散输入状态 | 0F | |
CRC校验码 | 低字节 | E1 |
高字节 | 8C |
功能码04H:读取输入寄存器
例如:主机要读取从机地址为01H,起始寄存器地址为02H的1个输入寄存器数据,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 04 | |
起始寄存器地址 | 高字节 | 00 |
低字节 | 02 | |
寄存器数量 | 高字节 | 00 |
低字节 | 01 | |
CRC校验 | 低字节 | 90 |
高字节 | 0A |
如果从机输入寄存器02H的数据为3344H,从机返回:
从机返回 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 04 | |
字节数 | 02 | |
寄存器05H数据 | 高字节 | 33 |
低字节 | 44 | |
CRC校验码 | 低字节 | AD |
高字节 | F3 |
功能码03H:读保持寄存器
例如:主机要读取从机地址为01H,起始寄存器地址为05H的2个保持寄存器数据,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 03 | |
起始寄存器地址 | 高字节 | 00 |
低字节 | 05 | |
寄存器数量 | 高字节 | 00 |
低字节 | 02 | |
CRC校验 | 低字节 | D4 |
高字节 | 0A |
如果从机保持寄存器05H、06H的数据为1122H、3344H,从机返回:
从机返回 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 03 | |
字节数 | 04 | |
寄存器05H数据 | 高字节 | 11 |
低字节 | 22 | |
寄存器06H数据 | 高字节 | 33 |
低字节 | 44 | |
CRC校验码 | 低字节 | 4B |
高字节 | C6 |
功能码06H:写单个保持寄存器
例如:主机写入9988H的数据给从机地址为01H,寄存器地址为0050H的寄存器,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 06 | |
寄存器地址 | 高字节 | 00 |
低字节 | 50 | |
写入值 | 高字节 | 99 |
低字节 | 88 | |
CRC校验 | 低字节 | E3 |
高字节 | ED |
从机返回与主机请求相同;
功能码10H:写多个保持寄存器
例如:主机要把数据0005H、2233H保存到从机地址为01H,起始寄存器地址为0020H的2个寄存器中,主机发送:
主机发送 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 10 | |
起始寄存器地址 | 高字节 | 00 |
低字节 | 20 | |
寄存器数量 | 高字节 | 00 |
低字节 | 02 | |
写入字节数 | 04 | |
0000H 寄存器待写入 | 高字节 | 00 |
低字节 | 05 | |
0001H 寄存器待写入 | 高字节 | 22 |
低字节 | 33 | |
CRC校验 | 低字节 | B9 |
高字节 | 03 |
功能码10H操作,从机返回:
从机返回 | 发送数据(HEX) | |
地址码 | 01 | |
功能码 | 10 | |
起始寄存器地址 | 高字节 | 00 |
低字节 | 20 | |
寄存器数量 | 高字节 | 00 |
低字节 | 02 | |
CRC校验 | 低字节 | 40 |
高字节 | 02 |
三、解决思路
创建一个缓冲区用来存放串口每次接收到的数据,串口收到数据后,我们就直接判断缓冲区的头字节是否为头码内容,如果符合要求,则根据数据长度接收完这帧数据,之后进行CRC校验判断,若能满足,则表示这帧数据是对的。
若这帧数据不能满足校验,则说明这是一帧错误是数据,有可能我们拿到的头码是伪头码,挨个遗弃字节,直到再在缓冲区中重新找到头码,重新处理帧数据。
四、实例
准备
在modbus仿真软件中准备数据
设备地址为1
编写程序进行读取
发送为>>> 01 03 00 00 00 04 44 09
接收的为<<< 01 03 08 00 01 00 02 00 03 00 04 0D 14
实例
创建winform项目,添加一个按钮触发定时器读操作
Cs文件代码
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Windows.Forms;namespace ModbusReadDemo
{public partial class Form1 : Form{List<byte> btBuf = new List<byte>(4096);//读返回数据int iReadData1 = 0;int iReadData2 = 0;int iReadData3 = 0;int iReadData4 = 0;public Form1(){InitializeComponent();}/// <字节数组转16进制字符串>/// <param name="bytes"></param>/// <returns> String 16进制显示形式</returns>public static string byteToHexStr(byte[] bytes){string returnStr = "";try{if (bytes != null){for (int i = 0; i < bytes.Length; i++){returnStr += bytes[i].ToString("X2");returnStr += " "; //两个16进制用空格隔开,方便看数据}}return returnStr;}catch (Exception){return returnStr;}}private void spt_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs e){try{if (spt.IsOpen){int iLength = spt.BytesToRead;byte[] btData = new byte[iLength];//读数据暂存CRC CRC = new CRC();byte[] crc = new byte[2];spt.Read(btData, 0, iLength);btBuf.AddRange(btData);//缓存数据if (btBuf.Count < 8) //数据区尚未接收完整{return;}while (btBuf.Count >= 8) //至少包含帧头(地址)(1字节)、功能码(1字节)、校验位(2字节)等;最少返回8个字节{if (btBuf[0] == 0x01)//判定帧头为01{#region 读返回//btBuf[1] 功能码 读 btBuf[2]字节长度if (btBuf[1] == 0X03 & btBuf[2] == 0X08) //传输数据有帧头,用于判断{int len = btBuf[2];if (btBuf.Count < len + 5) //数据区尚未接收完整{break;}byte[] ReceiveBytes = new byte[len + 5];//读数据暂存btBuf.CopyTo(0, ReceiveBytes, 0, len + 5);CRC.CalculateCrc16(ReceiveBytes, out crc[1], out crc[0]).ToString("X");//生成验证码if ((btBuf[(len + 5) - 2] == crc[0]) & (btBuf[(len + 5) - 1] == crc[1]))//校验码验证{string strRecv = byteToHexStr(ReceiveBytes);Trace.Write("串口接收" + strRecv + "\n");try{#region 读地址的数据转化为int类型byte[] btR1 = { btBuf[4], btBuf[3], 0, 0 };//byte转化为int需要4个字节,btData[4]数据低字节, btData[3]数据高字节iReadData1 = BitConverter.ToInt32(btR1, 0);byte[] btR2 = { btBuf[6], btBuf[5], 0, 0 };iReadData2 = BitConverter.ToInt32(btR2, 0);byte[] btR3 = { btBuf[8], btBuf[7], 0, 0 };//byte转化为int需要4个字节,btData[4]数据低字节, btData[3]数据高字节iReadData3 = BitConverter.ToInt32(btR3, 0);byte[] btR4 = { btBuf[10], btBuf[9], 0, 0 };iReadData4 = BitConverter.ToInt32(btR4, 0);Trace.Write("第一个数" + iReadData1.ToString()+"\n");Trace.Write("第二个数" + iReadData2.ToString() + "\n");Trace.Write("第三个数" + iReadData3.ToString() + "\n");Trace.Write("第四个数" + iReadData4.ToString() + "\n");#endregionbtBuf.RemoveRange(0, len + 5);//数据清除}catch{btBuf.RemoveAt(0);}}else{btBuf.RemoveAt(0);}}else{btBuf.RemoveAt(0);}#endregion}else{btBuf.RemoveAt(0);}}}}// catch (Exception ex)catch{// MessageBox.Show("数据失败!"+ex, "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);}}//开始读取private void button1_Click(object sender, EventArgs e){/* 从D55-D71寄存器读 17个寄存器,30个字节,地址默认1,功能码(03为读寄存器单元),* 起始地址高位(初始化地址38,寄存器D55),起始地址低位,位数高字节,位数低字节,低位CRC校验,高位CRC校验 */spt.Open();timer1.Enabled = true;}//定时发送查询寄存器数据private void timer1_Tick(object sender, EventArgs e){if (spt.IsOpen == true){Byte[] bt = new byte[8] { 0X01, 0X03, 0X00, 0X00, 0X00, 0X04, 0X44, 0X09 };spt.Write(bt, 0, 8);//Trace.Write("串口发送");}else{//Trace.Write("串口未打开");}}}
}
CRC代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;namespace ModbusReadDemo
{public class CRC{private readonly byte[] _auchCRCHi = new byte[]//crc高位表{0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41,0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,0x80, 0x41, 0x00, 0xC1, 0x81, 0x40};private readonly byte[] _auchCRCLo = new byte[]//crc低位表{0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06,0x07, 0xC7, 0x05, 0xC5, 0xC4, 0x04, 0xCC, 0x0C, 0x0D, 0xCD,0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09,0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A,0x1E, 0xDE, 0xDF, 0x1F, 0xDD, 0x1D, 0x1C, 0xDC, 0x14, 0xD4,0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3,0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3,0xF2, 0x32, 0x36, 0xF6, 0xF7, 0x37, 0xF5, 0x35, 0x34, 0xF4,0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A,0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29,0xEB, 0x2B, 0x2A, 0xEA, 0xEE, 0x2E, 0x2F, 0xEF, 0x2D, 0xED,0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26,0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60,0x61, 0xA1, 0x63, 0xA3, 0xA2, 0x62, 0x66, 0xA6, 0xA7, 0x67,0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F,0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68,0x78, 0xB8, 0xB9, 0x79, 0xBB, 0x7B, 0x7A, 0xBA, 0xBE, 0x7E,0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5,0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71,0x70, 0xB0, 0x50, 0x90, 0x91, 0x51, 0x93, 0x53, 0x52, 0x92,0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C,0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B,0x99, 0x59, 0x58, 0x98, 0x88, 0x48, 0x49, 0x89, 0x4B, 0x8B,0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C,0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42,0x43, 0x83, 0x41, 0x81, 0x80, 0x40};public ushort CalculateCrc16(byte[] buffer, out byte crcLo, out byte crcHi)//协议默认低位在前{crcHi = 0xff; // high crc byte initializedcrcLo = 0xff; // low crc byte initialized for (int i = 0; i < buffer.Length - 2; i++){int crcIndex = crcHi ^ buffer[i]; // calculate the crc lookup indexcrcHi = (byte)(crcLo ^ _auchCRCHi[crcIndex]);crcLo = _auchCRCLo[crcIndex];}return (ushort)(crcHi << 8 | crcLo);}}
}
丛机的仿真器打开,设置串口端口号等参数,开启连接,运行软件
点击读按钮,定时器触发读。
调试输出如下:
串口接收01 03 08 00 01 00 02 00 03 00 04 0D 14
第一个数1
第二个数2
第三个数3
第四个数4
总结:通过上述实例很好的演示了接收数据不全的问题。