计算机网络Socket入门:UDP

Linux UDP Socket 编程入门:从 echo server 到字典服务与聊天室

摘要:本文系统整理 UDP 服务端与客户端的基本流程,讲清楚 socketbindrecvfromsendtosockaddr_inINADDR_ANY、地址转换函数以及客户端是否需要显式绑定端口等问题,并进一步扩展到字典服务器和简易聊天室设计。

前言

学完网络基础后,真正进入网络编程,绕不开 socket。Socket 是应用层与传输层之间的抽象接口,它屏蔽了底层网络协议的复杂性,让开发者能够以类似文件读写的方式操作网络连接。

对 TCP 来说,我们经常会看到 listenacceptconnect 这些接口,它们构成了 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 那种连接建立过程,所以服务端不需要 listenaccept,客户端也不需要先 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 / writerecv / 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 也不例外。后续的 bindrecvfromsendto 都围绕这个文件描述符操作。

三、服务端为什么必须 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与业务逻辑解耦。服务端只负责:

  1. 接收客户端请求
  2. 调用业务处理函数
  3. 将处理结果发送回客户端

我们可以定义一个通用的处理函数类型:

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服务器框架就可以支持多种服务:

  1. 计算器服务
cpp 复制代码
auto calculator_handler = [](const std::string& req) -> std::string {
    // 解析 "3 + 5" 这样的表达式并计算结果
    // 返回计算结果或错误信息
};
  1. 时间服务
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";
};
  1. 简易键值存储
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 性能与可靠性考虑

在实际部署字典服务时,还需要考虑:

  1. 并发处理:UDP服务器本身是单线程的,对于高并发场景,可以考虑使用线程池或异步IO模型。
  2. 超时与重传:UDP不保证可靠传输,客户端需要实现超时重传机制。
  3. 数据验证:服务端应验证请求格式,防止缓冲区溢出等安全问题。
  4. 字典更新:支持热重载字典文件,无需重启服务。

通过这个从echo server到字典服务器的演进,我们可以看到UDP编程的核心模式:定义清晰的请求-响应协议,分离网络IO与业务逻辑,构建可扩展的服务框架。这为后续实现更复杂的应用(如聊天室)奠定了坚实基础。

九、封装 UdpSocket:让代码更像工程

进一步可以做一层封装,把底层 socket 操作封装到 UdpSocket 类中。

封装后的接口大致是:

方法 作用
Socket() 创建 UDP socket
Bind(ip, port) 绑定本地地址
RecvFrom(...) 接收数据,并可返回对端 IP/端口
SendTo(...) 向指定 IP/端口发送数据
Close() 关闭文件描述符

这种封装的好处是:业务层不用反复写 sockaddr_inrecvfromsendto 这些细节。

例如通用 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;

收到某个客户端消息后:

  1. 记录该客户端地址;
  2. 拼接消息前缀,比如 [ip:port]# hello
  3. 把广播任务推入线程池;
  4. 线程池将消息发送给所有在线用户。

流程可以表示为:
#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_insin_addrstruct 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_ptoninet_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 模型上叠加业务逻辑和工程结构。