• 【计算机网络学习之路】UDP socket编程


    前言

    本系列文章是计算机网络学习的笔记,欢迎大佬们阅读,纠错,分享相关知识。希望可以与你共同进步。

    一. 网络通信本质

    上篇博客说到,MAC地址标识网卡的全球唯一性,IP地址标识计算机在公网中的唯一性。要想进行网络通信,就必须知道目的主机的IP地址
    但是这还不够,数据只是成功送到了目的主机,并没有被处理。QQ消息要发到QQ,微信消息要发到微信。数据不仅要送达目的主机,还要送达目的程序,也就是进程

    所以网络通信的本质是
    两个主机的两个进程基于网络的进程间通信

    网络通信的过程:

    1. 先将数据通过OS,将数据发送到目标主机(TCP/IP协议),其中IP标识公网上唯一的一台主机
    2. 在本主机收到数据后,推送给上层指定的进程

    那么如何标识进程呢?——端口号

    端口号

    首先,回答为什么不使用pid?

    1. 并不是所有的进程都需要接收发送网络数据
    2. 网络属于文件系统的一部分,同样使用pid会增加耦合度

    接下来介绍端口号

    端口号(port)是传输层协议的内容

    • 端口号是一个2字节16位的整数
    • 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理
    • IP地址+端口号能标识网络上唯一一台主机的一个进程
    • 一个端口号只能被一个进程占用
    • 一个进程可以绑定多个端口号

    端口号的作用

    操作系统会维护一张端口号和pid对应的hash表,通过端口号可以找到对应进程pid,然后获取进程结构体,其中就有文件fd
    将网络数据写入文件,进程就可以从文件中读取网络数据了,如此就将网络通信转化成文件读写

    TCP与UDP

    TCP和UDP都是传输层协议

    TCP协议

    • 有连接
    • 可靠传输
    • 面向字节流

    UDP协议

    • 无连接
    • 不可靠传输
    • 面向数据报

    可靠与不可靠传输不是褒义和贬义的关系,可靠意味着需要有更多资源保证可靠,也有很多场景适合不可靠传输

    网络字节序

    内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大小端之分。网络数据流同样也有大小端之分
    小端是将低位数据放到低地址,高位数据放到高地址,大端反之

    那么如何定义网络数据流的地址呢?

    • 发送方主机通常将发送缓冲区的数据按内存地址从低到高发出
    • 接收方主机把从网络上接到的字节一次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存
    • 因此,网络数据流的地址应这样规定:先发出的数据时低地址,后发出数据时高地址
    • TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节
    • 不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据
    • 如果当前发送方主机是小端,就需要先将数据转成大端,否则就忽略,直接发送即可

    在这里插入图片描述

    为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用库函数网络字节序主机字节序的转换

    在这里插入图片描述

    • 记忆:h表示host主机,n表示network网络,l表示32位长整数,s表示16位短整数
    • 例如htonl表示32为长整数从主机字节序转为网络字节序
    • 如果主机是小端字节序,这些函数将参数做响应的大小端转换后返回
    • 如果主机是大端字节序,这些函数不做转换,直接返回

    C语言有定义表示该主机是大端还是小端,所以只需要判断一下宏即可知道本主机是大端还是小端

    二. socket编程接口

    上述说到,网络通信的本质是两台主机中的两个进程通信。
    在Linux学习中,进程通信有两个标准——System VPOSIX

    历史
    UNIX两大贡献者——贝尔实验室和BSD,在进程之间通信侧重不同,前者基于内核对进程之间的通信手段进行了改进,形成System V IPC,而后者则是基于网络形成了套接字

    POSIX是IEEE制定的标准,目的是为运行在不同操作系统上的软件提供统一的接口,实现者则是不同的操作系统内核开发人员。
    如今POSIX已经支持同主机的进程通信和网络通信,POSIX将会是大势所趋

    参考System V 与 POSIX

    本系列讲解的都是POSIX标准的接口

    socket()和sockaddr结构体

    socket()

    //创建socket 文件描述符
    int socket(int domain,int type,int protocol);
    
    • 1
    • 2

    在这里插入图片描述

    上述说到,OS通过端口号找到对应pid,找到对应进程,就可以找这个进程所有的文件,将网络数据写入文件,就将网络通信转换为文件读写

    socket()的作用就是创建一个网络文件,返回值int就是文件描述符

    • int domain:指定通信域在这里插入图片描述
      主要使用AF_UNIX(本主机的进程通信)AF_INET(网络通信),AF_INET6(IPv6的网络通信)

    • int type:指定通信语义
      常用的是SOCK_STREAM(面向字节流——TCP),SOCK_DGRAM(面向数据报——UDP)

    • int protocol:默认为0,OS会判断是使用TCP还是UDP

    这三个参数都将会标识该文件是网络文件


    sockaddr结构体

    OS使用sockaddr保存本主机信息。因为POSIX标准同时支持本主机进程通信和网络通信,所以用C语言模拟多态的形式实现着两种通信。

    具体操作如下:

    在socket常见API中

    //绑定端口号
    int bind(int socket,const struct sockaddr*address,socklen_t address_len);
    
    //接收请求
    int accept(int socket,struct sockaddr*address,socklen_t address_len);
    
    //建立连接
    int connect(int sockfd,const struct sockaddr*addr,socklen_t addrlen);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这三个接口的参数中,都有const struct sockaddr*

    在这里插入图片描述

    struct sockaddr是通用结构体,struct sockaddr_in是网络通信结构体,struct sockaddr_un是本主机进程通信结构体
    只要在传参时强转成sockaddr即可

    • IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型,16位端口号和32位IP地址
    • IPv4,IPv6地址类型分别定义为常数AF_INET(PF_INET也可以)和AF_INET6。只要取得某种sockaddr结构体的首地址,不需要具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容
    • socket API可以用struct sockaddr*类型表示,在使用的时候需要强转成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4,IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针作为参数

    sockaddr_in定义如下:

    在这里插入图片描述

    sin_zero是填充字段
    in_addr用来标识一个IPv4的IP地址,其实就是一个32位的整数

    在这里插入图片描述

    三. 简单echo服务

    接下来,简单实现UDP网络echo服务器(接收并送回数据)和客户端
    边写边讲解注意点

    makefile

    all:client server
    client:udp_client.cc
    	g++ -o $@ $^ -std=c++11
    server:udp_server.cc
    	g++ -o $@ $^ -std=c++11
    
    .PHONY:clean
    clean:
    	rm -f client server
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    先编写客户端

    udp_server.hpp

    #pragma once
    
    #include
    #include
    #include
    #include
    #include
    #include
    #include
    #include
    
    namespace ns_server
    {
        class UdpServer
        {
        public:
            UdpServer(){}
            void InitServer(){}//初始化服务器
            void Start(){}//启动服务器
            ~UdpServer(){}
        private:
        int _sock;//套接字
        uint16_t _port;//端口号
        std::string _ip;//IP地址
        };
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    udp_server.cc

    #include "udp_server.hpp"
    #include
    
    using namespace ns_server;
    using namespace std;
    
    
    int main()
    {
        unique_ptr<UdpServer> usvr(new UdpServer());
    
        usvr->InitServer();//初始化
        usvr->Start();//启动
    
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    以上是基本框架
    网络服务,服务器肯定需要端口号和IP地址,另外还需要保存套接字

    1. 创建套接字
    void InitServer()
    {
    	_sock=socket(AF_INET,SOCK_DGRAM,0);
        if(_sock<0)
    		std::cerr<<"create sock error,"<<strerror(errno)<<",errno:"<<errno<<std::endl;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    创建套接字失败会返回-1,并设置错误码

    1. 定义struct sockaddr_in结构体

    其中需要提供端口号和IP地址,我们通过构造函数获取

    //构造函数获取端口号和IP地址
    UdpServer(uint16_t port,std::string ip):_port(port),_ip(ip)
    {}
    
    //初始化服务器
    void InitServer()
    {
    	_sock=socket(AF_INET,SOCK_DGRAM,0);
    	if(_sock<0)
        std::cerr<<"create sock error,"<<strerror(errno)<<",errno:"<<errno<<std::endl;
    
        struct sockaddr_in local;
        bzero(&local,sizeof(local));//清空结构体
    
        local.sin_family=AF_INET;//地址类型
        local.sin_port=htons(_port);//端口号
        local.sin_addr.s_addr=inet_addr(_ip.c_str());//IP地址
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    但此时该sockaddr_in结构体仅仅是定义在栈帧上,并没有写入内核,没有和网络文件绑定

    所以需要使用bind()函数

    1. 绑定端口号
    //绑定端口号
    int bind(int socket,struct sockaddr*address,socklen_t address_len);
    
    • 1
    • 2
    • int sokcet:要绑定的套接字(网络文件描述符
    • const struct sockaddr:相关网络信息结构体
    • socklen_t address_len:结构体大小

    绑定失败返回值-1,并设置错误码

    //初始化服务器
    void InitServer()
    {
    	_sock=socket(AF_INET,SOCK_DGRAM,0);
        if(_sock<0)
        {
        	std::cerr<<"create sock error,"<<strerror(errno)<<std::endl;
            return 1;
        }
    
        struct sockaddr_in local;
        bzero(&local,sizeof(local));//清空结构体
    
        local.sin_family=AF_INET;//地址类型
        local.sin_port=htons(_port);//端口号
        local.sin_addr.s_addr=inet_addr(_ip.c_str());//IP地址
    
        //绑定结构体
        if(bind(_sock,(struct sockaddr*)&local,sizeof(local))<0)
        {
    		std::cerr<<"bind error,"<<strerror(errno)<<std::endl;
            return 2;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    注意,云服务器一般不允许绑定特定IP
    另外,如果服务器有多个网卡,则不管哪个网卡/哪个IP地址接收到的数据,只要是该端口号的,都应该接收
    所以服务器的IP一般如此设置

    local.sin_addr.s_addr=INADDR_ANY;
    
    • 1

    socket INADDR_ANY就是指定地址为0.0.0.0的这个地址,这个地址不是确定的地址,而是表示“所有地址”“任意地址”
    所以只要是发送给指定端口号的数据,无论是发送给本机的哪个IP地址的,都一并接收


    初始化服务器到此暂告一段落
    接下来是启动服务器

    服务器首先是需要一直运行的,即使在凌晨,我们一样可以玩游戏,看QQ

    因为是echo服务器,所以需要接收客户端发送的消息,然后再发送回去

    recvfrom()

    在这里插入图片描述

    • int sockfd:从哪个套接字读取数据
    • void * buf:存数据的缓冲区
    • size_t len:缓冲区大小
    • int flags:读取数据的方式(阻塞读或非阻塞读)
    • struct sockaddr* src_addr:输入输出型参数,获取客户端信息
    • socklen_t * addrlen:输入输出型参数,客户结构体大小。注意:输入src_addr的大小,返回发送方结构体大小
    • 返回值:读取数据的个数。错误返回-1并设置错误码

    sendto()

    在这里插入图片描述

    • int sockfd:往哪个套接字写数据
    • const void * buf:写的数据
    • size_t len:数据大小
    • int flags:写数据的方式(阻塞或非阻塞)
    • struct sockaddr* dest_addr:目的主机信息结构体
    • socklen_t * addrlen:结构体大小
    • 返回值:发送了多少数据。错误返回-1并设置错误码

    Start()代码如下:

    //启动服务器
    void Start()
    {
    	char buffer[1024];
        while(true)
        {
    		struct sockaddr_in client;
    		socklen_t len=sizeof(client);
    
            //缓冲区需要预留\0的位置
            int n=recvfrom(_sock,buffer,sizeof(buffer-1),0,(struct sockaddr*)&client,&len);
            if(n>0) buffer[n]='\0';
            else continue;
    
            //提取客户端信息
            std::string clientIp=inet_ntoa(client.sin_addr);
            uint16_t clientPort=ntohs(client.sin_port);
    
            std::cout<<"["<<clientIp<<" : "<<clientPort<<"]# "<<buffer<<std::endl;
            //送回数据
            //发送回去的数据不需要携带\0
            sendto(_sock,buffer,strlen(buffer),0,(struct sockaddr*)&client,sizeof(client));
         }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    接下来是udp_server.cc
    我们需要在启动服务器时指明端口号,类似 ./udp_server 8080

    #include "udp_server.hpp"
    #include"err.hpp"
    #include
    
    using namespace ns_server;
    using namespace std;
    
    //使用手册
    //   ./udp_server port
    static void usage(string proc)
    {
        cout<<"Usage:\n\t"<<proc<<" port\n"<<std::endl;
    }
    
    int main(int argc,char*argv[])
    {
        if(argc!=2)
        {
            usage(argv[0]);
            return -1;
        }
        //提取参数中的端口号
        uint16_t port=atoi(argv[1]);
    
        unique_ptr<UdpServer> usvr(new UdpServer(port));
        usvr->InitServer();
        usvr->Start();
    
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    如此,最基本的echo服务器完成。

    接下来是客户端的编写
    客户端简单编写一些,就不封装成类了

    客户端大致流程如下:

    1. 创建套接字
    2. 提取目标服务器信息
    3. 发送消息

    UDP的客户端并不需要bind,因为客户端的端口号不能指定,应该由操作系统分配。如果两个客户端自己绑定同一个端口号,那就不能同时运行了,所以为了避免这种情况,选择让操作系统分配闲置的端口号

    而操作系统会在客户端首次发送数据(sendto等)时,给客户端分配IP和端口号,然后bind套接字

    目标服务器是由运行程序时指定:如 ./client 127.0.0.1 8888

    代码如下:

    #include
    #include
    #include
    #include
    #include
    #include
    #include
    #include
    #include"err.hpp"
    
    using namespace std;
    
    static void usage(string proc)
    {
        cout<<"Usage\n\t"<<proc<<" serverIp serverPort"<<endl;
    }
    
    int main(int argc,char*argv[])
    {
        if(argc!=3)
        {
            usage(argv[0]);
            exit(USAGE_ERR);
        }
    
        //提取服务器信息
        string serverIp=argv[1];
        uint16_t serverPort=atoi(argv[2]);
    
        //创建套接字
        int sock=socket(AF_INET,SOCK_DGRAM,0);
        if(sock<0)
        {
            cerr<<"create sock error,"<<strerror(errno)<<endl;
            exit(SOCKET_ERR);
        }
        std::cout << "create socket success: " << sock << std::endl;
    
        //客户端不需要自己bind
        struct sockaddr_in server;
        memset(&server,0,sizeof(server));
        server.sin_family=AF_INET;
        server.sin_addr.s_addr=inet_addr(serverIp.c_str());
        server.sin_port=htons(serverPort);
    
        while(true)
        {
            cout<<"please enter your message# ";
            string message;
            getline(cin,message);
    
            sendto(sock,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));
    
            char buffer[1024];//接收返回的数据
            struct sockaddr_in tmp;//发送方
            memset(&tmp,0,sizeof(tmp));
            socklen_t len=sizeof(tmp);
    
            int n=recvfrom(sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&tmp,&len);
            if(n>0)
            {
                buffer[n]='\0';
                cout<<"server echo# "<<buffer<<endl;
            }
    
        }
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68

    在这里插入图片描述

    结束语

    UDP socket编程的内容到此就结束了,感谢看到此处。
    欢迎大家纠错和补充
    如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。
    在这里插入图片描述

  • 相关阅读:
    RichView Table 表格对齐
    真实环绕的魅力,飞利浦杜比全景声影院B8967开箱
    【云原生Kubernetes】部署K8S集群架构(admin部署)
    【Vue 开发实战】实战篇 # 32:如何使用路由管理用户权限
    机器学习---初识贝叶斯分类器
    智慧公厕管理系统:让公共厕所管理变得更高效。
    环印国际学校(GIIS)IB成绩38.6分,有什么秘密?
    Java Spring Boot: 极简配置与快速开发的利器
    【自然语言处理三-自注意self attention】
    Effective C++条款20:宁以pass-by-reference-to-const替换pass-by-value
  • 原文地址:https://blog.csdn.net/m0_72563041/article/details/134484744