
文章目录
- 一、前言:学完 C++,如何让程序真正实现网络通信?
- 二、什么是 UDP?
- 三、Socket 是什么?
- 四、理解网络地址结构体 sockaddr_in
- 五、为什么 UDP 服务器通常需要 bind()?
- 六、实现第一个 UDP 程序:Echo 回显服务器
- 七、实现 UDP 客户端
- 八、把 UDP 程序封装成 C++ 类
- 九、使用 nocopy 禁止对象拷贝
- 十、从 Echo 到网络字典:把网络与业务分开
- 十一、使用回调函数实现通用 UDP 服务器
- 十二、实现简易 UDP 聊天室
- 十三、UDP 客户端的发送与接收可以分开吗?
- 十四、地址转换函数与线程安全
- 十五、网络程序中容易忽略的问题
- 十六、知识点总结
- 十七、写在最后
- 结尾
一、前言:学完 C++,如何让程序真正实现网络通信?
在学习 C++ 的过程中,我们已经掌握了变量、函数、类、对象、STL 容器等基础知识。
但是,一个程序如果只能在本地运行,能不能与另一台计算机通信呢?例如:
- 编写一个客户端,将输入的消息发送给服务器。
- 编写一个服务器,收到消息后原样返回。
- 实现一个网络字典,输入英文单词就能查询中文释义。
- 实现一个简易聊天室,让多个用户通过网络发送消息。
这些功能都涉及网络编程。
在 Linux 环境下,C++ 网络编程通常会使用 Socket 接口。Socket 可以理解为应用程序进行网络通信时使用的一种编程接口。
本文将结合 UDP 网络编程的知识,从最基础的 Socket 创建开始,逐步实现 Echo 回显服务器、英译汉网络字典和简易聊天室,并介绍代码封装、回调函数以及 IP 地址转换等重要知识。
学习完本文,你将能够理解一个简单的 UDP 网络程序是如何工作的。
二、什么是 UDP?
2.1 UDP 的基本概念
UDP 的英文全称是 User Datagram Protocol,即用户数据报协议。
它是一种传输层协议,主要特点是通信过程简单、开销较小,不需要像 TCP 那样先建立连接。
在 UDP 通信中,发送方可以将数据报发送到指定的 IP 地址和端口号,接收方通过对应的 Socket 接收数据。
例如,我们可以把 UDP 通信理解为寄送明信片:
- 发送方写好消息。
- 指定接收方的地址。
- 将消息发送出去。
- 接收方收到后读取内容。
这个过程中不需要先建立一条持续的连接。
不过,UDP 不保证数据一定送达,也不保证多个数据报一定按发送顺序到达。因此,如果应用程序需要可靠传输,就需要自行设计确认、重传、排序等机制,或者选择 TCP。
2.2 UDP 与 TCP 的区别
| 对比项目 | UDP | TCP |
|---|---|---|
| 通信方式 | 无连接 | 面向连接 |
| 传输单位 | 数据报 | 字节流 |
| 可靠性 | 不保证可靠送达 | 提供可靠、有序的字节流传输 |
| 数据边界 | 保留数据报边界 | 不保留应用层消息边界 |
| 传输开销 | 通常较小 | 通常较大 |
| 常见场景 | 实时音视频、DNS、游戏通信 | 网页、文件传输、远程登录 |
需要注意,UDP 并不意味着一定比 TCP 快。实际性能取决于网络状况、数据大小、应用设计等因素。
2.3 UDP 通信模型
一个最简单的 UDP 通信模型如下:
UDP 客户端 UDP 服务器
| |
|-------- 发送请求 ---------------->|
| |
| 接收并处理请求
| |
|<------- 返回响应 ----------------|
| |
| 完成一次通信 |
UDP 不需要建立 TCP 式的连接,但客户端和服务器仍然需要知道对方的通信地址。
三、Socket 是什么?
Socket 是操作系统提供的一套网络通信编程接口。
在 Linux 下,常见的 Socket 相关函数包括:
socket():创建套接字。bind():将套接字绑定到本地 IP 地址和端口。sendto():发送 UDP 数据报。recvfrom():接收 UDP 数据报。close():关闭套接字。
这些函数构成了 UDP 通信的基础。
3.1 创建 UDP Socket
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
int main()
{
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket");
return 1;
}
std::cout << "Socket 创建成功,fd = "
<< sockfd << std::endl;
close(sockfd);
return 0;
}
代码中的关键语句是:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
其中:
AF_INET:使用 IPv4 地址族。SOCK_DGRAM:使用数据报套接字,通常用于 UDP。0:让系统根据套接字类型选择合适的默认协议。
如果创建成功,函数返回一个非负文件描述符;失败则返回 -1。
Linux 中的 Socket 文件描述符可以理解为操作系统为该通信端点分配的一个编号,后续的发送、接收等操作都需要使用它。
由于上面的代码使用了 close(),还需要包含:
#include <unistd.h>
四、理解网络地址结构体 sockaddr_in
网络通信不仅需要 Socket,还需要告诉操作系统通信使用什么地址和端口。
在 IPv4 Socket 编程中,经常使用 sockaddr_in 结构体。
struct sockaddr_in addr;
它主要包含以下信息:
struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
};
可以将它们理解为:
| 成员 | 含义 |
|---|---|
sin_family | 地址族,例如 AF_INET |
sin_port | 端口号,采用网络字节序 |
sin_addr | IPv4 地址 |
例如,要表示本机的 127.0.0.1:8080:
#include <cstring>
#include <arpa/inet.h>
#include <netinet/in.h>
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
这里有两个非常重要的函数。
4.1 htons():将端口转换为网络字节序
addr.sin_port = htons(8080);
htons 的含义是 host to network short,即将主机字节序的 16 位整数转换为网络字节序。
网络协议规定多字节整数使用网络字节序表示,因此设置端口时通常需要使用 htons()。
接收端需要将端口转换回主机字节序:
uint16_t port = ntohs(addr.sin_port);
4.2 inet_addr():将 IPv4 字符串转换为地址
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
inet_addr() 可以将点分十进制的 IPv4 字符串转换为适合填入地址结构体的网络地址表示。
它属于较旧的接口。现代代码可以优先考虑 inet_pton():
inet_pton(AF_INET, "127.0.0.1",
&addr.sin_addr);
如果需要支持 IPv6,也可以使用 inet_pton(),并根据需要选择 AF_INET6。
五、为什么 UDP 服务器通常需要 bind()?
5.1 bind() 的作用
bind() 的主要作用是把 Socket 绑定到指定的本地地址和端口。
int ret = bind(
sockfd,
(struct sockaddr*)&addr,
sizeof(addr)
);
服务器通常需要使用固定、已知的端口提供服务。
例如,服务器监听 8080 端口,客户端只要知道服务器 IP 和端口,就能向这个服务发送数据。
如果服务器没有按预期绑定地址和端口,客户端就无法通过约定的服务端点正常访问它。
5.2 服务器绑定 INADDR_ANY
博客中的服务器代码使用了:
local.sin_addr.s_addr = INADDR_ANY;
INADDR_ANY 表示绑定到本机所有适用的 IPv4 网络接口地址。
这样,服务器通常不需要提前指定某一块网卡的 IP 地址,就可以接收发送到该端口的 UDP 数据报。
例如:
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = INADDR_ANY;
随后调用:
bind(sockfd,
(struct sockaddr*)&local,
sizeof(local));
需要注意,绑定到 INADDR_ANY 不代表服务器可以绕过防火墙,也不意味着公网一定能够访问它。
5.3 为什么客户端通常不需要显式 bind()?
UDP 客户端也需要本地端口,但通常不必自己指定。
当客户端首次发送数据时,操作系统可以自动为 Socket 选择一个合适的本地 IP 地址和临时端口。
因此,最简单的 UDP 客户端通常只需要:
- 创建 Socket。
- 准备服务器地址。
- 使用
sendto()发送数据。 - 使用
recvfrom()接收响应。
如果业务需要固定的客户端端口或特定的本地网络接口,客户端同样可以显式调用 bind()。
六、实现第一个 UDP 程序:Echo 回显服务器
Echo 的意思是回声。
Echo 服务器的功能非常简单:客户端发送什么内容,服务器就原样返回什么内容。
这个程序非常适合入门,因为它涵盖了 UDP 通信中最重要的操作。
6.1 Echo 服务器的工作流程
客户端 服务器
| |
|-------- "Hello" ------------>|
| |
| recvfrom() 接收
| |
| sendto() 返回
| |
|<------- "Hello" --------------|
6.2 服务器端核心代码
以下示例适用于 Linux,展示创建 Socket、绑定端口、接收请求和返回响应的基本流程。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket");
return 1;
}
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (sockaddr*)&local,
sizeof(local)) < 0)
{
perror("bind");
close(sockfd);
return 1;
}
std::cout << "UDP Echo Server started"
<< std::endl;
while (true)
{
char buffer[1024];
sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(sockaddr*)&peer,
&len
);
if (n < 0)
{
perror("recvfrom");
continue;
}
buffer[n] = '\0';
std::cout << "收到消息:" << buffer
<< std::endl;
ssize_t sent = sendto(
sockfd,
buffer,
static_cast<size_t>(n),
0,
(sockaddr*)&peer,
len
);
if (sent < 0)
{
perror("sendto");
}
}
close(sockfd);
return 0;
}
这段代码的核心是 recvfrom() 和 sendto()。
6.3 recvfrom():接收数据
ssize_t n = recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(sockaddr*)&peer,
&len
);
主要参数如下:
sockfd:用于接收数据的 Socket。buffer:保存接收数据的缓冲区。sizeof(buffer) - 1:最多接收的字节数,为字符串结尾预留空间。0:默认接收标志。peer:记录发送方的地址。len:传入地址结构体容量,返回实际地址长度。
这里有一个容易忽略的细节:UDP 数据本身不一定以 '\0' 结尾。
因此,成功接收后,如果想把数据当作 C 风格字符串输出,就需要:
buffer[n] = '\0';
本例的缓冲区预留了一个字节,所以不会因为追加字符串结束符而越界。
另外,如果接收到的是二进制数据,就不应该把它当作普通字符串处理。
6.4 sendto():向客户端发送响应
sendto(
sockfd,
buffer,
static_cast<size_t>(n),
0,
(sockaddr*)&peer,
len
);
为什么这里可以直接使用 peer?
因为 recvfrom() 已经记录了发送请求的客户端地址。
服务器只要将响应发回这个地址,就能让客户端收到数据,而不必事先维护 TCP 式的连接。
七、实现 UDP 客户端
客户端需要知道服务器的 IP 地址和端口号。
例如,服务器运行在本机,监听 8888 端口,那么客户端可以将目标地址设置为 127.0.0.1:8888。
#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket");
return 1;
}
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(8888);
if (inet_pton(AF_INET, "127.0.0.1",
&server.sin_addr) != 1)
{
std::cerr << "IP 地址格式错误"
<< std::endl;
close(sockfd);
return 1;
}
std::string message;
while (std::getline(std::cin, message))
{
ssize_t n = sendto(
sockfd,
message.data(),
message.size(),
0,
(sockaddr*)&server,
sizeof(server)
);
if (n < 0)
{
perror("sendto");
break;
}
char buffer[1024];
sockaddr_in from{};
socklen_t len = sizeof(from);
ssize_t m = recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(sockaddr*)&from,
&len
);
if (m < 0)
{
perror("recvfrom");
break;
}
buffer[m] = '\0';
std::cout << "服务器返回:"
<< buffer << std::endl;
}
close(sockfd);
return 0;
}
该客户端先发送消息,然后等待服务器返回结果。
在这个最简单的示例中,客户端的接收操作可能一直等待。如果服务器没有启动、响应丢失或网络出现故障,程序就可能阻塞。真实项目中通常需要考虑接收超时、重试和错误处理。
八、把 UDP 程序封装成 C++ 类
如果所有网络操作都写在 main() 中,程序很快就会变得复杂。
例如,Echo 服务器需要创建 Socket、绑定端口、接收消息和返回消息。网络字典也需要这些操作,聊天室同样需要。
如果每实现一个功能,就复制一份底层网络代码,程序将很难维护。
因此,可以把网络通信相关的操作封装到类中。
8.1 封装 UdpSocket
创建UdpSocket 类将 Socket 的创建、关闭、接收和发送统一封装起来。
其核心接口可以设计为:
class UdpSocket
{
public:
bool Socket();
bool Close();
bool Bind(const std::string& ip,
uint16_t port);
bool RecvFrom(std::string* buf,
std::string* ip,
uint16_t* port);
bool SendTo(const std::string& buf,
const std::string& ip,
uint16_t port);
private:
int fd_;
};
这样,上层业务代码不必每次都直接操作复杂的 sockaddr_in 结构体。
例如:
UdpSocket sock;
sock.Socket();
sock.Bind("0.0.0.0", 8888);
接收消息时:
std::string message;
std::string client_ip;
uint16_t client_port = 0;
sock.RecvFrom(&message,
&client_ip,
&client_port);
发送消息时:
sock.SendTo("Hello",
client_ip,
client_port);
以上是接口示意,实际实现还需要补全系统调用、错误处理以及资源管理逻辑。
8.2 为什么使用 RAII 管理资源?
Socket 属于需要显式释放的系统资源。
如果程序创建 Socket 后忘记关闭,就可能造成文件描述符泄漏。
C++ 中常用 RAII(资源获取即初始化)管理这类资源:在对象构造时获取资源,在对象析构时释放资源。
例如:
class UdpSocket
{
public:
UdpSocket()
: fd_(-1)
{
}
~UdpSocket()
{
if (fd_ >= 0)
{
close(fd_);
}
}
private:
int fd_;
};
这个例子只演示析构时关闭资源的思想。完整实现还需要保证 fd_ 初始化正确、避免重复关闭,并正确处理复制与移动语义。
对于持有唯一 Socket 资源的对象,通常还应该禁止无意义的拷贝,避免两个对象误以为自己各自拥有同一个文件描述符。
九、使用 nocopy 禁止对象拷贝
再介绍一个 nocopy 类,用来禁止对象的拷贝构造和拷贝赋值。
class nocopy
{
public:
nocopy() = default;
nocopy(const nocopy&) = delete;
nocopy& operator=(const nocopy&) = delete;
~nocopy() = default;
};
其中最关键的是:
nocopy(const nocopy&) = delete;
以及:
nocopy& operator=(const nocopy&) = delete;
= delete 表示明确禁止调用相应函数。
如果服务器类继承 nocopy:
class UdpServer : public nocopy
{
// ...
};
那么服务器对象通常就不能被拷贝构造或拷贝赋值。
这样做的原因是,服务器对象内部持有 Socket 等资源,如果随意复制,可能导致资源所有权不明确、重复关闭等问题。
需要注意,禁止拷贝不等于自动禁止移动。若需要控制移动语义,还应根据资源管理设计明确处理移动构造和移动赋值。
十、从 Echo 到网络字典:把网络与业务分开
Echo 服务器只会原样返回消息,但网络程序可以执行各种业务逻辑。
例如,客户端输入 hello,服务器返回“你好”;输入 apple,服务器返回“苹果”。
这个程序就是一个简单的英译汉网络字典。
10.1 使用 unordered_map 保存词典
C++ STL 中的 unordered_map 可以保存键值对。
例如:
#include <string>
#include <unordered_map>
std::unordered_map<std::string, std::string> dict{
{"hello", "你好"},
{"world", "世界"},
{"apple", "苹果"},
{"book", "书"},
{"cat", "猫"}
};
其中:
- 键是英文单词。
- 值是对应的中文释义。
查询单词时,可以使用 find():
auto it = dict.find("hello");
if (it == dict.end())
{
// 没有找到
}
else
{
// it->second 是对应的释义
}
平均情况下,unordered_map 的查找复杂度为 (O(1)),适合用于这种简单的词典查询。
10.2 编写翻译函数
我们可以把翻译功能独立出来:
void Translate(const std::string& req,
std::string* resp)
{
auto it = dict.find(req);
if (it == dict.end())
{
*resp = "未查到该单词";
return;
}
*resp = it->second;
}
这里使用了输入参数 req 和输出参数 resp。
req表示客户端发送的请求。resp用来保存服务器生成的响应。
这样,翻译函数就不必关心数据是从键盘输入的,还是通过网络接收的。
它只需要负责完成单词查询。
10.3 从文件加载词典
如果把所有词条直接写在源代码中,添加新单词就需要修改代码并重新编译。
更好的方式是将词典保存在文本文件中。
例如,dict.txt:
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
hello: 你好
world: 世界
程序启动时读取文件,将词条加载到 unordered_map 中。
#include <fstream>
#include <string>
#include <unordered_map>
void LoadDict(
const std::string& filename,
std::unordered_map<std::string,
std::string>& dict)
{
std::ifstream in(filename);
if (!in.is_open())
{
return;
}
std::string line;
while (std::getline(in, line))
{
auto pos = line.find(": ");
if (pos == std::string::npos)
{
continue;
}
std::string key = line.substr(0, pos);
std::string value =
line.substr(pos + 2);
dict[key] = value;
}
}
这里使用了几个常见的 C++ 文件与字符串操作:
std::ifstream:读取文件。std::getline():按行读取内容。find():查找分隔符。substr():截取字符串。
这体现了一个重要的设计思想:将数据存储、网络通信和业务处理拆分开来。
十一、使用回调函数实现通用 UDP 服务器
如果 Echo 服务器和网络字典各自实现一套服务器代码,就会出现大量重复逻辑。
它们实际上只有业务处理部分不同:
- Echo 服务器:将请求原样返回。
- 网络字典:查询单词并返回释义。
- 聊天室:将消息转发给在线用户。
那么,能否只写一套 UDP 服务器,让不同业务使用不同的处理函数?
答案是回调函数。
11.1 什么是回调函数?
回调函数的核心思想是:把一个函数作为参数传给另一个函数或对象,在合适的时机再调用它。
例如:
void Process()
{
std::cout << "处理业务逻辑"
<< std::endl;
}
void Start(void (*callback)())
{
callback();
}
调用时:
Start(Process);
Start() 不需要知道业务处理的具体内容,只需要在适当的时候调用传入的函数。
这种方式能够降低网络通信模块与业务逻辑之间的耦合。
11.2 使用 std::function 封装回调
课件中使用了 C++11 的 std::function:
#include <functional>
#include <string>
using Handler =
std::function<void(
const std::string&,
std::string*)>;
这个类型表示:一个接收请求字符串和响应输出参数、返回 void 的可调用对象。
它可以接收普通函数、函数对象,也可以接收符合签名要求的 Lambda 表达式。
例如:
void Translate(const std::string& req,
std::string* resp)
{
*resp = "翻译结果:" + req;
}
服务器启动时,可以将它传入:
server.Start("0.0.0.0",
8888,
Translate);
当服务器收到消息时,调用:
handler(req, &resp);
服务器就能根据传入的业务函数生成响应。
11.3 通用服务器的处理流程
UDP 客户端
|
| 发送请求
v
+--------------+
| UDP 服务端 |
+--------------+
|
| recvfrom()
v
+--------------+
| 获取请求内容 |
+--------------+
|
| 调用回调函数
v
+--------------+
| 业务处理 |
+--------------+
|
| 生成响应
v
+--------------+
| sendto() |
+--------------+
|
v
UDP 客户端
通过这种设计,网络层只负责通信,业务层只负责处理请求。
以后想增加新的功能时,通常只需要编写新的业务处理函数,而不必重新实现底层 UDP 通信流程。
十二、实现简易 UDP 聊天室
掌握 UDP 收发、地址封装和回调机制后,就可以进一步实现简易聊天室。
12.1 聊天室的基本需求
一个简易聊天室通常需要:
- 记录当前在线用户。
- 接收用户发送的消息。
- 将消息转发给其他在线用户。
- 在用户退出时移除对应记录。
UDP 本身没有连接建立和断开事件,因此服务器通常需要根据客户端发送的消息来维护在线状态。
例如,用户首次发来消息时,服务器可以将该用户记录为在线用户;当用户发送约定的 QUIT 消息时,再将其移除。
需要注意,这只是教学示例中的简化方案。真实聊天室还需要心跳、超时清理、身份验证等机制,否则客户端异常退出后,服务器可能一直保留过期的在线记录。
12.2 如何识别一个用户?
可以通过客户端的 IP 地址和端口号识别一个 UDP 通信端点。
例如:
192.168.1.10:50001
192.168.1.11:50002
192.168.1.12:50003
这些地址分别代表不同的网络端点。
可以将它们封装为 InetAddr 类,统一保存 IP、端口和底层地址结构。
class InetAddr
{
public:
std::string Ip() const
{
return ip_;
}
uint16_t Port() const
{
return port_;
}
private:
std::string ip_;
uint16_t port_;
sockaddr_in addr_;
};
这里仅展示类的基本结构。实际实现需要在构造时正确初始化地址成员,并提供访问底层地址的接口。
如果要比较两个端点,可以重载 operator==:
bool operator==(const InetAddr& other) const
{
return ip_ == other.ip_
&& port_ == other.port_;
}
这样就可以通过比较 IP 和端口来判断两个地址对象是否代表同一个端点。
但要注意,IP 和端口只能标识一个网络端点,并不等于用户的真实身份。实际应用不应仅凭地址就完成用户认证。
12.3 使用 vector 保存在线用户
聊天室可以使用 std::vector 保存在线用户列表:
std::vector<InetAddr> online_users;
当新用户到来时,先检查是否已经存在:
bool IsExist(const InetAddr& peer)
{
for (const auto& user : online_users)
{
if (user == peer)
{
return true;
}
}
return false;
}
如果不存在,就添加到列表中。
收到聊天消息后,服务器遍历在线用户列表,并使用 sendto() 将消息发送给各个用户。
在真正实现时,需要考虑是否将消息发送给发送者本人,以及用户退出后如何从列表中安全删除记录。
对于规模较小的教学程序,使用 vector 足以展示核心思路。用户数量增多后,可以根据需求选择更合适的数据结构。
12.4 聊天室的消息路由
聊天室可以将接收到的消息整理成:
192.168.1.10:50001# 大家好!
然后将这条消息转发给其他在线用户。
这里的关键不再是简单地把消息原样返回,而是根据消息发送者和在线用户列表决定消息的接收方。
这一步通常称为消息路由。
Route 类就承担了类似的职责:记录在线用户、检查用户是否存在、添加和删除用户,并将消息转发给在线用户。
通过将路由逻辑独立封装,服务器的网络通信模块就不必了解每条消息应该如何分发。
十三、UDP 客户端的发送与接收可以分开吗?
在简单的 Echo 客户端中,程序通常先发送消息,再接收响应。
但是,聊天室中可能随时有其他用户发送消息。如果客户端只有在自己发送消息之后才接收数据,就可能无法及时显示其他用户的消息。
一种解决方法是将发送和接收分开。
13.1 使用两个线程
可以让:
- 发送线程负责读取用户输入并调用
sendto()。 - 接收线程负责调用
recvfrom()并显示收到的消息。
结构如下:
UDP 客户端
|
+-------+-------+
| |
v v
发送线程 接收线程
| |
sendto() recvfrom()
| |
v v
服务器 显示消息
客户端使用了类似的设计。
UDP Socket 可以用于发送和接收数据。将发送和接收分别放在不同线程中,有助于实现更及时的消息显示。
但多线程并不意味着所有问题都会自动解决。实际代码还需要考虑线程退出、资源释放、输入阻塞,以及共享数据的同步问题。
十四、地址转换函数与线程安全
在 UDP 编程中,除了 htons()、ntohs(),还经常接触 inet_ntoa()、inet_pton() 和 inet_ntop()。
14.1 inet_ntoa() 的注意事项
特别指出,inet_ntoa() 会返回一个指向内部静态存储区的指针。
例如:
char* p = inet_ntoa(addr.sin_addr);
通常不需要手动释放这个指针,因为它不是通过调用方负责释放的动态内存获得的。
但要注意,后续调用可能覆盖此前的转换结果,而且该接口不适合需要可靠线程安全行为的多线程代码。
因此,现代程序更推荐使用 inet_ntop()。
14.2 使用 inet_ntop()
char ip[INET_ADDRSTRLEN];
if (inet_ntop(AF_INET,
&addr.sin_addr,
ip,
sizeof(ip)) != nullptr)
{
std::cout << ip << std::endl;
}
inet_ntop() 将二进制地址转换为可读字符串,并由调用方提供缓冲区。
这样可以避免依赖共享的静态字符串缓冲区。
需要注意,INET_ADDRSTRLEN 适用于 IPv4;IPv6 地址应使用对应的 IPv6 地址结构与缓冲区长度。
十五、网络程序中容易忽略的问题
完成基本功能后,还需要注意以下细节。
15.1 检查系统调用的返回值
创建 Socket、绑定地址、接收数据、发送数据都可能失败。
例如:
if (sockfd < 0)
{
perror("socket");
return 1;
}
错误处理有助于快速定位端口占用、权限不足、地址格式错误等问题。
15.2 不要假设 UDP 数据一定送达
UDP 不提供可靠传输保证。即使 sendto() 成功返回,也不能证明对方应用程序已经收到并处理了数据。
对于重要业务,可以增加应用层确认、超时重试和请求编号等机制。
15.3 注意端口号冲突
如果服务器端口已经被其他程序占用,bind() 可能失败。
调试时应检查错误信息,并确认客户端连接的是正确的 IP 和端口。
15.4 不要把网络数据直接当成可信输入
客户端发送的数据可能格式错误、长度异常,甚至是恶意构造的数据。
因此,服务器必须对接收内容进行校验。对于聊天室,还应考虑消息长度限制、身份验证、频率限制等安全问题。
15.5 注意 UDP 数据报大小
UDP 数据报有大小限制,而且较大的数据报可能被 IP 层分片,增加丢包风险。
实际开发中应合理设计消息大小;如果业务需要传输大量数据,可以考虑拆分数据报,或者改用更合适的传输协议。
十六、知识点总结
学习完这篇文章,我们可以把 UDP 网络编程的核心知识归纳为以下几类。
| 知识点 | 核心作用 |
|---|---|
socket() | 创建网络通信端点 |
bind() | 绑定本地地址和端口 |
sendto() | 发送 UDP 数据报 |
recvfrom() | 接收数据报并获取发送方地址 |
sockaddr_in | 保存 IPv4 地址与端口信息 |
htons()、ntohs() | 转换端口的字节序 |
inet_pton()、inet_ntop() | 转换可读 IP 字符串与二进制地址 |
unordered_map | 保存网络字典中的词条 |
std::function | 统一表示不同类型的回调对象 |
vector | 保存简易聊天室的在线用户列表 |
nocopy | 禁止对象拷贝,帮助管理资源所有权 |
| 多线程 | 将发送与接收等任务分开执行 |
整个项目可以按照以下路线逐步实现:
- 创建 Socket,理解文件描述符。
- 编写 Echo 服务器与客户端,掌握数据报收发。
- 理解地址结构体、端口绑定与地址转换。
- 封装
UdpSocket,减少重复的底层代码。 - 实现网络字典,将通信模块与业务模块分离。
- 使用回调函数构建通用 UDP 服务器。
- 扩展到聊天室,实现在线用户管理与消息路由。
- 最后考虑多线程、异常处理和网络安全。
十七、写在最后
网络编程并不是把 sendto() 和 recvfrom() 学会就结束了。
真正重要的是理解应用程序如何通过 Socket 与操作系统交互,如何使用 IP 地址和端口找到通信对象,以及如何在网络层与业务层之间建立清晰的代码结构。
从 Echo 回显服务器到网络字典,再到聊天室,我们可以看到程序的网络通信方式并没有发生根本变化,变化的是服务器接收到请求之后执行的业务逻辑。
这也正是代码封装与回调函数的价值:把通用的网络通信流程复用起来,让业务代码专注于真正需要解决的问题。
对于 C++ 初学者,建议先亲手实现 Echo 服务器和客户端,确认能够正常收发数据;再逐步增加词典查询、在线用户管理和消息转发功能。通过不断扩展同一个小项目,就能把 Linux Socket、C++ 类设计、STL 和多线程知识真正联系起来。
当你能够独立完成一个结构清晰、能够正常通信并具备基本错误处理的 UDP 小项目时,就已经迈出了从 C++ 语法学习走向实际网络开发的重要一步。
结尾
uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!
👀
【关注】 跟随我一起深耕技术领域,见证每一次成长。
❤️
【点赞】 让优质内容被更多人看见,让知识传递更有力量。
⭐
【收藏】 把核心知识点存好,在需要时随时查、随时用。
💬
【评论】 分享你的经验或疑问,评论区一起交流避坑!
不要忘记给博主“一键四连”哦! |
结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主“一键四连”哦!
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2502_90266547/article/details/167701834




