文章目录
- 一、前言:学完 C++,如何让程序真正实现网络通信?
- 二、什么是 UDP?
- 2.1 UDP 的基本概念
- 2.2 UDP 与 TCP 的区别
- 2.3 UDP 通信模型
- 三、Socket 是什么?
- 3.1 创建 UDP Socket
- 四、理解网络地址结构体 sockaddr_in
- 4.1 htons():将端口转换为网络字节序
- 4.2 inet_addr():将 IPv4 字符串转换为地址
- 五、为什么 UDP 服务器通常需要 bind()?
- 5.1 bind() 的作用
- 5.2 服务器绑定 INADDR_ANY
- 5.3 为什么客户端通常不需要显式 bind()?
- 六、实现第一个 UDP 程序:Echo 回显服务器
- 6.1 Echo 服务器的工作流程
- 6.2 服务器端核心代码
- 6.3 recvfrom():接收数据
- 6.4 sendto():向客户端发送响应
- 七、实现 UDP 客户端
- 八、把 UDP 程序封装成 C++ 类
- 8.1 封装 UdpSocket
- 8.2 为什么使用 RAII 管理资源?
- 九、使用 nocopy 禁止对象拷贝
- 十、从 Echo 到网络字典:把网络与业务分开
- 10.1 使用 unordered_map 保存词典
- 10.2 编写翻译函数
- 10.3 从文件加载词典
- 十一、使用回调函数实现通用 UDP 服务器
- 11.1 什么是回调函数?
- 11.2 使用 std::function 封装回调
- 11.3 通用服务器的处理流程
- 十二、实现简易 UDP 聊天室
- 12.1 聊天室的基本需求
- 12.2 如何识别一个用户?
- 12.3 使用 vector 保存在线用户
- 12.4 聊天室的消息路由
- 十三、UDP 客户端的发送与接收可以分开吗?
- 13.1 使用两个线程
- 十四、地址转换函数与线程安全
- 14.1 inet_ntoa() 的注意事项
- 14.2 使用 inet_ntop()
- 十五、网络程序中容易忽略的问题
- 15.1 检查系统调用的返回值
- 15.2 不要假设 UDP 数据一定送达
- 15.3 注意端口号冲突
- 15.4 不要把网络数据直接当成可信输入
- 15.5 注意 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有所帮助,不要忘记给博主“一键四连”哦!