UDP协议详解
UDP协议是一种无连接、面向数据报的轻量级传输协议,具有开销小、速度快的特点,适用于实时性要求高的场景(如语音通话、视频传输)。服务器开发流程包括创建套接字和绑定地址两个关键步骤,其中绑定地址时需特别注意网络字节序转换和0.0.0.0地址的使用。UDP通信使用recvfrom和sendto函数进行数据收发,能获取客户端地址信息实现消息回显。端口使用需遵循规范,服务器需绑定固定端口,而客户端端口由系
UDP协议详解
一、UDP协议到底是什么?
咱们得先明确UDP的“性格”——它是TCP/IP协议族里“轻量级”的存在,核心特点就两个:无连接和面向数据报。
什么是无连接?比如你给朋友发微信消息,不用先打电话确认“你在不在”,直接发就行;UDP也是如此,客户端和服务器通信前,不用像TCP那样三次握手建立连接,直接发数据报就好。这就导致UDP的开销很小,速度快,但也有缺点:数据报可能丢包、乱序,服务器也不会确认“收到了”——不过对于实时性要求高的场景(比如语音通话、视频弹幕、设备心跳包),这些缺点可以接受,毕竟快比绝对可靠更重要。
那面向数据报又是什么意思?你可以把数据报理解成“一个完整的包裹”,每个包裹里都有完整的目标地址(IP+端口),服务器收到后要么完整处理,要么直接丢弃,不会像TCP那样把数据拆成片段再拼接。比如你发“Hello UDP”,服务器收到的就是完整的这串字符串,不会少一个字母。
搞懂这两个核心特性,后面的开发逻辑就顺理成章了——因为UDP无连接,所以服务器不用“等连接”,绑定地址后直接收发数据;因为面向数据报,所以收发要用专门的函数(recvfrom/sendto),而不是read/write。
二、UDP服务器编码
UDP服务器的开发流程其实特别固定,本质就两步:创建套接字和绑定地址。但别小看这两步,尤其是“绑定”,里面藏着很多实战中会踩的坑。
2.1 第一步:创建套接字
套接字(Socket)是操作系统提供的网络通信接口,你可以把它理解成“网线的接口”——没有它,程序就没法和网络交互。创建UDP套接字的代码,不管是Linux还是Windows,核心参数都一样:
// Linux下创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// Windows下类似,只是类型和初始化不同,后面会讲跨平台
这里要解释三个参数:
AF_INET:表示用IPv4协议,这是目前最常用的,如果你用IPv6,就改成AF_INET6;SOCK_DGRAM:表示用UDP协议(数据报协议),如果是TCP,就改成SOCK_STREAM;- 第三个参数填0:表示让系统自动选择对应的协议(UDP只有一种,所以填0就行)。
创建后要检查sockfd是否小于0——如果小于0,说明创建失败(比如权限不够、系统资源不足),这时候得打印错误信息并退出。
2.2 第二步:绑定地址——告诉系统“我要收哪个IP和端口的消息”
创建好套接字后,程序还不知道“要监听哪个IP、哪个端口”,这时候就需要“绑定”(bind)操作。绑定的本质,是把用户层定义的“IP+端口”信息,传给操作系统内核,让内核把这个信息和刚才创建的套接字关联起来——以后内核收到发往这个IP和端口的数据报,就会交给这个套接字对应的程序。
2.2.1 先理解“地址结构体”:要传什么给内核?
绑定前,得先定义一个“地址结构体”(sockaddr_in),把IP和端口填进去。这里有个关键知识点:结构体只能整体初始化,不能整体赋值。比如:
// 正确:定义时整体初始化(C语言风格)
struct sockaddr_in local_addr = {
.sin_family = AF_INET, // 必须和socket的AF_INET一致
.sin_port = htons(8080), // 端口号,要转成网络字节序
.sin_addr.s_addr = htonl(INADDR_ANY) // IP地址,INADDR_ANY就是0.0.0.0
};
// 错误:定义后整体赋值,编译器会报错
local_addr = {AF_INET, htons(8080), htonl(INADDR_ANY)};
这里又涉及两个重要概念:
- 网络字节序:不同电脑的字节序可能不一样(比如x86是小端,网络协议规定用大端),所以端口和IP必须用
htons(主机到网络短整型)、htonl(主机到网络长整型)转成大端,否则不同机器通信会乱码; - sin_addr.s_addr:存储IP地址的字段,
INADDR_ANY是系统定义的宏,对应0.0.0.0,后面会详细讲它的作用。
2.2.2 绑定的核心坑:为什么云服务器不能绑公网IP?用0.0.0.0就对了
你可能会遇到这样的问题:在云服务器上写代码,想绑定公网IP(比如120.xx.xx.xx),结果绑定失败,错误码是99(Address not available)。这不是代码错了,而是云服务器的特性——云服务器的公网IP是虚拟化的,不是主机实际的网卡IP,所以不能直接绑定。
那该怎么办?用INADDR_ANY(也就是0.0.0.0)做“任意地址绑定”。它的作用是:让服务器接收发往“本机所有IP”的数据报。比如你的云服务器有两个内网IP(172.17.0.2和172.17.0.3),绑0.0.0.0后,发往这两个IP的8080端口的报文,服务器都能收到;如果只绑172.17.0.2,那发往172.17.0.3的报文就收不到了。
所以实战中,服务器的IP绑定几乎都是用0.0.0.0,除非你明确只需要监听某个特定IP。
2.2.3 绑定函数的调用:注意类型强转
绑定的函数是bind,参数要注意类型强转——因为bind的第二个参数要求是sockaddr*类型,而我们定义的是sockaddr_in*,所以需要强转:
// 绑定操作
socklen_t addr_len = sizeof(local_addr);
int ret = bind(sockfd, (struct sockaddr*)&local_addr, addr_len);
if (ret < 0) {
perror("bind error"); // 打印错误原因
close(sockfd); // 失败要关闭套接字
exit(1);
}
绑定失败的常见原因:
- 端口被占用:比如8080已经被其他程序占用,这时候换个端口就行;
- 绑定公网IP(云服务器):前面说过,改成0.0.0.0;
- 用了1024以下的端口(普通用户):比如想绑80端口,需要root权限,普通用户会报Permission denied。
三、IP与端口
聊完绑定,必须单独说IP和端口的规范——这是网络编程的“交通规则”,不遵守就会出问题。
3.1 端口号:1024是道“分水岭”
端口号是16位的整数,范围是0~65535,但不是所有端口都能随便用:
- 0~1023:系统保留端口:这些端口是预留给系统服务的,比如HTTP用80、HTTPS用443、SSH用22、MySQL用3306。普通用户(非root)不能绑定这些端口,强行绑定会报“权限不足”;
- **102465535:普通用户可用端口**:但建议用800065535的端口,因为1024~8000之间可能有其他程序常用的端口(比如Tomcat用8080、Nginx用8080),容易冲突。
比如你想绑80端口,普通用户启动程序会报错,用sudo(Linux)或管理员权限(Windows)启动就能成功——但实战中不建议这么做,除非你开发的是HTTP这类标准服务。
另外,一个端口只能被一个进程绑定。比如你启动了一个UDP服务器绑8080,再启动另一个绑8080的服务器,第二个就会失败,错误码是98(Address already in use)。
3.2 客户端与服务器的端口差异
你可能会问:服务器必须绑定固定端口,那客户端要不要绑定?答案是:客户端必须有端口,但不用显式绑定。
为什么?
- 服务器要固定端口:因为客户端需要知道“往哪个端口发消息”。比如你用浏览器访问百度,必须知道百度的HTTP服务在80端口;如果服务器端口随机,客户端就找不到它了;
- 客户端不用显式绑定:如果客户端显式绑端口,多个客户端(比如两个抖音APP)可能会绑同一个端口,导致后启动的客户端失败。操作系统会在客户端首次调用sendto发送数据时,自动分配一个临时端口(通常是1024以上),这样就不会冲突。
举个例子:你用手机发微信消息,微信客户端不会自己绑端口,而是系统分配一个临时端口(比如56789),消息发往微信服务器的固定端口(比如8080);服务器收到消息后,会通过客户端的临时端口把回复发回来——客户端的端口是什么,你不用关心,系统会处理。
四、UDP通信实战:收发消息与回显
绑定成功后,UDP服务器就可以开始收发消息了。因为UDP无连接,所以不用“监听”(listen)和“接受连接”(accept),直接循环收发就行。
4.1 核心函数:recvfrom与sendto
UDP收发消息用的是recvfrom和sendto,而不是TCP的read和write——因为UDP需要知道“数据从哪来”和“数据往哪发”。
4.1.1 收消息:recvfrom——知道“谁发的消息”
recvfrom的作用是从套接字接收数据,并获取发送方(客户端)的地址信息:
char buf[1024] = {0}; // 接收缓冲区
struct sockaddr_in client_addr; // 存储客户端地址
socklen_t client_addr_len = sizeof(client_addr);
// 接收消息:成功返回收到的字节数,失败返回-1
ssize_t recv_len = recvfrom(
sockfd, // 套接字
buf, // 接收缓冲区
sizeof(buf)-1, // 缓冲区大小(留1个字节存'\0',当字符串处理)
0, // 标志位,0表示阻塞(没消息就等)
(struct sockaddr*)&client_addr, // 输出参数:客户端地址
&client_addr_len // 输入输出参数:地址长度
);
if (recv_len < 0) {
perror("recvfrom error");
continue; // 收消息失败,继续等下一条
}
// 把缓冲区当成字符串处理,加'\0'
buf[recv_len] = '\0';
这里的关键是客户端地址(client_addr) ——通过它,我们能拿到客户端的IP和端口:
- IP地址:
client_addr.sin_addr.s_addr,需要用inet_ntoa转成字符串(比如从网络字节序的192.168.1.10,转成"192.168.1.10"); - 端口号:
client_addr.sin_port,需要用ntohs转成主机字节序(比如从网络字节序的56789,转成主机的56789)。
4.1.2 发消息:sendto——知道“发给谁”
收到客户端消息后,如果要回复(比如回显功能),就用sendto,把消息发回给客户端的地址:
// 回显功能:拼接前缀后发回
char echo_buf[1024] = {0};
snprintf(echo_buf, sizeof(echo_buf), "Server Echo: %s", buf);
// 发送消息:成功返回发送的字节数,失败返回-1
ssize_t send_len = sendto(
sockfd, // 套接字
echo_buf, // 要发送的数据
strlen(echo_buf), // 数据长度
0, // 标志位,0表示阻塞
(struct sockaddr*)&client_addr, // 目标地址(客户端地址)
client_addr_len // 地址长度
);
if (send_len < 0) {
perror("sendto error");
continue;
}
这里要注意:sendto的目标地址,必须是recvfrom拿到的客户端地址——这样才能精准地把消息发回给对应的客户端。
4.2 客户端实现:比服务器更简单
客户端的逻辑比服务器简单,因为不用绑定地址——创建套接字后,直接发消息给服务器就行。
4.2.1 客户端核心流程
- 创建UDP套接字(和服务器一样);
- 定义服务器的地址结构体(填服务器的IP和端口);
- 循环读取用户输入,用
sendto发给服务器; - 用
recvfrom接收服务器的回复,打印出来。
代码示例(Linux下):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main(int argc, char* argv[]) {
// 检查命令行参数:需要传入服务器IP和端口
if (argc != 3) {
printf("Usage: %s <server_ip> <server_port>\n", argv[0]);
exit(1);
}
const char* server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
// 1. 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket error");
exit(1);
}
// 2. 定义服务器地址
struct sockaddr_in server_addr = {0};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port);
// 把字符串IP转成网络字节序
if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
perror("invalid server ip");
close(sockfd);
exit(1);
}
socklen_t server_addr_len = sizeof(server_addr);
// 3. 循环收发消息
char buf[1024] = {0};
while (1) {
// 读取用户输入
printf("Please enter message: ");
fgets(buf, sizeof(buf)-1, stdin);
// 去掉fgets读入的换行符
buf[strcspn(buf, "\n")] = '\0';
// 发送消息给服务器
sendto(sockfd, buf, strlen(buf), 0,
(struct sockaddr*)&server_addr, server_addr_len);
// 接收服务器回复
char recv_buf[1024] = {0};
ssize_t recv_len = recvfrom(sockfd, recv_buf, sizeof(recv_buf)-1, 0,
NULL, NULL); // 客户端不用关心回复的地址,填NULL
if (recv_len < 0) {
perror("recvfrom error");
continue;
}
recv_buf[recv_len] = '\0';
printf("Server reply: %s\n", recv_buf);
}
// 4. 关闭套接字(循环不会退出,实际中需要信号处理)
close(sockfd);
return 0;
}
4.2.2 客户端的命令行参数:怎么知道服务器地址?
客户端启动时,必须知道服务器的IP和端口——所以我们通过命令行参数传入,比如:
./udp_client 120.xx.xx.xx 8080
如果没传参数,就打印使用说明(Usage: ...),这是实战中常用的设计——总不能把服务器IP硬编码在客户端里,不然服务器IP变了,客户端就得重新编译。
五、代码解耦:让UDP服务器更灵活
前面写的回显服务器,消息处理逻辑(拼接“Server Echo: ”)和网络代码(recvfrom/sendto)混在一起了。如果想把处理逻辑改成“把消息转成大写”,就得改服务器的核心代码——这很麻烦,也容易出错。
这时候就需要解耦:把网络通信和业务逻辑分开,让服务器只负责“收发消息”,业务逻辑交给外部函数处理。
5.1 用std::function实现解耦
C++中的std::function可以封装函数(普通函数、lambda、成员函数),我们可以用它定义一个“消息处理函数”的类型,然后让服务器接收这个函数作为参数——这样想改业务逻辑时,只需要传不同的函数就行。
5.1.1 定义消息处理函数类型
#include <functional>
#include <string>
// 消息处理函数类型:输入是客户端消息,输出是处理后的消息
using MsgHandler = std::function<std::string(const std::string&)>;
5.1.2 服务器类的改造
把服务器封装成类,在构造函数中传入MsgHandler,收到消息后调用这个函数处理:
class UdpServer {
public:
UdpServer(uint16_t port, MsgHandler handler)
: port_(port), handler_(handler) {
// 初始化:创建套接字、绑定地址
InitServer();
}
// 启动服务器,循环收发消息
void Run() {
char buf[1024] = {0};
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
while (1) {
// 收消息
ssize_t recv_len = recvfrom(sockfd_, buf, sizeof(buf)-1, 0,
(struct sockaddr*)&client_addr, &client_addr_len);
if (recv_len < 0) {
perror("recvfrom error");
continue;
}
buf[recv_len] = '\0';
std::string msg(buf);
// 调用外部传入的处理函数
std::string reply = handler_(msg);
// 发消息
sendto(sockfd_, reply.c_str(), reply.size(), 0,
(struct sockaddr*)&client_addr, client_addr_len);
}
}
private:
void InitServer() {
// 创建套接字
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0) {
perror("socket error");
exit(1);
}
// 绑定地址
struct sockaddr_in local_addr = {0};
local_addr.sin_family = AF_INET;
local_addr.sin_port = htons(port_);
local_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sockfd_, (struct sockaddr*)&local_addr, sizeof(local_addr)) < 0) {
perror("bind error");
close(sockfd_);
exit(1);
}
}
private:
int sockfd_;
uint16_t port_;
MsgHandler handler_; // 消息处理函数
};
5.1.3 使用服务器:传入不同的处理函数
比如想实现“回显”和“大写转换”两个功能,只需要写两个处理函数:
// 回显处理函数
std::string EchoHandler(const std::string& msg) {
return "Echo: " + msg;
}
// 大写转换处理函数
std::string UpperHandler(const std::string& msg) {
std::string res = msg;
for (char& c : res) {
if (c >= 'a' && c <= 'z') {
c -= 32;
}
}
return "Upper: " + res;
}
int main() {
// 1. 启动回显服务器
// UdpServer server(8080, EchoHandler);
// 2. 启动大写转换服务器
UdpServer server(8080, UpperHandler);
server.Run();
return 0;
}
这样一来,业务逻辑和网络代码完全分开了——想加新功能(比如过滤敏感词),只需要写新的处理函数,不用改服务器的核心代码,扩展性大大提升。
六、UDP的实际应用场景
学会了基础开发,咱们得知道UDP在实战中能做什么。下面三个场景,覆盖了大部分UDP的常用场景,也是面试中常问的。
6.1 场景一:远程命令执行(需做安全检查)
思路很简单:客户端发shell命令(比如ls -l),服务器收到后执行命令,把结果返回给客户端。但这里有个大问题:如果客户端发rm -rf /(删除服务器所有文件),后果不堪设想——所以必须做安全检查。
6.1.1 安全检查:过滤危险命令
我们可以维护一个“违禁命令列表”,收到命令后遍历检查,如果包含违禁词,就拒绝执行:
#include <vector>
#include <string>
// 违禁命令列表:可以根据需求扩展
const std::vector<std::string> forbidden_cmds = {
"rm", "shutdown", "reboot", "touch", "mkdir", "rmdir"
};
// 安全检查函数:返回true表示安全,false表示危险
bool IsCmdSafe(const std::string& cmd) {
for (const auto& forbidden : forbidden_cmds) {
// 检查命令是否包含违禁词
if (cmd.find(forbidden) != std::string::npos) {
return false;
}
}
return true;
}
6.1.2 执行命令:用popen获取结果
popen是C标准库的函数,可以执行shell命令,并通过文件指针获取命令的输出结果:
std::string CmdHandler(const std::string& cmd) {
// 1. 安全检查
if (!IsCmdSafe(cmd)) {
return "Error: Forbidden command!";
}
// 2. 执行命令,读取结果
FILE* fp = popen(cmd.c_str(), "r"); // "r"表示读命令的输出
if (fp == nullptr) {
return "Error: Execute command failed!";
}
char buf[1024] = {0};
std::string result;
while (fgets(buf, sizeof(buf)-1, fp) != nullptr) {
result += buf;
}
// 3. 关闭文件指针
pclose(fp);
return result.empty() ? "Command executed successfully (no output)" : result;
}
这样,客户端发ls -l,服务器就返回当前目录的文件列表;发rm test.txt,就返回“禁止命令”的提示——既实现了功能,又保证了安全。
6.2 场景二:跨平台通信(Windows客户端 ↔ Linux服务器)
UDP协议是跨平台的,所以Windows客户端和Linux服务器可以直接通信——关键是处理Windows和Linux的套接字差异。
6.2.1 Windows客户端的差异点
Windows的套接字需要额外初始化和清理,核心差异有三个:
- 头文件和库:需要包含
winsock2.h,链接ws2_32.lib库; - 初始化WSA:启动时要调用
WSAStartup初始化 Winsock 库,退出时调用WSACleanup清理; - 类型和函数:套接字类型是
SOCKET(不是int),关闭套接字用closesocket(不是close),错误码用WSAGetLastError获取。
Windows客户端代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
// 链接ws2_32.lib库
#pragma comment(lib, "ws2_32.lib")
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage: %s <server_ip> <server_port>\n", argv[0]);
return 1;
}
const char* server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
// 1. 初始化Winsock库
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
printf("WSAStartup error: %d\n", WSAGetLastError());
return 1;
}
// 2. 创建UDP套接字
SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == INVALID_SOCKET) {
printf("socket error: %d\n", WSAGetLastError());
WSACleanup();
return 1;
}
// 3. 定义服务器地址
struct sockaddr_in server_addr = {0};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port);
// 字符串IP转网络字节序(Windows用inet_addr,Linux用inet_pton)
server_addr.sin_addr.s_addr = inet_addr(server_ip);
if (server_addr.sin_addr.s_addr == INADDR_NONE) {
printf("invalid server ip\n");
closesocket(sockfd);
WSACleanup();
return 1;
}
int server_addr_len = sizeof(server_addr);
// 4. 循环收发消息
char buf[1024] = {0};
while (1) {
printf("Please enter message: ");
fgets(buf, sizeof(buf)-1, stdin);
buf[strcspn(buf, "\n")] = '\0';
// 发送消息
int send_len = sendto(sockfd, buf, strlen(buf), 0,
(struct sockaddr*)&server_addr, server_addr_len);
if (send_len == SOCKET_ERROR) {
printf("sendto error: %d\n", WSAGetLastError());
continue;
}
// 接收消息
char recv_buf[1024] = {0};
int recv_len = recvfrom(sockfd, recv_buf, sizeof(recv_buf)-1, 0,
NULL, NULL);
if (recv_len == SOCKET_ERROR) {
printf("recvfrom error: %d\n", WSAGetLastError());
continue;
}
recv_buf[recv_len] = '\0';
printf("Server reply: %s\n", recv_buf);
}
// 5. 清理
closesocket(sockfd);
WSACleanup();
return 0;
}
编译时,Windows下用VS编译要确保链接了ws2_32.lib;Linux下用g++编译普通客户端即可——这样,Windows客户端就能和Linux服务器互通消息了。
6.3 场景三:简易聊天室(群聊功能)
聊天室的核心是“消息广播”——一个客户端发消息,服务器把消息转发给所有在线客户端。要实现这个功能,需要解决两个问题:管理在线用户和多线程客户端。
6.3.1 服务器:管理在线用户与广播消息
- 在线用户管理:用
unordered_map存储在线用户,key是客户端的IP+端口(确保唯一),value是客户端的地址结构体; - 消息广播:收到客户端消息后,先把客户端加入在线列表(如果不在),然后遍历所有在线用户,用
sendto转发消息。
代码核心部分:
#include <unordered_map>
#include <string>
#include <arpa/inet.h> // for inet_ntoa
// 在线用户列表:key是"IP:端口",value是客户端地址
std::unordered_map<std::string, struct sockaddr_in> online_users;
// 广播消息函数
void BroadcastMessage(int sockfd, const std::string& msg,
const struct sockaddr_in& sender_addr) {
// 1. 生成客户端的"IP:端口"标识
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &sender_addr.sin_addr, client_ip, sizeof(client_ip));
uint16_t client_port = ntohs(sender_addr.sin_port);
std::string client_id = std::string(client_ip) + ":" + std::to_string(client_port);
// 2. 把发送者加入在线列表(如果不在)
if (online_users.find(client_id) == online_users.end()) {
online_users[client_id] = sender_addr;
printf("User %s online\n", client_id.c_str());
}
// 3. 拼接消息:加上发送者标识
std::string broadcast_msg = "[" + client_id + "]: " + msg;
// 4. 遍历在线用户,转发消息
for (const auto& user : online_users) {
const struct sockaddr_in& addr = user.second;
sendto(sockfd, broadcast_msg.c_str(), broadcast_msg.size(), 0,
(struct sockaddr*)&addr, sizeof(addr));
}
}
// 服务器的消息处理函数
void ChatHandler(int sockfd) {
char buf[1024] = {0};
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
while (1) {
// 收消息
ssize_t recv_len = recvfrom(sockfd, buf, sizeof(buf)-1, 0,
(struct sockaddr*)&client_addr, &client_addr_len);
if (recv_len < 0) {
perror("recvfrom error");
continue;
}
buf[recv_len] = '\0';
std::string msg(buf);
// 广播消息
BroadcastMessage(sockfd, msg, client_addr);
}
}
6.3.2 客户端:多线程改造(边发边收)
单线程客户端有个问题:如果客户端在等用户输入(fgets阻塞),就没法接收其他客户端的消息——所以需要把客户端改成多线程,一个线程收消息,一个线程发消息。
用结构体传递线程参数(套接字和服务器地址):
#include <thread>
// 线程参数结构体
struct ThreadData {
int sockfd;
struct sockaddr_in server_addr;
int server_addr_len;
};
// 收消息线程函数
void RecvThread(void* arg) {
ThreadData* data = (ThreadData*)arg;
char recv_buf[1024] = {0};
while (1) {
ssize_t recv_len = recvfrom(data->sockfd, recv_buf, sizeof(recv_buf)-1, 0,
NULL, NULL);
if (recv_len < 0) {
perror("recvfrom error");
continue;
}
recv_buf[recv_len] = '\0';
// 打印其他客户端的消息(换行分开)
printf("\n%s\nPlease enter message: ", recv_buf);
fflush(stdout); // 刷新缓冲区,确保消息及时显示
}
}
// 发消息线程函数(主线程也可以做,这里单独放出来清晰)
void SendThread(void* arg) {
ThreadData* data = (ThreadData*)arg;
char buf[1024] = {0};
while (1) {
printf("Please enter message: ");
fgets(buf, sizeof(buf)-1, stdin);
buf[strcspn(buf, "\n")] = '\0';
sendto(data->sockfd, buf, strlen(buf), 0,
(struct sockaddr*)&data->server_addr, data->server_addr_len);
}
}
int main(int argc, char* argv[]) {
// 省略参数检查、创建套接字、定义服务器地址的代码...
// 准备线程参数
ThreadData data;
data.sockfd = sockfd;
data.server_addr = server_addr;
data.server_addr_len = server_addr_len;
// 创建收消息线程
std::thread recv_thread(RecvThread, &data);
recv_thread.detach(); // 分离线程,不用等它结束
// 主线程做发消息
SendThread(&data);
close(sockfd);
return 0;
}
这样,客户端就能一边收消息(其他用户发的),一边发消息(自己输入的),不会互相阻塞——这才是聊天室该有的样子。
七、细节
最后,总结几个实战中常见的问题,下次遇到就能快速解决:
7.1 云服务器通信不通:检查安全组
本地(同一台机器)客户端和服务器能通信,但跨网络(比如你用家里的电脑连云服务器)就不通——90%是因为云服务器的安全组没开放对应的端口。
云服务商(阿里云、腾讯云)为了安全,默认会关闭大部分端口,你需要手动在控制台开放端口:
- 登录云服务器控制台,找到“安全组”;
- 新增一条入站规则:端口范围填你服务器的端口(比如8080),授权对象填0.0.0.0/0(允许所有IP访问,测试用,生产环境要限制IP);
- 保存规则后,再试跨网络通信,通常就能通了。
7.2 客户端收不到消息:检查端口是否被分配
客户端没显式绑定端口,首次sendto后,操作系统才会分配临时端口——如果sendto前调用recvfrom,会收不到消息吗?不会,但此时客户端还没端口,服务器没法回复消息。所以客户端的逻辑应该是“先发送,再接收”,或者确保首次收发是发送。
7.3 消息乱码:检查字节序转换
如果客户端和服务器在不同字节序的机器上(比如x86和ARM),端口或IP没转成网络字节序,就会导致消息乱码或通信失败。记住:所有端口和IP在传递前,都要转成网络字节序(htons/htonl),接收后转成主机字节序(ntohs/ntohl)。
八、复习总结
下次复习时,你可以按照这个逻辑串联:
- UDP特性:无连接、面向数据报→决定了服务器不用建连接,收发用recvfrom/sendto;
- 服务器流程:创建套接字(AF_INET+SOCK_DGRAM)→绑定地址(0.0.0.0+1024以上端口)→循环收发;
- IP与端口:0.0.0.0绑定所有IP,1024以下端口需root,客户端不用显式绑端口;
- 代码实现:recvfrom拿客户端地址,sendto发消息,用std::function解耦;
- 应用场景:远程命令(安全检查)、跨平台通信(Windows初始化WSA)、聊天室(在线用户+多线程客户端);
- 实战坑点:云服务器安全组、字节序转换、客户端多线程。
更多推荐



所有评论(0)