【Linux网络】UDP 服务器怎么写?从 socket、bind 到 recvfrom/sendto,手写完整回显服务器

文章目录

  • 前言
  • [一、先看 UDP 服务器的工作流程](#一、先看 UDP 服务器的工作流程)
  • [二、创建 UDP Socket](#二、创建 UDP Socket)
    • [2.1 `socket` 的三个参数](#2.1 socket 的三个参数)
    • [2.2 为什么创建以后还要绑定](#2.2 为什么创建以后还要绑定)
  • [三、服务器显式调用 `bind`](#三、服务器显式调用 bind)
    • [3.1 `bind` 的参数](#3.1 bind 的参数)
    • [3.2 为什么服务器必须显式绑定](#3.2 为什么服务器必须显式绑定)
    • [3.3 为什么通常绑定 `INADDR_ANY`](#3.3 为什么通常绑定 INADDR_ANY)
  • 四、接收数据:`recvfrom`
    • [4.1 为什么接收数据时还要拿到对端地址](#4.1 为什么接收数据时还要拿到对端地址)
    • [4.2 端口转换不能漏掉](#4.2 端口转换不能漏掉)
  • 五、发送数据:`sendto`
  • [六、完整的 UDP 回显服务器](#六、完整的 UDP 回显服务器)
    • [6.1 `InetAddr.hpp`](#6.1 InetAddr.hpp)
    • [6.2 `UdpServer.hpp`](#6.2 UdpServer.hpp)
    • [6.3 服务器入口](#6.3 服务器入口)
  • [七、完整的 UDP 客户端](#七、完整的 UDP 客户端)
    • [7.1 客户端为什么没有调用 `bind`](#7.1 客户端为什么没有调用 bind)
    • [7.2 为什么服务器不能也这样做](#7.2 为什么服务器不能也这样做)
    • [7.3 Makefile 与运行方式](#7.3 Makefile 与运行方式)
  • 八、几个容易写错的地方
    • [8.1 地址结构没有清零](#8.1 地址结构没有清零)
    • [8.2 端口忘记转换字节序](#8.2 端口忘记转换字节序)
    • [8.3 `recvfrom` 没有预留结束符](#8.3 recvfrom 没有预留结束符)
    • [8.4 忘记初始化 `addrlen`](#8.4 忘记初始化 addrlen)
    • [8.5 把 `INADDR_ANY` 理解成远端地址](#8.5 把 INADDR_ANY 理解成远端地址)
  • 总结

前言

上一篇把 IP、端口、网络字节序和 sockaddr_in 这些预备知识串了起来。这一篇开始真正写 UDP 程序。

我们先完成一个最简单的 Echo Server:客户端发送什么,服务器就原样返回什么。程序虽然简单,但会把 UDP 编程最核心的四个接口全部走一遍:

text 复制代码
socket -> bind -> recvfrom -> sendto

本文先把回显程序写完整,重点放在 UDP 接口行为、服务器和客户端的绑定差异,以及一份数据报怎样完成往返。下一篇再把网络收发封装起来,改造成英译汉服务器。


一、先看 UDP 服务器的工作流程

UDP 不需要像 TCP 那样先建立连接。服务器的基本流程可以直接整理成四步:

  1. 调用 socket 创建 UDP Socket;
  2. 调用 bind 绑定本地端口;
  3. 调用 recvfrom 接收客户端数据,同时取得客户端地址;
  4. 调用 sendto,根据刚得到的客户端地址返回响应。

服务器通常放在循环中不断执行第 3、4 步。一个请求处理完以后,它继续等待下一份数据报。

客户端的流程更短:创建 Socket,填写服务器地址,调用 sendto 发送,再调用 recvfrom 接收响应。客户端也需要本地 IP 和端口,但通常不必自己显式绑定,操作系统会在第一次发送时完成临时绑定。

二、创建 UDP Socket

2.1 socket 的三个参数

函数原型如下:

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

UDP IPv4 程序通常这样调用:

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

三个参数分别表示:

  • AF_INET:使用 IPv4 地址族;
  • SOCK_DGRAM:创建面向数据报的 Socket,也就是 UDP;
  • 0:让系统根据前两个参数选择默认协议。

调用成功后返回一个非负文件描述符,失败返回 -1 并设置 errno。这个返回值就是应用程序后续访问内核 Socket 的入口。

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    std::cerr << "socket error: " << strerror(errno) << std::endl;
    return 1;
}

这里再区分一次:IP + port 是网络地址,Socket 是内核创建的通信对象,sockfd 是进程访问这个对象的文件描述符。三者有关联,但不能直接画等号。

2.2 为什么创建以后还要绑定

socket() 只完成了通信对象的创建。此时内核知道它是一个 IPv4 UDP Socket,却还不知道服务器要在哪个本地端口接收数据。

这就像打开了一个网络文件,但还没有告诉系统"发往本机哪个端口的数据应该交给我"。服务器接下来要调用 bind,把本地地址信息写入内核 Socket。

三、服务器显式调用 bind

3.1 bind 的参数

函数原型如下:

cpp 复制代码
int bind(int sockfd,
         const struct sockaddr* addr,
         socklen_t addrlen);

参数含义:

  • sockfd:socket() 返回的文件描述符;
  • addr:要绑定的本地地址;
  • addrlen:地址结构的实际大小。

IPv4 程序填写的是 sockaddr_in,传参时转换为通用的 sockaddr*:

cpp 复制代码
struct sockaddr_in local;
bzero(&local, sizeof(local));

local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = htonl(INADDR_ANY);

int ret = bind(sockfd,
               reinterpret_cast<struct sockaddr*>(&local),
               sizeof(local));
if (ret < 0) {
    std::cerr << "bind error: " << strerror(errno) << std::endl;
    close(sockfd);
    return 2;
}

bzero 的作用是把结构体内存清零,也可以使用:

cpp 复制代码
memset(&local, 0, sizeof(local));

先清零不是为了好看,而是防止结构体中未显式赋值的填充字段残留随机数据。

3.2 为什么服务器必须显式绑定

服务器需要一个稳定入口。客户端程序要提前知道服务器的 IP 和端口,才能把请求发送过来。如果服务器每次启动都使用随机端口,客户端就无法确定目标地址。

这和常用服务号码类似:服务端地址要稳定,访问者才能找到它。所以服务器代码通常从命令行或配置文件中读取端口,再主动执行 bind。

服务器显式绑定的核心原因,不是"代码规定必须这样写",而是服务器必须向客户端提供稳定、可预期的访问入口。

3.3 为什么通常绑定 INADDR_ANY

服务器可以绑定某个具体本地 IP,也可以把 sin_addr.s_addr 设置为 INADDR_ANY。

cpp 复制代码
local.sin_addr.s_addr = htonl(INADDR_ANY);

INADDR_ANY 的值是 0.0.0.0。它表示这个 Socket 接受发往本机任意 IPv4 接口、目标端口匹配的数据。

假设一台服务器同时有环回地址、内网地址和其他网卡地址。如果程序只绑定 127.0.0.1,那么它只能接收从环回路径到达该地址的数据;客户端使用内网地址访问时,不会匹配这个绑定。

127.0.0.1 是本地环回地址,数据不会真正经过外部网络,适合在同一台机器上测试客户端和服务器。

绑定机器上的内网地址也可以,但此时只有发往这个地址和端口的报文会匹配。

云服务器还有一个容易误解的情况:控制台显示的公网 IP 不一定直接配置在虚拟机网卡上,平台可能通过公网与内网地址映射完成转发。如果这个公网 IP 并不属于本机接口,程序直接 bind 它会失败。

因此,通用服务器通常绑定 INADDR_ANY,再由操作系统和云平台的网络配置决定数据从哪个接口进入。

可以用下面的命令查看 UDP 监听状态:

bash 复制代码
netstat -anup
  • -a:显示所有相关 Socket;
  • -n:直接显示数字地址和端口;
  • -u:显示 UDP;
  • -p:显示对应进程信息。

四、接收数据:recvfrom

4.1 为什么接收数据时还要拿到对端地址

UDP 没有提前建立连接。服务器收到一份数据报后,不仅要知道数据内容,还要知道它来自哪个客户端,否则无法把响应发回去。

recvfrom 正好同时返回这两类信息:

cpp 复制代码
ssize_t recvfrom(int sockfd,
                 void* buf,
                 size_t len,
                 int flags,
                 struct sockaddr* src_addr,
                 socklen_t* addrlen);

各参数含义:

  • sockfd:要读取的 UDP Socket;
  • buf:用户缓冲区;
  • len:缓冲区大小;
  • flags:读取选项,当前填 0;
  • src_addr:输出对端地址;
  • addrlen:值---结果参数,调用前写结构体容量,返回后表示实际地址长度。

典型写法如下:

cpp 复制代码
char buffer[1024];
struct sockaddr_in peer;
socklen_t peer_len = sizeof(peer);

ssize_t n = recvfrom(sockfd,
                     buffer,
                     sizeof(buffer) - 1,
                     0,
                     reinterpret_cast<struct sockaddr*>(&peer),
                     &peer_len);
if (n > 0) {
    buffer[n] = '\0';
}

缓冲区预留一个字节,再手动补 \0,是为了把收到的内容当作 C 字符串输出。UDP 报文本身并不会自动附带字符串结束符。

默认情况下,recvfrom 没有数据可读时会阻塞等待。它不是一直占用 CPU 空转,而是让进程在内核中等待,数据到达后再被唤醒。

4.2 端口转换不能漏掉

peer.sin_port 保存的是网络字节序,打印前要转换:

cpp 复制代码
uint16_t port = ntohs(peer.sin_port);

IP 地址可以暂时使用 inet_ntoa(peer.sin_addr) 转成点分十进制字符串。后面还会讨论它的静态缓冲区问题,并给出更推荐的 inet_ntop 写法。

五、发送数据:sendto

sendto 的函数原型如下:

cpp 复制代码
ssize_t sendto(int sockfd,
               const void* buf,
               size_t len,
               int flags,
               const struct sockaddr* dest_addr,
               socklen_t addrlen);

前四个参数描述"通过哪个 Socket 发送什么数据",后两个参数描述"发给谁"。

cpp 复制代码
ssize_t m = sendto(sockfd,
                   buffer,
                   static_cast<size_t>(n),
                   0,
                   reinterpret_cast<struct sockaddr*>(&peer),
                   peer_len);

服务器刚刚通过 recvfrom 拿到 peer,现在把同一个地址交给 sendto,响应就能回到原客户端。

同一个 UDP Socket 既可以调用 recvfrom,也可以调用 sendto。也就是说,UDP 通信支持全双工:通信双方都具备发送和接收能力,读写可以分别进行。

全双工不等于每段代码真的在同一瞬间执行。 单线程程序仍然按语句顺序运行;如果客户端希望一边等待输入、一边持续接收消息,可以使用两个线程分别负责发送和接收,第三篇聊天室会用到这个结构。

六、完整的 UDP 回显服务器

这部分按照实际工程拆成服务器头文件、服务器入口、客户端和 Makefile。目录结构如下:

text 复制代码
udp_echo/
├── InetAddr.hpp
├── UdpServer.hpp
├── UdpServer.cc
├── UdpClient.cc
└── makefile

UdpServer.hpp 负责封装服务器,UdpServer.cc 只保留启动逻辑;客户端单独放在 UdpClient.cc。这样代码不会全部挤在一个文件里,后面改造成字典服务器时也能直接复用。

先实现一个轻量地址类,把 sockaddr_in 转成便于打印的 IP 和端口。

6.1 InetAddr.hpp

cpp 复制代码
#pragma once

#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstdint>
#include <string>

class InetAddr {
public:
    explicit InetAddr(const sockaddr_in& addr)
        : addr_(addr), port_(ntohs(addr.sin_port)) {
        char ip[INET_ADDRSTRLEN] = {0};
        inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof(ip));
        ip_ = ip;
    }

    const std::string& Ip() const { return ip_; }
    uint16_t Port() const { return port_; }
    const sockaddr_in& NetAddr() const { return addr_; }

    std::string StringAddr() const {
        return ip_ + ":" + std::to_string(port_);
    }

private:
    sockaddr_in addr_{};
    std::string ip_;
    uint16_t port_ = 0;
};

这里直接使用 inet_ntop,避免把 inet_ntoa 返回的静态缓冲区指针长期保存下来。

6.2 UdpServer.hpp

cpp 复制代码
#pragma once

#include "InetAddr.hpp"

#include <cerrno>
#include <cstring>
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>

class UdpServer {
public:
    explicit UdpServer(uint16_t port)
        : port_(port) {}

    bool Init() {
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd_ < 0) {
            std::cerr << "socket error: " << strerror(errno) << std::endl;
            return false;
        }

        sockaddr_in local{};
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        local.sin_addr.s_addr = htonl(INADDR_ANY);

        int ret = bind(sockfd_,
                       reinterpret_cast<sockaddr*>(&local),
                       sizeof(local));
        if (ret < 0) {
            std::cerr << "bind error: " << strerror(errno) << std::endl;
            return false;
        }
        return true;
    }

    void Start() {
        char buffer[1024];

        for (;;) {
            sockaddr_in peer{};
            socklen_t peer_len = sizeof(peer);

            ssize_t n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0,
                                 reinterpret_cast<sockaddr*>(&peer),
                                 &peer_len);
            if (n < 0) {
                std::cerr << "recvfrom error: "
                          << strerror(errno) << std::endl;
                continue;
            }

            buffer[n] = '\0';
            InetAddr client(peer);
            std::cout << "[" << client.StringAddr() << "]# "
                      << buffer << std::endl;

            ssize_t m = sendto(sockfd_, buffer, static_cast<size_t>(n), 0,
                               reinterpret_cast<sockaddr*>(&peer),
                               peer_len);
            if (m < 0) {
                std::cerr << "sendto error: "
                          << strerror(errno) << std::endl;
            }
        }
    }

    ~UdpServer() {
        if (sockfd_ >= 0) {
            close(sockfd_);
        }
    }

private:
    uint16_t port_;
    int sockfd_ = -1;
};

这份代码的主线并不复杂:

text 复制代码
创建Socket -> 绑定端口 -> 接收数据和来源地址 -> 原样返回

recvfrom 得到的 peer 不只是为了打印。服务器能回复正确客户端,靠的就是这份源地址信息。

6.3 服务器入口

cpp 复制代码
#include "UdpServer.hpp"

#include <cstdlib>
#include <iostream>

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

    uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
    UdpServer server(port);

    if (!server.Init()) {
        return 2;
    }

    server.Start();
    return 0;
}

这段代码保存为 UdpServer.cc。服务器启动时只接收端口,不要求手动填写本地 IP,因为 UdpServer::Init() 已经绑定 INADDR_ANY。

七、完整的 UDP 客户端

客户端必须知道服务器地址,因此从命令行接收 server_ip 和 server_port。

cpp 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cerrno>
#include <cstring>
#include <iostream>
#include <string>

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

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        std::cerr << "socket error: " << strerror(errno) << std::endl;
        return 2;
    }

    sockaddr_in server{};
    server.sin_family = AF_INET;
    server.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));

    int ret = inet_pton(AF_INET, argv[1], &server.sin_addr);
    if (ret != 1) {
        std::cerr << "invalid server ip" << std::endl;
        close(sockfd);
        return 3;
    }

    for (;;) {
        std::string request;
        std::cout << "Please Enter# ";
        if (!std::getline(std::cin, request)) {
            break;
        }

        ssize_t n = sendto(sockfd,
                           request.data(),
                           request.size(),
                           0,
                           reinterpret_cast<sockaddr*>(&server),
                           sizeof(server));
        if (n < 0) {
            std::cerr << "sendto error: " << strerror(errno) << std::endl;
            break;
        }

        char buffer[1024];
        sockaddr_in peer{};
        socklen_t len = sizeof(peer);
        ssize_t m = recvfrom(sockfd,
                             buffer,
                             sizeof(buffer) - 1,
                             0,
                             reinterpret_cast<sockaddr*>(&peer),
                             &len);
        if (m < 0) {
            std::cerr << "recvfrom error: " << strerror(errno) << std::endl;
            break;
        }

        buffer[m] = '\0';
        std::cout << "server echo# " << buffer << std::endl;
    }

    close(sockfd);
    return 0;
}

7.1 客户端为什么没有调用 bind

客户端当然也要有本地 IP 和端口,否则服务器无法回复。区别在于:它不需要在代码中显式调用 bind。

当客户端第一次调用 sendto 时,操作系统会根据路由选择合适的本地 IP,并从临时端口范围中选择一个未冲突的端口,完成隐式绑定。

这样做有两个直接好处:

  1. 不同客户端不会因为写死同一个本地端口而互相冲突;
  2. 客户端通常不需要对外提供稳定入口,具体临时端口是多少并不重要。

需要强调的是:客户端不是"不绑定",而是一般不由应用程序显式绑定。

7.2 为什么服务器不能也这样做

如果服务器也让系统随机选端口,客户端就不知道该向哪里发送请求。客户端端口只需要当前会话中唯一,服务器端口则必须提前对外约定,两者的角色不同。

客户端通常由操作系统隐式绑定临时端口;服务器必须显式绑定稳定端口。两边都需要本地端点,只是分配方式不同。

7.3 Makefile 与运行方式

完整工程可以使用下面的 Makefile:

makefile 复制代码
.PHONY: all clean

all: udpclient udpserver

udpclient: UdpClient.cc
	g++ -o $@ $^ -std=c++17

udpserver: UdpServer.cc
	g++ -o $@ $^ -std=c++17

clean:
	rm -f udpclient udpserver

编译:

bash 复制代码
make

先启动服务器:

bash 复制代码
./udpserver 8888

再启动客户端。若在同一台机器上测试,可以访问环回地址:

bash 复制代码
./udpclient 127.0.0.1 8888

若两端位于同一局域网,客户端应填写服务器实际可达的内网 IP。云服务器还要确认安全组和系统防火墙已经放行对应 UDP 端口。

服务端显示"bind 成功"只说明本地端口已经建立,不能证明远端一定可达。跨主机测试还要同时检查目标 IP、路由、防火墙和云平台安全组。

八、几个容易写错的地方

8.1 地址结构没有清零

声明 sockaddr_in 后应初始化为零,再填写字段。否则未赋值的字节可能保留栈上的随机内容。

cpp 复制代码
sockaddr_in addr{};

这种 C++ 写法已经完成零初始化,也可以使用 memset 或 bzero。

8.2 端口忘记转换字节序

下面的写法是错误的:

cpp 复制代码
local.sin_port = 8888;

正确写法是:

cpp 复制代码
local.sin_port = htons(8888);

从地址结构中读取端口时再使用 ntohs。

8.3 recvfrom 没有预留结束符

如果准备把数据当字符串输出,缓冲区大小应传 sizeof(buffer) - 1,成功后再写入 buffer[n] = '\0'。

8.4 忘记初始化 addrlen

recvfrom 的最后一个参数不是单纯输出参数。调用前要设置:

cpp 复制代码
socklen_t len = sizeof(peer);

内核返回时再把实际长度写回去。

8.5 把 INADDR_ANY 理解成远端地址

INADDR_ANY 用于服务器本地绑定,表示接收发往本机任意 IPv4 接口、指定端口的数据。客户端不能把它当成一个正常的远端服务器地址去访问。

总结

这一篇完成了 UDP 编程的第一条完整链路:

  1. socket(AF_INET, SOCK_DGRAM, 0) 创建 UDP Socket;
  2. 服务器使用 bind 绑定稳定端口,通常绑定 INADDR_ANY;
  3. recvfrom 同时取得请求数据和客户端地址;
  4. sendto 根据目标地址发送响应;
  5. 客户端通常由操作系统在首次发送时隐式绑定临时端口;
  6. 回显程序已经把 UDP 的创建、绑定、接收和发送连成了一条完整链路。

到这里,一个客户端和一个服务器之间已经可以完成请求---响应。下一篇继续封装 Socket 和服务器框架,把回显业务替换成网络词典,并补充 IPv4 地址转换接口。

本文对应的完整 Socket 工程可以在代码仓库中查看:Socket 编程代码。

往期博客:

【Linux网络】网络基础下篇:数据到底怎么从进程 A 到进程 B?从 MAC、IP、端口到 Socket 一次理清

【Linux】线程安全进阶:单例模式、可重入函数与死锁排查

【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础

相关推荐
maochongsandai1101 小时前
Centos上运行docker应用命令汇总
linux·docker·centos
知已难寻1 小时前
00 · OpenStack 总纲——从云计算到 OpenStack 全景
运维·服务器·云原生·云计算·负载均衡·openstack·perl
小白的码BUG之路1 小时前
K8S -- 集群搭建环境基础
java·服务器·kubernetes
一只QAQ1 小时前
python语法学习
linux·python·学习
明王明王1 小时前
PVE 装 OpenWrt:不改现有网络,用单臂路由让指定虚拟机上网
网络·openwrt
2301_780789661 小时前
SSL 卸载 + WAF + 抗 D:高防 CDN 一站式解决 HTTPS 性能与安全
网络·云原生·ddos
moonsims1 小时前
AiBrainBox-UGV 通 · 感 · 算 · 控一体化地面机器人边缘智能平台 规格书
大数据·网络·人工智能·无人机
CS_Zero1 小时前
计算机网络通信5:网络间通信、路由器与网络地址转换
网络·智能路由器
七夜zippoe1 小时前
Agent 网关设计:统一入口、智能路由与负载均衡
运维·ai·log4j·负载均衡·agent·网关设计