当前位置: 首页 > news >正文

从零开始:C++ UDP通信实战教程

文章目录

  • 引言
  • 1. server 端
    • 1.1 UdpServer 类的结构
    • 1.2 UdpServer 的接口
      • 1.2.1 构造函数
      • 1.2.2 Init 函数
        • 1.2.2.1 创建套接字
        • 1.2.2.2 sockaddr_in 结构体
        • 1.2.2.3 绑定
      • 1.2.3 Start 函数
        • 1.2.3.1 recvfrom
        • 1.2.3.2 sendto
      • 1.2.4 模拟数据处理的行为
    • 1.3 server.cc
  • 2. client 端
  • 可以扩展的地方

引言

在网络编程中,UDP(User Datagram Protocol,用户数据报协议)是一种轻量级、无连接的传输层协议。与TCP(传输控制协议)不同,UDP不提供数据包的可靠传输、流量控制和拥塞控制,但它以其高效和低延迟的特性,在实时应用(如在线游戏、音视频流)中占据着重要地位。本教程将带领您从零开始,使用C++语言实现一个简单的UDP服务器和客户端,深入理解UDP通信的原理和实践。

我们将详细解析代码结构,包括套接字的创建、地址绑定、数据的发送与接收等核心环节,并探讨在实际开发中可能遇到的问题和扩展思路。无论您是网络编程的初学者,还是希望巩固UDP知识的开发者,本教程都将为您提供清晰的指导和实用的示例。

在这里插入图片描述

1. server 端

1.1 UdpServer 类的结构

class UdpServer {
private:int _sockfd; // 套接字文件描述符,标识网络连接uint16_t _port; // 服务器监听的端口号(网络字节序)// std::string _ip; // 服务器监听 ip (网络字节序),其实不需要,后面会讲bool _isrunning; // 服务器运行状态
}

私有成员变量解析:

  • _sockfd:和文件描述符类似,都是整数标识符,用于表示操作系统打开的资源,套接字资源就是一种资源
  • _port:这里的 _port 既是服务器监听的端口,也是客户端访问时的目标端口。服务器在启动时会绑定到指定端口(如 8080),等待用户连接;当客户端与服务器建立链接后,服务器的响应数据包会从同一端口(8080)发送回服务端
  • _ip:这里的 _ip 指的并非是服务器自身的 IP 地址,而是指定服务器监听的网络接口,一般情况下,我们的服务器都是会允许监听所有可用网络接口,所以保存这个变量并无意义
  • _isrunning:标识服务器的状态

1.2 UdpServer 的接口

server端需要两个操作:Init(初始化并创建套接字等信息),Start(启动服务器)。

1.2.1 构造函数

const int defaultfd = -1;UdpServer(uint16_t port, func_t func): _sockfd(defaultfd), _port(port),

构造很简单,就不过多解释了

1.2.2 Init 函数

在这里插入图片描述

在初始化中,我们需要完成两个任务:

  1. 创建UDP套接字
  2. 将套接字绑定本地 IP 地址与端口(以便客户端能够找到服务器)
1.2.2.1 创建套接字

要创建套接字,就得先来认识认识 socket 接口

  1. 函数原型与返回值
    #include <sys/socket.h>
    int socket(int domain, int type, int protocol);
    
    • 返回值
      • 成功:返回套接字描述符(非负整数,如 34)。
      • 失败:返回 -1,并设置 errno(如 EAFNOSUPPORTEINVAL)。
  2. 参数详解
    • domain (协议族/地址族)
      • AF_INET:IPv4 协议(本文采用)
      • AF_INET:IPv6 协议
    • type(套接字类型)
      • SOCK_DGRAM:无连接的数据报套接字,UDP 协议
      • SOCK_STREAM:面向连接的流式套接字,TCP 协议
      • SOCK_RAM:原始套接字,IP、ICMP 协议等
    • protocol(具体协议)
      • 0:默认协议

具体使用:

        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0) {std::cout << "socket error!" << std::endl;exit(1);}std::cout << "socket sucess, sockfd: " << _sockfd << std::endl;

在创建套接字之后,我们就要将套接字与对 IP 和端口进行绑定了。

1.2.2.2 sockaddr_in 结构体
#include <netinet/in.h>struct sockaddr_in {sa_family_t    sin_family; /* 地址族,必须为AF_INET(IPv4) */in_port_t      sin_port;   /* 端口号(网络字节序) */struct in_addr sin_addr;   /* IPv4地址结构体 */char           sin_zero[8]; /* 填充字节,使sockaddr_in与sockaddr长度相同 */
};/* IPv4地址结构体 */
struct in_addr {uint32_t       s_addr;     /* IPv4地址(网络字节序) */
};

我们在绑定之前需要先填充信息,再进行绑定。
需要注意的地方有两点:

  1. 在填充结构体之前我们需要对变量进行清零。(内存这东西你知道的,你不进行清零很容易导致随随机行为,如在数组越界时,打印出来的结果为 “烫烫烫烫烫烫”)
  2. 可以看到,sin_port 和 sin_addr 要求的都是网络字节序,我们本地存储的信息是本地字节序,在填充的时候需要使用接口进行转换

网络字节序转换函数

  • htons():将 16 位整数从主机字节序转换为网络字节序(如端口号)。
  • htonl():将 32 位整数从主机字节序转换为网络字节序(如 IP 地址)。
  • ntohs():将 16 位整数从网络字节序转换为主机字节序。
  • ntohl():将 32 位整数从网络字节序转换为主机字节序。

但是对于 IP,前面我们也说过了,一般是监听全部可用的网络可用接口,也就是将其设置为 INADDR_ANY

1.2.2.3 绑定
  1. 函数原型与返回值
    #include <sys/socket.h>int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    
    • 返回值
      • 成功:返回 0
      • 失败:返回 -1,并设置 errno(如 EADDRINUSEEACCES)。
  2. 参数详解
参数类型含义
sockfdintsocket() 创建的套接字描述符。
addrconst struct sockaddr*指向 sockaddr 结构体的指针,存储要绑定的 IP 地址和端口。需根据协议族初始化不同结构体(如 sockaddr_insockaddr_in6)。
addrlensocklen_t addr结构体的长度(如 sizeof(struct sockaddr_in))。

具体使用

		// 填充 sockaddr_in 结构体struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;// 本地格式 -> 网络序列local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;// bindint n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));if (n < 0) {std::cout << "bind error" << std::endl;exit(2);}std::cout << "bind sucess, sockfd: " << _sockfd << std::endl;

1.2.3 Start 函数

在这里插入图片描述

开启服务器之后,需要把服务器状态设置为运行,然后通过 _isrunning 的状态进行死循环操作。

对于服务器,我们认为它需要对客户端的数据进行接收,还要能发消息回应客户端

接下来介绍两个接口:recvfromsendto,分别用于 UDP 数据报的收和发

1.2.3.1 recvfrom
  1. 函数原型
    #include <sys/socket.h>ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
    
    • 返回值
      • 成功:返回接收到的字节数(>=0)。
      • 失败:返回 -1,并设置 errno(如 EAGAINECONNRESET)。
  2. 参数详解
参数类型含义
sockfdintsocket() 创建的套接字描述符(需已绑定地址,如 UDP 服务器)。
bufvoid*用于存储接收数据的缓冲区。
lensize_t缓冲区的最大长度(防止溢出)。
flagsint控制接收行为的标志位(如 MSG_DONTWAIT 非阻塞模式),通常设为 0
src_addrstruct sockaddr*指向结构体的指针,用于存储发送方的地址信息(如 IP 和端口)。
addrlensocklen_t*指向 src_addr 结构体长度的指针(输入时为初始长度,输出时为实际长度)。

1.2.3.2 sendto
  1. 函数原型
    #include <sys/socket.h>ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
    
    • 返回值
      • 成功:返回实际发送的字节数(>=0)。
      • 失败:返回 -1,并设置 errno(如 EAGAINENOTCONN)。
  2. 参数详解
参数类型含义
sockfdintsocket() 创建的套接字描述符(UDP 套接字或已连接的 TCP 套接字)。
bufconst void*要发送的数据缓冲区。
lensize_t要发送数据的长度(字节)。
flagsint控制发送行为的标志位(如 MSG_DONTWAIT 非阻塞模式),通常设为 0
dest_addrconst struct sockaddr* 指向目标地址的结构体指针(包含目标 IP 和端口)。
addrlensocklen_tdest_addr 结构体的长度(如 sizeof(struct sockaddr_in))。

有了这两个接口,我们就可以实现对数据的收发操作:

    void Start() {_isrunning = true;while (_isrunning) {char buffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);// 1. 收消息ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);if (s > 0) {int peer_port = ntohs(peer.sin_port); // 从网络中拿到的,网络序列std::string peer_ip = inet_ntoa(peer.sin_addr); // 4字节网络风格的IP -> 点分十进制的字符串风格的IPbuffer[s] = 0;std::cout << "[" << peer_ip << ":" << peer_port << "]#" << "buffer: " << buffer << std::endl; // 1. 消息内容 2. 谁发的// 2. 发消息std::string echo_string = "sever echo@ ";echo_string += buffer;sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr*)&peer, len);}}}

1.2.4 模拟数据处理的行为

我们可以使用函数包装器包装一个对数据进行处理的回调函数。

using func_t = std::function<std::string(const std::string&)>;

在server类内部增加一个对应的成员变量:

func_t _func; // 服务器的回调函数,用来对数据进行处理

server.cc 中由用户扩展一个数据处理的函数,在构造的时候使用该函数进行构造,然后后在收到消息之后通过回调扩展的数据处理函数对接收到的用户数据进行处理,最后再发送给客户端。
这样就完成了对于用户数据的接收、对数据进行处理、将处理后的数据返回给用户这三个服务器最基本的操作。

1.3 server.cc

server.cc 就太简单了,通过 UdpServer 创建一个对象,然后直接调用 Init 接口进行初始化,再调用 Start 接口启动服务器就行了。

唯一要注意的是,服务器的端口是需要手动传入,这就需要使用到带命令行参数的 main 函数,如果不记得的话自己去回顾一下,这里不再过多赘述。

#include <iostream>
#include <memory>
#include "UdpServer.hpp"// 仅用来测试,模拟服务器端对数据的处理过程
std::string defaulthandler(const std::string &message) {std::string hello = "hello, ";hello += message;return hello;
}// ./udpserver in port
int main(int argc, char *argv[]) {if (argc != 2) {std::cerr << "Usage: " << argv[0] << "port" << std::endl;return 1;}// std::string ip = argv[1];uint16_t port = std::stoi(argv[1]);std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, defaulthandler);usvr->Init();usvr->Start();return 0;
}

2. client 端

在这里插入图片描述

有了前面的基础,client 端的编写就变得十分简单了。

首先依然是创建套接字文件,而后进行服务器的 sockaddr_in 结构体的填写,这之后就可以通过死循环,不断地读取本地数据,不断向服务端发送数据,接收服务端返回的数据。

唯一值得注意的是:客户端其实并不需要显式绑定固定地址
为什么?
对于服务端而言,它是需要长期监听固定端口,让客户端找到,所以服务端需要一个固定的地址。
那客户端呢?客户端只要连接到服务器,自身的地址并不重要,让系统自动分配就好了,反正在服务端接收客户端的信息时,服务端是能够接收到客户端的地址的,有了这个就足够临时的通信了。

自动绑定的实现原理
当客户端首次 sendto 的时候,系统会自动完成以下操作:

  1. 选择本地 IP:根据目标 IP 选择合适的网卡(如通过路由表)。
  2. 分配临时端口:从临时端口范围(如 32768-60999)中选择未被使用的端口。
  3. 隐式绑定:将套接字与选定的 IP 和端口绑定(等价于调用 bind()

其他的就不用过多解释了,直接看代码吧

#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>// ./udpclient server_ip server_port
int main(int argc, char *argv[]) {if (argc != 3) {std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;return 1;}std::string server_ip = argv[1];uint16_t server_port = std::stoi(argv[2]);// 1. 创建socketint sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0) {std::cerr << "socket error" << std::endl;return 2;}// 填写服务器信息struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(server_port);server.sin_addr.s_addr = inet_addr(server_ip.c_str());while (true) {std::string input;std::cout << "Please Enter# ";std::getline(std::cin, input);int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));(void)n;char buffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);int m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);if (m > 0) {buffer[m] = 0;std::cout << buffer << std::endl;}}return 0;
}

可以扩展的地方

本文中写的 UDP 服务器只是一个最基础的架构,我们可以在此之上对其进行扩展。

  • 服务器需要大量用户访问的时候
    我们可以采用线程池来管理用户线程

    • 主线程:负责接收所有用户的数据,将数据放入任务队列
    • 线程池:从任务队列中获取任务,处理数据并广播给其他用户
  • 聊天室
    可以使用缓存池并使用生产者 - 消费者模型

    • 生产者:主线程(接收用户消息)
    • 消费者:线程池中的工作线程(处理并广播消息)
    • 缓存池:线程安全的消息队列
  • 同一用户的读写
    同一用户的读写往往是需要能够同时进行的,采用独立的读写线程进行处理:

    • 发送线程:用户输入消息 -> 发送到服务器
    • 接收线程:循环接收服务器广播消息 -> 显示给用户

    这两个操作可并行,但需要注意:

    • 共享资源保护:用户界面(如聊天窗口)被多线程访问,需要加锁同步,不然读写会混在一起很难看
  • 多用户间的读写
    服务器处理多用户消息时,需保证:

    • 写操作互斥:多个用户同时发送消息时,消息入队操作需要加锁
    • 读写并发:广播消息时,可以采用读写锁
      • 读锁(共享锁):允许多个线程同时读取消息队列
      • 写锁(排他锁):仅一个线程能向队列中添加新消息
http://www.lryc.cn/news/591508.html

相关文章:

  • 【Python】通过cmd的shell命令获取局域网内所有IP、MAC地址,通过主机名获取IP
  • CCLink IE转ModbusTCP网关配置无纸记录器(上篇)
  • Redis 生产实战 7×24:容量规划、性能调优、故障演练与成本治理 40 条军规
  • Baumer工业相机堡盟工业相机如何通过YoloV8模型实现人物识别(C#)
  • MacOS安装linux虚拟机
  • Kubernetes架构原理与集群环境部署
  • Spring Boot 自动配置:从 spring.factories 到 AutoConfiguration.imports 的演变
  • MySQL安全修改表结构、加索引:ON-Line-DDL工具有哪些
  • 数据产品结构:从数据接入到可视化的完整架构指南
  • 学习C++、QT---27(QT中实现记事本项目实现行列显示、优化保存文件的功能的讲解)
  • Spring Boot 参数校验:@Valid 与 @Validated
  • 关于vector中的erase的强调
  • Leetcode刷题营第二十八题:二叉树的前序遍历
  • Effective Python 条款7 用列表推导来取代map和filter
  • c++之 KMP 讲解
  • 网络原理 —— HTTP
  • 深入理解Collections.addAll方法
  • Python 离线安装 PyInstaller 的完整步骤(以python3.11.4-amd64.exe为例)
  • Trae IDE:打造完美Java开发环境的实战指南
  • 产品经理如何绘制服务蓝图(Service Blueprint)
  • 基于5G系统的打孔LDPC编码和均匀量化NMS译码算法matlab性能仿真
  • Oracle 成本优化器(CBO)与数据库统计信息:核心原理与实践
  • 线程(三) linux 同步
  • .NET Framework版本信息获取(ASP.NET探针),获取系统的.NET Framework版本
  • pycharm提交项目到github及问题解决
  • ubuntu基础搭建
  • 【Android代码】绘本翻页时通过AI识别,自动通过手机/pad朗读绘本
  • 基于单片机公交车报站系统/报站器
  • 分支和循环语句
  • Kotlin集合与空值