news 2026/10/14 3:33:42

C++ 网络编程入门:从零实现 UDP 通信、网络字典与简易聊天室

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ 网络编程入门:从零实现 UDP 通信、网络字典与简易聊天室



🎬 个人主页:道尔柯南

❄专栏传送门:《C语言》《C++》《Linux操作系统》
昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。

文章目录

    • 一、前言:学完 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 的区别

对比项目UDPTCP
通信方式无连接面向连接
传输单位数据报字节流
可靠性不保证可靠送达提供可靠、有序的字节流传输
数据边界保留数据报边界不保留应用层消息边界
传输开销通常较小通常较大
常见场景实时音视频、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_addrIPv4 地址

例如,要表示本机的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 客户端通常只需要:

  1. 创建 Socket。
  2. 准备服务器地址。
  3. 使用sendto()发送数据。
  4. 使用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 聊天室的基本需求

一个简易聊天室通常需要:

  1. 记录当前在线用户。
  2. 接收用户发送的消息。
  3. 将消息转发给其他在线用户。
  4. 在用户退出时移除对应记录。

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禁止对象拷贝,帮助管理资源所有权
多线程将发送与接收等任务分开执行

整个项目可以按照以下路线逐步实现:

  1. 创建 Socket,理解文件描述符。
  2. 编写 Echo 服务器与客户端,掌握数据报收发。
  3. 理解地址结构体、端口绑定与地址转换。
  4. 封装UdpSocket,减少重复的底层代码。
  5. 实现网络字典,将通信模块与业务模块分离。
  6. 使用回调函数构建通用 UDP 服务器。
  7. 扩展到聊天室,实现在线用户管理与消息路由。
  8. 最后考虑多线程、异常处理和网络安全。

十七、写在最后

网络编程并不是把sendto()和recvfrom()学会就结束了。

真正重要的是理解应用程序如何通过 Socket 与操作系统交互,如何使用 IP 地址和端口找到通信对象,以及如何在网络层与业务层之间建立清晰的代码结构。

从 Echo 回显服务器到网络字典,再到聊天室,我们可以看到程序的网络通信方式并没有发生根本变化,变化的是服务器接收到请求之后执行的业务逻辑。

这也正是代码封装与回调函数的价值:把通用的网络通信流程复用起来,让业务代码专注于真正需要解决的问题。

对于 C++ 初学者,建议先亲手实现 Echo 服务器和客户端,确认能够正常收发数据;再逐步增加词典查询、在线用户管理和消息转发功能。通过不断扩展同一个小项目,就能把 Linux Socket、C++ 类设计、STL 和多线程知识真正联系起来。

当你能够独立完成一个结构清晰、能够正常通信并具备基本错误处理的 UDP 小项目时,就已经迈出了从 C++ 语法学习走向实际网络开发的重要一步。


结尾

uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!

道尔柯南

C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主

👀【关注】跟随我一起深耕技术领域,见证每一次成长。
❤️【点赞】让优质内容被更多人看见,让知识传递更有力量。
⭐【收藏】把核心知识点存好,在需要时随时查、随时用。
💬【评论】分享你的经验或疑问,评论区一起交流避坑!

不要忘记给博主“一键四连”哦!

“今日目标达成!”

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主“一键四连”哦!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:33:40

LLM_E2E端到端

EAI&#xff1a;Embodied Artificial Intelligent&#xff0c;E2E&#xff1a;End to End&#xff0c;LLM&#xff1a;Large Language Mode 人工智能【端到端 End-to-End】 一、端到端的定义 标准定义 端到端&#xff08;End-to-End&#xff09;&#xff1a;一种机器学习建模与…

作者头像 李华
网站建设 2026/10/14 3:33:24

图片与声音(云存储 + 音频播放 + 扩展)

本文面向微信小程序初学者&#xff0c;通过一个「图片与声音」案例&#xff0c;演示如何利用云开发存储突破主包 2M 大小限制。读者将学会 image 组件、InnerAudioContext 音频控制、数据与事件绑定&#xff0c;以及 onLoad 生命周期函数的综合运用&#xff0c;并掌握播放速率与…

作者头像 李华
网站建设 2026/10/14 3:32:49

小公司大数据面试实战指南:Kafka/Flink/Hive故障排查与最小可行架构

1. 为什么这份“上海小公司大数据面试题汇总”值得你花一整晚细读我带过三届校招实习生&#xff0c;也帮五家不同行业的中小团队做过技术选型评估。每次聊到“大数据岗位怎么准备”&#xff0c;几乎所有人都会下意识点开大厂面经、翻看《Hadoop权威指南》目录&#xff0c;或者刷…

作者头像 李华
网站建设 2026/10/14 3:32:34

LVS 凭什么比 Nginx 快?四种工作模式与调度算法拆开讲

Nginx 和 HAProxy 做负载均衡&#xff0c;都要把数据包从内核态提到用户态、处理完再送回去&#xff1b;LVS 直接在内核里改完地址就转发了&#xff0c;这一步之差就是数量级的差距。这篇不碰实验环境&#xff0c;只把原理讲透&#xff1a;先分清「管理工具」与「转发引擎」谁在…

作者头像 李华