Linux UDP Socket 编程入门:从 echo server 到字典服务与聊天室
摘要:本文系统整理 UDP 服务端与客户端的基本流程,讲清楚
socket、bind、recvfrom、sendto、sockaddr_in、INADDR_ANY、地址转换函数以及客户端是否需要显式绑定端口等问题,并进一步扩展到字典服务器和简易聊天室设计。
前言
学完网络基础后,真正进入网络编程,绕不开 socket。Socket 是应用层与传输层之间的抽象接口,它屏蔽了底层网络协议的复杂性,让开发者能够以类似文件读写的方式操作网络连接。
对 TCP 来说,我们经常会看到 listen、accept、connect 这些接口,它们构成了 TCP 面向连接、可靠传输的基石。而 UDP 的流程更简单,没有连接建立过程,服务端和客户端主要围绕两个核心接口展开:
text
recvfrom 接收数据
sendto 发送数据
UDP 简单,不代表不用认真理解。很多初学者会卡在这些问题上:
- 为什么服务端要
bind? 因为服务端需要在一个众所周知的端口上等待客户端请求,bind就是将 socket 与这个固定端口关联起来。 - 客户端到底要不要
bind? 客户端也需要本地端口,但通常由操作系统在首次发送时自动分配,因此一般不需要显式bind。 sockaddr_in里面为什么端口要htons? 网络字节序(大端序)与主机字节序可能不同,htons(host to network short)确保端口号在网络传输中格式统一。INADDR_ANY是什么意思? 它表示绑定本机所有可用的网络接口(IP 地址),让服务端可以接收发往任何本地 IP 的请求。- 为什么
inet_ntoa在多线程环境下不推荐? 它返回指向静态缓冲区的指针,多线程同时调用可能导致数据被覆盖,应使用线程安全的inet_ntop。
这篇文章按从简单到工程化的顺序,把 UDP Socket 编程从 echo server 写到字典服务器,再扩展到聊天室模型。我们将逐步深入,不仅展示代码片段,更解释每个 API 的设计意图和常见陷阱,帮助你真正掌握 UDP 网络编程的核心思想与实践技巧。
一、UDP Socket 编程的整体流程
UDP 是无连接的传输层协议。它没有 TCP 那种连接建立过程,所以服务端不需要 listen 和 accept,客户端也不需要先 connect 才能发数据。
UDP 服务端基本流程:
#mermaid-svg-lOXj0cI1W0VLcIn0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lOXj0cI1W0VLcIn0 .error-icon{fill:#552222;}#mermaid-svg-lOXj0cI1W0VLcIn0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lOXj0cI1W0VLcIn0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .marker.cross{stroke:#333333;}#mermaid-svg-lOXj0cI1W0VLcIn0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lOXj0cI1W0VLcIn0 p{margin:0;}#mermaid-svg-lOXj0cI1W0VLcIn0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .cluster-label text{fill:#333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .cluster-label span{color:#333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .cluster-label span p{background-color:transparent;}#mermaid-svg-lOXj0cI1W0VLcIn0 .label text,#mermaid-svg-lOXj0cI1W0VLcIn0 span{fill:#333;color:#333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .node rect,#mermaid-svg-lOXj0cI1W0VLcIn0 .node circle,#mermaid-svg-lOXj0cI1W0VLcIn0 .node ellipse,#mermaid-svg-lOXj0cI1W0VLcIn0 .node polygon,#mermaid-svg-lOXj0cI1W0VLcIn0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lOXj0cI1W0VLcIn0 .rough-node .label text,#mermaid-svg-lOXj0cI1W0VLcIn0 .node .label text,#mermaid-svg-lOXj0cI1W0VLcIn0 .image-shape .label,#mermaid-svg-lOXj0cI1W0VLcIn0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-lOXj0cI1W0VLcIn0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lOXj0cI1W0VLcIn0 .rough-node .label,#mermaid-svg-lOXj0cI1W0VLcIn0 .node .label,#mermaid-svg-lOXj0cI1W0VLcIn0 .image-shape .label,#mermaid-svg-lOXj0cI1W0VLcIn0 .icon-shape .label{text-align:center;}#mermaid-svg-lOXj0cI1W0VLcIn0 .node.clickable{cursor:pointer;}#mermaid-svg-lOXj0cI1W0VLcIn0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .arrowheadPath{fill:#333333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lOXj0cI1W0VLcIn0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lOXj0cI1W0VLcIn0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lOXj0cI1W0VLcIn0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lOXj0cI1W0VLcIn0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lOXj0cI1W0VLcIn0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lOXj0cI1W0VLcIn0 .cluster text{fill:#333;}#mermaid-svg-lOXj0cI1W0VLcIn0 .cluster span{color:#333;}#mermaid-svg-lOXj0cI1W0VLcIn0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lOXj0cI1W0VLcIn0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lOXj0cI1W0VLcIn0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-lOXj0cI1W0VLcIn0 .icon-shape,#mermaid-svg-lOXj0cI1W0VLcIn0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lOXj0cI1W0VLcIn0 .icon-shape p,#mermaid-svg-lOXj0cI1W0VLcIn0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lOXj0cI1W0VLcIn0 .icon-shape .label rect,#mermaid-svg-lOXj0cI1W0VLcIn0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lOXj0cI1W0VLcIn0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lOXj0cI1W0VLcIn0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lOXj0cI1W0VLcIn0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} socket 创建 UDP 套接字
bind 绑定本地 IP 和端口
recvfrom 等待客户端数据
处理请求
sendto 返回响应
UDP 客户端基本流程:
#mermaid-svg-UKA5NkMQIp4una5E{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UKA5NkMQIp4una5E .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UKA5NkMQIp4una5E .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UKA5NkMQIp4una5E .error-icon{fill:#552222;}#mermaid-svg-UKA5NkMQIp4una5E .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UKA5NkMQIp4una5E .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UKA5NkMQIp4una5E .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UKA5NkMQIp4una5E .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UKA5NkMQIp4una5E .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UKA5NkMQIp4una5E .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UKA5NkMQIp4una5E .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UKA5NkMQIp4una5E .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UKA5NkMQIp4una5E .marker.cross{stroke:#333333;}#mermaid-svg-UKA5NkMQIp4una5E svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UKA5NkMQIp4una5E p{margin:0;}#mermaid-svg-UKA5NkMQIp4una5E .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UKA5NkMQIp4una5E .cluster-label text{fill:#333;}#mermaid-svg-UKA5NkMQIp4una5E .cluster-label span{color:#333;}#mermaid-svg-UKA5NkMQIp4una5E .cluster-label span p{background-color:transparent;}#mermaid-svg-UKA5NkMQIp4una5E .label text,#mermaid-svg-UKA5NkMQIp4una5E span{fill:#333;color:#333;}#mermaid-svg-UKA5NkMQIp4una5E .node rect,#mermaid-svg-UKA5NkMQIp4una5E .node circle,#mermaid-svg-UKA5NkMQIp4una5E .node ellipse,#mermaid-svg-UKA5NkMQIp4una5E .node polygon,#mermaid-svg-UKA5NkMQIp4una5E .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UKA5NkMQIp4una5E .rough-node .label text,#mermaid-svg-UKA5NkMQIp4una5E .node .label text,#mermaid-svg-UKA5NkMQIp4una5E .image-shape .label,#mermaid-svg-UKA5NkMQIp4una5E .icon-shape .label{text-anchor:middle;}#mermaid-svg-UKA5NkMQIp4una5E .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UKA5NkMQIp4una5E .rough-node .label,#mermaid-svg-UKA5NkMQIp4una5E .node .label,#mermaid-svg-UKA5NkMQIp4una5E .image-shape .label,#mermaid-svg-UKA5NkMQIp4una5E .icon-shape .label{text-align:center;}#mermaid-svg-UKA5NkMQIp4una5E .node.clickable{cursor:pointer;}#mermaid-svg-UKA5NkMQIp4una5E .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UKA5NkMQIp4una5E .arrowheadPath{fill:#333333;}#mermaid-svg-UKA5NkMQIp4una5E .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UKA5NkMQIp4una5E .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UKA5NkMQIp4una5E .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UKA5NkMQIp4una5E .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UKA5NkMQIp4una5E .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UKA5NkMQIp4una5E .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UKA5NkMQIp4una5E .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UKA5NkMQIp4una5E .cluster text{fill:#333;}#mermaid-svg-UKA5NkMQIp4una5E .cluster span{color:#333;}#mermaid-svg-UKA5NkMQIp4una5E div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UKA5NkMQIp4una5E .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UKA5NkMQIp4una5E rect.text{fill:none;stroke-width:0;}#mermaid-svg-UKA5NkMQIp4una5E .icon-shape,#mermaid-svg-UKA5NkMQIp4una5E .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UKA5NkMQIp4una5E .icon-shape p,#mermaid-svg-UKA5NkMQIp4una5E .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UKA5NkMQIp4una5E .icon-shape .label rect,#mermaid-svg-UKA5NkMQIp4una5E .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UKA5NkMQIp4una5E .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UKA5NkMQIp4una5E .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UKA5NkMQIp4una5E :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} socket 创建 UDP 套接字
填充服务器地址
sendto 发送请求
recvfrom 接收响应
对比 TCP:
| 对比项 | UDP | TCP |
|---|---|---|
| 是否有连接 | 无连接 | 有连接 |
服务端是否 listen |
不需要 | 需要 |
服务端是否 accept |
不需要 | 需要 |
| 主要收发接口 | recvfrom / sendto |
read / write 或 recv / send |
| 通信对象信息 | 每次收发都需要地址信息 | 连接建立后由连接描述 |
UDP 的每个数据报都相对独立。服务端收到一个数据报时,也会同时拿到对端的 IP 和端口,这样才能用 sendto 把响应发回去。
二、服务端第一步:创建 socket
UDP 创建 socket 的代码如下:
cpp
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket");
exit(1);
}
三个参数分别表示:
| 参数 | 含义 |
|---|---|
AF_INET |
使用 IPv4 |
SOCK_DGRAM |
使用 UDP 数据报 |
0 |
使用默认协议 |
socket 返回的是文件描述符。Linux 中很多资源都被抽象成文件,socket 也不例外。后续的 bind、recvfrom、sendto 都围绕这个文件描述符操作。
三、服务端为什么必须 bind
服务端需要让客户端知道自己在哪里等请求,因此服务端端口通常是固定的、众所周知的。
比如我们希望服务端监听 8888 端口,就要把 socket 和本地地址绑定起来:
cpp
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = INADDR_ANY;
int ret = bind(sockfd, (struct sockaddr*)&local, sizeof(local));
if (ret < 0) {
perror("bind");
exit(2);
}
这里有几个关键点。
sin_family = AF_INET 表示地址类型是 IPv4。
sin_port = htons(8888) 表示端口号要从主机字节序转成网络字节序。
sin_addr.s_addr = INADDR_ANY 表示绑定本机所有可用网卡地址。
这里需要特别注意:云服务器不建议直接绑定公网 IP,编写服务器时也不推荐强行绑定某个明确 IP,通常写成 INADDR_ANY 更灵活。
INADDR_ANY 的含义可以理解为:
text
只要数据是发到本机这个端口的,不管从哪块网卡进来,都可以接收。
如果服务器有多块网卡,每块网卡有不同 IP,使用 INADDR_ANY 可以避免手动选择具体网卡地址。
四、服务端如何接收和回显数据
UDP 服务端接收数据使用 recvfrom:
cpp
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&peer, &len);
参数里有两个很重要的点:
| 参数 | 作用 |
|---|---|
buffer |
保存收到的数据 |
peer |
保存发送方地址信息 |
收到数据后,服务端可以把数据原样发回客户端:
cpp
if (n > 0) {
buffer[n] = '\0';
sendto(sockfd, buffer, strlen(buffer), 0,
(struct sockaddr*)&peer, len);
}
这里的 peer 就是客户端地址。UDP 没有连接,所以服务端每次响应时都需要明确告诉内核:这份数据要发给谁。
五、完整示例:UDP echo server
下面是一个为了帮助理解而整理的最小 echo server。
cpp
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " port\n";
return 1;
}
uint16_t port = std::stoi(argv[1]);
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket");
return 2;
}
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
perror("bind");
close(sockfd);
return 3;
}
char buffer[1024];
while (true) {
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&peer, &len);
if (n <= 0) {
continue;
}
buffer[n] = '\0';
std::cout << "[" << inet_ntoa(peer.sin_addr)
<< ":" << ntohs(peer.sin_port)
<< "]# " << buffer << std::endl;
sendto(sockfd, buffer, strlen(buffer), 0,
(struct sockaddr*)&peer, len);
}
close(sockfd);
return 0;
}
编译运行:
bash
g++ udp_echo_server.cc -std=c++11 -o udp_echo_server
./udp_echo_server 8888
六、客户端要不要 bind
这里有一个很重要的结论:客户端一定会 bind,但通常不需要显式 bind。
这句话容易绕,拆开看就清楚了。
客户端发数据时,也必须有自己的 IP 和端口,否则服务端不知道响应发回哪里。因此从网络通信角度看,客户端一定需要本地端口。
但客户端数量可能非常多,如果每个客户端都手动指定固定端口,很容易冲突。更常见的做法是:客户端创建 socket 后,不显式调用 bind,第一次 sendto 时由操作系统自动选择一个临时端口。
text
服务端端口:固定,方便客户端找到它
客户端端口:随机,操作系统自动分配即可
客户端填充的是服务器地址:
cpp
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
发送请求:
cpp
sendto(sock, inbuffer.c_str(), inbuffer.size(), 0,
(struct sockaddr*)&server, sizeof(server));
接收响应:
cpp
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t n = recvfrom(sock, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&temp, &len);
七、完整示例:UDP echo client
cpp
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
#include <string>
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " server_ip server_port\n";
return 1;
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
perror("socket");
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 line;
std::cout << "Please Enter# ";
std::getline(std::cin, line);
if (!std::cin || line == "quit") {
break;
}
ssize_t n = sendto(sock, line.c_str(), line.size(), 0,
(struct sockaddr*)&server, sizeof(server));
if (n <= 0) {
perror("sendto");
break;
}
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t m = recvfrom(sock, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&peer, &len);
if (m > 0) {
buffer[m] = '\0';
std::cout << "server echo# " << buffer << std::endl;
}
}
close(sock);
return 0;
}
编译运行:
bash
g++ udp_echo_client.cc -std=c++11 -o udp_echo_client
./udp_echo_client 127.0.0.1 8888
预期交互:
text
Please Enter# hello
server echo# hello
Please Enter# udp
server echo# udp
八、从 echo server 到字典服务器
echo server 只是把请求原样返回,展示了 UDP 最基本的请求-响应模式。接下来,我们可以将业务逻辑升级为"英译汉字典",这是一个更贴近实际应用的例子。
8.1 字典数据的设计与加载
一个实用的字典服务需要从外部文件加载词条,而不是在代码中硬编码。假设我们有一个 dict.txt 文件,每行格式为 英文单词: 中文释义:
text
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
computer: 计算机
programming: 编程
network: 网络
socket: 套接字
udp: 用户数据报协议
服务端启动时,可以读取这个文件并构建哈希表:
cpp
#include <fstream>
#include <sstream>
#include <unordered_map>
std::unordered_map<std::string, std::string> LoadDict(const std::string& filename) {
std::unordered_map<std::string, std::string> dict;
std::ifstream file(filename);
if (!file.is_open()) {
std::cerr << "Failed to open dictionary file: " << filename << std::endl;
return dict; // 返回空字典或抛出异常
}
std::string line;
while (std::getline(file, line)) {
size_t colon_pos = line.find(':');
if (colon_pos == std::string::npos) {
continue; // 跳过格式错误的行
}
std::string key = line.substr(0, colon_pos);
std::string value = line.substr(colon_pos + 1);
// 去除首尾空格
key.erase(0, key.find_first_not_of(" \t"));
key.erase(key.find_last_not_of(" \t") + 1);
value.erase(0, value.find_first_not_of(" \t"));
value.erase(value.find_last_not_of(" \t") + 1);
if (!key.empty() && !value.empty()) {
dict[key] = value;
}
}
file.close();
return dict;
}
8.2 查询逻辑的完善
之前的 Translate 函数比较简单,我们可以增强它,支持大小写不敏感查询和模糊匹配建议:
cpp
#include <algorithm>
#include <cctype>
std::string Translate(const std::unordered_map<std::string, std::string>& dict,
const std::string& req) {
// 精确匹配(原词)
auto it = dict.find(req);
if (it != dict.end()) {
return it->second;
}
// 尝试小写匹配
std::string lower_req = req;
std::transform(lower_req.begin(), lower_req.end(), lower_req.begin(),
[](unsigned char c) { return std::tolower(c); });
it = dict.find(lower_req);
if (it != dict.end()) {
return it->second;
}
// 简单模糊匹配:查找包含请求词的词条
std::vector<std::string> suggestions;
for (const auto& pair : dict) {
if (pair.first.find(lower_req) != std::string::npos) {
suggestions.push_back(pair.first);
if (suggestions.size() >= 3) break; // 最多返回3个建议
}
}
if (!suggestions.empty()) {
std::string result = "未找到精确匹配,您是否想查:";
for (size_t i = 0; i < suggestions.size(); ++i) {
if (i > 0) result += "、";
result += suggestions[i];
}
return result;
}
return "未找到该单词";
}
8.3 服务端架构设计:分离IO与业务逻辑
字典服务的核心设计思想是将网络IO与业务逻辑解耦。服务端只负责:
- 接收客户端请求
- 调用业务处理函数
- 将处理结果发送回客户端
我们可以定义一个通用的处理函数类型:
cpp
using RequestHandler = std::function<std::string(const std::string&)>;
然后修改服务端的主循环:
cpp
void RunUdpServer(int port, RequestHandler handler) {
// 创建socket并绑定(代码同前)
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// ... bind 代码 ...
char buffer[1024];
while (true) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&client_addr, &addr_len);
if (n <= 0) continue;
buffer[n] = '\0';
std::string request(buffer);
// 调用业务处理函数
std::string response = handler(request);
// 发送响应
sendto(sockfd, response.c_str(), response.size(), 0,
(struct sockaddr*)&client_addr, addr_len);
// 可选:记录日志
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
std::cout << "[" << client_ip << ":" << ntohs(client_addr.sin_port)
<< "] query: \"" << request << "\" -> \"" << response << "\"" << std::endl;
}
}
8.4 完整字典服务器示例
结合以上组件,我们可以构建一个完整的字典服务器:
cpp
#include <iostream>
#include <string>
#include <unordered_map>
#include <functional>
// 加载字典和翻译函数(实现同上)
std::unordered_map<std::string, std::string> LoadDict(const std::string& filename);
std::string Translate(const std::unordered_map<std::string, std::string>& dict,
const std::string& req);
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " <port> <dict_file>\n";
return 1;
}
uint16_t port = std::stoi(argv[1]);
std::string dict_file = argv[2];
// 加载字典
auto dict = LoadDict(dict_file);
if (dict.empty()) {
std::cerr << "Dictionary is empty or failed to load.\n";
return 1;
}
std::cout << "Loaded " << dict.size() << " word pairs from " << dict_file << std::endl;
// 定义处理函数(捕获字典引用)
auto handler = [&dict](const std::string& req) -> std::string {
return Translate(dict, req);
};
// 运行服务器
RunUdpServer(port, handler);
return 0;
}
8.5 扩展思考:从字典到通用服务
这种设计模式的强大之处在于其通用性。只需更换 handler 函数,同一个UDP服务器框架就可以支持多种服务:
- 计算器服务:
cpp
auto calculator_handler = [](const std::string& req) -> std::string {
// 解析 "3 + 5" 这样的表达式并计算结果
// 返回计算结果或错误信息
};
- 时间服务:
cpp
auto time_handler = [](const std::string& req) -> std::string {
if (req == "time") {
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
return std::ctime(&time);
}
return "Send 'time' to get current time";
};
- 简易键值存储:
cpp
std::unordered_map<std::string, std::string> kv_store;
auto kv_handler = [&kv_store](const std::string& req) -> std::string {
// 解析 "SET key value" 或 "GET key" 命令
// 操作 kv_store 并返回结果
};
8.6 性能与可靠性考虑
在实际部署字典服务时,还需要考虑:
- 并发处理:UDP服务器本身是单线程的,对于高并发场景,可以考虑使用线程池或异步IO模型。
- 超时与重传:UDP不保证可靠传输,客户端需要实现超时重传机制。
- 数据验证:服务端应验证请求格式,防止缓冲区溢出等安全问题。
- 字典更新:支持热重载字典文件,无需重启服务。
通过这个从echo server到字典服务器的演进,我们可以看到UDP编程的核心模式:定义清晰的请求-响应协议,分离网络IO与业务逻辑,构建可扩展的服务框架。这为后续实现更复杂的应用(如聊天室)奠定了坚实基础。
九、封装 UdpSocket:让代码更像工程
进一步可以做一层封装,把底层 socket 操作封装到 UdpSocket 类中。
封装后的接口大致是:
| 方法 | 作用 |
|---|---|
Socket() |
创建 UDP socket |
Bind(ip, port) |
绑定本地地址 |
RecvFrom(...) |
接收数据,并可返回对端 IP/端口 |
SendTo(...) |
向指定 IP/端口发送数据 |
Close() |
关闭文件描述符 |
这种封装的好处是:业务层不用反复写 sockaddr_in、recvfrom、sendto 这些细节。
例如通用 UDP 服务器可以写成:
cpp
for (;;) {
std::string req;
std::string remote_ip;
uint16_t remote_port = 0;
bool ok = sock.RecvFrom(&req, &remote_ip, &remote_port);
if (!ok) {
continue;
}
std::string resp;
handler(req, &resp);
sock.SendTo(resp, remote_ip, remote_port);
}
这段代码的可读性明显比直接操作结构体更好。
十、简易聊天室:UDP 也可以全双工
继续扩展,就可以实现一个简单聊天室。
服务端维护一个在线用户列表:
cpp
std::vector<InetAddr> online_users;
pthread_mutex_t user_mutex;
收到某个客户端消息后:
- 记录该客户端地址;
- 拼接消息前缀,比如
[ip:port]# hello; - 把广播任务推入线程池;
- 线程池将消息发送给所有在线用户。
流程可以表示为:
#mermaid-svg-z2SSFS9k3BnAaZt9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-z2SSFS9k3BnAaZt9 .error-icon{fill:#552222;}#mermaid-svg-z2SSFS9k3BnAaZt9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-z2SSFS9k3BnAaZt9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .marker.cross{stroke:#333333;}#mermaid-svg-z2SSFS9k3BnAaZt9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-z2SSFS9k3BnAaZt9 p{margin:0;}#mermaid-svg-z2SSFS9k3BnAaZt9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .cluster-label text{fill:#333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .cluster-label span{color:#333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .cluster-label span p{background-color:transparent;}#mermaid-svg-z2SSFS9k3BnAaZt9 .label text,#mermaid-svg-z2SSFS9k3BnAaZt9 span{fill:#333;color:#333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .node rect,#mermaid-svg-z2SSFS9k3BnAaZt9 .node circle,#mermaid-svg-z2SSFS9k3BnAaZt9 .node ellipse,#mermaid-svg-z2SSFS9k3BnAaZt9 .node polygon,#mermaid-svg-z2SSFS9k3BnAaZt9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-z2SSFS9k3BnAaZt9 .rough-node .label text,#mermaid-svg-z2SSFS9k3BnAaZt9 .node .label text,#mermaid-svg-z2SSFS9k3BnAaZt9 .image-shape .label,#mermaid-svg-z2SSFS9k3BnAaZt9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-z2SSFS9k3BnAaZt9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-z2SSFS9k3BnAaZt9 .rough-node .label,#mermaid-svg-z2SSFS9k3BnAaZt9 .node .label,#mermaid-svg-z2SSFS9k3BnAaZt9 .image-shape .label,#mermaid-svg-z2SSFS9k3BnAaZt9 .icon-shape .label{text-align:center;}#mermaid-svg-z2SSFS9k3BnAaZt9 .node.clickable{cursor:pointer;}#mermaid-svg-z2SSFS9k3BnAaZt9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .arrowheadPath{fill:#333333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-z2SSFS9k3BnAaZt9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-z2SSFS9k3BnAaZt9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-z2SSFS9k3BnAaZt9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-z2SSFS9k3BnAaZt9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-z2SSFS9k3BnAaZt9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-z2SSFS9k3BnAaZt9 .cluster text{fill:#333;}#mermaid-svg-z2SSFS9k3BnAaZt9 .cluster span{color:#333;}#mermaid-svg-z2SSFS9k3BnAaZt9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-z2SSFS9k3BnAaZt9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-z2SSFS9k3BnAaZt9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-z2SSFS9k3BnAaZt9 .icon-shape,#mermaid-svg-z2SSFS9k3BnAaZt9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-z2SSFS9k3BnAaZt9 .icon-shape p,#mermaid-svg-z2SSFS9k3BnAaZt9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-z2SSFS9k3BnAaZt9 .icon-shape .label rect,#mermaid-svg-z2SSFS9k3BnAaZt9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-z2SSFS9k3BnAaZt9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-z2SSFS9k3BnAaZt9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-z2SSFS9k3BnAaZt9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端发送消息
服务器 recvfrom
记录在线用户
封装广播任务
线程池执行任务
sendto 给所有在线用户
这里的在线用户列表会被多个线程访问,所以需要互斥锁保护。
客户端也变成两个线程:
| 线程 | 职责 |
|---|---|
| 发送线程 | 从标准输入读取消息,调用 sendto |
| 接收线程 | 调用 recvfrom,持续接收服务器广播 |
UDP 的一个特点是:同一个 socket 文件描述符既可以读,也可以写 。因此客户端可以用一个 sockfd,一个线程读,一个线程写。
十一、地址转换函数
IPv4 编程中,sockaddr_in 的 sin_addr 是 struct in_addr 类型,本质上保存 32 位 IP 地址。
但我们平时写 IP 地址习惯用点分十进制字符串,例如:
text
127.0.0.1
192.168.1.10
所以需要字符串和二进制地址之间互相转换。
常见函数:
| 函数 | 作用 |
|---|---|
inet_aton |
IPv4 字符串转 struct in_addr |
inet_addr |
IPv4 字符串转 32 位网络字节序整数 |
inet_pton |
字符串转网络地址,支持 IPv4/IPv6 |
inet_ntoa |
struct in_addr 转 IPv4 字符串 |
inet_ntop |
网络地址转字符串,支持 IPv4/IPv6 |
新代码更推荐使用 inet_pton 和 inet_ntop,因为它们既支持 IPv4,也支持 IPv6,并且 inet_ntop 由调用者提供缓冲区,更适合多线程环境。
示例:
cpp
#include <arpa/inet.h>
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof(ip));
十二、关于 inet_ntoa 的坑
inet_ntoa 会返回一个 char*:
cpp
char* p = inet_ntoa(addr.sin_addr);
看起来像是函数内部给我们准备好了字符串,但它的返回结果通常放在静态存储区中,不需要调用者释放。
问题也在这里:多次调用可能覆盖上一次结果。
例如:
cpp
char* p1 = inet_ntoa(addr1.sin_addr);
char* p2 = inet_ntoa(addr2.sin_addr);
printf("p1: %s, p2: %s\n", p1, p2);
如果内部使用同一块静态缓冲区,那么第二次调用会覆盖第一次调用的结果。
APUE 明确指出 inet_ntoa 不是线程安全函数;在某些系统上测试未必复现问题,可能是具体实现内部做了保护。但写可移植、多线程网络程序时,不应该依赖这种实现细节。
更稳妥的写法是使用 inet_ntop:
cpp
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof(ip));
缓冲区由调用者提供,生命周期清楚,也更适合多线程。
十三、常见问题与易错点
服务端需要显式 bind,因为客户端必须知道服务端端口。
客户端也需要本地端口,但通常不显式 bind,让操作系统在首次发送时自动分配。
recvfrom 的最后两个参数不要随便省略。对 UDP 来说,服务端需要知道客户端地址,才能把响应发回去。
端口号要使用 htons 转成网络字节序。
INADDR_ANY 不是某个具体公网 IP,它表示绑定本机所有可用地址。
UDP 没有连接,sendto 每次都要带上目标地址。
inet_ntoa 返回静态存储区结果,多线程环境推荐 inet_ntop。
在线用户列表、共享容器、日志输出等共享资源,如果被多个线程访问,需要加锁保护。
总结
UDP Socket 编程的核心流程其实很短:
text
服务端:socket -> bind -> recvfrom -> sendto
客户端:socket -> sendto -> recvfrom
但真正写好 UDP 程序,需要理解每个接口背后的设计:服务端为什么要绑定固定端口,客户端为什么通常不显式绑定,sockaddr_in 如何描述 IPv4 地址,sendto/recvfrom 为什么都要带地址信息,地址转换函数为什么会影响线程安全。
掌握 echo server 后,再写字典服务器、通用 UDP 封装、聊天室,本质上都是在同一套 IO 模型上叠加业务逻辑和工程结构。