前言

在 Linux 网络编程中,TCP 和 UDP 是传输层最重要的两类协议。TCP 强调连接管理与可靠传输,适合文件传输、HTTP 通信、金融交易等可靠性优先的场景;UDP 则强调无连接、低延迟、轻量高效,适合直播推流、实时游戏、DNS 查询、物联网数据上报等实时性优先的场景。

很多初学者一提到 UDP,只记住了“不可靠”三个字。但真正写代码时,更重要的是理解:UDP 为什么不可靠、它把哪些能力交给了应用层、它的 Socket 编程模型和 TCP 有什么不同、服务端和客户端分别应该怎么写。

本文保留原文的核心学习路线,把重点内容整理成一篇发布友好的精华版:先讲协议特性,再讲 API,再通过 Echo 服务和在线字典服务把代码落地,最后总结通用封装思想与高频踩坑点。


一、UDP 协议核心特性

1. 无连接

UDP 通信前不需要建立连接,只要知道对方的 IP 和端口,就可以直接发送数据。它没有 TCP 的三次握手和四次挥手,因此通信成本更低,启动速度更快。

这也是 UDP 在实时场景中很受欢迎的原因:能直接发,就不先绕一圈建立连接。

2. 不保证可靠

UDP 不提供确认应答、超时重传、序列号、乱序重排等机制。它只负责尽力发送,不保证数据一定到达,也不保证顺序和唯一性。

但这不是缺点,而是取舍。UDP 把可靠性设计交给应用层,换来更小的协议开销和更低的传输延迟。如果业务需要可靠性,可以自己在应用层实现 ACK、重传、序列号、去重、心跳等机制。

3. 面向数据报

UDP 是面向数据报的。发送端一次 sendto 发送一个完整报文,接收端一次 recvfrom 接收一个完整报文,报文之间天然有边界。

这点和 TCP 非常不同。TCP 是面向字节流的,没有消息边界;UDP 保留消息边界,但接收缓冲区必须足够大,否则报文会被截断,剩余部分直接丢弃。


二、UDP Socket 编程模型

服务端核心流程

服务端需要固定端口供客户端访问,所以必须显式 bind

socket() -> bind() -> recvfrom() -> 业务处理 -> sendto() -> close()

客户端核心流程

客户端一般不需要显式 bind。第一次调用 sendto 时,操作系统会自动给客户端分配一个随机可用端口。

socket() -> 填充服务端地址 -> sendto() -> recvfrom() -> close()

这里要记住一句话:服务端端口必须稳定,客户端端口只要唯一。


三、UDP 编程核心 API

1. socket:创建套接字

#include <sys/socket.h>

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket");
    exit(1);
}

说明:

  • AF_INET:使用 IPv4
  • SOCK_DGRAM:使用数据报套接字,也就是 UDP
  • 第三个参数填 0,系统会自动匹配 UDP 协议

2. bind:绑定 IP 和端口

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");
    exit(2);
}

说明:

  • htons(port):端口号从主机序转网络序
  • INADDR_ANY:绑定本机所有网卡地址
  • 云服务器上一般不要直接绑定公网 IP,推荐绑定 INADDR_ANY

3. recvfrom:接收数据并获取发送端地址

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);
if (n > 0) {
    buffer[n] = 0;
    std::cout << "client: "
              << inet_ntoa(peer.sin_addr)
              << ":"
              << ntohs(peer.sin_port)
              << " msg: "
              << buffer
              << std::endl;
}

注意:网络发来的数据是原始字节,如果要按 C 字符串打印,必须手动补 \0

4. sendto:发送数据到指定目标

sendto(sockfd, buffer, strlen(buffer), 0,
       (struct sockaddr*)&peer, len);

UDP 没有连接,因此每次发送都要指定目标地址。


四、V1 版本:UDP Echo 回显服务

Echo 服务是 UDP 入门最经典的案例:客户端发什么,服务端就回什么。它的重点不在业务,而在于打通 UDP 的完整通信链路。

1. Echo 服务端核心代码

#include <iostream>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        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");
        return 3;
    }

    std::cout << "UDP Echo Server Start, port: " << port << std::endl;

    while (true) {
        char buffer[1024];
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr*)&client, &len);
        if (n <= 0) continue;

        buffer[n] = 0;

        std::cout << "[" << inet_ntoa(client.sin_addr)
                  << ":" << ntohs(client.sin_port)
                  << "]# " << buffer << std::endl;

        sendto(sockfd, buffer, strlen(buffer), 0,
               (struct sockaddr*)&client, len);
    }

    close(sockfd);
    return 0;
}

核心细节:

  • 服务端必须 bind,否则客户端不知道发到哪里
  • recvfrom 会拿到客户端 IP 和端口
  • sendto 必须使用客户端地址把数据发回去
  • buffer[n] = 0 是防止打印乱码的关键

2. Echo 客户端核心代码

#include <iostream>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }

    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 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 msg;
        std::cout << "Please Enter# ";
        std::cin >> msg;

        sendto(sockfd, msg.c_str(), msg.size(), 0,
               (struct sockaddr*)&server, sizeof(server));

        char buffer[1024];
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);

        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr*)&temp, &len);
        if (n > 0) {
            buffer[n] = 0;
            std::cout << "server echo# " << buffer << std::endl;
        }
    }

    close(sockfd);
    return 0;
}

客户端不显式 bind,它在第一次 sendto 时由操作系统自动分配端口。这一点非常重要。


五、V2 版本:UDP 在线英译汉字典服务

Echo 服务验证的是通信链路,字典服务验证的是业务拆分。我们让客户端输入英文单词,服务端查询字典并返回中文释义。

1. 字典类设计

#include <iostream>
#include <fstream>
#include <string>
#include <unordered_map>

class Dictionary {
public:
    Dictionary(const std::string& filename = "Dict.txt") {
        Load(filename);
    }

    std::string Translate(const std::string& word) {
        auto it = dict_.find(word);
        if (it == dict_.end()) {
            return "Unknown word!";
        }
        return it->second;
    }

private:
    void Load(const std::string& filename) {
        std::ifstream in(filename);
        if (!in.is_open()) {
            std::cerr << "open dict file failed" << std::endl;
            return;
        }

        std::string key, value;
        while (in >> key >> value) {
            dict_[key] = value;
        }
    }

private:
    std::unordered_map<std::string, std::string> dict_;
};

示例词典文件:

apple 苹果
banana 香蕉
hello 你好
world 世界
computer 计算机

2. 通用 UDP 服务端雏形

这里的重点是通过回调函数把“网络通信”和“业务处理”解耦。

#include <functional>
#include <string>

using Handler = std::function<std::string(const std::string&)>;

class UdpServer {
public:
    UdpServer(uint16_t port, Handler handler)
        : port_(port), handler_(handler), sockfd_(-1) {}

    void Init() {
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd_ < 0) {
            perror("socket");
            exit(1);
        }

        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");
            exit(2);
        }
    }

    void Start() {
        while (true) {
            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);
            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*)&peer, len);
        }
    }

private:
    uint16_t port_;
    Handler handler_;
    int sockfd_;
};

这个设计的精华在这里:

using Handler = std::function<std::string(const std::string&)>;

UDP 服务端不关心业务是什么。它只负责接收请求、调用处理函数、发送响应。

3. 字典服务端主函数

#include <memory>
#include "Dictionary.hpp"
#include "UdpServer.hpp"

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }

    uint16_t port = std::stoi(argv[1]);
    auto dict = std::make_unique<Dictionary>();

    UdpServer server(port, [&dict](const std::string& word) {
        return dict->Translate(word);
    });

    server.Init();
    server.Start();

    return 0;
}

这段代码就是原文最值得吸收的设计思想:服务端框架不变,业务通过 lambda 注入。以后想实现计算器服务、天气服务、命令服务,只需要换回调函数。


六、进阶封装:UdpSocket / UdpServer / UdpClient

当代码写到第二个版本时,就会发现 socketbindsendtorecvfrom 这些逻辑会反复出现。为了提高复用性,可以进一步封装。

1. UdpSocket 封装

class UdpSocket {
public:
    UdpSocket() : fd_(-1) {}

    bool Socket() {
        fd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (fd_ < 0) {
            perror("socket");
            return false;
        }
        return true;
    }

    bool Bind(const std::string& ip, uint16_t port) {
        struct sockaddr_in addr;
        memset(&addr, 0, sizeof(addr));
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);
        addr.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(ip.c_str());

        if (bind(fd_, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
            perror("bind");
            return false;
        }
        return true;
    }

    bool RecvFrom(std::string* out, std::string* ip, uint16_t* port) {
        char buffer[1024] = {0};
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        ssize_t n = recvfrom(fd_, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr*)&peer, &len);
        if (n < 0) {
            perror("recvfrom");
            return false;
        }

        out->assign(buffer, n);
        if (ip) *ip = inet_ntoa(peer.sin_addr);
        if (port) *port = ntohs(peer.sin_port);
        return true;
    }

    bool SendTo(const std::string& data, const std::string& ip, uint16_t port) {
        struct sockaddr_in peer;
        memset(&peer, 0, sizeof(peer));
        peer.sin_family = AF_INET;
        peer.sin_port = htons(port);
        peer.sin_addr.s_addr = inet_addr(ip.c_str());

        ssize_t n = sendto(fd_, data.c_str(), data.size(), 0,
                           (struct sockaddr*)&peer, sizeof(peer));
        return n >= 0;
    }

    void Close() {
        if (fd_ >= 0) {
            close(fd_);
            fd_ = -1;
        }
    }

private:
    int fd_;
};

2. 通用服务端封装

using Service = std::function<void(const std::string&, std::string*)>;

class UdpServer {
public:
    UdpServer(const std::string& ip, uint16_t port, Service service)
        : ip_(ip), port_(port), service_(service) {
        sock_.Socket();
    }

    void Start() {
        sock_.Bind(ip_, port_);

        while (true) {
            std::string request;
            std::string client_ip;
            uint16_t client_port = 0;

            if (!sock_.RecvFrom(&request, &client_ip, &client_port)) {
                continue;
            }

            std::string response;
            service_(request, &response);

            sock_.SendTo(response, client_ip, client_port);
        }
    }

private:
    std::string ip_;
    uint16_t port_;
    Service service_;
    UdpSocket sock_;
};

这个版本已经具备了“通用 UDP 服务框架”的味道:网络层负责收发,业务层负责处理,二者通过回调函数连接。


七、Makefile 示例

.PHONY: all clean

CXX = g++
CXXFLAGS = -std=c++17 -Wall

all: UdpEchoServer UdpEchoClient DictServer DictClient

UdpEchoServer: UdpEchoServer.cpp
	$(CXX) $(CXXFLAGS) -o UdpEchoServer UdpEchoServer.cpp

UdpEchoClient: UdpEchoClient.cpp
	$(CXX) $(CXXFLAGS) -o UdpEchoClient UdpEchoClient.cpp

DictServer: DictServer.cpp Dictionary.hpp UdpServer.hpp
	$(CXX) $(CXXFLAGS) -o DictServer DictServer.cpp

DictClient: DictClient.cpp
	$(CXX) $(CXXFLAGS) -o DictClient DictClient.cpp

clean:
	rm -f UdpEchoServer UdpEchoClient DictServer DictClient

运行方式示例:

./UdpEchoServer 8080
./UdpEchoClient 127.0.0.1 8080

./DictServer 8081
./DictClient 127.0.0.1 8081

八、核心考点与踩坑指南

1. UDP 和 TCP 的核心区别

TCP 面向连接、可靠传输、面向字节流,有流量控制和拥塞控制;UDP 无连接、不保证可靠、面向数据报,头部开销小,延迟低。

2. 服务端为什么必须 bind?

服务端要提供固定入口,客户端必须知道请求发到哪个端口,所以服务端必须显式 bind

3. 客户端为什么不推荐显式 bind?

客户端端口没有业务含义,只要唯一即可。如果多个客户端都绑定同一个固定端口,会直接冲突。交给操作系统自动分配是最合适的。

4. INADDR_ANY 的作用

INADDR_ANY 表示绑定本机所有网卡。服务器有多个 IP 时,客户端访问任意网卡地址都能被服务端接收。

5. UDP 会不会粘包?

UDP 不存在 TCP 意义上的粘包,因为 UDP 保留报文边界。但如果接收缓冲区太小,报文会被截断,剩余部分会丢失。

6. inet_ntoa 是否线程安全?

不是。inet_ntoa 返回的是静态缓冲区,多次调用会覆盖旧结果。多线程场景建议使用 inet_ntop

7. recvfrom 后打印乱码

原因通常是没有手动补 \0

ssize_t n = recvfrom(...);
if (n > 0) buffer[n] = 0;

8. 客户端收不到服务端响应

常见原因:

  • 服务端只绑定了 127.0.0.1
  • 防火墙没有开放 UDP 端口
  • 云服务器安全组没有放行端口
  • 服务端返回时没有使用客户端的真实 IP 和端口

9. UDP 丢包怎么办?

UDP 本身不保证可靠。可以从两个方向优化:

  • 系统层:调大 socket 接收缓冲区
  • 应用层:实现 ACK、超时重传、序列号、去重、心跳机制

结语

本文从 UDP 的协议特性出发,梳理了 Socket 编程的核心 API,并通过 Echo 回显服务和在线字典服务,把 UDP 的收发流程、地址绑定、字节序转换、业务解耦完整串了起来。

UDP 编程看起来 API 不多,但真正写好并不只是会调用 socketbindsendtorecvfrom。更重要的是理解它的设计边界:它不维护连接,不保证可靠,不处理业务语义。正因为它足够轻,应用层才有更大的自由度去设计自己的协议。

建议学习路线很简单:先敲 Echo 服务,打通通信链路;再写字典服务,理解业务解耦;最后封装 UdpSocketUdpServerUdpClient,形成自己的 UDP 编程模板。走完这三步,Linux UDP Socket 编程的主干基本就掌握了。

更多推荐