从零开始: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 函数
在初始化中,我们需要完成两个任务:
- 创建UDP套接字
- 将套接字绑定本地 IP 地址与端口(以便客户端能够找到服务器)
1.2.2.1 创建套接字
要创建套接字,就得先来认识认识 socket
接口
- 函数原型与返回值
#include <sys/socket.h> int socket(int domain, int type, int protocol);
- 返回值:
- 成功:返回套接字描述符(非负整数,如
3
、4
)。 - 失败:返回
-1
,并设置errno
(如EAFNOSUPPORT
、EINVAL
)。
- 成功:返回套接字描述符(非负整数,如
- 返回值:
- 参数详解
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地址(网络字节序) */
};
我们在绑定之前需要先填充信息,再进行绑定。
需要注意的地方有两点:
- 在填充结构体之前我们需要对变量进行清零。(内存这东西你知道的,你不进行清零很容易导致随随机行为,如在数组越界时,打印出来的结果为 “烫烫烫烫烫烫”)
- 可以看到,sin_port 和 sin_addr 要求的都是网络字节序,我们本地存储的信息是本地字节序,在填充的时候需要使用接口进行转换
网络字节序转换函数
htons()
:将 16 位整数从主机字节序转换为网络字节序(如端口号)。htonl()
:将 32 位整数从主机字节序转换为网络字节序(如 IP 地址)。ntohs()
:将 16 位整数从网络字节序转换为主机字节序。ntohl()
:将 32 位整数从网络字节序转换为主机字节序。
但是对于 IP,前面我们也说过了,一般是监听全部可用的网络可用接口,也就是将其设置为 INADDR_ANY
1.2.2.3 绑定
- 函数原型与返回值
#include <sys/socket.h>int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 返回值:
- 成功:返回
0
。 - 失败:返回
-1
,并设置errno
(如EADDRINUSE
、EACCES
)。
- 成功:返回
- 返回值:
- 参数详解
参数 | 类型 | 含义 |
---|---|---|
sockfd | int | 由 socket() 创建的套接字描述符。 |
addr | const struct sockaddr* | 指向 sockaddr 结构体的指针,存储要绑定的 IP 地址和端口。需根据协议族初始化不同结构体(如 sockaddr_in 或 sockaddr_in6 )。 |
addrlen | socklen_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
的状态进行死循环操作。
对于服务器,我们认为它需要对客户端的数据进行接收,还要能发消息回应客户端
接下来介绍两个接口:recvfrom
和 sendto
,分别用于 UDP 数据报的收和发
1.2.3.1 recvfrom
- 函数原型
#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
(如EAGAIN
、ECONNRESET
)。
- 成功:返回接收到的字节数(
- 返回值:
- 参数详解
参数 | 类型 | 含义 |
---|---|---|
sockfd | int | 由 socket() 创建的套接字描述符(需已绑定地址,如 UDP 服务器)。 |
buf | void* | 用于存储接收数据的缓冲区。 |
len | size_t | 缓冲区的最大长度(防止溢出)。 |
flags | int | 控制接收行为的标志位(如 MSG_DONTWAIT 非阻塞模式),通常设为 0 。 |
src_addr | struct sockaddr* | 指向结构体的指针,用于存储发送方的地址信息(如 IP 和端口)。 |
addrlen | socklen_t* | 指向 src_addr 结构体长度的指针(输入时为初始长度,输出时为实际长度)。 |
1.2.3.2 sendto
- 函数原型
#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
(如EAGAIN
、ENOTCONN
)。
- 成功:返回实际发送的字节数(
- 返回值:
- 参数详解
参数 | 类型 | 含义 |
---|---|---|
sockfd | int | 由 socket() 创建的套接字描述符(UDP 套接字或已连接的 TCP 套接字)。 |
buf | const void* | 要发送的数据缓冲区。 |
len | size_t | 要发送数据的长度(字节)。 |
flags | int | 控制发送行为的标志位(如 MSG_DONTWAIT 非阻塞模式),通常设为 0 。 |
dest_addr | const struct sockaddr* 指向目标地址的结构体指针(包含目标 IP 和端口)。 | |
addrlen | socklen_t | dest_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
的时候,系统会自动完成以下操作:
- 选择本地 IP:根据目标 IP 选择合适的网卡(如通过路由表)。
- 分配临时端口:从临时端口范围(如 32768-60999)中选择未被使用的端口。
- 隐式绑定:将套接字与选定的 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 服务器只是一个最基础的架构,我们可以在此之上对其进行扩展。
-
服务器需要大量用户访问的时候
我们可以采用线程池来管理用户线程- 主线程:负责接收所有用户的数据,将数据放入任务队列
- 线程池:从任务队列中获取任务,处理数据并广播给其他用户
-
聊天室
可以使用缓存池并使用生产者 - 消费者模型- 生产者:主线程(接收用户消息)
- 消费者:线程池中的工作线程(处理并广播消息)
- 缓存池:线程安全的消息队列
-
同一用户的读写
同一用户的读写往往是需要能够同时进行的,采用独立的读写线程进行处理:- 发送线程:用户输入消息 -> 发送到服务器
- 接收线程:循环接收服务器广播消息 -> 显示给用户
这两个操作可并行,但需要注意:
- 共享资源保护:用户界面(如聊天窗口)被多线程访问,需要加锁同步,不然读写会混在一起很难看
-
多用户间的读写
服务器处理多用户消息时,需保证:- 写操作互斥:多个用户同时发送消息时,消息入队操作需要加锁
- 读写并发:广播消息时,可以采用读写锁
- 读锁(共享锁):允许多个线程同时读取消息队列
- 写锁(排他锁):仅一个线程能向队列中添加新消息