1、概述
一臺典型的工控設備通常包括若干通訊接口(網絡、串口、CAN等),以及若干數字IO、AD通道等。運行于設備核心平臺的應用程序通過操作這些接口,實現特定的功能。通常為了高效高精度完成整個通訊控制流程,應用程序采用C/C++語言來編寫。圖1表現了典型工控設備的組成關系。
典型工控設備框圖
工控設備的另一個特點是鑒于設備大多是24小時連續運行,且無人值守,所以基本的工控設備是無顯示的。英創的工控主板ESM6800、ESM335x等都大量的應用于這類無頭工控設備之中。
在實際應用中,部分客戶需要基于已有的無頭工控設備,增加顯示界面功能,以滿足新的應用需求。顯然保持已有的基本工控處理程序不變,通過相對獨立的技術手段來實現顯示功能,最符合客戶的利益訴求。為此我們發展了一種雙進程的程序設計方案來滿足客戶的這一需求。該方案的第一個進程,以客戶已有的用C/C++寫的基礎工控進程為基礎,僅增加一個面向本地IP(127.0.0.1)的偵聽線程,用于向顯示進程提供必要的運行工況數據。圖2為增添了服務線程的工控進程:
帶有偵聽線程的基礎工控進程
方案的第二個進程則主要用于實現顯示界面,可以采用各種手段來實現,本文中介紹了使用Qt的QML語言加通訊插件的界面設計方法。第二個進程(具體是通訊插件單元)通過本地IP,以客戶端方式與基礎工控進程進行Socket通訊,完成進程間數據交換。顯示進程以及與工控進程的關系如圖3所示:
顯示進程與工控進程
2、系統設計
鑒于工業控制領域對系統運行的穩定性要求,控制系統更加傾向于將底層硬件控制部分與上層界面顯示分開,兩部分以雙進程的形式各自獨立運行。底層硬件控制部分將會監控系統硬件,管理外設等,同時收集系統的狀態;而上層界面顯示部分主要用于顯示系統狀態,并實現少量的系統控制功能,方便維護人員查看系統運行狀態并且根據當前狀態進行系統的調整。由于顯示界面不一定是所有設備都配置,而且顯示部分的程序更加復雜,從而更容易出現程序運行時的錯誤,將控制與顯示分開能夠避免由于顯示部分的程序問題而影響到整個控制系統的運行,而且沒有配置顯示屏的設備也可以直接運行底層的控制程序,增加了系統程序的兼容性。顯示與控制分離后,由于顯示界面程序不需要處理底層硬件的管理控制,在設計時可以更加注重于界面的美化,而且界面程序可以采用不同的編程語言進行開發,比如使用Qt C++或者Android java,本文將介紹基于Linux + Qt的雙進程示例程序供客戶在實際開發中參考,關于Android程序請參考我們官網的另一篇文章:《Android雙應用進程Demo程序設計》。
如上圖所示。整個系統分為控制和顯示兩個進程,底層硬件控制部分可以獨立運行,使用多線程管理不同的硬件設備,監控硬件狀態,將狀態發送給socket服務器,并且從socket服務器接收命令來更改設備狀態。Socket服務器也是一個獨立的線程,通過本地網絡通信集中處理來自硬件控制線程以及顯示程序的消息。顯示界面需要連接上socket服務器才能正確的顯示設備的狀態,同時提供必須的人工控制接口,供設備使用過程中人為調整設備運行狀態。目前在ESM6802工控主板上,界面程序可以采用Qt C++編寫,也可以使用Android java進行開發,本文僅介紹采用Qt的界面程序。顯示程序界面用QML搭建,與底層通信的部分用獨立的Qt QML插件實現,這樣顯示部分進一部分離為數據處理和界面開發,使得界面設計可以更加快捷。程序的整體界面效果如下圖所示:
目前我們只提供了串口(SERIAL)和GPIO兩部分的例程。下面將集中介紹程序中通過本地IP實現兩個進程通信的部分供客戶在實際開發中參考。
3、控制端C程序
控制端程序主要分為兩個部分,一個部分用于控制具體的硬件運行(下文稱為控制器),另一個部分為socket服務器,用于與顯示程序之間進行通信。由于本方案主要是為了展示在已有控制程序的基礎上,增加顯示界面功能,以滿足新的應用需求,所以我們在此重點介紹在已有控制程序中加入socket服務器的部分,不再詳細介紹各硬件的具體控制的實現。
增加本地IP通信的功能,首先需要在控制進程中新加入一個socket服務器線程,用于消息的集中管理,實現底層硬件與上層的界面程序的信息交換,socket服務器線程運行的函數體代碼如下:
static void *_init_server(void *param) { int server_sockfd, client_sockfd; int server_len; struct sockaddr_in server_address; struct sockaddr_in client_address;
server_sockfd = socket(AF_INET, SOCK_STREAM, 0); server_address.sin_family = AF_INET; server_address.sin_addr.s_addr = inet_addr("127.0.0.1");//通過本地ip通信 server_address.sin_port = htons(9733); server_len = sizeof(server_address); bind(server_sockfd, (struct sockaddr *)&server_address, server_len);
listen(server_sockfd, 5);
int res; pthread_t client_thread; pthread_attr_t attr; char id[4]; client_element *client_t;
while(1) { if(!client_has_space(clients)) { printf("to many client, wait for one to quit...\n"); sleep(2); continue; } printf("server waiting\n"); client_sockfd = accept(server_sockfd, (struct sockaddr *)&client_address, (socklen_t *)&server_len);
//get and save client id read(client_sockfd, &id, 4); if((id[0]!='I') && (id[1]!='D')) { printf("illegal client id, drop it\n"); close(client_sockfd); continue; }
client_t = accept_client(clients, id, client_sockfd); printf("client: %s connected\n", id);
//create a new thread to handle this connection res = pthread_attr_init(&attr); if( res!=0 ) { printf("Create attribute failed\n" ); } // 設置線程綁定屬性 res = pthread_attr_setscope( &attr, PTHREAD_SCOPE_SYSTEM ); // 設置線程分離屬性 res += pthread_attr_setdetachstate( &attr, PTHREAD_CREATE_DETACHED ); if( res!=0 ) { printf( "Setting attribute failed\n" ); }
res = pthread_create( &client_thread, &attr, (void *(*) (void *))socked_thread_func, (void*)client_t ); if( res!=0 ) { close( client_sockfd ); del_client(clients, client_sockfd); continue; } pthread_attr_destroy( &attr ); } } |
此函數創建一個socket用于監聽(listen)等待顯示程序連接,當接受(accept)一個連接之后創建一個新的線程用于消息處理,主要用于維護socket連接的狀態,解析消息的收發方,并將消息轉送到對應的接收方,在顯示程序建立連接之前或者連接斷開之后,控制器發送的消息將不會進行發送了,而控制器依然在正常運行,用于處理消息的新線程如下:
static void *socked_thread_func(void *p) { client_element *client_p = (client_element *)p; printf("started socked_thread_func for client: %s\n", client_p->id); fd_set fdRead; int ret, lenth; struct timeval aTime; struct msg_head msg_h; char *buf = (char *)&msg_h; //from:2 char to 2 char msglenth:1 int buf[0] = client_p->id[2]; buf[1] = client_p->id[3]; char msg[100]; client_element *send_to; struct tcp_info info; int tcp_info_len=sizeof(info); while(1) { FD_ZERO(&fdRead); FD_SET(client_p->sockfd, &fdRead);
aTime.tv_sec = 2; aTime.tv_usec = 0;
getsockopt(client_p->sockfd, IPPROTO_TCP, TCP_INFO, &info, (socklen_t *)&tcp_info_len); if(info.tcpi_state == 1) { //printf("$$$%d tcp connection established...\n", client_p->sockfd); ; } else { printf("$$$%d tcp connection closed...\n", client_p->sockfd); break; }
ret = select( client_p->sockfd+1,&fdRead,NULL,NULL,&aTime );
if (ret > 0) { //判斷是否讀事件 if (FD_ISSET(client_p->sockfd, &fdRead)) { //data available, so get it! lenth = read( client_p->sockfd, buf+2, 6 ); if( lenth != 6 ) { continue; } // 對接收的數據進行處理,這里為簡單的數據轉發 lenth = read(client_p->sockfd, msg, msg_h.lenth); if(lenth == msg_h.lenth) { send_to = find_client(clients, msg_h.to); //printf("try to send to client %s\n", msg_h.to); if(send_to == NULL) { printf("can't find target client\n"); continue; } write(send_to->sockfd, &msg_h, sizeof(struct msg_head)); write(send_to->sockfd, msg, lenth); } // 處理完畢 } } } close( client_p->sockfd ); del_client(clients, client_p->sockfd); pthread_exit( NULL ); } |
這里收到消息后就解析消息頭,發送到指定的端口去(控制器或者顯示進程),由于實際應用中socket傳送數據可能存在分包的情況,客戶需要自行定義消息的數據格式來保證數據的完整性,以及對數據進行更嚴格的驗證。
另一方面對于已有的控制器來說,需要在原來的基礎上進行修改,在主線程中與socket服務器建立連接:
sockedfd = socket(AF_INET, SOCK_STREAM, 0); address.sin_family = AF_INET; address.sin_addr.s_addr = inet_addr("127.0.0.1"); address.sin_port = htons(9733); len = sizeof(address); do { res = connect(sockedfd, (struct sockaddr *)&address, len); if(res == -1) { perror("oops: connect error"); } }while(res == -1); write(sockedfd, "IDG1", 4); printf("###connected to server\n"); |
然后建立兩個線程分別處理數據(data_thread_func)和命令(command_thread_func),其中data_thread_func用于監聽硬件狀態,并且發送相應的狀態消息給socket服務器,而command_thread_func用于監聽socket服務器的消息等待命令,用于改變硬件運行狀態,不需要界面帶有控制功能的客戶可以不實現commad_thread_func。以GPIO控制器為例:
void *gpio_controller::data_thread_func(void* lparam) { gpio_controller *pSer = (gpio_controller*)lparam;
fd_set fdRead; int ret=0; struct timeval aTime; unsigned int pinstates = 0; struct msg_head buf_h;
while( 1 ) { FD_ZERO(&fdRead); FD_SET(pSer->interface_fd,&fdRead);
aTime.tv_sec = 2; aTime.tv_usec = 0;
//等待硬件消息,這里是GPIO狀態改變 ret = select( pSer->interface_fd+1,&fdRead,NULL,NULL,&aTime );
if (ret < 0 ) { //關閉 perror("select wrong"); pSer->close_interface(pSer->interface_fd); break; }
else { //select超時或者GPIO狀態發生了改變,讀取GPIO狀態,發送給socket服務器 pinstates = INPINS; ret = GPIO_PinState(pSer->interface_fd, &pinstates); if(ret < 0) { printf("GPIO_PinState::failed %d\n", ret); break; } sprintf((char *)&buf_h.to[0], "D1"); buf_h.lenth = sizeof(pinstates); write(pSer->sockedfd, (void *)&buf_h.to[0], 6); write(pSer->sockedfd, (void *)&pinstates, sizeof(pinstates)); } } printf( "ReceiveThreadFunc finished\n"); pthread_exit( NULL ); }
void *gpio_controller::command_thread_func(void* lparam) { gpio_controller *pSer = (gpio_controller*)lparam;
fd_set fdRead; int ret, len; struct timeval aTime; struct outcom{ unsigned int outpin; unsigned int outstate; }; struct outcom out; struct msg_head buf_h;
while( 1 ) { FD_ZERO(&fdRead); FD_SET(pSer->sockedfd,&fdRead);
aTime.tv_sec = 3; aTime.tv_usec = 300000;
//等待socket服務器的消息 ret = select( pSer->sockedfd+1,&fdRead,NULL,NULL,&aTime ); if (ret < 0 ) { //關閉 pSer->close_interface(pSer->interface_fd); break; }
if (ret > 0) { //判斷是否讀事件 if (FD_ISSET(pSer->sockedfd,&fdRead)) { len = read(pSer->sockedfd, &buf_h, sizeof(buf_h)); //獲取socket服務器發送的信息,進行解析 if(len != sizeof(struct outcom)) { printf("###invalid command lenth: %d, terminate\n", len); } len = read(pSer->sockedfd, &out, buf_h.lenth);
//write command switch(out.outstate) { case 0: GPIO_OutClear(pSer->interface_fd, out.outpin); if(ret < 0) printf("GPIO_OutClear::failed %d\n", ret); //printf("GPIO_OutClear::succeed %d\n", ret); break; case 1: GPIO_OutSet(pSer->interface_fd, out.outpin); if(ret < 0) printf("GPIO_OutSet::failed %d\n", ret); //printf("GPIO_OutSet::succeed %d\n", ret); break; default: printf("###wrong gpio state %d, no operation\n", out.outstate); ret = -1; break; } if(ret < 0) break; } } } printf( "ReceiveThreadFunc finished\n"); pthread_exit( NULL ); } |
這里兩個函數主要任務都是處理數據,data_thread_func使用select函數來等待輸入GPIO的狀態改變事件,如果有狀態改變或者select等待超時都讀取一次GPIO的狀態,然后發送給socket服務器;command_thread_func監聽服務器的消息,收到消息后進行解析,然后根據消息來操作GPIO輸出信號。
通過這兩個函數便與socket服務器建立了消息溝通通道,而socket服務器會自動將數據轉發到顯示進程,這種實現可以使得對已有程序的改動降到很低的程度。實際實現中,可以在socket服務器中增加狀態機等其他功能,記錄硬件狀態信息等。
4、顯示程序
顯示部分我們采用Qt來搭建,主要分為QML搭建的界面以及Qt c++編寫的數據處理插件。QML是Qt提供的一種描述性的腳本語言,類似于css,可以在腳本里創建圖形對象,并且支持各種圖形特效,以及狀態機等,同時又能跟Qt寫的C++代碼進行方便的交互,使用起來非常方便。采用QML加插件的方式主要是為了將界面設計與程序邏輯解耦,一般的系統開發中界面設計的變動往往多于后臺邏輯,因此采用QML加插件的方式將界面設計與邏輯分離有利于開發人員的分工,加速產品迭代速度,降低后期維護成本。而且QML解釋性語言的特性使得其語法更加簡單,可以將界面設計部分交給專業的設計人員開發,而不要求設計人員會c++等編程語言。Qt底層對QML做了優化,將會優先使用硬件圖形加速器進行界面的渲染,也針對觸摸屏應用做了優化,使用QML能夠更簡單快捷的搭建流暢、優美的界面。QML也支持嵌入Javascript處理邏輯,但是底層邏輯處理使用Qt C++編寫插件,能夠更好的控制數據結構,數據處理也更加高效,Qt提供了多種方式將C++數據類型導入QML腳本中,更多詳細資料可以查看Qt官方的文檔。由于篇幅原因,我們在另外一篇文章:《使用QML進行界面開發》中更詳細地介紹了QML及插件的實現,在此我們還是集中介紹socket消息處理部分。
本例程中數據處理插件的任務就是連接socket服務器,與服務器進行通信,接收消息進行解析然后提供給QML界面,以及從QML界面獲取消息給socket服務器發送命令。插件中通過socket進行通信的部分代碼如下:
void MsgClient::cServer(void* param) { MsgClient *client = (MsgClient *)param; int ret; int len; struct sockaddr_in address; int sockedfd = socket(AF_INET, SOCK_STREAM, 0); printf("sockedfd: %d\n", sockedfd); client->sockedfd = sockedfd; address.sin_family = AF_INET; address.sin_addr.s_addr = inet_addr("127.0.0.1"); //本地IP通信 address.sin_port = htons(9733); len = sizeof(address); do { printf("Client: connecting...\n"); ret = ::connect(sockedfd, (struct sockaddr *)&address, len); //建立連接 if(ret == -1) { perror("oops: connect to server error"); } sleep(2); }while(ret == -1); write(sockedfd, "IDD1", 4); printf("Client: connected to server\n"); emit client->serverConnected(); fd_set fdRead; struct timeval aTime; char buf[100]; unsigned int pinstates; struct msg_head buf_h; while(!client->exit_flag) { FD_ZERO(&fdRead); FD_SET(sockedfd, &fdRead); aTime.tv_sec = 3; aTime.tv_usec = 0; ret = select(sockedfd+1, &fdRead, NULL, NULL, &aTime); //等待消息 if(ret < 0) { perror("someting wrong with select"); } if(ret > 0) { if(FD_ISSET(sockedfd, &fdRead)) { len = read(sockedfd, &buf_h, sizeof(buf_h)); int i; switch (buf_h.from[0]) { //解析消息 case 'S': //串口信息 i = buf_h.from[1] - '0'; len = read(sockedfd, buf, buf_h.lenth); client->rmsgQueue[i] << buf; if(i == client->m_interface) emit client->newMsgRcved(); memset(buf, 0, sizeof(buf)); break; case 'G': //GPIO信息 len = read(sockedfd, &pinstates, buf_h.lenth); printf("get GPIO pinstates\n"); client->updateGPIOState(pinstates); break; default: break; } } } } close(sockedfd); pthread_exit(NULL); } |
如代碼所示,插件首先通過本地IP127.0.0.1與socket服務器建立連接(connect),然后等待socket服務器的消息(select),收到消息后進行解析,判斷是哪個硬件控制器發送的消息,然后更新相應的顯示界面,這里的代碼相對簡單,只是為了展示通過本地IP實現顯示進程與控制進程之間的通信,實際使用中客戶需要對數據進行更嚴格的檢驗。
使用QML搭建串口控制界面如下圖所示:
GPIO控制器的顯示效果如下:
由于篇幅原因,我們在此不詳細介紹實現界面的QML腳本了,將會在另一篇文章中進行專門的介紹,感興趣的用戶可以關注我們官網上的文章更新,或者向我們要取程序源碼。用戶在實際開發中可以參考此方式實現顯示進程與控制進程之間的通信,從而實現單獨的顯示進程,對已有的控制進程的更改控制到很小的程度,一方面減少了由于程序修改而造成控制程序的不穩定,另一方面使用QML又能快速的搭建界面,解決顯示設備狀態的需求。
5、總結
實際測試過程中,我們在ESM6802工控板上運行本文介紹的程序,底層控制程序直接可以開機后臺運行,顯示程序開機后手動加載,通過本地IP地址與控制程序的socket服務器連接,然后實時更新系統狀態,也能及時響應人工控制,如改變輸出GPIO的輸出狀態,關掉顯示程序之后,控制程序繼續正常運行,之后還可以再次啟動顯示程序。
將底層控制與顯示分開后,程序開發分工可以更加細致,也一定程度上增加了控制系統的穩定性,減小了維護成本。同時使用QML進行界面開發能夠更加方便快速的更新系統的顯示效果,完成產品迭代。由于底層控制與顯示之間采用socket進行通信,顯示部分也可以采用其他的開發環境,比如ESM6802也支持Android開發,用戶在產品升級換代的時候就能夠直接沿用底層控制部分的程序,而只對上層顯示部分的程序進行調整。
有興趣的客戶可以和我們的工程師進行溝通獲取更多信息以及程序代碼。
本文PDF下載:Linux雙進程應用示例
成都英創信息技術有限公司 028-8618 0660