文章目录
- 前言
- [一、先看 UDP 服务器的工作流程](#一、先看 UDP 服务器的工作流程)
- [二、创建 UDP Socket](#二、创建 UDP Socket)
-
- [2.1 `socket` 的三个参数](#2.1
socket的三个参数) - [2.2 为什么创建以后还要绑定](#2.2 为什么创建以后还要绑定)
- [2.1 `socket` 的三个参数](#2.1
- [三、服务器显式调用 `bind`](#三、服务器显式调用
bind) -
- [3.1 `bind` 的参数](#3.1
bind的参数) - [3.2 为什么服务器必须显式绑定](#3.2 为什么服务器必须显式绑定)
- [3.3 为什么通常绑定 `INADDR_ANY`](#3.3 为什么通常绑定
INADDR_ANY)
- [3.1 `bind` 的参数](#3.1
- 四、接收数据:`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 服务器入口)
- [6.1 `InetAddr.hpp`](#6.1
- [七、完整的 UDP 客户端](#七、完整的 UDP 客户端)
-
- [7.1 客户端为什么没有调用 `bind`](#7.1 客户端为什么没有调用
bind) - [7.2 为什么服务器不能也这样做](#7.2 为什么服务器不能也这样做)
- [7.3 Makefile 与运行方式](#7.3 Makefile 与运行方式)
- [7.1 客户端为什么没有调用 `bind`](#7.1 客户端为什么没有调用
- 八、几个容易写错的地方
-
- [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 那样先建立连接。服务器的基本流程可以直接整理成四步:
- 调用
socket创建 UDP Socket; - 调用
bind绑定本地端口; - 调用
recvfrom接收客户端数据,同时取得客户端地址; - 调用
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,并从临时端口范围中选择一个未冲突的端口,完成隐式绑定。
这样做有两个直接好处:
- 不同客户端不会因为写死同一个本地端口而互相冲突;
- 客户端通常不需要对外提供稳定入口,具体临时端口是多少并不重要。
需要强调的是:客户端不是"不绑定",而是一般不由应用程序显式绑定。
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 编程的第一条完整链路:
socket(AF_INET, SOCK_DGRAM, 0)创建 UDP Socket;- 服务器使用
bind绑定稳定端口,通常绑定INADDR_ANY; recvfrom同时取得请求数据和客户端地址;sendto根据目标地址发送响应;- 客户端通常由操作系统在首次发送时隐式绑定临时端口;
- 回显程序已经把 UDP 的创建、绑定、接收和发送连成了一条完整链路。
到这里,一个客户端和一个服务器之间已经可以完成请求---响应。下一篇继续封装 Socket 和服务器框架,把回显业务替换成网络词典,并补充 IPv4 地址转换接口。
本文对应的完整 Socket 工程可以在代码仓库中查看:Socket 编程代码。
往期博客: