文章目录
- 前言
- 一、先看 UDP 服务器的工作流程
- 二、创建 UDP Socket
- 2.1 `socket` 的三个参数
- 2.2 为什么创建以后还要绑定
- 三、服务器显式调用 `bind`
- 3.1 `bind` 的参数
- 3.2 为什么服务器必须显式绑定
- 3.3 为什么通常绑定 `INADDR_ANY`
- 四、接收数据:`recvfrom`
- 4.1 为什么接收数据时还要拿到对端地址
- 4.2 端口转换不能漏掉
- 五、发送数据:`sendto`
- 六、完整的 UDP 回显服务器
- 6.1 `InetAddr.hpp`
- 6.2 `UdpServer.hpp`
- 6.3 服务器入口
- 七、完整的 UDP 客户端
- 7.1 客户端为什么没有调用 `bind`
- 7.2 为什么服务器不能也这样做
- 7.3 Makefile 与运行方式
- 八、几个容易写错的地方
- 8.1 地址结构没有清零
- 8.2 端口忘记转换字节序
- 8.3 `recvfrom` 没有预留结束符
- 8.4 忘记初始化 `addrlen`
- 8.5 把 `INADDR_ANY` 理解成远端地址
- 总结
前言
上一篇把 IP、端口、网络字节序和sockaddr_in这些预备知识串了起来。这一篇开始真正写 UDP 程序。
我们先完成一个最简单的 Echo Server:客户端发送什么,服务器就原样返回什么。程序虽然简单,但会把 UDP 编程最核心的四个接口全部走一遍:
socket -> bind -> recvfrom -> sendto本文先把回显程序写完整,重点放在 UDP 接口行为、服务器和客户端的绑定差异,以及一份数据报怎样完成往返。下一篇再把网络收发封装起来,改造成英译汉服务器。
一、先看 UDP 服务器的工作流程
UDP 不需要像 TCP 那样先建立连接。服务器的基本流程可以直接整理成四步:
- 调用
socket创建 UDP Socket; - 调用
bind绑定本地端口; - 调用
recvfrom接收客户端数据,同时取得客户端地址; - 调用
sendto,根据刚得到的客户端地址返回响应。
服务器通常放在循环中不断执行第 3、4 步。一个请求处理完以后,它继续等待下一份数据报。
客户端的流程更短:创建 Socket,填写服务器地址,调用sendto发送,再调用recvfrom接收响应。客户端也需要本地 IP 和端口,但通常不必自己显式绑定,操作系统会在第一次发送时完成临时绑定。
二、创建 UDP Socket
2.1socket的三个参数
函数原型如下:
#include<sys/types.h>#include<sys/socket.h>intsocket(intdomain,inttype,intprotocol);UDP IPv4 程序通常这样调用:
intsockfd=socket(AF_INET,SOCK_DGRAM,0);三个参数分别表示:
AF_INET:使用 IPv4 地址族;SOCK_DGRAM:创建面向数据报的 Socket,也就是 UDP;0:让系统根据前两个参数选择默认协议。
调用成功后返回一个非负文件描述符,失败返回-1并设置errno。这个返回值就是应用程序后续访问内核 Socket 的入口。
intsockfd=socket(AF_INET,SOCK_DGRAM,0);if(sockfd<0){std::cerr<<"socket error: "<<strerror(errno)<<std::endl;return1;}这里再区分一次:IP + port是网络地址,Socket 是内核创建的通信对象,sockfd是进程访问这个对象的文件描述符。三者有关联,但不能直接画等号。
2.2 为什么创建以后还要绑定
socket()只完成了通信对象的创建。此时内核知道它是一个 IPv4 UDP Socket,却还不知道服务器要在哪个本地端口接收数据。
这就像打开了一个网络文件,但还没有告诉系统“发往本机哪个端口的数据应该交给我”。服务器接下来要调用bind,把本地地址信息写入内核 Socket。
三、服务器显式调用bind
3.1bind的参数
函数原型如下:
intbind(intsockfd,conststructsockaddr*addr,socklen_t addrlen);参数含义:
sockfd:socket()返回的文件描述符;addr:要绑定的本地地址;addrlen:地址结构的实际大小。
IPv4 程序填写的是sockaddr_in,传参时转换为通用的sockaddr*:
structsockaddr_inlocal;bzero(&local,sizeof(local));local.sin_family=AF_INET;local.sin_port=htons(8888);local.sin_addr.s_addr=htonl(INADDR_ANY);intret=bind(sockfd,reinterpret_cast<structsockaddr*>(&local),sizeof(local));if(ret<0){std::cerr<<"bind error: "<<strerror(errno)<<std::endl;close(sockfd);return2;}bzero的作用是把结构体内存清零,也可以使用:
memset(&local,0,sizeof(local));先清零不是为了好看,而是防止结构体中未显式赋值的填充字段残留随机数据。
3.2 为什么服务器必须显式绑定
服务器需要一个稳定入口。客户端程序要提前知道服务器的 IP 和端口,才能把请求发送过来。如果服务器每次启动都使用随机端口,客户端就无法确定目标地址。
这和常用服务号码类似:服务端地址要稳定,访问者才能找到它。所以服务器代码通常从命令行或配置文件中读取端口,再主动执行bind。
服务器显式绑定的核心原因,不是“代码规定必须这样写”,而是服务器必须向客户端提供稳定、可预期的访问入口。
3.3 为什么通常绑定INADDR_ANY
服务器可以绑定某个具体本地 IP,也可以把sin_addr.s_addr设置为INADDR_ANY。
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 监听状态:
netstat-anup-a:显示所有相关 Socket;-n:直接显示数字地址和端口;-u:显示 UDP;-p:显示对应进程信息。
四、接收数据:recvfrom
4.1 为什么接收数据时还要拿到对端地址
UDP 没有提前建立连接。服务器收到一份数据报后,不仅要知道数据内容,还要知道它来自哪个客户端,否则无法把响应发回去。
recvfrom正好同时返回这两类信息:
ssize_trecvfrom(intsockfd,void*buf,size_t len,intflags,structsockaddr*src_addr,socklen_t*addrlen);各参数含义:
sockfd:要读取的 UDP Socket;buf:用户缓冲区;len:缓冲区大小;flags:读取选项,当前填0;src_addr:输出对端地址;addrlen:值—结果参数,调用前写结构体容量,返回后表示实际地址长度。
典型写法如下:
charbuffer[1024];structsockaddr_inpeer;socklen_t peer_len=sizeof(peer);ssize_t n=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,reinterpret_cast<structsockaddr*>(&peer),&peer_len);if(n>0){buffer[n]='\0';}缓冲区预留一个字节,再手动补\0,是为了把收到的内容当作 C 字符串输出。UDP 报文本身并不会自动附带字符串结束符。
默认情况下,recvfrom没有数据可读时会阻塞等待。它不是一直占用 CPU 空转,而是让进程在内核中等待,数据到达后再被唤醒。
4.2 端口转换不能漏掉
peer.sin_port保存的是网络字节序,打印前要转换:
uint16_tport=ntohs(peer.sin_port);IP 地址可以暂时使用inet_ntoa(peer.sin_addr)转成点分十进制字符串。后面还会讨论它的静态缓冲区问题,并给出更推荐的inet_ntop写法。
五、发送数据:sendto
sendto的函数原型如下:
ssize_tsendto(intsockfd,constvoid*buf,size_t len,intflags,conststructsockaddr*dest_addr,socklen_t addrlen);前四个参数描述“通过哪个 Socket 发送什么数据”,后两个参数描述“发给谁”。
ssize_t m=sendto(sockfd,buffer,static_cast<size_t>(n),0,reinterpret_cast<structsockaddr*>(&peer),peer_len);服务器刚刚通过recvfrom拿到peer,现在把同一个地址交给sendto,响应就能回到原客户端。
同一个 UDP Socket 既可以调用recvfrom,也可以调用sendto。也就是说,UDP 通信支持全双工:通信双方都具备发送和接收能力,读写可以分别进行。
全双工不等于每段代码真的在同一瞬间执行。单线程程序仍然按语句顺序运行;如果客户端希望一边等待输入、一边持续接收消息,可以使用两个线程分别负责发送和接收,第三篇聊天室会用到这个结构。
六、完整的 UDP 回显服务器
这部分按照实际工程拆成服务器头文件、服务器入口、客户端和 Makefile。目录结构如下:
udp_echo/ ├── InetAddr.hpp ├── UdpServer.hpp ├── UdpServer.cc ├── UdpClient.cc └── makefileUdpServer.hpp负责封装服务器,UdpServer.cc只保留启动逻辑;客户端单独放在UdpClient.cc。这样代码不会全部挤在一个文件里,后面改造成字典服务器时也能直接复用。
先实现一个轻量地址类,把sockaddr_in转成便于打印的 IP 和端口。
6.1InetAddr.hpp
#pragmaonce#include<arpa/inet.h>#include<netinet/in.h>#include<cstdint>#include<string>classInetAddr{public:explicitInetAddr(constsockaddr_in&addr):addr_(addr),port_(ntohs(addr.sin_port)){charip[INET_ADDRSTRLEN]={0};inet_ntop(AF_INET,&addr.sin_addr,ip,sizeof(ip));ip_=ip;}conststd::string&Ip()const{returnip_;}uint16_tPort()const{returnport_;}constsockaddr_in&NetAddr()const{returnaddr_;}std::stringStringAddr()const{returnip_+":"+std::to_string(port_);}private:sockaddr_in addr_{};std::string ip_;uint16_tport_=0;};这里直接使用inet_ntop,避免把inet_ntoa返回的静态缓冲区指针长期保存下来。
6.2UdpServer.hpp
#pragmaonce#include"InetAddr.hpp"#include<cerrno>#include<cstring>#include<iostream>#include<string>#include<unistd.h>#include<sys/socket.h>classUdpServer{public:explicitUdpServer(uint16_tport):port_(port){}boolInit(){sockfd_=socket(AF_INET,SOCK_DGRAM,0);if(sockfd_<0){std::cerr<<"socket error: "<<strerror(errno)<<std::endl;returnfalse;}sockaddr_in local{};local.sin_family=AF_INET;local.sin_port=htons(port_);local.sin_addr.s_addr=htonl(INADDR_ANY);intret=bind(sockfd_,reinterpret_cast<sockaddr*>(&local),sizeof(local));if(ret<0){std::cerr<<"bind error: "<<strerror(errno)<<std::endl;returnfalse;}returntrue;}voidStart(){charbuffer[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';InetAddrclient(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_tport_;intsockfd_=-1;};这份代码的主线并不复杂:
创建Socket -> 绑定端口 -> 接收数据和来源地址 -> 原样返回recvfrom得到的peer不只是为了打印。服务器能回复正确客户端,靠的就是这份源地址信息。
6.3 服务器入口
#include"UdpServer.hpp"#include<cstdlib>#include<iostream>intmain(intargc,char*argv[]){if(argc!=2){std::cerr<<"Usage: "<<argv[0]<<" port"<<std::endl;return1;}uint16_tport=static_cast<uint16_t>(std::stoi(argv[1]));UdpServerserver(port);if(!server.Init()){return2;}server.Start();return0;}这段代码保存为UdpServer.cc。服务器启动时只接收端口,不要求手动填写本地 IP,因为UdpServer::Init()已经绑定INADDR_ANY。
七、完整的 UDP 客户端
客户端必须知道服务器地址,因此从命令行接收server_ip和server_port。
#include<arpa/inet.h>#include<netinet/in.h>#include<sys/socket.h>#include<unistd.h>#include<cerrno>#include<cstring>#include<iostream>#include<string>intmain(intargc,char*argv[]){if(argc!=3){std::cerr<<"Usage: "<<argv[0]<<" server_ip server_port"<<std::endl;return1;}intsockfd=socket(AF_INET,SOCK_DGRAM,0);if(sockfd<0){std::cerr<<"socket error: "<<strerror(errno)<<std::endl;return2;}sockaddr_in server{};server.sin_family=AF_INET;server.sin_port=htons(static_cast<uint16_t>(std::stoi(argv[2])));intret=inet_pton(AF_INET,argv[1],&server.sin_addr);if(ret!=1){std::cerr<<"invalid server ip"<<std::endl;close(sockfd);return3;}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;}charbuffer[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);return0;}7.1 客户端为什么没有调用bind
客户端当然也要有本地 IP 和端口,否则服务器无法回复。区别在于:它不需要在代码中显式调用bind。
当客户端第一次调用sendto时,操作系统会根据路由选择合适的本地 IP,并从临时端口范围中选择一个未冲突的端口,完成隐式绑定。
这样做有两个直接好处:
- 不同客户端不会因为写死同一个本地端口而互相冲突;
- 客户端通常不需要对外提供稳定入口,具体临时端口是多少并不重要。
需要强调的是:客户端不是“不绑定”,而是一般不由应用程序显式绑定。
7.2 为什么服务器不能也这样做
如果服务器也让系统随机选端口,客户端就不知道该向哪里发送请求。客户端端口只需要当前会话中唯一,服务器端口则必须提前对外约定,两者的角色不同。
客户端通常由操作系统隐式绑定临时端口;服务器必须显式绑定稳定端口。两边都需要本地端点,只是分配方式不同。
7.3 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编译:
make先启动服务器:
./udpserver8888再启动客户端。若在同一台机器上测试,可以访问环回地址:
./udpclient127.0.0.18888若两端位于同一局域网,客户端应填写服务器实际可达的内网 IP。云服务器还要确认安全组和系统防火墙已经放行对应 UDP 端口。
服务端显示“bind 成功”只说明本地端口已经建立,不能证明远端一定可达。跨主机测试还要同时检查目标 IP、路由、防火墙和云平台安全组。
八、几个容易写错的地方
8.1 地址结构没有清零
声明sockaddr_in后应初始化为零,再填写字段。否则未赋值的字节可能保留栈上的随机内容。
sockaddr_in addr{};这种 C++ 写法已经完成零初始化,也可以使用memset或bzero。
8.2 端口忘记转换字节序
下面的写法是错误的:
local.sin_port=8888;正确写法是:
local.sin_port=htons(8888);从地址结构中读取端口时再使用ntohs。
8.3recvfrom没有预留结束符
如果准备把数据当字符串输出,缓冲区大小应传sizeof(buffer) - 1,成功后再写入buffer[n] = '\0'。
8.4 忘记初始化addrlen
recvfrom的最后一个参数不是单纯输出参数。调用前要设置:
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 编程代码。
往期博客:
【Linux网络】网络基础下篇:数据到底怎么从进程 A 到进程 B?从 MAC、IP、端口到 Socket 一次理清
【Linux】线程安全进阶:单例模式、可重入函数与死锁排查
【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础