frankfan的胡思乱想

学海无涯,回头是岸

网络编程基础概念与UDP

网络协议 TCP/IP协议栈 UDP socket

今天讲授的主要内容是网络编程的基础概念以及UDP发送数据的API基础用法。

(掌握网络编程的API使用并不复杂,甚至网络编程API后面的业务逻辑都比API本身复杂很多倍,窃以为,只有了解了网络编程中的一些核心概念与原理,才能远离API仔,才能知道接口如此设计的真正意图,也是学习网络编程的真正精髓所在,如果只是单纯使用那些网络API,那么很难理解前辈们的智慧也难以体会到网络协议栈设计的精妙思想,(老王的科普几乎没有对的....汗),所以本篇所谓笔记算是对计算机网络编程的入门科普与socket相关API的一个讲解)

计算机网络这个话题非常宏大,涉及面非常广(著作汗牛充栋),在这篇笔记里我打算找一个切入点,来聊聊网络编程中最为关键的几个话题。

分两个层面

  • 计算机网络通信是在解决什么问题?
  • 相关的网络API函数为什么这么设计(这个函数是在干什么事情)

计算机网络通信是在解决什么问题?

我们在学习网络编程中,始终需要围绕关注的核心问题是2.5个(一切概念原理等都从此出发):

  • 怎么将A主机上A进程的数据,发送给B主机的B进程。
  • 怎么将A主机上A进程的数据,完整的发送给B主机B进程。(这里的完整可以是有序,也可以是无序,但必须完整)。

另外0.5个问题是 『怎么将A主机上A进程的数据,更快的发送给B主机的B进程

这是网络编程世界的核心世界观,所有设计的协议、API几乎都围绕着这2.5个使命进行着。

在开始讨论这几个话题时,我们逐一来聊聊基本概念。

网络世界中,用『IP地址』来描述一个『主机』,这个IP地址有4个字节(ipv4),通过这4个字节来表示一台计算机(或者网络设备),如果能让任何其他网络设备『找』到自己这台主机,那么这台主机必须具备『公网IP』(这也是公网二字的由来),这样的IP全球唯一独一无二,应该知道,4个字节能够描述的IP数量仅40多亿。

而更多的是局域网IP,这样的IP地址不支持直接被访问,我们的手机、公司、家里的电脑都有属于自己的IP,但,这不过是局域网IP了。

image.png

这里的网关(路由器)可以认为是广义上的网关,可能是你公司路由器,也可以是运营上在某个小区入口网络的网关,这个网关设备拥有独立公网IP,在网络上可以被其他设备访问到,而网关后面的设备则形成了一个局域网,局域网中的主机也有独立的属于自己的IP地址,但,这世界上可有N多个局域网,A、B两个局域网中的IP地址相不相同无所谓,都可以。因为,这些主机『躲在』网关后面,而不是『暴露』在因特网上。

我们的电脑手机就是躲在网关后面的这些主机。网关就是这个局域网的边界,局域网有局域网世界的玩法。

这里挑几个玩法说下:

  • 0.0.0.0这个IP是啥,干啥用的?

这是我们局域网设备中的一个『空主机』IP,一台主机接入局域网后,在没有获取到自己的IP之前,是没有IP地址的,就是0.0.0.0,什么时候这台主机有IP呢?(除了手动指定外,通常我们并没有手动指定过),当主机发现自己是0.0.0.0后,便会广播一条信息,向255.255.255.255这个地址广播,这个地址意味着『所有局域网中的主机』,那么局域网中的所有主机以及网关都能收到这条信息,这个广播有个诉求,就是给自己分配一个IP地址,这个诉求只有网关中的DHCP(动态主机配置协议)服务器才能响应,其他主机都会直接无视丢掉这个广播信息。需要说明的是,255.255.255.255被称为广播地址,广播的范围在以网关为边界的局域网中。

  • arp协议是啥,干啥用的?

主机要能收到网络数据,必须要有网卡,每个网卡都有一个网卡地址,只有知道了这个网卡地址才能将网络数据发送到这张网卡上,这是物理限制。

也就是意味着,当网关收到数据,需要将这条数据发送到具体的某个主机时(如主机3),那么必须要知道主机3的网卡地址,网关是怎么知道主机3的网卡地址的呢?

网关知道主机3的IP地址,此时会向局域网广播一条信息,问172.20.10.6(主机3的IP地址)这个主机是谁,请回答一下,把你的网卡地址告诉我。(当不是这个IP的主机收到这条广播时,会直接无视,只有是自己的IP时,才会应答,当然,其他某主机可以伪装,假装网关询问的是自己,于是把自己主机的网卡告诉了网关...这就是所谓的『arp毒化』)

这就是arp协议的作用。

上面两条就是局域网世界中的玩法。


我们知道了主机的标的物为IP地址,那么怎么标的一个主机中的进程呢?

非常简单,每个数据包中都包含一个目标进程的索引与源进程的索引,这个索引2个字节(0-65535),被称为端口号。通过这个端口号与进程关联起来。

目前为止,我们解决了主机与进程的问题,也就是说在网络数据包中,必须有4个关键信息

  • 目标IP地址
  • 目标端口号
  • 源IP地址
  • 源端口号

无论是什么协议(格式)的数据包,都少不了这4个关键信息。

这4个关键信息是怎么从用户态被封装成一个数据包,经过内核最后通过网卡发送出去的呢?

秘密就在内核中的『网络协议栈』

image.png

秘密就在内核网络协议栈里。用户进程调用数据发送函数(如sendTo),用户态数据经过socket接口,从用户态拷贝进内核中,经过内核中的网络协议栈处理后,最终得到发向目标进程的数据。最终数据包中包含了目标IP、源IP、目标端口、源端口,这些信息都是在内核网络协议栈中添加的。

更具体而言,传输层负责添加端口信息,网络层负责添加IP信息(当然,这是它们的基本工作,此外它们还担负了更多其他的任务,本节不做赘述,详情在后面的学习内容)

image.png

在传输层中,我们可以选择是用TCP协议还是UDP协议来处理我们的用户数据。

这次我们使用的是UDP的传输方式,因此经过传输层后,完整的数据包长这样:

image.png

发送数据时,数据包从用户态向下流动(接受数据时则相反),每经过一层协议栈,进程数据包就被相应协议添加一层包头,每层包头都对应着相应协议的相关控制信息,所以,当数据从用户进程到最终经过网卡发出去时,变成了这样:

image.png

观察上面的UDP数据包结构,其中用2个字节表示UDP数据包的长度(包头与用户数据有效荷载),也就是说一个UDP数据包一共可以承载65536个字节的数据,那么,是不是意味着我们采用UDP发送数据包时,可以一次性发送65536个字节的数据呢?

回答是,可以,也不可以。

可以,是因为从网络接口设计与协议规则上,没有任何问题,用户可以发送的有效荷载是65536-8(upd包头长度),技术上完全没问题。

不可以。通常链路层为了最优传输效率,将链路层的最大传输单位(Maximum Transmission Unit 简称MTU)设置成1500字节的有效荷载(除去自己这层的头部),若数据包的长度大于这个长度,这个数据包则会被分成很多片,每片都符合链路层的大小限制需求,然后发送出去,到了对端的主机协议栈中,网络层将这些分片组装成原始UDP包大小,然后通过socket递交给用户层进程,而在组装分片时,一旦一个分片出现错误,比如因为网络问题而丢失,那么整个组装就会失败,那么这个UDP包便会丢弃。也就是说随着分片的增加,UDP最终传输成功的几率就会降低,原始UDP包越大,被分的片就越多,最终对端组装失败的几率就越大,UDP丢包率就越高。所以从这个角度上讲,我们不应该传输大尺寸的UDP包。

(MTU值可以手动设置,但是有可能并不一定起作用,因为数据经过的除了本机链路外,还要经过各种网关路由器等,这些网络设备都有自己的MTU,而整条通信链路的MTU取决于最小的那个...所以~)


采用UDP的方式传输数据时,有3个基本特性:

  • 发送端先后发送A、B两个UDP数据包,接受端收到的可能是B、A数据包。也就是说发送与接受的顺序不是一一对应的。
  • UDP包发出去以后,接收端并不一定能收到,如果没收到,不会给出任何反馈。
  • UDP传输数据的方式是打包传输,也就是说发送端发了一个996个字节的数据包,接收端接受到的只有一个数据包,并且这个包的大小就是996个字节。发送一个65535字节的数据包,尽管被分片,只要发送成功,接收端接受到的也只有1个数据包,并且大小是65535。(而TCP协议则复杂很多,是流式协议,发送与接受不存在包的概念...日后再聊)

以上我们所说的接收端、发送端分别是指目标主机的传输层和源主机的传输层。(当然,在UDP这个上下文中,我们也可以直接认为是指目标进程与源进程)

socket编程之UDP

//51asm_server.cpp
#include <iostream>
#include <Winsock2.h>//Winsock2这个库起步比windows.h晚,头文件中定义了windows.h这个头文件中早已定义的变量,因此,如果#include <Winsock2.h>写在#include <windows.h>之后,则会因为windows.h中早已定义的变量在Winsock2.h也存在,所以编译器重定义错误。而其实Winsock2.h文件中使用了条件编译宏,所以如果先导入Winsock2,则能避免windows.h重复导入相关变量
#include <windows.h>
#pragma comment(lib, "Ws2_32.lib")//windows中使用socket相关接口定义在Ws2_32这个动态库中

int main(){
    
  WORD wVersionRequested;
  WSADATA wsaData;//使用默认的socket配置
  wVersionRequested = MAKEWORD(2, 2);
  int err = WSAStartup(wVersionRequested, &wsaData);//这个函数的本质主要是用来绑定进程与socket库的使用的,socket库的内部实现有不同的版本,通过这个函数来配置,指定使用哪个socket版本库,此外这个函数还能配置进程中的相关socket,比如本进程能支持多少socket对象,指定udp最大包的大小等。
  if (err != 0) {                              
    return 0;
  }

  if (LOBYTE(wsaData.wVersion) != 2 ||
    HIBYTE(wsaData.wVersion) != 2) {
    WSACleanup();
    return 0;
  }
    
  //以上配置函数时模板代码,通常可以认为无需改动就能用在实际项目中。
   
  //首先创建一个套接字,建立用户层进程与内核网络协议栈的沟通桥梁
  SOCKET serverSocket = socket(AF_INET,//指定通信的类型,比如指定IP协议层是使用ipv4类型,还是ipv6类型(ip地址的长度不一样),还可以指定是否是面向链路层,如果是的话那么就能直接获取到链路层的头部信息(在一些底层通信工具中可能是设置的这种类型),这里设置的是AF_INET,这也是最为常用的,意为使用面向IP(ipv4)的套接字簇
                               SOCK_DGRAM, //指定套接字的类型,如流式通信、数据报通信等。这两种方式的区别在于数据被封包传输的方式不同。数据报类型表示发送方发送的报文个数与接受的是完全对等的(除非丢包),比如送快递,双11你买了10个快递,那么你就能收到10个包裹,可能这10个包裹收到的顺序与你下单的顺序不一定相同,不管快递公司中途怎么合并拆分运输,总之,送到你手里是10个包裹。发10次你就需要接受10次,不多不少。(除非丢快递)
                               IPPROTO_UDP);//指定具体什么传输层协议。这里使用的是udp协议
  if (serverSocket == INVALID_SOCKET)
  {
    printf("创建serverSocket失败 \r\n");
    WSACleanup();
    return 0;
  }
   
  //沟通渠道已打通,需要告诉协议栈那4个必备参数。此处,本进程中我们作为服务端,内核协议栈只需要我们告诉它本进程需要绑定的IP是多少,端口号是多少 。而另外2个数据(源IP与源端口)内核协议栈从网卡接受到数据时自己填充。
  sockaddr_in si;
  si.sin_family = AF_INET;
  si.sin_port = htons(0x5566);//指定本进程的端口是多少, htons这个函数是指将主机字节序转换为网络字节序,因为远程不同主机通信时,有可能内存大小端不同,这样就可能造成数据因为大小端的不一致而导致错误,所以无论是服务端还是客户端,在绑定端口时都先将其转化为“网络字节序”。虽然通常而言网络字节序是大端,但是我们不应该天然的认为这就是大端,或许某个新型嵌入式设备中是小端呢?因此我们不对其做预设,因为本身“网络字节序”就是对大小端的封装。
  si.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");  //这个地址是回环地址,也就代表着本地主机地址,当本地一个服务端与一个客户端通信时,指定127.0.0.1这个主机地址时,数据从客户端进程通过socket进入内核协议栈后,会直接在内核中将客户端数据拷贝到服务端进程中,而不会走网卡转发,因此速度非常快。
  int nRet = bind(serverSocket, (sockaddr*)&si, sizeof(si));//将端口与ip地址与socket对象绑定,内核协议栈中设置了相关地址
  if (nRet == SOCKET_ERROR)
  {
    printf("绑定端口失败 \r\n");
    WSACleanup();
    return 0;
  } 
    
   while (true)//在服务端主线程中开启死循环,对外提供服务。当然,这里是玩具的写法,因为这种单线程的方式效率低下,同一时刻只能对一个客户端提供服务,也无法同时对客户端进程读写操作。
  {
    sockaddr_in siRecv;//创建客户端的相关信息容器,等执行recvfrom函数时,让协议栈将客户端的相关信息填充进去
    int nNameLen = sizeof(siRecv);
    char aryBuf[MAXBYTE] = { 0 };
       
    nRet = recvfrom(serverSocket, aryBuf, sizeof(aryBuf), 0, (sockaddr*)&siRecv, &nNameLen);//从网络协议中读取数据,其实就是从协议栈的缓存中将数据拷贝出来。这是一个阻塞调用,如果此时协议栈缓存中没有数据,则会一直阻塞在这里,当前线程处于阻塞(挂起)状态,此时CPU并不会分配时间片给该线程,直到缓存中有数据时,从缓存中数据读空,继续往下执行。因为这是UDP包,所以客户端发送过来的数据有多大,用户态接收端能够读取到的数据就有多大,如果发送过来是1000字节,那么协议栈缓存中收到的udp包数据就有1000字节,如果用户态进程中的缓存数组只有500个字节,那么内核协议栈中的缓存便不能完全读取干净,此时协议栈缓存中剩下的数据会直接清空丢弃。同时,如果客户端发送udp数据包过快,而服务端用户态中读取数据过慢,则很快协议栈中的缓存便被占满,无法写入新的数据,此时新到的udp包会被协议栈直接丢弃,而对此,客户端一无所知。
       //这个函数除了读取协议栈缓存中的数据外,还会将udp包中的对端相关信息填充到用户态准备的结构体中
    if (nRet == SOCKET_ERROR)
    {
      printf("发生错误 \r\n");
      WSACleanup();
      return 0;
    }
    printf("服务器收到消息:%s", aryBuf);


    // 3) 回发数据
    char aryBuf0[MAXBYTE] = { "recv ok \r\n" };
    nRet = sendto(serverSocket, aryBuf0, sizeof(aryBuf0), 0, (sockaddr*)&siRecv, sizeof(siRecv));//将数据发送给对端,此时实际是将用户态进程中的数据拷贝进协议栈的缓存中,交由协议栈去处理,若发送过快,本机协议栈缓存占满,则发送失败。因此高频的大容量数据发送非常容易导致协议栈缓存被占满而发送失败//有意思的是,可以多线程操作同一个udp socket.(而tcp socket不行)
    if (nRet == SOCKET_ERROR)
    {
      printf("发送数据失败 \r\n");
      WSACleanup();
      return 0;
    }
    printf("服务器回复消息:%s", aryBuf0);

  }  
    
    //以上这个服务器是demo级别的 只能用来演示基本的udp socket接口的使用,并没有考虑效率问题,也就是根本没有IO多路复用这回事。具体内容后续课程讲解。
   closesocket(serverSocket);
   WSACleanup();
    return 0;
}
//51asm_client.cpp
#include <iostream>
#include <Winsock2.h>
#include <windows.h>
#pragma comment(lib, "Ws2_32.lib")

int main(){
    
  //模板代码
  WORD wVersionRequested;
  WSADATA wsaData;
  wVersionRequested = MAKEWORD(2, 2);
  int err = WSAStartup(wVersionRequested, &wsaData);
  if (err != 0) {
    return 0;
  }

  if (LOBYTE(wsaData.wVersion) != 2 ||
    HIBYTE(wsaData.wVersion) != 2) {
    WSACleanup();
    return 0;
  }
 
  //
  SOCKET sockClient = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
  if (sockClient == INVALID_SOCKET)
  {
    printf("创建socket失败 \r\n");
    WSACleanup();
    return 0;
  }
    
    //UDP socket 作为客户端,不需要绑定端口,发送数据时协议栈会从空闲端口中随机指定一个,因此只需要指定目标IP地址即可
    sockaddr_in siServer;
    siServer.sin_family = AF_INET;
    siServer.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    char aryBuf[MAXBYTE] = {0 };
    std::cin >> aryBuf;
    
    //向目标服务器发送数据
    //同样的道理,快速的发送udp数据包 既有可能占满本机协议栈缓存,导致发送失败,也可能导致服务器端协议栈缓存被占满,导致直接丢包,不过udp是非面向链接的协议,因此发送数据无需做过多筹备工作,直接发送即可,成功失败无发送端无关。
    int nRet = sendto(sockClient, aryBuf, sizeof(aryBuf), 0, (sockaddr*)&siServer, sizeof(siServer));
    if (nRet == SOCKET_ERROR)
    {
      printf("发送数据失败 \r\n");
      WSACleanup();
      return 0;
    }
    printf("向服务器发送消息:%s \r\n", aryBuf);
    
    closesocket(sockClient);
    WSACleanup();
    return 0;
}

posted on 2021-12-28 00:12  shadow_fan  阅读(62)  评论(0编辑  收藏  举报

导航