onps棧使用說明(3)——tcp、udp通訊測試

2022-11-12 21:00:16

4. tcp使用者端

       在協定棧原始碼工程下,存在一個用vs2015建立的TcpServerForStackTesting工程。其執行在windows平臺下,模擬實際應用場景下的tcp伺服器。當tcp使用者端連線到伺服器後,伺服器會立即下發一個1100多位元組長度的控制報文到使用者端。之後在整個tcp鏈路存續期間,伺服器會每隔一段隨機的時間(90秒到120秒之間)下發控制報文到使用者端,模擬實際應用場景下伺服器主動下發指令、資料到使用者端的情形。使用者端則連續上發資料包文到伺服器,伺服器回饋一個應答報文給使用者端。使用者端如果收不到該應答報文則會立即重發,直至收到應答報文或超過重試次數後重連伺服器。總之,整個測試場景的設計目標就是完全契合常見的商業應用需求,以此來驗證協定棧的核心功能指標是否完全達標。用vs2015開啟這個工程,設定管理器指定目標平臺為x64。main.cpp檔案的頭部定義了伺服器的埠號以及報文長度等資訊:

#define SRV_PORT         6410 //* 伺服器埠
#define LISTEN_NUM       10   //* 最大監聽數
#define RCV_BUF_SIZE     2048 //* 接收緩衝區容量
#define PKT_DATA_LEN_MAX 1200 //* 報文攜帶的資料最大長度,凡是超過這個長度的報文都將被丟棄

我們可以依據實際情形調整上述設定並利用這個模擬伺服器測試tcp使用者端的通訊功能。

……
#include "onps.h"

#define PKT_FLAG 0xEE //* 通訊報文的頭部和尾部標誌
typedef struct _ST_COMMUPKT_HDR_ { //* 資料及控制指令報文頭部結構
    CHAR bFlag;         //* 報文頭部標誌,其值參看PKT_FLAG宏
    CHAR bCmd;          //* 指令,0為資料包文,1為控制指令報文
    CHAR bLinkIdx;      //* tcp鏈路標識,當存在多個tcp鏈路時,該欄位用於標識這是哪一個鏈路
    UINT unSeqNum;      //* 報文序號
    UINT unTimestamp;   //* 報文被傳送時刻的unix時間戳
    USHORT usDataLen;   //* 攜帶的資料長度
    USHORT usChechsum;  //* 校驗和(crc16),覆蓋除頭部和尾部標誌字串之外的所有欄位
} PACKED ST_COMMUPKT_HDR, *PST_COMMUPKT_HDR; 

typedef struct _ST_COMMUPKT_ACK_ { //* 資料即控制指令應答報文結構
    ST_COMMUPKT_HDR stHdr; //* 報文頭
    UINT unTimestamp;      //* unix時間戳,其值為被應答報文攜帶的時間戳
    CHAR bLinkIdx;         //* tcp鏈路標識,其值為被應答報文攜帶的鏈路標識
    CHAR bTail;            //* 報文尾部標誌,其值參看PKT_FLAG宏
} PACKED ST_COMMUPKT_ACK, *PST_COMMUPKT_ACK;

//* 提前申請一塊靜態儲存時期的緩衝區用於tcp使用者端的接收和傳送,因為接收和傳送的報文都比較大,所以不使用動態申請的方式
#define RCV_BUF_SIZE     1300           //* 接收緩衝區容量
#define PKT_DATA_LEN_MAX 1200           //* 報文攜帶的資料最大長度,凡是超過這個長度的報文都將被丟棄
static UCHAR l_ubaRcvBuf[RCV_BUF_SIZE]; //* 接收緩衝區
static UCHAR l_ubaSndBuf[sizeof(ST_COMMUPKT_HDR) + PKT_DATA_LEN_MAX]; //* 傳送緩衝區,ST_COMMUPKT_HDR為通訊報文頭部結構體
int main(void)
{
    EN_ONPSERR enErr; 
    SOCKET hSocket = INVALID_SOCKET;
    
    if(open_npstack_load(&enErr))
    {    
        printf("The open source network protocol stack (ver %s) is loaded successfully. \r\n", ONPS_VER);
        
        //* 協定棧載入成功,在這裡初始化ethernet網路卡或等待ppp鏈路就緒
    #if 0
        emac_init(); //* ethernet網路卡初始化函數,並註冊網路卡到協定棧
    #else
        while(!netif_is_ready("ppp0")) //* 等待ppp鏈路建立成功
            os_sleep_secs(1); 
    #endif
    }
    else
    {
        printf("The open source network protocol stack failed to load, %s\r\n", onps_error(enErr));
        return -1; 
    }
    
    //* 分配一個socket         
    if(INVALID_SOCKET == (hSocket = socket(AF_INET, SOCK_STREAM, 0, &enErr))) 
    {
        //* 返回了一個無效的socket,列印錯誤紀錄檔
        printf("<1>socket() failed, %s\r\n", onps_error(enErr)); 
        return -1; 
    }
    
    //* 連線成功則connect()函數返回0,非0值則連線失敗
    if(connect(hSocket, "192.168.0.2", 6410, 10))
    {
        printf("connect 192.168.0.2:6410 failed, %s\r\n", onps_get_last_error(hSocket, NULL));
        close(hSocket);
        return -1; 
    }
    
    //* 等待接收伺服器應答或控制報文的時長(即recv()函數的等待時長),單位:秒。0不等待;大於0等待指定秒數;-1一直
    //* 等待直至資料到達或報錯。設定成功返回TRUE,否則返回FALSE。這裡我們設定recv()函數不等待
    //* 注意,只有連線成功後才可設定這個接收等待時長,在這裡我們設定接收不等待,recv()函數立即返回,非阻塞型
    if(!socket_set_rcv_timeout(hSocket, 0, &enErr))
        printf("socket_set_rcv_timeout() failed, %s\r\n", onps_error(enErr));
    
    INT nThIdx = 0;
    while(TRUE && nThIdx < 1000)
    {
        //* 接收,前面已經設定recv()函數不等待,有資料則讀取資料後立即返回,無資料則立即返回
        INT nRcvBytes = recv(hSocket, ubaRcvBuf, sizeof(ubaRcvBuf));
        if(nRcvBytes > 0)
        {
            //* 收到報文,處理之,報文有兩種:一種是應答報文;另一種是伺服器主動下發的控制報文
            //* 在這裡新增你的自定義程式碼
            ……
        }
        
        //* 傳送資料包文到伺服器,首先封裝要傳送的資料包文,PST_COMMUPKT_HDR其型別為指向ST_COMMUPKT_HDR結構體的指
        //* 針,這個結構體是與TcpServerForStackTesting伺服器通訊用的報文頭部結構
        PST_COMMUPKT_HDR pstHdr = (PST_COMMUPKT_HDR)l_ubaSndBuf;
        pstHdr->bFlag = (CHAR)PKT_FLAG; 
        pstHdr->bCmd = 0x00; 
        pstHdr->bLinkIdx = (CHAR)nThIdx++; 
        pstHdr->unSeqNum = unSeqNum; 
        pstHdr->unTimestamp = time(NULL); 
        pstHdr->usDataLen = 900; //* 填充亂資料,亂資料長度加ST_COMMUPKT_HDR結構體長度不超過l_ubaSndBuf的長度即可
        pstHdr->usChechsum = 0; 
        pstHdr->usChechsum = crc16(l_ubaSndBuf + sizeof(CHAR), sizeof(ST_COMMUPKT_HDR) - sizeof(CHAR) + 900, 0xFFFF); 
        l_ubaSndBuf[sizeof(ST_COMMUPKT_HDR) + 900] = PKT_FLAG; 

        //* 傳送上面已經封裝好的資料包文
        INT nPacketLen = sizeof(ST_COMMUPKT_HDR) + pstHdr->usDataLen + 1;
        INT nSndBytes = send(hSocket, l_ubaSndBuf, nPacketLen, 3); 
        if(nSndBytes != nPacketLen) //* 與實際要傳送的資料不相等的話就意味著傳送失敗了
        {
            printf("<err>sent %d bytes failed, %s\r\n", nPacketLen, onps_get_last_error(hSocket, &enErr));
            
            //* 關閉socket,斷開當前tcp連線,釋放佔用的協定棧資源
            close(hSocket);
            return -1; 
        }
    }
    
    //* 關閉socket,斷開當前tcp連線,釋放佔用的協定棧資源
    close(hSocket);
    
    return 0; 
}

編寫tcp使用者端的幾個關鍵步驟:

  1. 呼叫socket函數,申請一個資料流(tcp)型別的socket;
  2. connect()函數建立tcp連線;
  3. recv()函數等待接收伺服器下發的應答及控制報文;
  4. send()函數將封裝好的資料包文傳送給伺服器;
  5. close()函數關閉socket,斷開當前tcp連線;

真實場景下,單個tcp報文攜帶的資料長度的上限基本在1K左右。所以,在上面給出的功能測試程式碼中,單個通訊報文的長度也設定在這個範圍內。使用者端迴圈上報伺服器的資料包文的長度900多位元組,伺服器下發開發板的控制報文長度1100多位元組。

       與傳統的socket程式設計相比,除了上述幾個函數的原型與Berkeley sockets標準有細微的差別,在功能及使用方式上沒有任何改變。之所以對函數原型進行調整,原因是傳統的socket程式設計模型比較繁瑣——特別是阻塞/非阻塞的設計很不簡潔,需要一些看起來很「突兀」地額外編碼,比如select操作。在設計協定棧的socket模型時,考慮到類似select之類的操作細節完全可以藉助rtos的號誌機制將其封裝到底層實現,從而達成簡化使用者編碼,讓socket程式設計更加簡潔、優雅的目的。因此,最終呈現給使用者的協定棧socket模型部分偏離了Berkeley標準。

5. tcp伺服器

       常見的tcp伺服器要完成的工作無外乎就是接受連線請求,接收使用者端上發的資料,下發應答或控制報文,清除不活躍的使用者端以釋放其佔用的系統資源。因此,tcp伺服器的功能測試程式碼分為兩部分實現:一部分在主執行緒完成啟動tcp伺服器、等待接受連線請求這兩項工作(為了突出主要步驟,清除不活躍使用者端的工作在這裡省略);另一部分單獨建立一個執行緒完成讀取使用者端資料並下發應答報文的工作。

……
#include "onps.h"

#define LTCPSRV_PORT        6411 //* tcp測試伺服器埠
#define LTCPSRV_BACKLOG_NUM 5    //* 排隊等待接受連線請求的使用者端數量
static SOCKET l_hSockSrv;        //* tcp伺服器socket,這是一個靜態儲存時期的變數,因為伺服器資料接收執行緒也要使用這個變數

//* 啟動tcp伺服器
SOCKET tcp_server_start(USHORT usSrvPort, USHORT usBacklog)
{
    EN_ONPSERR enErr;
    SOCKET hSockSrv; 
    
    do {
        //* 申請一個socket
        hSockSrv = socket(AF_INET, SOCK_STREAM, 0, &enErr); 
        if(INVALID_SOCKET == hSockSrv)
            break; 
        
        //* 繫結地址和埠,功能與Berkeley sockets提供的bind()函數相同
        if(bind(hSockSrv, NULL, usSrvPort))
            break;
        
        //* 啟動監聽,同樣與Berkeley sockets提供的listen()函數相同
        if(listen(hSockSrv, usBacklog))
            break;         
        return hSockSrv;
    } while(FALSE); 
    
    //* 執行到這裡意味著前面出現了錯誤,無法正常啟動tcp伺服器了
    if(INVALID_SOCKET != hSockSrv)
        close(hSockSrv); 
    printf("%s\r\n", onps_error(enErr)); 
    
    //* tcp伺服器啟動失敗,返回一個無效的socket控制程式碼
    return INVALID_SOCKET;
}

//* 完成tcp伺服器的資料讀取工作
static void THTcpSrvRead(void *pvData)
{
  SOCKET hSockClt; 
  EN_ONPSERR enErr; 
  INT nRcvBytes; 
  UCHAR ubaRcvBuf[256]; 

  while(TRUE)
  {
      //* 等待使用者端有新資料到達
      hSockClt = tcpsrv_recv_poll(l_hSockSrv, 1, &enErr); 
      if(INVALID_SOCKET != hSockClt) //* 有效的socket
      {
          //* 注意這裡一定要儘量讀取完畢該使用者端的所有已到達的資料,因為每個使用者端只有新資料到達時才會觸發一個訊號到使用者
          //* 層,如果你沒有讀取完畢就只能等到該使用者端送達下一組資料時再讀取了,這可能會導致資料處理延遲問題
          while(TRUE)
          {
              //* 讀取資料
              nRcvBytes = recv(hSockClt, ubaRcvBuf, 256);
              if(nRcvBytes > 0)
              {
                  //* 原封不動的回送給使用者端,利用回顯來模擬伺服器回饋應答報文的場景
                  send(hSockClt, ubaRcvBuf, nRcvBytes, 1);       
              }
              else //* 已經讀取完畢
              {
                  if(nRcvBytes < 0)
                  {
                      //* 協定棧底層報錯,這裡需要增加你的容錯程式碼處理這個錯誤並列印錯誤資訊
                      printf("%s\r\n", onps_get_last_error(hSocket, NULL));
                  }
                  break; 
              }
          }  
      }
      else //* 無效的socket
      {
          //* 返回一個無效的socket時需要判斷是否存在錯誤,如果不存在則意味著1秒內沒有任何資料到達,否則列印這個錯誤
          if(ERRNO != enErr)
          {
              printf("tcpsrv_recv_poll() failed, %s\r\n", onps_error(enErr)); 
              break; 
          }
      }
  }
}

int main(void)
{
    EN_ONPSERR enErr; 
    
    if(open_npstack_load(&enErr))
    {    
        printf("The open source network protocol stack (ver %s) is loaded successfully. \r\n", ONPS_VER);
        
        //* 協定棧載入成功,在這裡初始化ethernet網路卡,並註冊網路卡到協定棧
        emac_init();
    }
    else
    {
        printf("The open source network protocol stack failed to load, %s\r\n", onps_error(enErr));
        return -1; 
    }
    
    //* 啟動tcp伺服器
    l_hSockSrv = tcp_server_start(LTCPSRV_PORT, LTCPSRV_BACKLOG_NUM); 
    if(INVALID_SOCKET != l_hSockSrv)
    {
        //* 在這裡新增工作執行緒啟動程式碼,啟動tcp伺服器資料讀取執行緒THTcpSrvRead
        ……
    }
    
    //* 進入主執行緒的主邏輯處理迴圈,等待tcp使用者端連線請求到來
    while(TRUE)
    {
        //* 接受連線請求
        in_addr_t unCltIP; 
        USHORT usCltPort; 
        SOCKET hSockClt = accept(l_hSockSrv, &unCltIP, &usCltPort, 1, &enErr); 
        if(INVALID_SOCKET != hSockClt)
        {
            //* 在這裡你自己的程式碼處理新到達的使用者端
            ……
        }
        else
        {
            printf("accept() failed, %s\r\n", onps_error(enErr));
            break;
        }
    }
    
    //* 關閉socket,釋放佔用的協定棧資源
    close(l_hSockSrv);
    
    return 0; 
}

編寫tcp伺服器的幾個主要步驟: 

  1. 呼叫socket函數,申請一個資料流(tcp)型別的socket;
  2. bind()函數繫結一個ip地址和埠號;
  3. listen()函數啟動監聽;
  4. accept()函數接受一個tcp連線請求;
  5. 呼叫tcpsrv_recv_poll()函數利用協定棧提供的poll模型(非傳統的select模型)等待使用者端資料到達;
  6. 呼叫recv()函數讀取使用者端資料並處理之,直至所有資料讀取完畢返回第5步,獲取下一個已送達資料的使用者端socket;
  7. 定期檢查不活躍的使用者端,呼叫close()函數關閉tcp鏈路,釋放使用者端佔用的協定棧資源;

與傳統的tcp伺服器程式設計並沒有兩樣。

       協定棧實現了一個poll模型用於伺服器的資料讀取。poll模型利用了rtos的號誌機制。當某個tcp伺服器埠有一個或多個使用者端有新的資料到達時,協定棧會立即投遞一個或多個訊號到使用者層。注意,協定棧投遞訊號的數量取決於新資料到達的次數(tcp層每收到一個攜帶資料的tcp報文記一次),與使用者端數量無關。使用者通過tcpsrv_recv_poll()函數得到這個訊號,並得到最先送達資料的使用者端socket,然後讀取該使用者端送達的資料。注意這裡一定要把所有資料讀取出來。因為訊號被投遞的唯一條件就是有新的資料到達。沒有訊號, tcpsrv_recv_poll()函數無法得到一個有效的使用者端socket,那麼剩餘資料就只能等到該使用者端再次送達新資料時再讀了。

       其實,poll模型的運作機制非常簡單。tcp伺服器每收到一組新的資料,就會將該資料所屬的使用者端socket放入接收佇列尾部,然後投訊號。所以,資料到達、獲取socket與投遞訊號是一系列的連鎖反應,且一一對應。tcpsrv_recv_poll()函數則在使用者層接著完成連鎖反應的後續動作:等訊號、摘取接收佇列首部節點、取出首部節點儲存的socket、返回該socket以告知使用者立即讀取資料。非常簡單明瞭,沒有任何拖泥帶水。從這個運作機制我們可以看出:

  1. poll模型的運轉效率取決於rtos的號誌處理效率;
  2. tcpsrv_recv_poll()函數每次返回的socket有可能是同一個使用者端的,也可能是不同使用者端;
  3. 單個使用者端已送達的資料長度與訊號並不一一對應,一一對應的是該使用者端新資料到達的次數與訊號投遞的次數,所以當資料讀取次數小於訊號數時,存在讀取資料長度為0的情形;
  4. tcpsrv_recv_poll()函數返回有效的sokcet後,儘量讀取全部資料到使用者層進行處理,否則會出現剩餘資料無法讀取的情形,如果使用者端不再上發新的資料的話;

6. udp通訊

       相比tcp,udp通訊功能的實現相對簡單很多。為udp繫結一個固定埠其就可以作為伺服器使用,反之則作為一個使用者端使用。

……
#include "onps.h"

#define RUDPSRV_IP   "192.168.0.2" //* 遠端udp伺服器的地址
#define RUDPSRV_PORT 6416          //* 遠端udp伺服器的埠
#define LUDPSRV_PORT 6415          //* 本地udp伺服器的埠

//* udp通訊用緩衝區(接收和傳送均使用)
static UCHAR l_ubaUdpBuf[256];

int main(void)
{
    EN_ONPSERR enErr; 
    SOCKET hSocket = INVALID_SOCKET;
    
    if(open_npstack_load(&enErr))
    {    
        printf("The open source network protocol stack (ver %s) is loaded successfully. \r\n", ONPS_VER);
        
        //* 協定棧載入成功,在這裡初始化ethernet網路卡或等待ppp鏈路就緒
    #if 0
        emac_init(); //* ethernet網路卡初始化函數,並註冊網路卡到協定棧
    #else
        while(!netif_is_ready("ppp0")) //* 等待ppp鏈路建立成功
            os_sleep_secs(1); 
    #endif
    }
    else
    {
        printf("The open source network protocol stack failed to load, %s\r\n", onps_error(enErr));
        return -1; 
    }
    
    //* 分配一個socket         
    if(INVALID_SOCKET == (hSocket = socket(AF_INET, SOCK_STREAM, 0, &enErr))) 
    {
        //* 返回了一個無效的socket,列印錯誤紀錄檔
        printf("<1>socket() failed, %s\r\n", onps_error(enErr)); 
        return -1; 
    }
    
#if 0
    //* 如果是想建立一個udp伺服器,這裡需要呼叫bind()函數繫結地址和埠
    if(bind(hSocket, NULL, LUDPSRV_PORT))
    {
        printf("bind() failed, %s\r\n", onps_get_last_error(hSocket, NULL)); 
        
        //* 關閉socket釋放佔用的協定棧資源
        close(hSocket);
        return -1; 
    }
#else
    //* 建立一個udp使用者端,在這裡可以呼叫connect()函數繫結一個固定的目標伺服器,接下來就可以直接使用send()函數傳送
    //* 資料,當然在這裡你也可以什麼都不做(不呼叫connect()),但接下來你需要使用sendto()函數指定要傳送的目標地址
    if(connect(hSocket, RUDPSRV_IP, RUDPSRV_PORT, 0))
    {
        printf("connect %s:%d failed, %s\r\n", RUDPSRV_IP, RUDPSRV_PORT, onps_get_last_error(hSocket, NULL)); 

        //* 關閉socket釋放佔用的協定棧資源
        close(hSocket); 
        return -1; 
    }
#endif
    
    //* 與tcp使用者端測試一樣,接收資料之前要設定udp鏈路的接收等待的時間,單位:秒,這裡設定recv()函數等待1秒
    if(!socket_set_rcv_timeout(hSocket, 1, &enErr))
        printf("socket_set_rcv_timeout() failed, %s\r\n", szNowTime, onps_error(enErr));

    INT nCount = 0; 
    while(TRUE && nCount < 1000)
    {
        //* 發緩衝區填充一段字串然後得到其填充長度
        sprintf((char *)l_ubaUdpBuf, "U#%d#%d#>1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", time(NULL), nCount++); 
        INT nSendDataLen = strlen((const char *)l_ubaUdpBuf);
        
        //* 呼叫send()函數傳送資料,如果實際傳送長度與字串長度不相等則說明傳送失敗
        if(nSendDataLen != send(hSocket, l_ubaUdpBuf, nSendDataLen, 0)) 
            printf("send failed, %s\r\n", onps_get_last_error(hSocket, NULL));
        
        //* 接收對端資料之前清0,以便本地能夠正確輸出收到的對端回饋的字串
        memset(l_ubaUdpBuf, 0, sizeof(l_ubaUdpBuf));
        
        //* 呼叫recv()函數接收資料,如果想知道對端地址呼叫recvfrom()函數,在這裡recv()函數為阻塞模式,最長阻塞1秒(如果未收到任何udp報文的話)
        INT nRcvBytes = recv(hSocket, l_ubaUdpBuf, sizeof(l_ubaUdpBuf)); 
        if(nRcvBytes > 0)
            printf("recv %d bytes, Data = <%s>\r\n", nRcvBytes, (const char *)l_ubaUdpBuf);
        else
        {
            //* 小於0則意味著recv()函數報錯
            if(nRcvBytes < 0)
            {
                printf("recv failed, %s\r\n", onps_get_last_error(hSocket, NULL)); 
                
                //* 關閉socket釋放佔用的協定棧資源
                close(hSocket);
                break; 
            }
        }
    }
    
    //* 關閉socket,斷開當前tcp連線,釋放佔用的協定棧資源
    close(hSocket);
    
    return 0; 
}

udp通訊程式設計依然遵循了傳統習慣,主要程式設計步驟還是那些:

  1. 呼叫socket函數,申請一個SOCK_DGRAM(udp)型別的socket;
  2. 如果想建立伺服器,呼叫bind()函數;想與單個目標地址通訊,呼叫connect()函數;與任意目標地址通訊則什麼都不用做;
  3. 呼叫send()或sendto()函數傳送udp報文;
  4. 呼叫recv()或recvfrom()函數接收udp報文;
  5. close()函數關閉socket釋放當前佔用的協定棧資源;