Bootstrap

Linux:UDP Socket编程(代码实战)


1. 网络字节序

① 首先我们需要知道什么是字节序?

字节序就是CPU对内存数据的存取顺序

② 其次,字节序中又分为大端字节序小端字节序

大端字节序就是数据的低位保存在高地址,高位保存在低地址
小端字节序就是数据的低位保存在低地址,高位保存在高地址

举个例子来看一下,现在有一个数据0x12345678

在大端字节序的存储

在这里插入图片描述

在小端字节序的存储

在这里插入图片描述

网络字节序传输的时候,是按照大端字节序进行的传输。

主机字节序:指的是机器自己本身具体的字节序

主机是大端,那么主机字节序就是大端
主机是小端,主机字节序就是小端

在网络数据需要继续转发之前:主机字节序均需转换为网络字节序

网络数据接收之前:网络字节序转化为主机字节序

那么问题来了,为什么网络数据需要进行转化网络字节序?

解答:① 网络规定采用大端字节序作为网络字节序
路由设备或交换机需要对网络数据进行分用到网络层,以便于获取到目的IP地址,而这些设备在继续分用的时候,默认是按照网络字节序进行分用的

2. TCP、UDP的简单了解

2.1 UDP协议

① 无连接

当UDP客户端想要给UDP服务端发送数据的时候,只需要知道服务端的ip地址和端口就可以直接发送了,它所引申出来的含义就是在发送数据之前是不知道服务器的状态信息的

② 不可靠传输

不保证UDP的数据一定会到达对端数据(当发生网络丢包的时候,网络数据可能会丢失)。

③ 面向数据报

UDP数据是整条数据进行接收和发送的,也就是说上一条信息和下一条的信息是没有联系的。

2.2 TCP协议

① 有连接

连接双方在发送数据之前,需要进行连接,即需要提前告知,沟通连接的信息。

② 可靠传输

保证数据是有序并且可靠的到达对端

③ 面向字节流

上一次和下一次的数据直接是没有明显的数据边界的,他就像管道一样,接收方是无法确定发送方是到达分几次进行对数据进行传输的。这种现象就被称为TCP粘包问题。

3. UDP的Socket编程

3.1 编程的流程

在进行套接字的编程的时候,我们需要考虑发送方和接收方,就像线程需要考虑并行一样,是同等重要的,换句话说,我们需要考虑的就是客户端和服务端,而这种考虑客户端和服务端的思想,本质上就是一个CS模型。

用图表示如下:
在这里插入图片描述

UDP协议的进一步探索:

在这里插入图片描述

3.2 Socket的接口

首先,Socket的头文件均包含在#include <sys/socket.h>中。

3.2.1 创建套接字

int socket(int domain, int type, int protocol);

参数:

  • domain:指定当前的地址或指定网络层到底使用什么协议
    ① AF_INET:ipv4网络(默认)
    ② AF_INET6:ipv6网络
    ③ AF_VNIX:本地域套接字
  • type:创建套接字类型
    ① SOCK_DGRAM:用户数据报套接字(UDP协议)
    ② SOCK_STREAM:流式套接字(TCP协议)
  • protocol:表示要使用的协议
    ① 0:表示使用套接字类型默认的协议
    ② IPPROTO_TCP:6,TCP协议
    ③ IPPROTO_UDP:17,UDP协议

我们可以在/usr/include/netinet/in.h中看到关于IPPROTO_TCPIPPROTO_UDP的相关定义
在这里插入图片描述

返回值

返回套接字的描述符,其实套接字描述符本质上就是一个文件描述符,它指向内存中的一块缓冲区

  • >= 0 :创建成功
  • < 0:创建失败

创建套接字的原因:

在应用层中创建一块与协议相关的缓冲区,用来保存网络层传给应用层的数据。

碎片知识:DNS协议,能将相应的域名转换为ip地址。

3.2.2 绑定地址信息

int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

参数:

  • sockfd:socket函数返回的套接字描述符,将创建出来的套接字和ip、端口进行绑定。
  • addr:地址信息结构,它的类型是一个struct sockaddr,是通用的地址信息结构

在这里插入图片描述
将在下面对该结构体进行详解。

  • addrlen:地址信息结构的长度
    要这个参数的原因在本质上市需要对网络能解析多少字节做一个限制,防止访问越界。

通用地址信息结构

struct sockaddr {
	sa_family_t sa_family;
	char        sa_data[14];
}

我们在调用bind函数传参的时候,并不是直接传入一个sockaddr的结构体指针进去,而是传入一个对应地址信息结构的结构体指针,并将其强转为sockaddr这个类型。

ipv4的地址信息结构为struct sockaddr_in,具体信息如下图:

在这里插入图片描述
总结一下就是ipv4的地址信息结构大小为16Byte,其中有用的字节数为8Byte,剩下的8Byte全部用来填充通用地址信息结构的字符数组。

并且struct in_addr的定义如下:
在这里插入图片描述

ipv6的地址信息结构如下图:

在这里插入图片描述

本地域套接字的地址信息结构如下:

在这里插入图片描述
本地域套接字是用来作为本地域进程进行进程间通信(IPC)的手段。

如图:
在这里插入图片描述
通用的数据结构只有16Byte,而当传入的是一个本地域地址信息结构(110Byte)的时候,是不会产生越界的,因为就像int *被转为short *,当它去访问的时候,就算自己原来有4个字节,但是依旧只会访问其中的前两个字节,在此也是一个道理,就算它原来有110个字节,但是我每次只访问其中的16字节,这样就不会发生越界的情况。

3.2.3 发送接口

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, \

const struct sockaddr *dest_addr, socklen_t addrlen);

参数:

  • sockfd:待发送数据的套接字描述符。
  • buf:要发送的数据。
  • len:发送的长度。
  • flags:0,代表阻塞发送。
  • dest_addr:目标主机的地址信息结构,主要包含目标主机的 ip 和 端口。
  • addrlen:目标主机的地址信息结构的长度。

返回值:

成功则返回具体发送的字节数,失败则返回-1。

还记得我们在上面画的那张编程流程的图吗,在客户端上我们写着不推荐绑定地址信息,这是为什么呢?

解答:本质上是不想让客户端在启动的时候,都是绑定一个端口的,因为一个端口只能被一个进程所绑定,换句话说假设客户端1绑定了端口,而当本地在启动客户端2的时候,就会绑定失败,也就是客户端在启动的时候只能存在一个,不能存在多个,举个实际的例子的话就是像QQ、微信这样的聊天软件,如果绑定了地址信息的话,就一次只能打开一个,不能同时打开多个(应用不能够分身)。

那么,客户端没有主动的绑定端口,则UDP客户端在调用sendto函数发送消息的时候,就会自动绑定一个空闲的端口,或者说操作系统会自动分配一个空闲的端口

注意:在绑定地址信息结构的时候,只能绑定自己网卡上的ip地址,查看自己网卡上的ip可使用ifconfig命令

3.2.4 接收接口

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,\
struct sockaddr *src_addr, socklen_t *addrlen);

参数:

  • buf:将数据接收到buf中
  • len:buf的最大接收能力
  • flags:0,代表阻塞接收
  • src_addr:数据来源的主句的地址信息结构(用来为后面回复消息做准备)
  • addrlen:是一个输入输出型参数。

①输入:在接收之前要准备的对端地址信息结构的长度
②输出:实际接收回来的地址信息结构的长度

返回值:

成功返回接收的字节数量,失败则返回-1。

3.2.5 关闭接口

int close(int sockfd);

4. UDP Socket的代码实战

4.1 前言

在实战之前,我们还需要连接这几个接口函数:

① 将输入的端口号从主机字节序转为网络字节序(以及相反的情况)

  • uint32_t htonl(uint32_t hostlong);
  • uint16_t htons(uint16_t hostshort);
  • uint32_t ntohl(uint32_t netlong);
  • uint16_t ntohs(uint16_t netshort);

② 将我们输入的代表 ipv4地址的字符串转为对应的地址信息。

in_addr_t inet_addr(const char *cp);

它主要干了两件事情:
a. 将字符串的点分十进制的ip地址转为uint32_t
b. 将uint32_t从主机字节序转为网络字节序。

扩展:inet_addr(“0.0.0.0”)代表网卡地址中任意的地址(注意是任意,不是任一);inet_addr(“127.0.0.1”)代表着是本地回环网卡地址。

③ 将从网络中接收到的地址信息转为ip地址的字符串

char *inet_ntoa(struct in_addr in);

它也干了两件事情:
a. 将网络字节序uint32_t转为主机字节序。
b. 将uint32_t转为点分十进制的char *

④ 查看端口的使用情况,我们可以使用netstat -anp | grep [端口号]命令

⑤ 我们规定UDP的客户端一定是先给服务器发送数据的

4.2 服务端代码的实现

首先我们来理一理服务端实现的逻辑。

udp:面向数据报,无连接
服务端的实现逻辑:

  1. 创建套接字:socket

a.首先要指定地址域(AF_INET、AF_INET6、AF_UNIX)
b.其次再创建套接字的类型(SOCK_DGRAM、SOCK_STREAM)
c.最后,再指定套接字要使用的协议(0、IPPROTO_TCP:6、TPPROTO_UDP:17)

  1. 然后要绑定相应的地址信息:bind

a.套接字描述符
b.地址信息结构(通用的数据结构):在传入的时候对其传入的具体类型进行相应的强转。
c.地址信息结构的长度(本质上就是对网络能解析多少字节做出限制,防止越界的行为产生)。

这里的越界行为:举个例子来说,假设如果没有这个长度的限制:
那么如果传入的是sockaddr_in结构体(ipv4),当对其进行初始化的时候,若是给其中的地址域信息(__SOCKADDR_COMMON)传入AF_UNIX,那么当bind函数拿到我们所传入的结构体,再对其地址域进行解析的时候,发现是本地域套接字,由于没有长度的限制,则它会向下读110Byte但是我们所定义的sockaddr_in是一个ipv4的结构体,它的大小就只有16Byte,因此,再向下读的时候就会发生越界访问的问题,综上所述,我们需要这个长度来对传入的结构体应该访问的长度做一个限制。

  tips:__SOCKADDR_COMMON是一个sa_family_t类型,它占用两个Byte
  1. 由于服务端在回复消息之前,需要知道客户端的ip和地址,因此,我们在这里规定
    客户端首先需要向服务器发生一条消息,然后,服务端再做相应的回复。
    因此,接下来我们需要做的就是对网络数据的接收。(recvfrom)

  2. 处理数据:

    a. 获取ip地址:我们可以直接从上面的结构体中获取到相应的ip地址信息但是我们需要首先将网络字节序转为主机字节序,其次再将uint32_t的 ip地址转为我们所需要的点分十进制的字符串。 使用:inet_ntoa()
    b. 获取端口:同理,我们需要将网络字节序转为主机字节序,然后再进行使用可以使用:ntohs函数(short)、ntohl函数(long)

  3. 接下来我们就需要对接收到的数据进行处理了,将处理完的数据根据在前面接收到
    的客户端的ip和端口信息,再将处理完的结果返回给目标客户端,形成一个闭环
    ,因此,再处理完数据之后,我们要将数据发送到目标客户端。(sendto)

至此,一个大抵的服务端的逻辑流程已经梳理完毕。

代码实现如下:

#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <iostream>

using namespace std;

int main()
{
    //1.创建ipv4的套接字
    int sockfd = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
    if(sockfd < 0)
    {
        cout << "socket create failed" << endl;
        return 0;
    }
    
    //创建ipv4的结构体,这也是服务端的ip和端口号
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(18989);
    //这里需要注意的是inet_addr包含在#include <arpa/inet.h>
    addr.sin_addr.s_addr = inet_addr("172.17.0.5");

    //2.绑定地址信息结构(sockaddr)
    int ret = bind(sockfd,(struct sockaddr*)&addr,sizeof(addr));
    if(ret < 0)
    {
        cout << "bind failed" << endl;
        return 0;
    }
    
    //3.接收和发送来自客户端的消息
    while(1)
    {
        char buf[1024] = {0};
        struct sockaddr_in recv_addr;
        socklen_t recvlen = sizeof(recv_addr);
        ssize_t recv_size = recvfrom(sockfd,buf,sizeof(buf)-1,0,(struct sockaddr *)&recv_addr,&recvlen);
        if(recv_size < 0)
        {
            cout << "recvfrom failed" << endl;
            return 0;
        }
        //走到这表示接收到消息了
        printf("i am server,i recv : %s,i recv sucess %s:%d\n",buf,inet_ntoa(recv_addr.sin_addr),ntohs(recv_addr.sin_port));

        //然后开始处理和发送数据
        memset(buf,'\0',sizeof(buf));
        sprintf(buf,"hello client, i am server, %s:%d\n",inet_ntoa(addr.sin_addr),ntohs(addr.sin_port));

        ssize_t s_ret = sendto(sockfd,buf,strlen(buf),0,(struct sockaddr *)&recv_addr,sizeof(recv_addr));
        if(s_ret < 0)
        {
            cout << "sendto failed" << endl;
            return 0;
        }
            
    }

    close(sockfd);
    return 0;
}

4.3 客户端代码的实现

同理,我们也来理一理客户端的实现逻辑。

udp:面向数据报,无连接
客户端的实现逻辑:

  1. 创建套接字:

a.首先要指定地址域(AF_INET、AF_INET6、AF_UNIX)
b.其次再创建套接字的类型(SOCK_DGRAM、SOCK_STREAM)
c.最后,再指定套接字要使用的协议(0、IPPROTO_TCP:6、IPPROTO_UDP:17)

  1. 首先要向服务端发送消息:

(服务端是不推荐进行地址绑定的,因为一个端口只能被一个进程所绑定,如果对在客户端对地址进行了绑定,那么客户端就只能存在一个,不能同时开启多个),为了让服务端首先能获取到客户端的ip和端口,我们规定的是客户端首先需要向服务端发送数据 (sendto)’

在要发送给服务端的地址信息结构的时候,我们需要注意的是:服务器的ip和端口是在我们写之前就已经清楚知道的信息,我们不需要也不能够在不知情的情况下拿到其ip和端口信息。因此,这里就需要我们自己定义一个地址信息结构(sockaddr_in)并主动的调用结构体里面的成员进行赋值。

但是这里也出现了相应的问题,我们在初始化sockaddr_in的变量的时候.
I.在初始化端口信息的时候,我们输入的端口是一个uint16_t类型的变量,但是我们还需要将对应的主机字节序转化为网络字节序,使用htons()函数即可完成上面的工作
扩展:h:host、to、n:net、s:short(2Byte)

II.再初始化ip地址的时候,由于我们输入的是一个字符串,因此我们首先需要将输入字符串的点分十进制的ip地址转化为uint32_t, 其次,我们还需将其对应的主机字节序转化为网络字节序:使用 inet_addr() 即可实现上述两个工作
那么如果我们需要主动绑定呢?简单,调用bind函数即可。

  1. 接收服务器返回回来的数据(recvfrom),这里和服务器的recvfrom的逻辑一样就不再过多描述

这就是客户端实现的一个基本逻辑。

代码如下:

#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>

using namespace std;

int main()
{
    //1.创建套接字
    int sockfd = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
    if(sockfd < 0)
    {
        cout << "socket failed" << endl;
        return 0;
    }

    //2.直接发送消息
    while(1)
    {
        char buf[1024] = {0};
        sprintf(buf,"i am client1");

        struct sockaddr_in addr;
        addr.sin_family = AF_INET;
        addr.sin_port = htons(18989);
        addr.sin_addr.s_addr = inet_addr("172.17.0.5");
        ssize_t s_ret = sendto(sockfd,buf,strlen(buf),0,(struct sockaddr *)&addr,sizeof(addr));
        if(s_ret < 0)
        {
            cout << "sento failed" << endl;
            return 0;
        }

        //3.接收消息
        memset(buf,'\0',sizeof(buf));
        
        struct sockaddr_in recv_addr;
        socklen_t socklen = sizeof(recv_addr);

        ssize_t recv_ret = recvfrom(sockfd,buf,sizeof(buf)-1,0,(struct sockaddr *)&recv_addr,&socklen);
        if(recv_ret < 0)
        {
            cout << "recvfrom failed" << endl;
            return 0;
        }
        printf("%s\n",buf);
    }

    return 0;
}

4.4 结果验证

当我们将服务端和客户端执行起来的时候,我们希望看到的结果是服务端和客户端在互相收发消息。并且我们所自己指定的18989端口现在是被我们的服务端所占用。

结果如下:

服务器端:
在这里插入图片描述
客户端:
在这里插入图片描述
端口验证:
在这里插入图片描述

由于我们并没有在客户端绑定地址信息,因此我们可以开启多个客户端来和服务器进行通信。

服务器端:
在这里插入图片描述
由于服务器发送给客户端的消息都是一样的,因此,在这里就不对其进行打开了。

4.5 代码的改进

由于客户端和服务端都是在用UDP的这一套接口,分别实现的话,代码复用率会极高,因此我们可以考虑把这个逻辑封装为一个类,然后在服务器端和客户端分别调用这个接口就可以了。

代码如下:

udp.h

#pragma once

#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdlib.h>
#include <iostream>
#include <string>

using namespace std;

class udp_class
{
    public:
        udp_class();

        void bindAddressInfo(string ip_address,uint16_t port);

        void recvfromInUdp(char* buf,size_t len,struct sockaddr_in* recv_addr,socklen_t* socklen);
        void sentoInfo(char* buf,struct sockaddr_in* sendto_addr);
        ~udp_class();
    protected:
        typedef struct sockaddr_in Sock_UDP;
        typedef struct sockaddr Sock_Default;

    private:
        int sockfd_;
        Sock_UDP addr_;

};

udp.cpp

#include "udp.h"

udp_class::udp_class()
{
    sockfd_ = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
    if(sockfd_ < 0)
    {
        cout << "socket failed" << endl;
        close(sockfd_);
        exit(0);
    }
}

void udp_class::bindAddressInfo(string ip_address,uint16_t port)
{
    addr_.sin_family = AF_INET;
    addr_.sin_addr.s_addr = inet_addr(ip_address.c_str());
    addr_.sin_port = htons(port);

    socklen_t socklen = sizeof(addr_);

    int ret = bind(sockfd_,(Sock_Default *)&addr_,socklen);
    if(ret < 0)
    {
        cout << "bind failed" << endl;
        close(sockfd_);
        exit(0);
    }
}


void udp_class:: recvfromInUdp(char* buf,size_t len,struct sockaddr_in* recv_addr,socklen_t* socklen)
{
    ssize_t recv_size = recvfrom(sockfd_,buf,len,0,(Sock_Default *)recv_addr,socklen);
    if(recv_size < 0)
    {
        cout << "recvfrom failed" << endl;
        close(sockfd_);
        exit(0);
    }

}


void udp_class:: sentoInfo(char* buf,struct sockaddr_in* sendto_addr)
{
    ssize_t sen_ret = sendto(sockfd_,buf,strlen(buf),0,(Sock_Default *)sendto_addr,sizeof(*sendto_addr));
    if(sen_ret < 0)
    {
        cout << "sendto failed" << endl;
        close(sockfd_);
        exit(0);
    }
}


udp_class:: ~udp_class()
{
    close(sockfd_);
}

服务端:

#include "udp.h"

using namespace std;

int main()
{
    //1.创建ipv4的套接字
    udp_class uc;
    
    //创建ipv4的结构体,这也是服务端的ip和端口号
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(18989);
    //这里需要注意的是inet_addr包含在#include <arpa/inet.h>
    addr.sin_addr.s_addr = inet_addr("172.17.0.5");

    //2.绑定地址信息结构(sockaddr)
    uc.bindAddressInfo("172.17.0.5",18989);
    
    //3.接收和发送来自客户端的消息
    while(1)
    {
        sleep(1);
        char buf[1024] = {0};
        struct sockaddr_in recv_addr;
        socklen_t recvlen = sizeof(recv_addr);
        uc.recvfromInUdp(buf,sizeof(buf)-1,&recv_addr,&recvlen);
        //走到这表示接收到消息了
        printf("i am server,i recv : %s,i recv sucess %s:%d\n",buf,inet_ntoa(recv_addr.sin_addr),ntohs(recv_addr.sin_port));

        //然后开始处理和发送数据
        memset(buf,'\0',sizeof(buf));
        sprintf(buf,"hello client, i am server, %s:%d\n",inet_ntoa(addr.sin_addr),ntohs(addr.sin_port));

        uc.sentoInfo(buf,&recv_addr);
    }
    return 0;
}

客户端:

#include "udp.h"

using namespace std;


int main()
{
    //1.创建套接字
    udp_class uc;

    //2.直接发送消息
    while(1)
    {
        sleep(1);
        char buf[1024] = {0};
        sprintf(buf,"i am client1");

        struct sockaddr_in addr;
        addr.sin_family = AF_INET;
        addr.sin_port = htons(18989);
        addr.sin_addr.s_addr = inet_addr("172.17.0.5");

        uc.sentoInfo(buf,&addr);

        //3.接收消息
        memset(buf,'\0',sizeof(buf));
        
        struct sockaddr_in recv_addr;
        socklen_t socklen = sizeof(recv_addr);

        uc.recvfromInUdp(buf,sizeof(buf)-1,&recv_addr,&socklen);

        printf("%s\n",buf);
    }

    return 0;
}

结果验证:

服务端
在这里插入图片描述
客户端
在这里插入图片描述

;