前言:
本項目的孵化說來也是機緣巧合的事,本人於13年杭州某大學畢業后去了一家大型的國企工作,慢慢的走上了工業軟件,上位機軟件開發的道路。於14年正式開發基於windows的軟件,當時可選的技術棧就是MFC和C#的winform,后來就發現C#的更為簡單一些,那就直接干,先做再說。需要做一些界面相關的軟件,就直接采用了C#的winform,基礎不夠,百度來湊。后來領導就下達了一個任務,開發一個硫化機系統的上位機,對某個車間共計五六十台硫化機進行監控和曲線查看。由於沒有可參考的界面程序,開發起來就比較費勁,具體有什么功能,都是干嘛的,工藝需要什么等等都是未知數,沒辦法,只有硬着頭皮和現場的工藝人員,電氣人員,來回溝通交流,加上一些我自己的理解,正式踏上了工業軟件開發的道路。
開始做項目的時候,硫化機設備是采用PLC作為主控制器的,第一道攔路虎就是如何將三菱的PLC(邏輯控制器,通常作為設備的核心控制單元)的數據給拿到我的軟件中來呢?這真是一個棘手的問題啊,首先就是百度,搜索到了MX component組件,初步試了試,真的比較麻煩,關鍵還沒弄通。然后就去看看有沒有其他的方式實現,后來就在工廠的備件庫里看到了三菱的以太網模塊QJ71E71-100,然后就搜索支持的通訊說明,在三菱的官網上找到了通信說明,打開一看,我去,這么長篇大論。那也沒有辦法,按照邊測試邊開發,勉勉強強讀到了我想要讀的數據(當然,這時候的代碼基本都是寫死的),又開始解析數據成真實的數據,然后研究如何存入數據庫中去,再研究怎么顯示曲線,到這里為止,這個項目的基本技術難題基本算是攻克了,持續的迭代,那是后話了。
在接下來的兩三年里,接觸並開發了好幾個類似的項目,發現通常工業軟件的需求是采集,分析,存數據庫,顯示。后來對通信的理解深入,由單機軟件發展成了CS架構的軟件,微軟的數據庫SQL Server本來就支持局域網訪問。后來在17年趁着換工作和考駕照的間隙,梳理了上份工作積累的經驗,和實際的需求,再加上自己的代碼水平也稍微進步了一點點,就整理成了HslCommunication,並將之開源出來,初步的功能是三菱PLC的數據讀寫,C#軟件之間的數據通信。后來又集成了modbus協議,西門子,歐姆龍,ab plc,三菱串口等等,發現寫庫的要求和寫簡單程序的要求並不是等同的,要寫成庫的話,需要保證功能靈活性,你寫的代碼基本符合大多數人的使用需求,而不是某種特定的情況。也就是說,有些人可能簡單的使用而已,而有些人會深入使用,壓榨性能。然后就是代碼了,所有寫代碼的標准的最終目的都是為了讓代碼可讀性增強,可維護性增強,方便快速的理解,升級,查錯。這方便確實卻要經驗積累。
做這個項目(HslCommunication)的目標和開源的初衷是方便廣大的像我這種的在工廠一線的軟件工程師,我一直覺得我們不應該把自己看做是程序員,程序員的角色更像是碼農,主要工作就是敲代碼,而軟件工程師應該是更大的定義,設計軟件的整體架構和開發的。這幾年大多數工作都開始意識到工業軟件,上位機軟件,數據追述系統,SCADA軟件,MES軟件開發的重要性,所以像我這樣的有通信需求的人應該不在少數,況且開源有助於別人來一起改進,和代碼測試。所以在開源之后,在博客園就陸陸續續的寫了一些文章,比如如何使用C#和三菱PLC通訊,C#和西門子通訊等等。從博客園的點擊量來看,確實有大量的工廠的程序員有這方便的需求,而直接采用socket來開發,比較晦澀難懂,坑又比較多,事實上確實有很多人來報告了bug。幫助我修復了這個組件,提高了穩定性。再次感謝所有使用或是報告bug的萬千網友,沒有你們的支持就沒有本項目的今天。
由於我也是這個項目的使用者之一,實際上我自己在工作或是其他方面的使用也是很頻繁的,在開發項目上就會站在使用者的角度出發,比如我想讀取三菱PLC的D100的數據,能不能有個組件一兩行代碼就可以實現?偽代碼的邏輯就是
1. 實例化
2. 讀
這樣才算是簡單的操作,本着這樣設計思想,最終有了現在的開源項目。
HslCommunication 能干什么?
相比大多數人比較關心這個問題,綜合前言的介紹,這個組件主要是用於工業通信的,也有兩個程序之間的通訊,還有其他雜七雜八的功能,更像是我的工具插件。各種小功能,擴展的小功能等等。直接上圖:

這是這個開源項目的demo程序,基本上將80%的功能列舉出來了,當然還有一些小功能沒有列舉。大多數支持的設備都在上面進行顯示了,可以方便的進行測試,看看是不是可以實現讀寫的操作(對現場實際在生產的設備應當注意寫入不正確的數據會導致意外事故發生)。比如我們來看看三菱的PLC的demo程序:

其他的截圖畫面就不一一舉例了,都是類似或是基本類似的。可以方便的使用demo進行測試。
特別注意,本組件實現的所有的通訊都是基於socket直接實現的,通信部分不依賴任何第三方通訊庫或是組件安裝,也就是說,你拿個dll可以直接和PLC通訊,這對於部署,開發調試,升級都是非常方便的。
當你需要進行PLC通信時,可以先用demo程序進行測試,如果demo程序可以讀寫,那么用本組件也就絕對可以讀寫,有些PLC的參數如果不清楚,就需要聯系電氣工程師進行確認。比如AB PLC的slot,不知道該寫什么,就嘗試為0,如果不行,就只能聯系電氣工程師解決這個問題。
demo項目的意義:當我開發了三菱PLC的通訊程序和西門子的通訊程序之后,我發現如果我想要測試一個新的PLC通不通?或是簡單的通過代碼讀PLC的某個地址的程序的時候,就好費勁,需要經常創建一些小項目,這些小項目本身並沒有什么實際的意義,就是簡單的讀個數據之類的。后來就想把這部分內容做成一個通用的測試,於是就有了demo項目,將本項目支持的各種設備都往界面上羅列,做成一個測試環境的demo程序,這樣當大家也有這樣的需求的時候,並不需要再新建一些無用的小項目了,本demo就基本上滿足大家所有的需求了。
demo項目的彩蛋:在18年11月之后,demo項目實現了版本控制和自動升級,12月之后實現了統計全球的使用情況,下圖就是demo項目 v5.6.2-最新 的2018年12月到2019年2月中旬的全球使用情況(這是不完全統計,舊版未統計,大量的舊版不支持自動更新,有些demo屏蔽了檢測,實際使用量應該遠超圖片所示)

整體框架說明
整個框架的項目結構如下:

首先文件夾 TestProject 里面的項目都是一些demo項目,當然最重要的就是 HslCommunicationDemo 項目了。就是最上面的demo項目的截圖,Hsl具體能干什么可以參照這個。
本項目使用了三個框架的項目,也就是說,本項目提供dll文件包含了三個框架版本:
- .net framework 3.5
- .net framework 4.5
- .net standard 2.0
維護三份源代碼顯然是什么痛苦的,所以我采用了維護一份源代碼,也就是 .Net 4.5的代碼,其他兩個項目引用.net 4.5的代碼,如果有不一致的地方,就用預編譯指令進行區分。例如在modbusserver類中

而 HslCommunication_Net45.Test 項目是一個單元測試項目,包含了一些代碼類的測試,還有示例代碼的編寫。所以我們的重點來看看 .net 4.5的項目即可,整體的結構如下圖:

BasicFramework 放些了一些基於的小工具的類,比如SoftBasic提供了大量小的靜態輔助方法,幫助你快速開發實現一些基礎的小功能的。
Core 里放置了一些本項目的核心代碼,所有網絡通信類的基礎類,基礎功能實現都在Core里。
Enthernet 里放置了一些高級程序語言之間的通信,比如兩個exe間通信,或是局域網兩台電腦通信,或是多個電腦程序通信。
LogNet 是實現了本項目的日志工具,可以方便的存儲日志信息。
ModBus 實現了基於網絡的modbus-tcp協議,modbus-rtu協議,modbus-server,modbus-ascii協議的通信。
Profinet 實現了三菱,西門子,歐姆龍,松下,ab plc的數據通信。
OperateResult 類說明
這個類為什么拿出來出來說呢?因為這個類貫穿了HSL整個項目,是本開源項目的思想之一。對這個類的理解,和對於本項目的理解至關重要。

左邊也即是這個類的位置,右邊是這個類的定義,在項目最初的開發階段,我遇到了一個問題,這也是軟件開發過程中大家都會遇到的問題,比如我要實現一個讀取PLC一個數據的操作,讀取成功了自然皆大歡喜,如果讀取失敗了呢?
我如何將讀取失敗,或是寫入失敗,或是操作失敗的信息傳遞給調用者呢?除了失敗的信息之外,應該還要包含一個為什么失敗的信息,PLC本身的失敗會返回一個錯誤碼,那就也需要一個錯誤碼。所以就有了 OperateResult 的雛形:
/// <summary>
/// 指示本次訪問是否成功
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 具體的錯誤描述
/// </summary>
public string Message { get; set; } = StringResources.Language.UnknownError;
/// <summary>
/// 具體的錯誤代碼
/// </summary>
public int ErrorCode { get; set; } = 10000;
於是就有了上面的三個屬性內容,但是這時候還有一點需要注意,返回的結果對象應該是可以帶內容的,比如你讀取了一個int數據,應該帶一個int的結果,讀取了一個short的數據,就應該帶一個short類型的數據,如果需要這個結果對象支持多類型的內容的話,查了查書,發現有個泛型的功能剛好合適,但是之后又發現,萬一我想要帶2個不同類型的結果對象時,那怎么辦?這時候就需要定義多個不同類型的 OperateResult 類型了。

此處定義多達十個的泛型對象,滿足絕大多數的情況請用。這個類型對象除了能返回帶有錯誤信息的結果對象之外,還允許進行結果路由,我們來看看這個項目里的一個方法:
/// <summary>
/// 使用底層的數據報文來通訊,傳入需要發送的消息,返回最終的數據結果,被拆分成了頭子節和內容字節信息
/// </summary>
/// <param name="socket">網絡套接字</param>
/// <param name="send">發送的數據</param>
/// <returns>結果對象</returns>
/// <remarks>
/// 當子類重寫InitializationOnConnect方法和ExtraOnDisconnect方法時,需要和設備進行數據交互后,必須用本方法來數據交互,因為本方法是無鎖的。
/// </remarks>
protected OperateResult<byte[], byte[]> ReadFromCoreServerBase(Socket socket, byte[] send )
{
LogNet?.WriteDebug( ToString( ), StringResources.Language.Send + " : " + BasicFramework.SoftBasic.ByteToHexString( send, ' ' ) );
TNetMessage netMsg = new TNetMessage
{
SendBytes = send
};
// 發送數據信息
OperateResult sendResult = Send( socket, send );
if (!sendResult.IsSuccess)
{
socket?.Close( );
return OperateResult.CreateFailedResult<byte[], byte[]>( sendResult );
}
// 接收超時時間大於0時才允許接收遠程的數據
if (receiveTimeOut >= 0)
{
// 接收數據信息
OperateResult<TNetMessage> resultReceive = ReceiveMessage(socket, receiveTimeOut, netMsg);
if (!resultReceive.IsSuccess)
{
socket?.Close( );
return new OperateResult<byte[], byte[]>( StringResources.Language.ReceiveDataTimeout + receiveTimeOut );
}
LogNet?.WriteDebug( ToString( ), StringResources.Language.Receive + " : " +
BasicFramework.SoftBasic.ByteToHexString( BasicFramework.SoftBasic.SpliceTwoByteArray( resultReceive.Content.HeadBytes,
resultReceive.Content.ContentBytes ), ' ' ) );
// Success
return OperateResult.CreateSuccessResult( resultReceive.Content.HeadBytes, resultReceive.Content.ContentBytes );
}
else
{
// Not need receive
return OperateResult.CreateSuccessResult( new byte[0], new byte[0] );
}
}
我們看到,方法里面的錯誤信息,可以由結果路由進行層層上傳,最終拋給調用者,代碼里需要做的就是發生錯誤的時候處理好后續的邏輯即可。這個類提供了幾個靜態方法快速的處理結果路由


通訊核心說明
講完了結果路由再來說說,整個網絡類的核心在於 NetworkBase類,在項目的開發過來中,尤其是開發了幾個不同的PLC和C#程序之間的服務器客戶端通信之后,發現有些底層代碼是有些重復的,所以經過不斷的提煉代碼形成了所有網絡的底層基類,這個類呢,只是提供了一個socket相關通用的操作邏輯,比如,創建並連接的socket對象,接收指定長度的數據,發送字節數據,關閉,接收流,發送流等等操作。

這個類實現了基礎的字節收發功能和連接斷開功能。接下來就是 NetworkDoubleBase 類的實現,實現了長短連接的操作,在我們實際讀寫設備的過程中,網絡狀況往往是差別很大,所以本項目的初衷就是同時支持長連接和短連接。根據大家需求的不同,
所謂的短連接是讀取的時候再連接,讀取完成就關閉連接。缺點就是連接打開和關閉耗時,影響讀取速率,優點就是對網絡狀況反饋即使,讀取失敗了就說明網絡斷了,適合頻率較低的讀寫。
長連接就是讀取開始前連接一次,就不再關閉,進行頻繁的讀取,最后再關閉,好處當然是高速了,缺點就是網絡狀況不是那么好的時候,效率比較低下,對網絡狀況反應也不及時。

短連接就是直接的實例化,然后讀取寫入操作,每一次操作都是一次完整的通信過程。
切換長連接有兩種辦法,效果是一致的,
1. 對象讀寫前調用ConnectServer();
2. 對象讀寫前調用SetPersistentConnection( );
這兩個方法都是雙模式類里支持並實現的。所有的派生類都符合這個調用機制。
實現了長短的連接后,還要實現設備的BCL類型的讀寫,本質是基於byte數組和C#基礎類型的轉換,但是這里有個問題,不同的PLC,modbus協議對於轉換的格式不是固定的,有可能是一樣的,有可能不是一樣的,所以又抽象出來一個 IByteTransform 接口

這個接口集成到了下面的設備交互的基類 NetworkDeviceBase 里,這個基類實現了一些基礎的類型的數據讀寫。

所以到這里可以看到,從NetworkDeviceBase類繼承出去的設備類(大部分的設備通信協議都是從這個繼承出去的),其基本的讀寫代碼都是一致的,關於解析協議,通信的底層都是封裝完畢,
通訊舉例說明
先舉例說明三菱PLC的讀寫操作:
// 實例化對象,指定PLC的ip地址和端口號
MelsecMcNet melsecMc = new MelsecMcNet( "192.168.1.110", 6000 );
// 連接對象
OperateResult connect = melsecMc.ConnectServer( );
if (!connect.IsSuccess)
{
Console.WriteLine( "connect failed:" + connect.Message );
return;
}
// 舉例讀取D100的值
short D100 = melsecMc.ReadInt16( "D100" ).Content;
melsecMc.ConnectClose( );
經過層層封裝后,讀寫的邏輯精簡為,實例化,連接,讀寫,關閉。無論是三菱的PLC,還是西門子的PLC,都是一致的,因為基類的模型都是一致的。
// 實例化對象,指定PLC的ip地址和端口號
SiemensS7Net siemens = new SiemensS7Net( SiemensPLCS.S1200, " 192.168.1.110" );
// 連接對象
OperateResult connect = siemens.ConnectServer( );
if (!connect.IsSuccess)
{
Console.WriteLine( "connect failed:" + connect.Message );
return;
}
// 舉例讀取M100的值
short M100 = siemens.ReadInt16( "M100" ).Content;
siemens.ConnectClose( );
當然,支持大多數的C#類型數據讀寫
MelsecMcNet melsec_net = new MelsecMcNet( "192.168.0.100", 6000 );
// 此處以D寄存器作為示例
short short_D1000 = melsec_net.ReadInt16( "D1000" ).Content; // 讀取D1000的short值
ushort ushort_D1000 = melsec_net.ReadUInt16( "D1000" ).Content; // 讀取D1000的ushort值
int int_D1000 = melsec_net.ReadInt32( "D1000" ).Content; // 讀取D1000-D1001組成的int數據
uint uint_D1000 = melsec_net.ReadUInt32( "D1000" ).Content; // 讀取D1000-D1001組成的uint數據
float float_D1000 = melsec_net.ReadFloat( "D1000" ).Content; // 讀取D1000-D1001組成的float數據
long long_D1000 = melsec_net.ReadInt64( "D1000" ).Content; // 讀取D1000-D1003組成的long數據
ulong ulong_D1000 = melsec_net.ReadUInt64( "D1000" ).Content; // 讀取D1000-D1003組成的long數據
double double_D1000 = melsec_net.ReadDouble( "D1000" ).Content; // 讀取D1000-D1003組成的double數據
string str_D1000 = melsec_net.ReadString( "D1000", 10 ).Content; // 讀取D1000-D1009組成的條碼數據
// 讀取數組
short[] short_D1000_array = melsec_net.ReadInt16( "D1000", 10 ).Content; // 讀取D1000的short值
ushort[] ushort_D1000_array = melsec_net.ReadUInt16( "D1000", 10 ).Content; // 讀取D1000的ushort值
int[] int_D1000_array = melsec_net.ReadInt32( "D1000", 10 ).Content; // 讀取D1000-D1001組成的int數據
uint[] uint_D1000_array = melsec_net.ReadUInt32( "D1000", 10 ).Content; // 讀取D1000-D1001組成的uint數據
float[] float_D1000_array = melsec_net.ReadFloat( "D1000", 10 ).Content; // 讀取D1000-D1001組成的float數據
long[] long_D1000_array = melsec_net.ReadInt64( "D1000", 10 ).Content; // 讀取D1000-D1003組成的long數據
ulong[] ulong_D1000_array = melsec_net.ReadUInt64( "D1000", 10 ).Content; // 讀取D1000-D1003組成的long數據
double[] double_D1000_array = melsec_net.ReadDouble( "D1000", 10 ).Content; // 讀取D1000-D1003組成的double數據
寫入的操作:
MelsecMcNet melsec_net = new MelsecMcNet( "192.168.0.100", 6000 );
// 此處以D寄存器作為示例
melsec_net.Write( "D1000", (short)1234 ); // 寫入D1000 short值 ,W3C0,R3C0 效果是一樣的
melsec_net.Write( "D1000", (ushort)45678 ); // 寫入D1000 ushort值
melsec_net.Write( "D1000", 1234566 ); // 寫入D1000 int值
melsec_net.Write( "D1000", (uint)1234566 ); // 寫入D1000 uint值
melsec_net.Write( "D1000", 123.456f ); // 寫入D1000 float值
melsec_net.Write( "D1000", 123.456d ); // 寫入D1000 double值
melsec_net.Write( "D1000", 123456661235123534L ); // 寫入D1000 long值
melsec_net.Write( "D1000", 523456661235123534UL ); // 寫入D1000 ulong值
melsec_net.Write( "D1000", "K123456789" ); // 寫入D1000 string值
// 讀取數組
melsec_net.Write( "D1000", new short[] { 123, 3566, -123 } ); // 寫入D1000 short值 ,W3C0,R3C0 效果是一樣的
melsec_net.Write( "D1000", new ushort[] { 12242, 42321, 12323 } ); // 寫入D1000 ushort值
melsec_net.Write( "D1000", new int[] { 1234312312, 12312312, -1237213 } ); // 寫入D1000 int值
melsec_net.Write( "D1000", new uint[] { 523123212, 213,13123 } ); // 寫入D1000 uint值
melsec_net.Write( "D1000", new float[] { 123.456f, 35.3f, -675.2f } ); // 寫入D1000 float值
melsec_net.Write( "D1000", new double[] { 12343.542312d, 213123.123d, -231232.53432d } ); // 寫入D1000 double值
melsec_net.Write( "D1000", new long[] { 1231231242312,34312312323214,-1283862312631823 } ); // 寫入D1000 long值
melsec_net.Write( "D1000", new ulong[] { 1231231242312, 34312312323214, 9731283862312631823 } ); // 寫入D1000 ulong值
這里舉例了三菱的PLC,實際上各種PLC的操作都是類似的。
Redis實現
除了上述的基本的設備通信,還實現了redis數據庫讀寫操作,分了兩個類實現,下圖為一般的通信功能

同時demo中實現了一個瀏覽redis服務器的界面功能

最后的總結
本通信庫實現了.net 3.5 和 .net 4.5的框架,還附帶了一些簡單的控件,此外還實現了.net standard版本,已在linux測試成功,由於官方在.net core2.2中還未實現串口類,所以暫時沒有實現串口相關的。
未來的方向,希望繼續優化代碼,架構,集成實現更多設備通信,方便廣大的網友直接開發測試。
開源地址:https://github.com/dathlin/HslCommunication
官網:http://www.hslcommunication.cn/
更多詳細的內容請查看源代碼的readme文件。
