Linux UDP通信系统

时间:2024-04-23 07:18:46

目录

一、socket编程接口

1、socket 常见API

socket():创建套接字

bind():将用户设置的ip和port在内核中和我们的当前进程关联

listen()

accept()

2、sockaddr结构

3、inet系列函数

二、UDP网络程序—发送消息

1、服务器udp_server.hpp

initServer()初始化服务器

Start()启动服务器

2、udp_server.cc

3、udp_client.cc 

4、log.hpp日志系统

5、模拟运行过程:

三、UDP网络程序—发送命令

1、popen函数

2、udp_server.hpp

四、实现广播处理结果给每个客户端

五、多线程通信

1、udp_server.hpp

2、udp_server.cc

3、udp_client.cc

udpSend函数

udpRecv函数

main() 函数

4、thread.hpp线程管理

5、log.hpp日志系统

六、Windows客户端


一、socket编程接口

1、socket 常见API

在socket编程中,这些API函数是用于实现网络通信的基本构建模块,下面是每个函数的详细说明:

socket():创建套接字

#include <sys/socket.h>
#include <sys/types.h>
int socket(int domain, int type, int protocol);

这个函数用于创建一个新的套接字。

参数含义如下:

  • domain:地址家族,比如AF_INET(IPv4)或AF_INET6(IPv6)。
  • type:套接字类型,对于TCP协议通常是SOCK_STREAM(面向连接的流式套接字),对于UDP协议则是SOCK_DGRAM(无连接的数据报套接字)。
  • protocol:使用的协议,通常设置为0,此时系统会选择domain和type指定的默认协议(对于TCP是IPPROTO_TCP,对于UDP是IPPROTO_UDP)。

返回值:

  • 函数返回值是一个套接字描述符(socket descriptor),它是系统用来引用这个套接字的句柄,后续的操作如绑定、监听、接受连接或发送数据都将使用这个描述符。

bind():将用户设置的ip和port在内核中和我们的当前进程关联

int bind(int socket, const struct sockaddr *address, socklen_t address_len);

此函数用于将套接字与一个特定的本地地址(IP地址和端口号)关联起来,主要是服务器端在启动服务之前需要调用此函数。

参数含义如下:

  • socket:之前由socket()函数返回的套接字描述符。
  • address:指向sockaddr结构体的指针,该结构体包含了要绑定的IP地址和端口号信息,对于TCP/UDP套接字,通常是sockaddr_in或sockaddr_in6结构体。
  • address_len:地址结构体的长度,即sizeof(sockaddr_in)或sizeof(sockaddr_in6)。

返回值:如果绑定成功,函数返回0;否则返回非零错误码。

listen()

int listen(int socket, int backlog);

只有对于TCP服务端套接字才需要调用此函数,它使套接字进入监听状态,等待客户端的连接请求。参数含义如下:

成功监听后返回0,出错则返回非零错误码。

  • socket:要监听的服务器端套接字描述符。
  • backlog:指定同时可以排队等待处理的最大连接数。超过这个数量的连接请求会被拒绝。

accept()

int accept(int socket, struct sockaddr* address, socklen_t* address_len);

也是只在TCP服务器端使用,用于接受一个客户端的连接请求。参数含义如下:

成功接受一个连接请求后,accept()函数返回一个新的套接字描述符,这个描述符用于与该客户端进行通信。同时,address参数所指向的结构体会填充上客户端的地址信息。

  • socket:已经监听的服务器端套接字描述符。
  • address:用于存储新连接客户端的地址信息的sockaddr结构体指针。
  • address_len:指向一个socklen_t变量的指针,用于记录地址结构体的实际大小,传入时应初始化为地址结构体的大小,返回时会更新为实际填充的大小。

connect()

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

该函数用于TCP客户端建立与服务器的连接。参数含义如下:

当成功连接到服务器时,connect()函数返回0;如果发生错误,则返回非零错误码。通过调用connect()函数,客户端与服务器建立起一条TCP连接,随后可以通过这个套接字进行双向数据传输。

  • sockfd:由socket()函数创建的客户端套接字描述符。
  • addr:指向包含服务器IP地址和端口号信息的sockaddr结构体指针。
  • addrlen:地址结构体的长度。

2、sockaddr结构

socket API作为一种跨网络协议的抽象接口层,旨在适应多种底层网络协议,如IPv4、IPv6及UNIX域套接字等。尽管各类协议的具体地址格式各异,socket API通过巧妙的设计保证了编程上的灵活性和一致性。

netinet/in.h头文件中,分别定义了IPv4和IPv6的地址结构。IPv4地址采用sockaddr_in结构体来封装,其中包含了16位表示地址家族的字段(通常是AF_INET)、16位的端口号以及32位的IPv4地址。而对于IPv6地址,有对应的sockaddr_in6结构体。

不论是IPv4还是IPv6,它们各自对应的标准地址类型常量分别为AF_INETAF_INET6。这样一来,只要持有某个sockaddr结构体的起始地址,通过查看地址家族字段,就能确切得知结构体内部其余字段的含义和布局。

        socket API巧妙地使用了struct sockaddr *这一通用类型来表示各种地址结构体,这意味着API函数可以接收指向不同类型(如sockaddr_insockaddr_in6或UNIX域套接字对应的结构体)的指针作为参数。这种设计带来的显著优点是增强了程序的通用性和可扩展性,使得同一套API函数能够轻松应对不同网络协议下的套接字地址处理,无需针对每种协议单独编写代码。在实际使用中,程序员可能需要将struct sockaddr *指针适当地强制转换为其真正指向的具体结构体类型,以便访问和操作相关的地址信息。

struct sockaddr_in 的结构定义大致如下:

struct sockaddr_in {
    sa_family_t sin_family;   // 地址族,对于 IPv4 地址,该字段应设置为 AF_INET(定义在 <sys/socket.h> 中)
    in_port_t   sin_port;     // 端口号,网络字节序(大端序),可以通过 htons() 函数将主机字节序转换为网络字节序
    struct in_addr sin_addr;  // 包含一个 IPv4 地址,实际上是 32 位无符号整数,通常通过 inet_addr() 或 inet_aton() 函数填充
    char         sin_zero[8]; // 未使用的填充字节,通常设为全零以保持与其他 `sockaddr` 结构一致,确保整个结构体大小至少为 16 字节
};

  • sin_family:这是一个用于标识地址家族的字段,对于 IPv4 地址而言,它的值应为 AF_INET

  • sin_port:用于存放网络服务的端口号,通常是一个16位无符号整数。由于网络协议规定端口号使用网络字节序(big-endian),所以经常需要用 htons() 函数将主机字节序转换为网络字节序后再赋值给 sin_port

  • sin_addr:这是一个嵌套的 in_addr 结构体,它本身包含一个32位的无符号整数 s_addr,用于表示IPv4地址。你可以通过 inet_addr() 函数将点分十进制的 IP 地址字符串转换成网络字节序的整数,然后赋值给 sin_addr.s_addr,或者直接赋值32位的整数值。

  • sin_zero:这是为了兼容早期的Berkeley sockets API设计的一个填充字段,确保结构体总长度与 struct sockaddr 保持一致,因为在很多函数调用中需要传递指向 struct sockaddr 的指针。现代编程实践中,通常会用 memset() 或 bzero() 函数将这部分清零。

在实际的网络编程中,比如创建一个TCP或UDP服务器或客户端时,struct sockaddr_in 会被用来封装服务器或客户端的IP地址和端口号信息,然后传递给诸如 bind()connect()sendto()recvfrom() 等系统调用函数。

3、inet系列函数

网络编程中涉及的inet系列函数是一组用于处理IP地址表示与转换的实用工具。这些函数在不同的编程语言和平台中可能有不同的实现,但它们的核心目的都是帮助程序员有效地操作IP地址,包括将字符串形式的IP地址转换为网络字节序的二进制表示,以及反之将二进制IP地址转换回人类可读的字符串形式。以下是几个常见的inet函数及其用途:

1、inet_addr:

in_addr_t inet_addr(const char *cp);

功能:将点分十进制表示的 IPv4 地址字符串(如 "192.168.0.1")转换为网络字节序的 32 位整数(in_addr_t 类型)。如果输入的字符串无法解析为有效 IPv4 地址,函数可能返回 INADDR_NONE(通常为 0xFFFFFFFF)。

2. inet_aton() (适用于C语言,IPv4)

功能: inet_aton()函数接受一个指向包含点分十进制IPv4地址的字符串(如 "192.168.0.1"),将其转换为网络字节序的32位整数,并存储在指定的struct in_addr结构体中。该函数常用于将用户输入或配置文件中的IP地址字符串解析为程序内部可以直接使用的二进制形式。

#include <arpa/inet.h>

char ip_str[] = "192.168.0.1";
struct in_addr ip_addr;

if (inet_aton(ip_str, &ip_addr) == 0) {
    // 处理错误或无效IP地址
} else {
    // ip_addr已成功填充为对应的网络字节序整数
}

3. inet_ntoa() (适用于C语言,IPv4)

功能: inet_ntoa()函数将一个struct in_addr结构体(其中包含网络字节序的IPv4地址)转换回点分十进制的字符串形式。这个函数主要用于将程序内部的二进制IP地址以人类可读的字符串输出。

#include <arpa/inet.h>
#include <stdio.h>

struct in_addr ip_addr = { .s_addr = 0xc0a80001 }; // 二进制表示的192.168.0.1

char* ip_str = inet_ntoa(ip_addr);

printf("IP address: %s\n", ip_str); // 输出 "IP address: 192.168.0.1"

4. inet_pton() (适用于IPv4和IPv6)

功能: inet_pton()函数("presentation to network"之意)是inet_aton()的泛化版本,支持IPv4和IPv6地址的转换。它接受一个地址族(如AF_INETAF_INET6)、一个指向包含IP地址字符串的指针,以及一个指向目标缓冲区的指针,用于存放转换后的网络字节序IP地址。对于IPv4,目标缓冲区通常是一个struct in_addr;对于IPv6,则是一个struct in6_addr

用法(C语言示例,IPv4):

#include <arpa/inet.h>

char ip_str[] = "192.168.0.1";
struct in_addr ip_addr;

int result = inet_pton(AF_INET, ip_str, &ip_addr);
if (result == 1) {
    // ip_addr已成功填充为对应的网络字节序整数
} else if (result == 0) {
    // 输入不是有效的IPv4地址字符串
} else {
    // 出现错误
}

5. inet_ntop() (适用于IPv4和IPv6)

功能: inet_ntop()函数("network to presentation"之意)是inet_ntoa()的泛化版本,同样支持IPv4和IPv6地址的转换。它接受一个地址族、一个指向网络字节序IP地址的指针,以及一个目标字符串缓冲区和其长度,将IP地址以人类可读的字符串形式写入缓冲区。

用法(C语言示例,IPv4):

#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>

struct in_addr ip_addr = { .s_addr = 0xc0a80001 }; // 二进制表示的192.168.0.1

char ip_str[INET_ADDRSTRLEN]; // 定义足够大的缓冲区存放IPv4地址字符串
int result = inet_ntop(AF_INET, &ip_addr, ip_str, sizeof(ip_str));
if (result != NULL) {
    printf("IP address: %s\n", ip_str); // 输出 "IP address: 192.168.0.1"
} else {
    // 出现错误
}

二、UDP网络程序—发送消息

//udp_server.hpp:
#ifndef _UDP_SERVER_HPP
#define _UDP_SERVER_HPP

#include "log.hpp"
#include <iostream>
#include <cstdio>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#define SIZE 1024

class UdpServer
{
public:
    UdpServer(uint16_t port, std::string ip = "") : _port(port), _ip(ip), _sock(-1) {}

    bool initServer()
    {
        _sock = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sock < 0)
        {
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }

        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());

        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }

        logMessage(NORMAL, "init udp server done ... %s", strerror(errno));
        return true;
    }

    void Start()
    {
        char buffer[SIZE];
        for(;;)
        {
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer));
            socklen_t len = sizeof(peer);

            ssize_t s = recvfrom(_sock, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
            if(s > 0)
            {
                buffer[s] = 0;

                uint16_t cli_port = ntohs(peer.sin_port);
                std::string cli_ip = inet_ntoa(peer.sin_addr);
                printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);

                sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
            }
        }
    }

    ~UdpServer()
    {
        if(_sock >= 0) close(_sock);
    }

private:
    uint16_t _port;
    std::string _ip;
    int _sock;
};

#endif


//udp_server.cc:
#include "udp_server.hpp"
#include <memory>
#include <cstdlib>

static void usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " port\n" << std::endl;
}

int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(1);
    }

    uint16_t port = atoi(argv[1]);
    std::unique_ptr<UdpServer> svr(new UdpServer(port));
    svr->initServer();
    svr->Start();
    return 0;
}


//udp_client.cc:
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

static void usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " serverIp serverPort\n"
              << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        usage(argv[0]);
        exit(1);
    }

    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0)
    {
        std::cerr << "socket error" << std::endl;
        exit(2);
    }

    std::string message;
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(atoi(argv[2]));
    server.sin_addr.s_addr = inet_addr(argv[1]);

    char buffer[1024];
    while(true)
    {
        std::cout << "请输入你的信息# ";
        std::getline(std::cin, message);
        if(message == "quit") break;

        sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof server);

        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        ssize_t s = recvfrom(sock, buffer, sizeof buffer, 0, (struct sockaddr*)&temp, &len);
        if(s > 0)
        {
            buffer[s] = 0;
            std::cout << "server echo# " << buffer << std::endl;
        }
    }

    close(sock);
    return 0;
}

1、服务器udp_server.hpp

udp_server.hpp是C++编写的UDP服务器头文件,它定义了一个名为UdpServer的类,用于创建和管理一个基于UDP协议的服务器。 

#define SIZE 1024

class UdpServer
{
public:
    UdpServer(uint16_t port, std::string ip = "")
        : _port(port), _ip(ip), _sock(-1)
    {}
    bool initServer()
    {
        // 开始初始化服务器,通过系统调用完成网络功能配置
        // 1. 创建UDP套接字
        _sock = socket(AF_INET, SOCK_DGRAM, 0); // 使用IPv4协议族,创建UDP套接字
        if (_sock < 0)
        {
            // 如果创建套接字失败,记录错误日志并退出程序
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }

        // 2. 绑定本地地址和端口到套接字
        // 定义一个IPv4结构体存储本地地址信息
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); // 清零结构体内容,防止遗留数据干扰

        // 设置协议族为IPv4
        local.sin_family = AF_INET;

        // 设置服务端监听的端口号,转换为主机字节序
        local.sin_port = htons(_port);

        // 设置服务端监听的IP地址
        // 如果_ip为空字符串,则绑定到任意可用IP(INADDR_ANY)
        // 否则,将点分十进制字符串形式的IP地址转换为网络字节序的整数值
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());

        // 执行bind操作,将套接字与本地地址和端口绑定
        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            // 如果绑定失败,记录错误日志并退出程序
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }

        // 绑定成功,记录日志
        logMessage(NORMAL, "init udp server done ... %s", strerror(errno));

        // 初始化服务器完成,返回成功标志
        return true;
    }
    ~UdpServer()
    {
        if (_sock >= 0)
            close(_sock);
    }

    // UdpServer 类的 Start 方法启动服务器进入持续监听模式,每当接收到客户端的 UDP 数据包时,
    // 服务器都会原样回复给客户端。
    void Start()
    {
        // 服务器将持续运行,不断循环等待和处理客户端的请求。
        char buffer[SIZE]; // 创建一个固定大小的缓冲区用于存储接收到的客户端数据,最大容量为 1024 字节。

        // 进入无限循环,使服务器成为常驻进程。
        for (;;) // 此处的空条件表示永久循环
        {
            // 初始化存储客户端信息的结构体,并清零,准备接收新客户端的数据。
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer));

            // 为存储客户端地址信息分配足够的缓冲区空间。
            socklen_t len = sizeof(peer);

            // 开始从套接字接收数据,同时获取发送数据的客户端地址信息。
            ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);

            // 如果成功接收到数据(接收到的数据长度大于0)
            if (s > 0)
            {
                // 在接收缓冲区末尾添加终止符,将其视为字符串处理。
                buffer[s] = 0;

                // 输出接收到的客户端数据及其来源信息。
                // 解析出客户端的端口号(从网络字节序转换为主机字节序)。
                uint16_t cli_port = ntohs(peer.sin_port); 

                // 将客户端的4字节网络序列IP地址转换为便于显示的字符串形式。
                std::string cli_ip = inet_ntoa(peer.sin_addr);

                // 输出客户端的IP地址、端口号及发送的数据内容。
                printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);

                // TODO: 在此处可以添加更多的数据处理逻辑。
            }

            // 不管是否接收到数据,都将之前接收到的数据原样发送回客户端。
            // 在此简化实现中,服务器简单地回显客户端发送的内容,
            sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
        }
    }

private:
    // 服务器监听的端口号(16位无符号整数)
    uint16_t _port;

    // 服务器绑定的 IP 地址(字符串形式)
    std::string _ip;

    // 存储已创建的套接字句柄
    int _sock; // 用于接收和发送数据的套接字描述符
};

#endif
  1. UdpServer类构造函数: 接收两个参数,一个是16位的整数型端口号uint16_t port,另一个是可选的字符串型IP地址std::string ip。默认情况下,若不提供IP地址,则服务器将监听所有网络接口。类中包含三个私有成员变量:服务器监听的端口号_port,可选的IP地址_ip,以及用于网络通信的套接字描述符_sock

  2. initServer()函数: 该函数负责初始化服务器,主要步骤如下:

    • 创建一个UDP套接字,使用AF_INET协议家族和SOCK_DGRAM套接字类型。
    • 若创建套接字失败,记录错误信息并退出程序。
    • 初始化一个sockaddr_in结构体local,设置其sin_familyAF_INETsin_port为服务器监听的端口号(转换为主机字节序),并将IP地址根据用户提供的字符串(或默认为INADDR_ANY)转换成网络字节序的整数值。
    • 调用bind函数将套接字与本地地址和端口关联。如果绑定失败,同样记录错误信息并退出程序。
    • 成功绑定后,记录一条日志信息表示服务器初始化完成。
  3. Start()函数: 该函数启动服务器的主循环,持续监听和处理来自客户端的请求。

    • 通过无限循环,不断地从套接字接收数据(使用recvfrom函数)。
    • 接收到数据后,将数据原样返回给客户端(使用sendto函数)。
    • 在这个例子中,服务器充当了一个简单的回显服务器,接收到客户端发送的消息后,原样返回给客户端。
  4. 析构函数: 当UdpServer对象生命周期结束时,析构函数会检查套接字是否已成功创建并处于打开状态,如果是,则关闭套接字,释放资源。

initServer()初始化服务器

initServer() 方法是 UdpServer 类中的一个成员函数,其主要目的是初始化一个 UDP 服务器,使其能够监听和接收特定 IP 地址和端口号上的数据报文。

#define SIZE 1024

class UdpServer
{
public:
    UdpServer(uint16_t port, std::string ip = "")
        : _port(port), _ip(ip), _sock(-1)
    {}
    bool initServer()
    {
        // 开始初始化服务器,通过系统调用完成网络功能配置
        // 1. 创建UDP套接字
        _sock = socket(AF_INET, SOCK_DGRAM, 0); // 使用IPv4协议族,创建UDP套接字
        if (_sock < 0)
        {
            // 如果创建套接字失败,记录错误日志并退出程序
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }

        // 2. 绑定本地地址和端口到套接字
        // 定义一个IPv4结构体存储本地地址信息
        struct sockaddr_in local;
        bzero(&local, sizeof(local)); // 清零结构体内容,防止遗留数据干扰

        // 设置协议族为IPv4
        local.sin_family = AF_INET;

        // 设置服务端监听的端口号,转换为主机字节序
        local.sin_port = htons(_port);

        // 设置服务端监听的IP地址
        // 如果_ip为空字符串,则绑定到任意可用IP(INADDR_ANY)
        // 否则,将点分十进制字符串形式的IP地址转换为网络字节序的整数值
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());

        // 执行bind操作,将套接字与本地地址和端口绑定
        if (bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            // 如果绑定失败,记录错误日志并退出程序
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }

        // 绑定成功,记录日志
        logMessage(NORMAL, "init udp server done ... %s", strerror(errno));

        // 初始化服务器完成,返回成功标志
        return true;
    }
    ~UdpServer()
    {
        if (_sock >= 0)
            close(_sock);
    }

private:
    // 服务器监听的端口号(16位无符号整数)
    uint16_t _port;

    // 服务器绑定的 IP 地址(字符串形式)
    std::string _ip;

    // 存储已创建的套接字句柄
    int _sock; // 用于接收和发送数据的套接字描述符
};

#endif

构造函数和析构函数是C++中类的重要组成部分,用于控制类对象的生命周期,即对象的创建和销毁过程。下面是关于UdpServer类构造函数和析构函数的详细讲解:

构造函数

UdpServer(uint16_t port, std::string ip = "")
    : _port(port), _ip(ip), _sock(-1)
{}
  1. 签名:构造函数接受两个参数:一个无符号16位整数uint16_t port(代表服务器监听的端口号)和一个默认为空字符串的std::string ip(代表服务器绑定的IP地址)。默认参数ip = ""意味着用户在创建对象时可以选择性地省略IP地址参数,此时服务器将绑定到任意可用IP(INADDR_ANY)。

  2. 初始化列表

    • _port(port):将传入的参数port值赋给_port,用于存储服务器监听的端口号。
    • _ip(ip):将传入的参数ip值赋给_ip,或者在用户未提供IP地址时使用默认的空字符串。此成员变量存储服务器绑定的IP地址。
    • _sock(-1):将-1赋给_sock,这是套接字描述符的初始值,表示尚未创建套接字。后续在initServer方法中创建并成功绑定套接字后,会将有效的套接字描述符赋给_sock

析构函数

~UdpServer()
{
    if (_sock >= 0)
        close(_sock);
}

析构函数检查_sock的值是否大于等于0(即套接字已成功创建并处于打开状态),如果是,则调用系统函数close(_sock)关闭套接字,释放与该套接字关联的系统资源。

bool initServer()

  1. 创建套接字

    _sock = socket(AF_INET, SOCK_DGRAM, 0);

    使用 socket() 系统调用来创建一个新的套接字。这里的参数含义如下:

    • AF_INET 表示使用IPv4地址簇,适用于大多数网络通信。
    • SOCK_DGRAM 表示使用无连接的UDP传输层协议,即数据报文协议。
    • 第三个参数通常设置为0,在UDP中不需要特别指定协议类型。

    如果创建套接字失败(即 _sock < 0),则记录致命错误日志,并通过 exit(2) 结束程序。

  2. 填充本地地址结构体

    struct sockaddr_in local;
    bzero(&local, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);

    创建一个 sockaddr_in 结构体实例 local,这是用于存储IPv4地址和端口号的结构。首先使用 bzero() 函数清零整个结构体,确保没有遗留数据。

    • local.sin_family 被设置为 AF_INET,表明这是一个IPv4地址结构。
    • local.sin_port 被设置为传入构造函数的 _port 变量值,但先进行了 htons() 函数转换。这是因为网络字节序通常是大端字节序,而端口号在内存中可能是小端字节序,所以需要用 htons() 函数将主机字节序转为网络字节序。
    • "192.168.110.132"是一个典型的点分十进制格式的IPv4地址,这种表示方式直观易读,便于我们理解和记忆。它由四个十进制数值组成,每个数值代表IP地址的一个八位字节(也称为一个“段”),彼此之间用点(.)分隔。具体来说:
    • 第一个数值,即"192",表示IP地址的第一个八位字节。这个字节的取值范围是0到255(对应二进制的00000000至11111111),恰好能容纳一个字节的全部可能值。

    • 后续数值,即"168"、"110"和"132",分别代表IP地址的第二、第三和第四个八位字节。它们同样遵循0到255的取值范围,每个字节独立承载部分地址信息。

    • 综上所述,一个IPv4地址通过四个这样的十进制数值串连起来,形成如"192.168.110.132"这样的点分十进制字符串。这种紧凑的四位结构,恰好能够用4个字节(每个字节8位,共32位)的二进制数据来完整表示,与IPv4地址的实际存储和传输格式相吻合。

      因此,从逻辑上看,一个IPv4地址无论采用点分十进制字符串形式还是其内在的4字节二进制形式,所蕴含的信息是完全一致的。在实际的网络通信中,应用程序通常需要在这两种表示形式之间进行转换,例如使用inet_addr()函数将点分十进制字符串转化为4字节的网络字节序整数,或者使用inet_ntoa()函数将4字节的网络字节序整数转换为点分十进制字符串,以适应不同场景的需求。

    • bzero()

      bzero() 是 C 语言标准库中的一个函数,主要用于将一段内存区域清零(即填充为全零)。不过要注意的是,这个函数在 C++11 标准后已被弃用,推荐使用 memset() 函数替代。

      函数原型如下:

      void bzero(void *s, size_t n);

      参数说明:

      • s:指向内存区域首地址的指针,你需要清零的内存块起始位置。
      • n:要清零的字节数。

      在上述代码片段中:

      bzero(&peer, sizeof(peer));

      这条语句的作用是将 peer 这个 sockaddr_in 结构体的所有成员变量清零。sizeof(peer) 计算出结构体的大小,然后调用 bzero() 函数将其所有字节都填充为零。这样做的目的是初始化结构体,确保之前的内容不会影响接下来的操作,尤其是涉及到网络通信时,往往需要确保结构体中存储的信息是已知和预期的初始状态。

      然而,在现代 C/C++ 编程中,建议使用 memset() 函数替换 bzero(),其功能相同,函数原型如下:

      void* memset(void* ptr, int value, size_t num);

      同样地,你可以使用 memset() 来达到同样的效果:

      memset(&peer, 0, sizeof(peer));

      htons() 

      htons() 是一个在 BSD Socket API 和许多其他网络编程接口中广泛使用的函数,主要用于将主机字节顺序(Host Byte Order)转换为网络字节顺序(Network Byte Order)。

      在网络通信中,不同的计算机体系结构可能会有不同的字节序(大端或小端)。为了确保不同机器之间的数据传输一致性,TCP/IP 协议规定了一系列的网络协议字段(如端口号、IP地址等)在传输过程中统一采用网络字节序(大端字节序,即高位字节在前)。

      函数原型如下:

      uint16_t htons(uint16_t hostshort);

      参数说明:

      hostshort:一个 16 位无符号整数,代表主机字节顺序下的数值。

      函数返回值:

      返回值为将输入的 hostshort 转换为网络字节顺序后的 16 位无符号整数。
       

      在上述代码片段中并没有直接使用 htons() 函数,但是有个类似的功能调用 ntohs() 的反向操作:

      uint16_t cli_port = ntohs(peer.sin_port);

      这里,peer.sin_port 是一个从网络字节顺序读取到的端口号,ntohs() 函数用于将其转换为主机字节顺序,使得本地程序能够正确理解和使用这个端口号。

      如果你需要将一个主机字节顺序的端口号转换为网络字节顺序再发送出去,这时候就会用到 htons() 函数,例如:

      uint16_t hostPort = 12345;
      uint16_t netPort = htons(hostPort);
      peer.sin_port = netPort;

      这样,peer.sin_port 就会被设置成网络字节顺序的端口号,可以在网络通信中正确传输。

  3. 处理IP地址

    local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());

    这段代码处理服务器监听的IP地址:

    • 如果 _ip 字符串为空,则设置 local.sin_addr.s_addr 为 INADDR_ANY,这意味着服务器将监听所有可用的网络接口,可以接收来自任何接口的UDP数据报文。
    • 如果 _ip 不为空,则调用 inet_addr(_ip.c_str()) 来将点分十进制形式的IP地址字符串转换为网络字节序的32位整数(4字节),然后赋值给 local.sin_addr.s_addr
  4. 绑定套接字

    if (bind(_sock, (struct sockaddr *)&local, sizeof(local)) < 0)

    使用 bind() 系统调用将之前填充好的 local 结构体与刚创建的套接字 _sock 关联起来,这样服务器就会在指定的IP地址和端口号上监听。若绑定失败,同样记录致命错误日志并结束程序。

  5. 初始化成功标志: 最后,方法会在成功执行上述操作后,记录一条普通级别日志,表示服务器初始化完成,并返回 true 表示初始化成功。

总之,initServer() 方法实现了创建并配置一个监听特定端口(可能针对特定IP地址)的UDP套接字,从而完成了UDP服务器的基本初始化过程。

Start()启动服务器

Start 函数的作用是启动一个永不退出的循环,循环中接收客户端发来的UDP数据包,简单处理后(此处仅为输出数据来源和内容),再将数据回送给客户端,从而实现了UDP回显服务器的功能。在实际应用中,可以根据需求在接收到数据后增加更多的业务逻辑处理代码。 

#define SIZE 1024

class UdpServer
{
public:
    // UdpServer 类的 Start 方法启动服务器进入持续监听模式,每当接收到客户端的 UDP 数据包时,
    // 服务器都会原样回复给客户端。
    void Start()
    {
        // 服务器将持续运行,不断循环等待和处理客户端的请求。
        char buffer[SIZE]; // 创建一个固定大小的缓冲区用于存储接收到的客户端数据,最大容量为 1024 字节。

        // 进入无限循环,使服务器成为常驻进程。
        for (;;) // 此处的空条件表示永久循环
        {
            // 初始化存储客户端信息的结构体,并清零,准备接收新客户端的数据。
            struct sockaddr_in peer;
            bzero(&peer, sizeof(peer));

            // 为存储客户端地址信息分配足够的缓冲区空间。
            socklen_t len = sizeof(peer);

            // 开始从套接字接收数据,同时获取发送数据的客户端地址信息。
            ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);

            // 如果成功接收到数据(接收到的数据长度大于0)
            if (s > 0)
            {
                // 在接收缓冲区末尾添加终止符,将其视为字符串处理。
                buffer[s] = 0;

                // 输出接收到的客户端数据及其来源信息。
                // 解析出客户端的端口号(从网络字节序转换为主机字节序)。
                uint16_t cli_port = ntohs(peer.sin_port); 

                // 将客户端的4字节网络序列IP地址转换为便于显示的字符串形式。
                std::string cli_ip = inet_ntoa(peer.sin_addr);

                // 输出客户端的IP地址、端口号及发送的数据内容。
                printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buffer);

                // TODO: 在此处可以添加更多的数据处理逻辑。
            }

            // 不管是否接收到数据,都将之前接收到的数据原样发送回客户端。
            // 在此简化实现中,服务器简单地回显客户端发送的内容,
            sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
        }
    }

private:
    // UdpServer 类的私有成员变量:
    // 服务器监听的端口号(16位无符号整数)
    uint16_t _port;

    // 服务器绑定的 IP 地址(字符串形式)
    std::string _ip;

    // 存储已创建的套接字句柄
    int _sock; // 用于接收和发送数据的套接字描述符
};

UdpServer 类的 Start 函数是服务器的核心部分,负责启动并维持服务器的运行,处理客户端发来的UDP数据包。下面是详细的解释:

首先,定义了一个固定大小的缓冲区 char buffer[SIZE],用于存放接收到的客户端数据。这里的 SIZE 定义为1024字节。

接下来是一个无限循环,这意味着服务器会一直运行,除非进程被外部因素终止(如手动停止或者出现致命错误)。

在循环内部:

  1. 初始化一个 sockaddr_in 结构体 peer 用来存放客户端的信息,使用 bzero 函数清零该结构体的所有字节。

  2. 设置 socklen_t len 为 sizeof(peer),表示用来接收客户端地址信息的缓冲区长度。

  3. 使用 recvfrom 系统调用从套接字 _sock 接收数据,填入 buffer 缓冲区,并同时获取发送数据的客户端地址信息(存放在 peer 结构体中)。sizeof(buffer)-1 用来确保接收的数据不会超过缓冲区容量,并且为字符串留出空位放置结束符。

     ssize_t s = recvfrom(_sock, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
  4. 如果 recvfrom 成功接收到数据(ssize_t s > 0),则在缓冲区末尾添加字符串结束符 '\0',这样我们可以将其视为 C 风格的字符串。
     

    recvfrom()

     recvfrom() 是一个在 BSD Socket API 中提供的函数,主要用于 UDP 协议下的网络编程,也可以用于 RAW 或者 Datagram 套接字类型。它的作用是从指定的套接字接收数据,并返回发送数据的远程主机地址信息。

    函数原型如下:

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

    各参数含义:
    sockfd: 已经通过 socket() 函数创建并经过 bind() 绑定的套接字描述符。
    buf: 一个指向缓冲区的指针,用于接收数据。接收到的数据将被复制到这个缓冲区中。
    len: 缓冲区的大小,即最多能接收多少字节的数据。
    flags: 可选标志,通常设置为 0。在某些情况下,可以传递 MSG_PEEK 或 MSG_WAITALL 等标志,但这些在 UDP 中并不常用。

    src_addr: 类型为struct sockaddr *的指针,用于接收发送数据的远程主机的地址信息。通常需要预先分配一个适当大小的sockaddr结构体(如sockaddr_insockaddr_in6)并将其地址传递给此参数。函数完成后,该结构体将被填充为发送方的地址和端口信息。

    addrlen: 类型为socklen_t *的指针,用于传递和接收地址结构体的长度。在调用recvfrom()前,需要将指向地址结构体长度的指针(如sizeof(struct sockaddr_in))赋给addrlen。函数执行完毕后,addrlen指向的内存将被更新为实际接收到的地址结构体长度。
     



    函数返回值:

    如果成功接收到数据,返回接收到的数据字节数。
    如果没有数据可接收并且套接字是非阻塞的,返回值为 0。
    如果发生错误,返回 -1,并设置 errno 错误码。

  5. 解析客户端的端口号和IP地址:从 peer.sin_port 中提取端口号,通过 ntohs 函数将网络字节序转换为主机字节序;从 peer.sin_addr 中提取IP地址,利用 inet_ntoa 函数将网络字节序的IP地址转换为点分十进制格式的字符串。
     

    ntohs

    ntohs 是 Network To Host Short 的缩写,这是一个用于网络编程的函数,主要用于处理字节序转换问题。在不同的计算机体系结构中,整数(包括短整型)的字节存储顺序可能是不同的:

    大端字节序(Big-Endian):高位字节存储在内存的低地址处。
    小端字节序(Little-Endian):低位字节存储在内存的低地址处。

    网络传输数据时规定采用统一的标准字节序——网络字节序(Network Byte Order),它是大端字节序。

    当网络上接收到的数据是以网络字节序表示的数值时,如果本地机器采用的是与之不同的字节序,则需要进行转换才能正确解析这些数值。例如,在 TCP/IP 网络通信中,端口号和 IP 地址的某些部分在网络传输中都是以大端字节序发送的。

    ntohs 函数的作用就是将一个16位的无符号短整型数从网络字节序转换为主机字节序。具体来说:

    uint16_t cli_port = ntohs(peer.sin_port);


    这条语句会把 peer 结构体中的 sin_port 成员(网络字节序的16位端口号)转换成本地主机使用的字节序,这样就可以在本地程序中正确地理解和使用这个端口号了。对于其他类型的整数,还有类似的函数:
    ntohl:用于32位无符号长整型,Network To Host Long。
    这些函数在大多数网络编程库(如 POSIX 的 <arpa/inet.h> 或 Windows 下的 <winsock2.h>)中都有提供。
    htonl:Host To Network Long。
    htons:Host To Network Short。
    输出接收到的数据及其来源信息,格式为 [IP地址:端口号]# 数据内容

  6. 最后,使用 sendto 函数将接收到的数据原封不动地回送给客户端。参数分别为:套接字 _sock、缓冲区 buffer、数据长度 strlen(buffer)、附加选项(在此设为0)、以及客户端地址信息 (struct sockaddr*)&peer 和其长度 len

     sendto

    sendto() 是一个在 BSD Socket API 中的函数,用于在无连接的套接字(如 UDP 套接字)上发送数据。此函数可用于向指定的网络地址发送数据报文。

    函数原型一般如下:

    ssize_t sendto(int socket, const void *buffer, size_t length, int flags,
                  const struct sockaddr *dest_addr, socklen_t dest_len);

    各个参数的含义:
    socket: 这是一个已建立的套接字描述符,由 socket() 函数创建并由 bind() 或 connect() 函数准备用于发送数据。

    buffer: 这是一个指向缓冲区的指针,包含了要发送的数据。

    length: 表示缓冲区内待发送数据的字节数。

    flags: 通常情况下,对于 sendto() 函数,这个参数设置为 0。但在某些特殊情况下,可以使用特定的标志,如 MSG_OOB(发送带外数据)或 MSG_DONTROUTE(不使用路由表)等。

    dest_addr: 这是一个指向 sockaddr 结构体的指针,包含了目标主机的地址信息。对于 IPv4 地址,通常使用 sockaddr_in 结构体。

    dest_len: 指定了 dest_addr 参数所指向的地址结构体的大小。

    sendto() 函数会尝试发送指定长度的数据,并返回实际发送出去的字节数。如果返回值小于 length,可能是因为发生了错误或者网络缓冲区空间不足等情况。

2、udp_server.cc

udp_server.cc文件中的main函数是整个UDP服务器程序的入口点,负责创建和启动一个基于UDP协议的回显服务器。服务器监听指定的端口,接收到客户端的数据后立即将其回传给客户端,形成一个基本的回显服务功能。 

#include "udp_server.hpp"
#include <memory>
#include <cstdlib>

static void usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " port\n" << std::endl;
}

int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(1);
    }
    uint16_t port = atoi(argv[1]);
    std::unique_ptr<UdpServer> svr(new UdpServer(port));
    svr->initServer();
    svr->Start();
    return 0;
}

udp_server.cc 文件包含了C++ UDP服务器的主程序,它主要完成了以下几项工作:

  1. 引入头文件:包含了之前定义的udp_server.hpp头文件,以及其他必要的系统库文件。

  2. 定义usage函数:这是一个辅助函数,用于输出程序的使用方法。在这个例子中,UDP服务器只需一个参数,即服务器要监听的端口号。

  3. 主函数main

    • 检查命令行参数的数量,确保用户输入了正确的端口号。如果参数数量不符,调用usage函数输出使用帮助并退出程序。
    • 根据用户输入的端口号创建一个UdpServer实例,并通过智能指针std::unique_ptr进行管理。
    • 调用UdpServer实例的initServer方法初始化服务器,主要包括创建UDP套接字、设置服务器监听的IP地址和端口号,并进行bind操作。
    • 初始化完成后,调用Start方法启动服务器主循环,该循环将持续监听客户端发来的UDP数据包,并原样回送给客户端。

3、udp_client.cc 

udp_client.cc 文件实现了一个简单的 UDP 客户端程序,它可以连接到指定的 UDP 服务器,并与服务器进行双向通信,实现类似回显的功能。当客户端发送消息给服务器时,服务器会原样返回该消息,客户端接收到服务器返回的消息后在控制台上展示。 

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

// 定义一个静态辅助函数,用于输出程序的正确使用方法
static void usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " serverIp serverPort\n"
              << std::endl;
}

// 示例命令行:./udp_client 127.0.0.1 8080
int main(int argc, char *argv[])
{
    // 检查命令行参数的数量是否为3(程序名 + IP地址 + 端口号)
    if (argc != 3)
    {
        // 参数数量不对,输出使用方法并退出程序
        usage(argv[0]);
        exit(1);
    }

    // 创建一个UDP套接字,使用IPv4协议,SOCK_DGRAM表示数据报文(UDP)类型
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0)
    {
        // 创建套接字失败,输出错误信息并退出程序
        std::cerr << "socket error" << std::endl;
        exit(2);
    }

    // 对于UDP客户端,通常不需要手动bind本地IP和端口
    // 因为当客户端首次发送数据时,操作系统会自动分配一个临时端口并绑定到本地IP

    // 定义用于存储服务器地址的结构体
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server)); // 初始化结构体所有成员为0

    // 设置协议族为IPv4
    server.sin_family = AF_INET;

    // 将命令行参数中的端口号转换为网络字节顺序(主机字节序转网络字节序)
    server.sin_port = htons(atoi(argv[2]));

    // 将命令行参数中的IP地址字符串转换为网络字节顺序的IP地址
    server.sin_addr.s_addr = inet_addr(argv[1]);

    // 定义缓冲区用于接收服务器响应的数据
    char buffer[1024];

    // 进入无限循环,直到用户输入"quit"为止
    while (true)
    {
        std::cout << "请输入你的信息# ";
        std::getline(std::cin, message);

        // 如果用户输入"quit",则跳出循环
        if (message == "quit")
            break;

        // 将客户端消息发送至服务器
        sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));

        // 清零临时结构体,用于存储接收到的服务器响应信息
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);

        // 接收服务器的响应数据
        ssize_t s = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr*)&temp, &len);

        // 如果成功接收到数据
        if (s > 0)
        {
            // 添加终止符,确保buffer是一个有效的C字符串
            buffer[s] = 0;

            // 输出接收到的服务器响应
            std::cout << "server echo# " << buffer << std::endl;
        }
    }

    // 关闭套接字,释放资源
    close(sock);

    // 主程序结束,返回0表示成功
    return 0;
}
  1. 首先,包含必要的头文件,如 <iostream><string><cstring><unistd.h><sys/socket.h><arpa/inet.h> 和 <netinet/in.h>,这些头文件包含了编写网络编程所需的基本函数和数据类型。

  2. 定义一个静态辅助函数 usage(),用于输出程序的正确用法。当命令行参数不足时,调用此函数打印帮助信息并退出程序。

  3. main() 函数是客户端程序的入口点,其接受两个命令行参数:服务器 IP 地址和端口号。如果参数数量不足,调用 usage() 输出用法并退出。

  4. 创建一个 UDP 套接字,使用 socket() 系统调用,参数 AF_INET 表示 IPv4,SOCK_DGRAM 表示使用 UDP 协议。如果创建套接字失败,输出错误信息并退出程序。

  5. 定义客户端要连接的服务器地址结构体 sockaddr_in server,并填充相关信息。设置家族类型为 AF_INET,将服务器端口号转换为主机字节序后赋值给 sin_port,使用 inet_addr() 函数将服务器 IP 地址从点分十进制字符串转换成网络字节序的整数值,存入 sin_addr.s_addr

  6. 在一个无限循环中执行以下操作:

         while(true)
        {
            std::cout << "请输入你的信息# ";
            std::getline(std::cin, message);
            if(message == "quit") break;
    
            sendto(sock, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof server);
    
            struct sockaddr_in temp;
            socklen_t len = sizeof(temp);
            ssize_t s = recvfrom(sock, buffer, sizeof buffer, 0, (struct sockaddr*)&temp, &len);
            if(s > 0)
            {
                buffer[s] = 0;
                std::cout << "server echo# " << buffer << std::endl;
            }
        }

    a. 提示用户输入消息。 b. 当用户输入 "quit" 时,跳出循环。 c. 使用 sendto() 函数将用户输入的消息发送到服务器。 d. 接收服务器返回的消息,使用 recvfrom() 函数,将接收到的数据存放在 buffer 中。 e. 如果成功接收到数据(即 recvfrom() 返回值大于 0),将 buffer 转换为字符串并在控制台输出。

  7. 循环结束后,关闭客户端套接字,确保资源释放。

为什么客户端没有绑定操作?

  1. 客户端需要绑定吗?

    • 理论上需要:如同服务端一样,客户端在发送和接收数据时也需要一个本地IP地址和端口。在UDP通信中,客户端与服务端之间的通信是基于四元组(源IP、源端口、目的IP、目的端口)进行的。因此,从技术角度来看,客户端确实需要绑定一个本地IP地址和端口才能进行网络通信。
  2. 为什么客户端通常不显式绑定?

    • 随机选择端口:当客户端发送数据时,如果没有预先绑定本地端口,操作系统会自动为其分配一个未被占用的临时端口。这种方式可以避免程序员手动指定端口带来的问题,如端口冲突(多个客户端同时使用同一端口)或端口资源浪费(固定端口可能长时间不释放)。
    • 简化程序逻辑:对于大多数客户端应用(如用户下载并使用的普通软件),程序员通常不需要关心客户端使用的具体端口,因为这并不影响用户正常使用。显式绑定端口只会增加程序复杂性和维护成本,而对用户体验几乎没有提升。
    • 适应性强:让操作系统自动为客户端分配端口,使得客户端在不同网络环境和设备上都能正常运行,无需担心端口被其他程序占用或特定端口被防火墙*等问题。
  3. 何时由操作系统自动绑定?

    • 首次发送数据时:在使用sendto()函数向服务端发送数据时,如果客户端尚未绑定本地端口,操作系统会在调用该函数的瞬间自动为客户端分配一个可用的本地端口,并隐式地执行bind()操作。之后,客户端便可以使用这个临时分配的端口继续进行通信。