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

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釋放當前佔用的協議棧資源;