设备控制软件编程涉及到的基本通信方式主要有TCP/IP与串口,用到的数据通信协议有Fins与ModBus。 更高级别的通信如.net中的Remoting与WCF在进行C/S架构软件开发时会采用。
本篇文章结合Fins/ModBus协议的指令帧结构与数据编码与解码过程,自定义了一套TcpChatter数据数据通信协议,编写了一个聊天程序,说明TCP/IP的在一个项目中应用。
本文涉及到的源代码工程项目为 - TcpChatter 后面附件提供源代码下载 ( OpenSource Code 软件版本:VS2008 语言:C#)
1 先普及几个基本概念
Socket
接触C/C++的人都知道,编写网络程序会用到Socket,对于Socket编程,其基本编程思想就是使用listen,accept,connect,send与write等几个操作来实现客户端与服务端的通信。
对于使用C#的程序员,.net为我们提供了Socket类来编写服务程序,提供了TcpClient来编写客户端程序。我们只需要知道如何使用listen,accept,connect,send与write操作就能编写我们需要的网络程序了。
简单的说:
Socket是支持TCP/IP协议的网络通信的基本操作单元,它是建立在TCP/IP协议上的一组编程接口,是我们编写代码,使用TCP/IP进行数据通信的入口,它是对TCP/IP协议栈的抽像,等效于是TCP/IP协议栈提供的对外编程接口,在.net中,只是说这个编程接口的实现由Micosoft为我们完成,我们做的唯一工作只是使用这些接口就能在我们的应用程序间进行TCP/IP通信了
TCP/IP - Transmission Control Protocol/Internet Protocol
传输控制协议/因特网互联协议,是Internet互联网络的基础,由传输层的TCP协议与网络层的IP协议构成。网络层负责在节点与节点之间传送数据包(IP数据包),该IP数据包由TCP协议来进行组装,IP数据包再通过它的下层协议以太网协议 (IEEE802)在光纤上进行传输,从而将不同的信息从一台计算机传送到了另一台计算机。
对于程序员,我们编写的程序要在不同的计算机间进行数据通信,可以通过Socket编程来使用TCP/IP,从而将我们的数据从一台计算机传到了另一台计算机。
PLC - Programmable Logic Controller
可编程逻辑控制器,一种数字运算操作的电子系统,专为工业环境应用而设计,与计算机一样,可以把它看成是一种用于工业控制的计算机,它也有自己的编程语言 - T形图,可以通过T形图编程来实现各种设备的控制。
Fins - Factory Interface Network Service
Fins协议是欧姆龙开发的用于工业自动化控制网络的指令/响应通信协议,它借助TCP/IP协议与串口通信协议,通过发送Fins指令实现在各种网络间的无缝通信,这里主工是PC与PLC的通信,我们PC可以通过发送Fins指令与PLC进行通信。
ModBus
Modbus是由Modicon公司于1979年发明,是全球第一个真正用于工业现场的总线协议。在工业控制系统中,目前ModBus已经成为一通用工业标准.
Modbus主要应用于电子控制器上的一种通用数据协议,借助TCP/IP协议与串口通信协议,通过发送Modbus指令实现在各种设备之间的通信。目前公司的温控表与PC间的通信采用ModBus。
2 TcpChatter消息传输结构
2.1 TcpChatter软件架构
与传统的软件系统一样,TcpChatter采用C/S架构,即客户端/服务端架构,Client参与通信会话,Server不参与通信会话,只负责将Client的消息通过Server进行转发,从而实现Client-Client间的通信。
整个TcpChatter的代码结构由 ChatServer + ChatClient构成
2.2 TcpChatter消息处理
如下图所示TcpChatterMessageTransaction,为TcpChatter系统中使用的消息处理结构。下图异展示了从 客户端A←→客户端B 的消息传递过程。
2.3 TcpChatter消息处理原理
在不同的客户端进行通信,客户端通过将消息封装为TcpChatter指定的数据格式 [TcpChater指令 + 数据] 然后发送给服务端程序ChatServer,服务端程序再将消息转发给指定的客户端,客户端收到消息后解析TcpChater数据包然后做其它的处理。
3 TcpChatter指令帧结构
目前工业控制中的温控主流采用串口通信,使用数据通信协议为ModBus协议,而与底层PLC通信则多采用Fins协议。下面分别解释ModBus协议与Fins协议的指令帧结构与TcpChatter指令帧结构。
TcpChatter指令 帧用于在客户端与服务端进行统一格式的数据通信。其基本构成为 : TcpChatter指令域 + TcpChatter数据域;
Fins协议与ModBus协议原理基本一样,其各自的指令帧结构 基本有2部分构成: 指令域 + 数据域
指令域为Fins协议与ModBus协议定义的数据通信格式,指令域字节长度也不一样.比如Fins指令有效指令域(Fin头 + Fin指令域)为12个节字,数据域长度能到2000字节,而ModBus协议有效指令域(地址 + 功能码 + CRC校验码)为4个字节,数据域为(256-4)字节或(260-4)字节。
数据域为发送的真正数据。由于受限于硬件设备通信的数据速率, 在串口与TCP/IP通信中, 指令域 + 数据域的总长度是有限制的。我们通过PC与设备进行通信,实际上是在反复的发送这些 数据包与解析这些数据包,从而达到PC与设备信息交互的目的。
熟悉嵌入式的人都知道,我们编写代码跟设备进行通信,基本是在通过操作设备的寄存器对寄存器进行读写从而达到控制设备状态与获取设备状态的目的。寄存器普通的有8位与16位,我们最常见的温控表是8位寄存器,正好一个byte,每一位都可以看成是硬件上的一个I/O,我们通过操作这些位从而操作了对应的硬件的I/O状态 (0/1),设备跟据这些I/O状态做出相应的动作。
3.1 Fins指令帧结构
(Fins响应帧(应答帧结构)结构与Fins指令帧结构类似)
3.2 ModBus指令帧结构
(ModBus也具备响应帧结构)
2.3 TcpChatter指令帧结构
TcpChatter指令 帧用于在客户端与服务端进行统一格式的数据通信。其基本构成为 : TcpChatter指令域 + TcpChatter数据域;
TcpChatter指令域构成: 命令头 + 命令请求模式+ 发送者ID + 收发模式 + 收接都ID + 预留指令
TcpChatter指令域长度: 8bytes
TcpChatter数据域长度: 20kb
3.4 TcpChatter 指令域结构 (该指令在通信过程中变换成了byte,可以进行位操作)
/// <summary> /// TcpChatter 数据通信命令格式定义 /// </summary> public struct LCmd { public int Head; // 有效命令开始标志(命令头) public int CmdMode; // 命令请求模式 public int SendID; // 发送者用户ID public int WR; // 发送或读写模式 public int RecvID; // 接收者用户ID public int Resv2; // 预留 public int Resv3; // 预留 public int Resv4; // 预留 }
3.5 TcpChatter 指令集
/// <summary> /// 应答请求命令 /// </summary> public enum CmdRequest { MinID = -1, Online = 0x01, // 在线请求 FixUser = 0x02, // 向固定用户发送消息请求 Flush = 0x03, // 向固定用户闪屏请求 FlushAll = 0x04, // 向所有用户闪屏请求 Broadcast = 0x05, // 广播消息请求 Offline = 0x06, // 离线请求 UpdateUsers = 0x07, // 用户列表更新请求 Success = 0x08, // 用户连接服务成功应答 InvalidUser = 0x09, // 非法用户名 - (预留) Failed = 0x0A, // 用户连接服务失败应答 InvalidCmd = 0xFF, // 非法命令包 - (预留) MaxID, }
4 ChatServer - TcpChatter服务端程序
4.1 ChatAgent服务端 获取客户端独立的Socket连接请求
在TcpChatter项目中,通过TcpListener创建一个监听端口获取Socket连接请求,不同的客户端连接请求(TcpClient的Connect),服务端会创建客户端各自独立的Socket对象,在ChatAgent中通过ClientContext管理了所有连接客户端的Socket,消息的转发通过各自不同的Socket进行。
4.1.1 ClientContext
ChatServer服务端通过dicClientContext 表保存了所有连接客户端的信息,当客户端异常或离线,其客户端资源会被从Server端移除。
private Dictionary<string, ClientContext> dicClientContext = new Dictionary<string, ClientContext>()
#region InnerClass - Client Instance Context class ClientContext { internal ClientContext() { } internal byte[] Buf { get; set; } internal byte[] HeadBuf { get; set; } internal byte[] DataBuf { get; set; } internal int UserID { get; set; } internal string UserName { get; set; } internal Thread MsgHandle { get; set; } internal Socket Skt { get; set; } } #endregion
4.2 监听连接请求与消息监听流程图
如下图所示,ChatServer启动了一个监听端口,当有新的连接请求达到,会生成新的Socket对象,同时启动Socket服务消息监听线程:
服务监听线程:客户端连接请求线程,有新的客户端成功连接服务端时会生成新的Socket对象。该线程为所有客户端服务。
Socket服务线程:服务监听线程的子线程,用于处理服务端使用Socket转发的消息。为指定Socket的独立客户端服务。
4.3 IChatAgent服务代理接口
TcpChatter的服务端接口含2个属性与2个接口
Name : 服务器名称
IsAlive:服务器激活状态
StartChatServer: 启动服务接口
StopChatServer:关闭服务接口
public interface IChatAgent { string Name { get;} bool IsAlive { get; } bool StartChatServer(); bool StopChatServer(); }
4.4 服务监听线程
/// <summary> /// 客户端 消息处理主线程 /// </summary> private void MessageProcessThread() { ClientContext client = null; while (IsAlive) { try { byte[] useNameBuf = new byte[MAXBUFSIZE]; // 监听连接请求对像 Socket msgSkt = tcpListener.AcceptSocket(); // 等待上线请求 int actualLens = msgSkt.Receive(useNameBuf); // 获取实际数据长度 byte[] buf = this.CopyArrayFrom(useNameBuf, actualLens); byte[] header = null; byte[] dataBuf = null; // 解析上线请求命令包 : 上线请求 + 用户名 LErrorCode error = this.ResolveDataPackage(buf, out header, out dataBuf); if (error != LErrorCode.Success) { Console.Error.WriteLine("ResolveDataPackage failed! LErrorCode = {0}", error); continue; } // 校验命令头 if (header[0] != ProtocolMsg.LCML) { Console.Error.WriteLine("Invalid cmmand head = {0}", header[0]); continue; } // 是否是上线请求 - 第 1 个命令必须是: 上线请求命令包 + 用户名 CmdRequest request = (CmdRequest)header[1]; if (request != CmdRequest.Online) { Console.Error.WriteLine("Invalid request command! Cmd = {0}", request); continue; } // 校验用户名的合法性 string user = this.GetStringFrom(dataBuf); if (!CheckUserInvalid(user)) { string msg = "User name " + user + " has been existed in TcpChatter system! User tried to join chatting failed!"; this.currentRequest = CmdRequest.Failed; this.currentRight = LProtocolRight.WR; msgSkt.Send(CurrentCmd); Console.Error.WriteLine(msg); continue; } // 服务端生成用户信息 并动态分配独立用户ID client = new ClientContext(); client.UserID = ChatAgent.ActiveID; client.UserName = user; client.Skt = msgSkt; dicClientContext.Add(user, client); this.currentRequest = CmdRequest.Success; this.currentRight = LProtocolRight.WR; this.senderID = client.UserID; // 发送登陆成功命令 msgSkt.Send(CurrentCmd); string sysmsg = string.Format("[系统消息]\n新用户 {0} 在[{1}] 已成功连接服务器[当前在线人数: {2}]\r\n\r\n", user, DateTime.Now, dicClientContext.Count); Console.WriteLine(SysInfo.Timestamp + sysmsg); Thread.Sleep(1000); // Sleep 1s Thread handle = new Thread(() => { if (PreMessageProcess(client, sysmsg)) { // 启用用户 消息监听线程 SubMsgProcessThread(client, sysmsg); } }); handle.Start(); dicClientContext[user].MsgHandle = handle; } catch (SocketException se) { Innerlog.Error(dcrlringType, "SocketException Current user = " + client.UserName + " was offline!", se); } catch (Exception ex) { Innerlog.Error(dcrlringType, "Exception Current user = " + client.UserName + " was offline!", ex); } } }
4.5 Socket服务消息线程
/// <summary> /// 用户消息监听线程 /// </summary> /// <param name="client"></param> private void SubMsgProcessThread(ClientContext clientx, string message) { ClientContext client = clientx; while (true) { try { byte[] msgBuf = new byte[MAXBUFSIZE]; // 监听 并接收数据 int actualLens = client.Skt.Receive(msgBuf); byte[] totalBuf = this.CopyArrayFrom(msgBuf, actualLens); byte[] headBuf = null; byte[] dataBuf = null; // 解析命令包 LErrorCode error = this.ResolveDataPackage(totalBuf, out headBuf, out dataBuf); client.HeadBuf = headBuf; client.DataBuf = dataBuf; client.Buf = totalBuf; if (error != LErrorCode.Success) continue; // 是否是有效命令 if (headBuf[0] != ProtocolMsg.LCML) continue; CmdRequest cmdHead = (CmdRequest)headBuf[1]; if (cmdHead == CmdRequest.InvalidCmd || cmdHead == CmdRequest.MaxID || cmdHead == CmdRequest.MinID) { Console.Error.WriteLine("Invalid Send Message!"); continue; } else { // 用户消息转发 UserMessageProcess(client); } } catch (Exception ex) { ClientOfflineProcess(client); //Innerlog.Error(dcrlringType, "Current user = " + client.UserName + " was offline!", ex); Thread.CurrentThread.Abort(); } } }
4.6 序列化
序列化与反序列化在TcpChatter中被用于消息的编码与解码。编码与解码过程可以详细的参看ChatAgent代码内部实现。
序列化描述了持久化一个对像对流的过程,反序列化则与此过程相反,表示从流到对象的重建过程。在.net中,消息传递,数据存储都大量的用到了序列化与反序列化的操作。
由于客户端与服务端消息传输以byte字节流的方式进行传输,当在客户端之前传递对象时需要对对象进行序列化。如传递客户端在线列表,该列表是一个Dictionary,在客户端与服务端进行Dictionary传递需用到序列化与反序列化。
见 TcpChatter CHTCommon中的SysInfo.cs
public static byte[] SerializeGraph<T>(T graph)
4.7 反序列化
见 TcpChatter CHTCommon中的SysInfo.cs
public static T DeserializeGraph<T>(byte[] bytes)
4.8 ChatClient
略
4.9 ChatServer 服务端程序
class Program { static void Main(string[] args) { int beginner = Win32Manager.TickCounter; Console.WriteLine("\r\n-----------------------------------------------------------------------"); Console.WriteLine(SysInfo.Timestamp + "ChatServer is starting........\r\n"); IChatAgent agent = new ChatAgent(null); int linkCounter = 0; bool isStarted = agent.StartChatServer(); while (!agent.IsAlive) { if (linkCounter++ > 10) { Console.WriteLine(SysInfo.Timestamp + "ChatServer start failed! Try LinkCounter = {0}",linkCounter); break; } Thread.Sleep(100); } Console.WriteLine(SysInfo.Timestamp + "Total ElapsedTime = {0}ms", (Win32Manager.TickCounter - beginner)); if (linkCounter < 10) Console.WriteLine(SysInfo.Timestamp + "ChatServer is running........"); Console.WriteLine("-----------------------------------------------------------------------\r\n"); Application.Run(); } }
5 TcpChatter运行测试
运行 ChatServer,如图1所示,输入端口服务启动
运行ChatClient,输入用户名就可以聊天了。当有新用户上线或新用户离线时,ChatServer控制台会显示当前用在线用户的情况。
运行ChatClient输入用户名
ChatClient聊天界面
附录:TcpChatter源代码下载 软件版本: VS2008 语言:C#