neo-T

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释放当前占用的协议栈资源;

相关文章: