Linux Socket UDP编程(一):手写UDP回显服务器与客户端,从socket、bind、recvfrom到网络字典服务器完整实战
Linux Socket UDP编程(一):手写UDP回显服务器与客户端,从socket、bind、recvfrom到网络字典服务器完整实战

文章目录
- Linux Socket UDP编程(一):手写UDP回显服务器与客户端,从socket、bind、recvfrom到网络字典服务器完整实战
- 一、为什么先学习UDP?
- 二、socket():创建UDP套接字
- 三、bind():为什么服务器需要绑定IP和端口?
- 四、recvfrom():服务器怎样接收UDP消息?
- 五、sendto():服务器怎样回复客户端?
- 六、V1:设计一个Echo Server
- 七、nocopy.hpp:为什么网络对象通常禁止拷贝?
- 八、Comm.hpp:公共错误码
- 九、InetAddr.hpp:封装IP地址与端口号
- 十、UdpServer.hpp:实现回显服务器
- 十一、ServerMain.cc:启动服务器
- 十二、UdpClient.cc:实现UDP客户端
- 十三、编写Makefile
- 十四、运行V1 Echo Server
- 十五、从Echo Server升级为网络字典服务器
- 十六、V2:设计Dict类
- 十七、回调函数:怎样把通信与业务分离?
- 十八、回调版UdpServer.hpp
- 十九、ServerMain.cc:把Dict作为业务处理器
- 二十、测试DictServer
- 二十一、进一步封装:为什么需要UdpSocket类?
- 二十二、实现udp_socket.hpp
- 二十三、使用UdpSocket封装通用UdpServer
- 二十四、使用UdpSocket封装UdpClient
- 二十五、封装版英译汉服务器与客户端
- 二十六、为什么要逐步封装,而不是一开始就设计完整框架?
- 总结
前言:已经理解网络协议,怎样让两台计算机真正通信?
在学习网络基础知识时,我们已经认识了 TCP/IP 五层模型:
应用层
|
传输层
|
网络层
|
数据链路层
|
物理层
同时也知道,网络通信需要使用 IP 地址和端口号等信息定位通信端点。
例如:
服务器IP:192.168.1.10
服务器Port:8888
但是,掌握这些概念之后,我们仍然需要解决一个实际问题:
如何编写一个 Linux 程序,让另一台计算机可以向它发送消息,并接收服务器返回的结果?
Linux 提供了 Socket 编程接口。
通过这些接口,应用程序可以使用操作系统已经实现的网络协议栈,而不需要自己编写完整的 IP 和 UDP 协议。
本篇采用 C++11,按照三个阶段逐步实现:
V1:UDP Echo Server
|
v
V2:UDP DictServer
|
v
V2:通用UDP Socket封装
首先实现最简单的回显服务器,再将服务器的通信功能与业务功能分离,最后封装可以重复使用的 UDP 网络编程组件。
一、为什么先学习UDP?
1.1 TCP和UDP都属于传输层协议
此前已经认识:
TCP:Transmission Control Protocol
UDP:User Datagram Protocol
两者都属于传输层,但提供的服务不同。
TCP 提供面向连接的可靠字节流服务。
UDP 则提供无连接的数据报服务。
本篇先学习 UDP,因为它不要求像 TCP 那样先建立连接,基础编程流程比较直接。
1.2 UDP是面向数据报的
例如客户端准备发送:
hello
可以通过一次:
sendto()
发送一个 UDP 数据报。
服务器通过:
recvfrom()
接收数据报。
要注意,UDP 并不保证数据报一定到达,也不保证到达顺序与发送顺序一致。
因此:
sendto 调用成功,不代表远端应用程序已经成功收到并处理消息。
1.3 UDP通信的基本过程
UDP Server UDP Client
socket() socket()
| |
v |
bind() |
| |
v |
recvfrom() <------ sendto() --------|
| |
v |
处理请求 |
| |
v |
sendto() --------> recvfrom() ------|
|
v
打印响应
观察这个流程,可以发现:
UDP 服务器通常需要:
socket()
bind()
recvfrom()
sendto()
UDP 客户端通常需要:
socket()
sendto()
recvfrom()
这里没有出现:
listen()
accept()
因为它们是我们以后学习 TCP 连接服务器时才会重点使用的接口。

二、socket():创建UDP套接字
2.1 函数原型
#include <sys/socket.h>
int socket(
int domain,
int type,
int protocol
);
三个参数分别用于确定:
地址族
Socket类型
具体协议
2.2 第一个参数:AF_INET
AF_INET
表示使用 IPv4 地址族。
如果学习 IPv6,会接触到:
AF_INET6
但当前只讨论 IPv4 Socket 编程。
2.3 第二个参数:SOCK_DGRAM
SOCK_DGRAM
表示数据报类型的 Socket。
对于常见的 IPv4 UDP 编程,可以写:
socket(AF_INET, SOCK_DGRAM, 0);
其中:
SOCK_DGRAM
对应 UDP 的数据报通信形式。
2.4 第三个参数:0
0
表示根据地址族和 Socket 类型选择默认协议。
因此:
int sockfd = socket(
AF_INET,
SOCK_DGRAM,
0
);
表示创建一个 IPv4 UDP Socket。
2.5 返回值
创建成功:
返回一个非负文件描述符
创建失败:
返回-1,并设置errno
例如:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr
<< "socket error: "
<< strerror(errno)
<< std::endl;
return 1;
}
2.6 为什么Socket返回int?
这是 Linux 网络编程与文件 IO 之间的重要联系。
我们以前学习:
open()
会得到文件描述符。
例如:
int fd = open("log.txt", O_RDONLY);
现在:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
也返回文件描述符。
因此可以理解为:
Linux 将 Socket 也纳入文件描述符管理体系。
应用程序通过 Socket 文件描述符,访问操作系统管理的网络通信对象。
当不再使用 Socket 时,需要:
close(sockfd);
释放相应资源。
三、bind():为什么服务器需要绑定IP和端口?
创建 Socket 后,我们获得了:
sockfd
但此时还没有给服务器明确指定用于接收请求的本地网络地址。
例如我们希望客户端访问:
服务器:192.168.1.10:8888
那么服务器必须让操作系统知道:
这个Socket应该使用哪个本地端口接收数据?
这就需要:
bind()
3.1 函数原型
#include <sys/socket.h>
int bind(
int sockfd,
const struct sockaddr* addr,
socklen_t addrlen
);
参数含义:
| 参数 | 作用 |
|---|---|
sockfd | 需要绑定的 Socket |
addr | 本地 Socket 地址 |
addrlen | 地址结构的长度 |
3.2 为什么使用sockaddr?
bind() 需要适配不同的地址族。
例如:
IPv4
IPv6
Unix Domain Socket
因此采用:
struct sockaddr*
作为通用接口参数。
但在 IPv4 编程中,我们实际填写的是:
struct sockaddr_in
3.3 sockaddr_in的主要字段
struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
/* 实现相关的填充字段 */
};
主要关注:
sin_family
地址族。
sin_port
端口号。
sin_addr
IPv4 地址。
3.4 填充服务器地址
struct sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = INADDR_ANY;
然后:
int ret = bind(
sockfd,
reinterpret_cast<struct sockaddr*>(&local),
sizeof(local)
);
注意:
填充结构体只是准备用户空间中的地址信息,只有调用 bind 后,才是请求内核为 Socket 设置对应的本地绑定。
3.5 为什么端口号要使用htons?
local.sin_port = htons(8888);
其中:
h:Host
to:转换到
n:Network
s:16位整数
htons() 用于将16位端口号转换为网络字节序。
因为 Socket 地址结构中的端口字段采用网络字节序。
3.6 INADDR_ANY是什么意思?
local.sin_addr.s_addr = INADDR_ANY;
INADDR_ANY 对应 IPv4 未指定地址:
0.0.0.0
它表示在绑定时不限定某一个具体的本地 IPv4 接口地址。
例如服务器有:
127.0.0.1
192.168.1.10
10.0.0.5
等本地地址时:
INADDR_ANY
可以让 Socket 在对应端口上接收发往合适本地地址的数据报。
需要注意:
0.0.0.0是绑定时使用的通配地址,并不是远程客户端应该填写的服务器目标IP。
本机测试时,客户端可以使用:
127.0.0.1
远程测试时,则需要填写服务器实际可到达的地址。
3.7 为什么云服务器常常使用INADDR_ANY?
云服务器可能通过云平台的网络地址转换或映射机制向外提供公网访问。
此时服务器操作系统内部实际配置的地址,不一定就是云平台显示的公网 IP。
如果直接尝试:
bind(sockfd, 公网IP);
但该公网 IP 并不属于操作系统本地接口,就可能出现:
Cannot assign requested address
因此,对于需要监听多个本地地址的普通 UDP 服务,可以使用:
INADDR_ANY
再通过云平台安全组、防火墙和网络配置控制外部访问。

四、recvfrom():服务器怎样接收UDP消息?
4.1 函数原型
#include <sys/socket.h>
ssize_t recvfrom(
int sockfd,
void* buf,
size_t len,
int flags,
struct sockaddr* src_addr,
socklen_t* addrlen
);
参数:
| 参数 | 含义 |
|---|---|
sockfd | 接收数据的 Socket |
buf | 接收缓冲区 |
len | 接收缓冲区大小 |
flags | 接收标志,基础实验使用0 |
src_addr | 输出发送方的地址 |
addrlen | 输入地址缓冲区大小,输出实际地址长度 |
4.2 为什么要返回发送方地址?
因为 UDP 是无连接的数据报协议。
同一个服务器 Socket 可以接收:
Client-A
Client-B
Client-C
分别发送的数据报。
所以服务器不仅需要知道:
对方发了什么?
还需要知道:
是谁发的?
例如:
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
调用:
ssize_t n = recvfrom(
sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
执行成功以后:
peer
就保存了对应数据报的发送方地址信息。
后面回发消息时会使用它。
4.3 为什么addrlen传入指针?
&len
因为它是一个输入输出参数。
调用之前:
socklen_t len = sizeof(peer);
表示地址缓冲区的大小。
调用以后:
len
会保存实际地址长度信息。
所以不能随意传入一个没有正确初始化的长度变量。
4.4 recvfrom的返回值
成功时返回收到的数据字节数。
失败时返回:
-1
这里有一个 UDP 编程的重要细节:
recvfrom返回0,在UDP中可以表示成功收到一个零长度数据报。
因此:
if (n > 0)
不能覆盖全部合法 UDP 数据报。
更准确的基本判断是:
if (n >= 0)
{
// 成功接收,包括长度为0的数据报
}
4.5 recvfrom会一直等待吗?
对于默认阻塞模式的 UDP Socket:
recvfrom()
在没有可用数据报时,通常会阻塞等待。
这意味着服务器没有收到数据时,不会自动不断循环执行后续业务代码。
只有收到数据、发生错误或满足其他返回条件时,调用才会返回。
五、sendto():服务器怎样回复客户端?
5.1 函数原型
ssize_t sendto(
int sockfd,
const void* buf,
size_t len,
int flags,
const struct sockaddr* dest_addr,
socklen_t addrlen
);
它与 recvfrom() 的关系非常直接。
recvfrom():
接收数据,并获得发送方地址
sendto():
发送数据,并指定接收方地址
5.2 为什么服务器回复时需要peer?
服务器前面已经通过:
recvfrom()
取得:
peer
其中包含客户端地址。
所以回复时可以写:
sendto(
sockfd,
buffer,
n,
0,
reinterpret_cast<struct sockaddr*>(&peer),
len
);
这样就可以把数据报发送回对应客户端。
5.3 为什么发送长度不一定使用strlen?
假设接收到的数据:
hello
使用:
strlen(buffer)
似乎可以得到消息长度。
但 UDP 数据报不一定是 C 风格字符串。
例如一个数据报可能包含:
61 00 62
其中存在:
\0
如果使用 strlen(),就会错误地在第一个零字节停止计数。
因此对于通用 UDP 回显服务器,最好直接使用:
recvfrom()
返回的实际数据长度。
例如:
sendto(sockfd, buffer, static_cast<size_t>(n), ...);
这能够正确处理包含零字节的消息。

六、V1:设计一个Echo Server
现在已经认识了四个关键接口:
socket()
bind()
recvfrom()
sendto()
接下来正式实现:
UDP Echo Server。
Echo 的含义是:
回显
也就是客户端发送什么,服务器就将相同内容返回给客户端。
例如:
Client发送:hello
Server回复:hello
6.1 项目组织
先将工程划分为:
udp_echo/
├── nocopy.hpp
├── Comm.hpp
├── InetAddr.hpp
├── UdpServer.hpp
├── ServerMain.cc
├── UdpClient.cc
└── Makefile
各文件职责:
| 文件 | 作用 |
|---|---|
nocopy.hpp | 禁止网络对象发生不合理的复制 |
Comm.hpp | 保存错误退出码等公共定义 |
InetAddr.hpp | 封装IPv4地址与端口 |
UdpServer.hpp | 封装UDP服务器 |
ServerMain.cc | 启动服务器 |
UdpClient.cc | 实现交互式客户端 |
Makefile | 构建客户端与服务器 |
这里使用独立文件组织代码,主要是为了让通信逻辑和程序入口更加清晰。
七、nocopy.hpp:为什么网络对象通常禁止拷贝?
7.1 文件描述符不是普通数据
假设服务器对象中保存:
int _sockfd;
如果直接复制服务器对象:
UdpServer server1;
UdpServer server2 = server1;
就可能产生两个对象都认为自己负责管理同一个 Socket 文件描述符的问题。
例如:
server1._sockfd = 3
server2._sockfd = 3
如果两个对象析构时都执行:
close(3);
就可能发生错误的资源管理。
因此,这类独占管理文件描述符的对象通常应该禁止复制。
7.2 实现nocopy类
nocopy.hpp:
#pragma once
class nocopy
{
public:
nocopy() = default;
nocopy(const nocopy&) = delete;
nocopy& operator=(const nocopy&) = delete;
~nocopy() = default;
};
其中:
= delete
表示禁止对应操作。
如果:
class UdpServer : public nocopy
那么:
UdpServer
就不能随意复制。
需要注意,这样做不等于自动实现了完整的移动语义。
这里只先保证服务器对象不会被简单复制。
八、Comm.hpp:公共错误码
Comm.hpp:
#pragma once
enum ExitCode
{
Usage_Err = 1,
Socket_Err,
Bind_Err
};
可以在不同错误位置返回不同退出码。
例如:
return Usage_Err;
表示命令行参数使用错误。
return Socket_Err;
表示创建 Socket 失败。
return Bind_Err;
表示地址绑定失败。
这样能够方便调用程序的 Shell 或其他程序区分错误类型。
九、InetAddr.hpp:封装IP地址与端口号
在服务器中:
recvfrom()
返回的地址类型是:
struct sockaddr_in
但我们通常希望直接打印:
127.0.0.1:50000
而不是反复手动处理:
sin_addr
sin_port
所以可以设计一个:
InetAddr
类。
9.1 需要保存哪些数据?
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
分别保存:
可读IP字符串
主机字节序端口号
原始Socket地址结构
9.2 InetAddr.hpp
#pragma once
#include <cstdint>
#include <string>
#include <arpa/inet.h>
#include <netinet/in.h>
class InetAddr
{
public:
explicit InetAddr(const struct sockaddr_in& addr)
: _addr(addr),
_port(ntohs(addr.sin_port))
{
char ipbuf[INET_ADDRSTRLEN] = {};
const char* result = inet_ntop(
AF_INET,
&_addr.sin_addr,
ipbuf,
sizeof(ipbuf)
);
_ip = result ? ipbuf : "Unknown";
}
std::string Ip() const
{
return _ip;
}
uint16_t Port() const
{
return _port;
}
std::string PrintDebug() const
{
return _ip + ":" + std::to_string(_port);
}
const struct sockaddr_in& GetAddr() const
{
return _addr;
}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
9.3 为什么使用ntohs?
回忆:
sin_port
采用网络字节序。
但我们希望得到便于程序处理的主机字节序端口。
所以:
_port = ntohs(addr.sin_port);
9.4 为什么需要将IP转成字符串?
因为:
addr.sin_addr
不是直接可读的:
127.0.0.1
字符串。
需要进行地址表示转换。
这里使用:
inet_ntop()
完成 IPv4 二进制地址到文本表示的转换。
它允许调用者提供自己的输出缓冲区。
后面会详细讲解:
inet_addr()
inet_ntoa()
inet_pton()
inet_ntop()
之间的区别。
9.5 PrintDebug的作用
例如:
InetAddr addr(peer);
然后:
std::cout << addr.PrintDebug();
可能输出:
127.0.0.1:52341
这样服务器就可以清楚地显示消息来自哪个网络端点。
十、UdpServer.hpp:实现回显服务器
服务器主要负责:
创建Socket
|
v
绑定本地端口
|
v
进入接收循环
|
v
收到客户端消息
|
v
将消息原样发送回客户端
10.1 完整UdpServer.hpp
#pragma once
#include <cstdint>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "nocopy.hpp"
#include "InetAddr.hpp"
const static uint16_t defaultport = 8888;
const static int defaultfd = -1;
const static int defaultsize = 1024;
class UdpServer : public nocopy
{
public:
explicit UdpServer(uint16_t port = defaultport)
: _port(port),
_sockfd(defaultfd)
{
}
void Init()
{
// 1. 创建UDP Socket
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr
<< "socket error: "
<< std::strerror(errno)
<< std::endl;
std::exit(SocketError);
}
std::cout
<< "socket success, sockfd: "
<< _sockfd
<< std::endl;
// 2. 设置本地地址
struct sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
// 3. 将Socket绑定到本地端口
int ret = bind(
_sockfd,
reinterpret_cast<struct sockaddr*>(&local),
sizeof(local)
);
if (ret < 0)
{
std::cerr
<< "bind error: "
<< std::strerror(errno)
<< std::endl;
std::exit(BindError);
}
std::cout
<< "bind success, port: "
<< _port
<< std::endl;
}
void Start()
{
char buffer[defaultsize];
while (true)
{
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
// 4. 接收客户端数据报
ssize_t n = recvfrom(
_sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
if (n < 0)
{
if (errno == EINTR)
{
continue;
}
std::cerr
<< "recvfrom error: "
<< std::strerror(errno)
<< std::endl;
continue;
}
InetAddr addr(peer);
// 字符串显示使用实际长度
std::string message(buffer, static_cast<size_t>(n));
std::cout
<< "[" << addr.PrintDebug() << "]# "
<< message
<< std::endl;
// 5. 原样回显收到的字节
ssize_t sent = sendto(
_sockfd,
buffer,
static_cast<size_t>(n),
0,
reinterpret_cast<struct sockaddr*>(&peer),
len
);
if (sent < 0)
{
std::cerr
<< "sendto error: "
<< std::strerror(errno)
<< std::endl;
}
}
}
~UdpServer()
{
if (_sockfd >= 0)
{
close(_sockfd);
}
}
private:
enum
{
SocketError = 2,
BindError = 3
};
uint16_t _port;
int _sockfd;
};
10.2 为什么使用两个成员变量?
uint16_t _port;
int _sockfd;
其中:
_port
表示服务器希望使用的本地端口。
_sockfd
表示内核分配的 Socket 文件描述符。
它们并不是同一种东西。
例如:
端口号:8888
Socket FD:3
端口号用于网络通信地址。
文件描述符用于当前进程操作 Socket 对象。
10.3 为什么Init和Start分开?
Init()
主要处理:
Socket创建
地址绑定
而:
Start()
主要处理:
接收和回复消息
这样可以把资源初始化与业务循环分开。
十一、ServerMain.cc:启动服务器
#include <iostream>
#include <string>
#include <cstdlib>
#include "UdpServer.hpp"
void Usage(const std::string& process)
{
std::cout
<< "Usage: "
<< process
<< " local_port"
<< std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return 1;
}
int value = std::stoi(argv[1]);
if (value <= 0 || value > 65535)
{
std::cerr << "invalid port" << std::endl;
return 1;
}
uint16_t port = static_cast<uint16_t>(value);
UdpServer server(port);
server.Init();
server.Start();
return 0;
}
服务器启动:
./udp_server 8888
表示:
创建UDP服务器
使用本地8888端口
此后默认阻塞在:
recvfrom()
等待客户端发送数据报。
十二、UdpClient.cc:实现UDP客户端
客户端主要负责:
读取用户输入
|
v
向服务器发送消息
|
v
等待服务器回复
|
v
打印回显结果
12.1 为什么客户端也要socket?
因为:
服务器需要网络通信端点
客户端同样需要网络通信端点。
所以两者都必须建立相应 Socket。
12.2 客户端是否必须显式bind?
这个问题非常重要。
服务器端通常需要固定端口。
例如:
8888
客户端必须知道向哪里发送数据。
但是客户端自身的端口通常不需要提前固定。
因此:
UDP客户端需要有本地端口,但一般不必主动显式调用bind。
当客户端首次进行需要本地绑定的操作时,操作系统可以自动为 Socket 选择本地地址和临时端口。
因此:
客户端需要本地端口
不等于:
客户端必须自己调用bind()
12.3 为什么客户端使用临时端口?
假设一台服务器同时被:
Client-A
Client-B
Client-C
访问。
它们可以分别使用:
Client-A:50001
Client-B:50002
Client-C:50003
等不同端口进行通信。
服务器则继续使用:
8888
接收相应请求。
这样服务器就能通过接收到的数据报携带的地址信息区分不同客户端。
12.4 完整客户端代码
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(const std::string& process)
{
std::cout
<< "Usage: "
<< process
<< " server_ip server_port"
<< std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return 1;
}
std::string serverip = argv[1];
int port = std::stoi(argv[2]);
if (port <= 0 || port > 65535)
{
std::cerr << "invalid port\n";
return 1;
}
uint16_t serverport = static_cast<uint16_t>(port);
// 1. 创建UDP Socket
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr
<< "socket error: "
<< std::strerror(errno)
<< std::endl;
return 2;
}
std::cout
<< "create socket success: "
<< sock
<< std::endl;
// 2. 准备服务器网络地址
struct sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
int ret = inet_pton(
AF_INET,
serverip.c_str(),
&server.sin_addr
);
if (ret != 1)
{
std::cerr << "invalid server IP\n";
close(sock);
return 1;
}
// 3. 交互式发送与接收
while (true)
{
std::string inbuffer;
std::cout << "Please Enter# ";
if (!std::getline(std::cin, inbuffer))
{
break;
}
if (inbuffer == "quit")
{
break;
}
// 发送UDP数据报
ssize_t n = sendto(
sock,
inbuffer.data(),
inbuffer.size(),
0,
reinterpret_cast<struct sockaddr*>(&server),
sizeof(server)
);
if (n < 0)
{
std::cerr
<< "sendto error: "
<< std::strerror(errno)
<< std::endl;
break;
}
// 等待服务器回显
char buffer[1024];
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t m = recvfrom(
sock,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
if (m >= 0)
{
std::string response(
buffer,
static_cast<size_t>(m)
);
std::cout
<< "server echo# "
<< response
<< std::endl;
}
else
{
std::cerr
<< "recvfrom error: "
<< std::strerror(errno)
<< std::endl;
break;
}
}
close(sock);
return 0;
}
这里使用:
inet_pton()
转换 IPv4 字符串。
与较早的:
inet_addr()
相比,它提供了更明确的地址解析成功与失败判断方式。
十三、编写Makefile
CXX := g++
CXXFLAGS := -std=c++11 -Wall -Wextra
.PHONY: all clean
all: udp_server udp_client
udp_server: ServerMain.cc
$(CXX) $(CXXFLAGS) -o $@ $^
udp_client: UdpClient.cc
$(CXX) $(CXXFLAGS) -o $@ $^
clean:
rm -f udp_server udp_client
其中编译命令前使用 Tab。
进入项目目录:
make
生成:
udp_server
udp_client
十四、运行V1 Echo Server
14.1 服务器终端
执行:
./udp_server 8888
可能输出:
socket success, sockfd: 3
bind success, port: 8888
此时服务器开始等待数据。
14.2 客户端终端
执行:
./udp_client 127.0.0.1 8888
输入:
hello
客户端可能输出:
Please Enter# hello
server echo# hello
再次输入:
Linux UDP Socket
可以得到:
server echo# Linux UDP Socket
14.3 服务器输出
服务器可能显示:
[127.0.0.1:52341]# hello
[127.0.0.1:52341]# Linux UDP Socket
注意:
52341
只是示例临时端口。
实际端口由系统选择,不要求与示例一致。
14.4 为什么服务器知道客户端端口?
因为:
recvfrom()
在返回数据的同时,通过:
peer
提供发送方地址。
然后:
InetAddr addr(peer);
就可以提取:
客户端IP
客户端Port
14.5 如何查看UDP Socket?
可以使用:
ss -lun
其中:
-u:UDP
-l:显示监听或相应接收状态的Socket
-n:数字形式显示地址与端口
也可以执行:
ss -uan
查看更多 UDP Socket 信息。
例如可能看到:
0.0.0.0:8888
表示对应 Socket 绑定在 IPv4 通配地址的 8888 端口。

十五、从Echo Server升级为网络字典服务器
现在已经实现:
Client发送内容
|
v
Server接收内容
|
v
Server原样返回
但回显服务器只是在验证 UDP 通信链路。
如果希望服务器具有实际业务功能,可以让服务器接收到英文单词以后,返回对应的中文解释。
例如:
Client发送:apple
Server返回:苹果
这种程序可以称为:
网络字典服务器(DictServer)。
15.1 为什么不直接修改Start中的业务代码?
例如:
void Start()
{
while (true)
{
recvfrom(...);
// 直接写字典查询
sendto(...);
}
}
这样虽然能够完成任务,但会导致:
UDP通信逻辑
和:
字典查询逻辑
紧密耦合。
以后如果要改成:
计算器服务器
或者:
字符串处理服务器
就需要不断修改 UdpServer。
因此我们希望:
让UdpServer只负责网络通信,具体如何处理请求由外部业务函数决定。
这就需要回调函数。
十六、V2:设计Dict类
首先准备本地字典文件。
dict.txt:
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天
这里使用:
英文: 中文
组织数据。
注意冒号后面存在一个空格。
16.1 为什么需要加载到内存?
一种最直接的做法是:
收到单词
|
v
打开dict.txt
|
v
逐行查找
|
v
返回翻译
但如果每收到一个请求就重新打开文件、遍历文件,就会产生重复的文件 IO。
对于这个小型字典,更合理的做法是:
服务器启动
|
v
一次性加载字典文件
|
v
使用内存中的哈希表进行查询
16.2 使用unordered_map
定义:
std::unordered_map<
std::string,
std::string
> _dict;
其中:
Key:英文单词
Value:中文翻译
例如:
_dict["apple"] = "苹果";
以后查询:
_dict.find("apple");
就可以找到对应翻译。
16.3 Dict.hpp完整实现
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
class Dict
{
private:
void LoadDict()
{
std::ifstream in(_confpath);
if (!in.is_open())
{
std::cerr
<< "open dict file error: "
<< _confpath
<< std::endl;
return;
}
const std::string sep = ": ";
std::string line;
while (std::getline(in, line))
{
if (line.empty())
{
continue;
}
std::size_t pos = line.find(sep);
if (pos == std::string::npos)
{
continue;
}
std::string key =
line.substr(0, pos);
std::string value =
line.substr(pos + sep.size());
_dict.insert(
std::make_pair(key, value)
);
}
}
public:
explicit Dict(const std::string& confpath)
: _confpath(confpath)
{
LoadDict();
}
std::string Translate(
const std::string& key
) const
{
auto iter = _dict.find(key);
if (iter == _dict.end())
{
return "Unknown";
}
return iter->second;
}
private:
std::string _confpath;
std::unordered_map<
std::string,
std::string
> _dict;
};
16.4 line.find(sep)是什么意思?
例如:
std::string line = "apple: 苹果";
调用:
auto pos = line.find(": ");
会找到:
冒号和空格
在字符串中的起始位置。
然后:
line.substr(0, pos);
提取:
apple
而:
line.substr(pos + sep.size());
提取:
苹果
这样就把一行文本拆成:
Key
Value
两个部分。
16.5 为什么使用find而不是operator[]?
因为:
_dict[key]
在 Key 不存在时,可能插入一个默认构造的值。
而查字典只需要查询,不应该因为查询一个不存在的单词就修改字典。
所以使用:
_dict.find(key);
更符合这里的业务语义。
【配图05:Dict读取dict.txt、解析分隔符并构建unordered_map】
(此处留白,后续插入图片)
十七、回调函数:怎样把通信与业务分离?
17.1 先定义业务函数
例如:
Dict gdict("./dict.txt");
void Execute(
const std::string& req,
std::string* resp
)
{
*resp = gdict.Translate(req);
}
其中:
req
表示客户端请求。
例如:
apple
而:
resp
用于保存响应。
例如:
苹果
17.2 为什么使用std::function?
C++11提供:
#include <functional>
可以定义:
using func_t = std::function<
void(const std::string&, std::string*)
>;
它表示一种可调用对象:
输入:请求字符串、响应字符串指针
返回:void
例如:
void Execute(
const std::string& req,
std::string* resp
);
就符合这个调用形式。
17.3 UdpServer增加回调成员
func_t _func;
构造时传入:
UdpServer(func_t func, uint16_t port)
: _func(func),
_port(port),
_sockfd(-1)
{
}
当服务器收到请求:
std::string request;
就执行:
std::string response;
_func(request, &response);
这样 UdpServer 不需要知道:
字典的具体数据结构
文件怎样加载
单词怎样翻译
它只需要按照约定调用业务函数即可。
17.4 V2服务端事件循环
核心代码变成:
void Start()
{
char buffer[1024];
while (true)
{
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
_sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<struct sockaddr*>(&peer),
&len
);
if (n < 0)
{
continue;
}
std::string req(
buffer,
static_cast<size_t>(n)
);
std::string resp;
// 业务处理
_func(req, &resp);
// 发送处理结果
sendto(
_sockfd,
resp.data(),
resp.size(),
0,
reinterpret_cast<struct sockaddr*>(&peer),
len
);
}
}
对比 V1:
sendto(..., 原始消息, ...);
V2:
_func(req, &resp);
sendto(..., resp, ...);
最大的区别不是网络接口变了,而是:
服务器的业务处理方式变成了可替换的回调函数。
十八、回调版UdpServer.hpp
下面给出可直接用于字典实验的实现。
它沿用前面的 nocopy.hpp 和 InetAddr.hpp。
#pragma once
#include <cstdint>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <iostream>
#include <string>
#include <functional>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "nocopy.hpp"
#include "InetAddr.hpp"
using func_t = std::function<
void(const std::string& req, std::string* resp)
>;
class UdpServer : public nocopy
{
public:
UdpServer(func_t func, uint16_t port = 8888)
: _port(port),
_sockfd(-1),
_func(func)
{
}
void Init()
{
_sockfd = socket(
AF_INET,
SOCK_DGRAM,
0
);
if (_sockfd < 0)
{
perror("socket");
std::exit(2);
}
struct sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(
_sockfd,
reinterpret_cast<sockaddr*>(&local),
sizeof(local)
) < 0)
{
perror("bind");
std::exit(3);
}
}
void Start()
{
char buffer[1024];
while (true)
{
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
_sockfd,
buffer,
sizeof(buffer),
0,
reinterpret_cast<sockaddr*>(&peer),
&len
);
if (n < 0)
{
if (errno == EINTR)
{
continue;
}
perror("recvfrom");
continue;
}
InetAddr addr(peer);
std::string req(
buffer,
static_cast<size_t>(n)
);
std::string resp;
_func(req, &resp);
std::cout
<< "[" << addr.PrintDebug() << "] "
<< req
<< " -> "
<< resp
<< std::endl;
if (sendto(
_sockfd,
resp.data(),
resp.size(),
0,
reinterpret_cast<sockaddr*>(&peer),
len
) < 0)
{
perror("sendto");
}
}
}
~UdpServer()
{
if (_sockfd >= 0)
{
close(_sockfd);
}
}
private:
uint16_t _port;
int _sockfd;
func_t _func;
};
十九、ServerMain.cc:把Dict作为业务处理器
#include <iostream>
#include <memory>
#include <string>
#include "UdpServer.hpp"
#include "Dict.hpp"
Dict gdict("./dict.txt");
void Execute(
const std::string& req,
std::string* resp
)
{
*resp = gdict.Translate(req);
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cerr
<< "Usage: "
<< argv[0]
<< " local_port"
<< std::endl;
return 1;
}
int value = std::stoi(argv[1]);
if (value <= 0 || value > 65535)
{
std::cerr << "invalid port\n";
return 1;
}
uint16_t port =
static_cast<uint16_t>(value);
std::unique_ptr<UdpServer> server(
new UdpServer(Execute, port)
);
server->Init();
server->Start();
return 0;
}
19.1 为什么使用unique_ptr?
这里:
std::unique_ptr<UdpServer>
用于管理服务器对象生命周期。
这种管理方式可以避免使用裸 new 后遗漏对应的 delete。
而且 UdpServer 管理 Socket 文件描述符,不适合被随意复制。
19.2 为什么选择函数回调?
因为:
Execute
可以独立实现业务处理。
例如现在是:
*resp = gdict.Translate(req);
以后也可以替换为:
*resp = "server echo: " + req;
或者其他业务函数。
UDP 通信主体不需要因此改变。
19.3 为什么不能只学习socket调用,而忽略封装?
因为实际开发中:
通信框架
与:
业务逻辑
通常需要进行职责分离。
回调函数就是一种简单而实用的解耦方式。

二十、测试DictServer
20.1 编译
当前目录:
dict_server/
├── nocopy.hpp
├── InetAddr.hpp
├── Dict.hpp
├── UdpServer.hpp
├── ServerMain.cc
├── UdpClient.cc
└── dict.txt
执行:
g++ ServerMain.cc \
-std=c++11 \
-Wall \
-Wextra \
-o dict_server
客户端可以复用 V1 的:
UdpClient.cc
编译:
g++ UdpClient.cc \
-std=c++11 \
-Wall \
-Wextra \
-o udp_client
20.2 启动字典服务器
./dict_server 8888
20.3 启动客户端
./udp_client 127.0.0.1 8888
输入:
apple
收到:
苹果
输入:
banana
收到:
香蕉
输入:
hello
收到:
你好
输入不存在的单词:
computer
返回:
Unknown
20.4 为什么客户端不需要修改?
因为客户端仍然按照:
发送请求
|
v
等待响应
|
v
打印响应
工作。
服务器只是把:
原样回显
替换成:
英译汉查询
所以客户端的基础 UDP 通信流程没有改变。
二十一、进一步封装:为什么需要UdpSocket类?
目前的服务器已经能够复用。
但观察:
UdpServer
可以发现它仍然直接使用:
socket()
bind()
recvfrom()
sendto()
close()
假设以后编写:
UDP聊天服务器
UDP监控服务
UDP消息转发服务
每个程序都重复处理这些基础接口,就会增加代码量。
因此,可以进一步封装:
UdpSocket
它负责统一提供:
Socket
Bind
RecvFrom
SendTo
Close
五种基础操作。
然后让:
UdpServer
UdpClient
共同使用 UdpSocket。
项目结构变为:
应用程序
|
v
UdpServer / UdpClient
|
v
UdpSocket
|
v
Linux Socket API
二十二、实现udp_socket.hpp
#pragma once
#include <cstdint>
#include <cerrno>
#include <cstdio>
#include <string>
#include <utility>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class UdpSocket
{
public:
UdpSocket()
: fd_(-1)
{
}
~UdpSocket()
{
Close();
}
UdpSocket(const UdpSocket&) = delete;
UdpSocket& operator=(const UdpSocket&) = delete;
bool Socket()
{
if (fd_ >= 0)
{
return false;
}
fd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (fd_ < 0)
{
perror("socket");
return false;
}
return true;
}
void Close()
{
if (fd_ >= 0)
{
close(fd_);
fd_ = -1;
}
}
bool Bind(
const std::string& ip,
uint16_t port
)
{
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
if (inet_pton(
AF_INET,
ip.c_str(),
&addr.sin_addr
) != 1)
{
return false;
}
int ret = bind(
fd_,
reinterpret_cast<sockaddr*>(&addr),
sizeof(addr)
);
if (ret < 0)
{
perror("bind");
return false;
}
return true;
}
bool RecvFrom(
std::string* buf,
std::string* ip = nullptr,
uint16_t* port = nullptr
)
{
char tmp[10240];
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
fd_,
tmp,
sizeof(tmp),
0,
reinterpret_cast<sockaddr*>(&peer),
&len
);
if (n < 0)
{
perror("recvfrom");
return false;
}
buf->assign(tmp, static_cast<size_t>(n));
if (ip != nullptr)
{
char ipbuf[INET_ADDRSTRLEN] = {};
const char* result = inet_ntop(
AF_INET,
&peer.sin_addr,
ipbuf,
sizeof(ipbuf)
);
if (!result)
{
return false;
}
*ip = ipbuf;
}
if (port != nullptr)
{
*port = ntohs(peer.sin_port);
}
return true;
}
bool SendTo(
const std::string& buf,
const std::string& ip,
uint16_t port
)
{
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
if (inet_pton(
AF_INET,
ip.c_str(),
&addr.sin_addr
) != 1)
{
return false;
}
ssize_t n = sendto(
fd_,
buf.data(),
buf.size(),
0,
reinterpret_cast<sockaddr*>(&addr),
sizeof(addr)
);
if (n < 0)
{
perror("sendto");
return false;
}
return static_cast<size_t>(n) == buf.size();
}
private:
int fd_;
};
22.1 为什么RecvFrom使用输出参数?
bool RecvFrom(
std::string* buf,
std::string* ip = nullptr,
uint16_t* port = nullptr
);
其中:
buf
用于输出实际收到的数据。
ip
用于输出发送方 IP。
port
用于输出发送方端口。
这样调用者可以选择:
RecvFrom(&message);
只获得数据。
也可以:
RecvFrom(&message, &remote_ip, &remote_port);
同时获得发送方地址。
22.2 为什么需要buf->assign(tmp,n)?
因为:
recvfrom()
提供的是数据字节数。
所以:
buf->assign(tmp, n);
能够根据实际字节数构造字符串。
不要求接收到的数据必须以:
'\0'
结尾。
22.3 为什么SendTo需要IP和Port?
因为 UDP 可以直接通过:
sendto()
指定数据报的目的地址。
因此封装:
SendTo(message, ip, port);
可以让上层代码更直接地表达:
把这条消息发给哪个网络端点?
22.4 这个封装有哪些边界?
这是一个用于学习的简化版本。
它还没有处理完整的 UDP 大数据报接收、截断检测、超时控制、重试策略和异常恢复。
例如:
char tmp[10240];
最多只能保存相应接收缓冲区允许的字节数。
如果实际 UDP 数据报大于缓冲区,普通 recvfrom() 可能只返回部分内容并丢弃剩余部分。
因此这种封装适合当前的小型文本实验,不等于完整通用的 UDP 网络库。
二十三、使用UdpSocket封装通用UdpServer
udp_server.hpp:
#pragma once
#include <cstdint>
#include <functional>
#include <iostream>
#include <string>
#include "udp_socket.hpp"
using Handler = std::function<
void(const std::string&, std::string* resp)
>;
class UdpServer
{
public:
UdpServer()
{
if (!sock_.Socket())
{
throw std::runtime_error(
"create UDP socket failed"
);
}
}
bool Start(
const std::string& ip,
uint16_t port,
Handler handler
)
{
if (!sock_.Bind(ip, port))
{
return false;
}
while (true)
{
std::string req;
std::string remote_ip;
uint16_t remote_port = 0;
if (!sock_.RecvFrom(
&req,
&remote_ip,
&remote_port
))
{
continue;
}
std::string resp;
// 业务回调
handler(req, &resp);
// 回复发送方
sock_.SendTo(
resp,
remote_ip,
remote_port
);
std::cout
<< "[" << remote_ip << ":"
<< remote_port << "] "
<< req << " -> " << resp
<< std::endl;
}
return true;
}
private:
UdpSocket sock_;
};
需要补充:
#include <stdexcept>
与直接使用系统调用的版本相比:
UdpServer
现在已经不需要直接调用:
recvfrom()
sendto()
因为这些操作都由:
UdpSocket
完成。
于是:
网络基础操作
和:
服务器事件循环
进一步分离。
二十四、使用UdpSocket封装UdpClient
udp_client.hpp:
#pragma once
#include <cstdint>
#include <stdexcept>
#include <string>
#include "udp_socket.hpp"
class UdpClient
{
public:
UdpClient(
const std::string& ip,
uint16_t port
)
: ip_(ip),
port_(port)
{
if (!sock_.Socket())
{
throw std::runtime_error(
"create UDP socket failed"
);
}
}
bool SendTo(const std::string& buf)
{
return sock_.SendTo(
buf,
ip_,
port_
);
}
bool RecvFrom(std::string* buf)
{
return sock_.RecvFrom(buf);
}
private:
UdpSocket sock_;
std::string ip_;
uint16_t port_;
};
这样客户端发送:
client.SendTo("apple");
接收:
std::string result;
client.RecvFrom(&result);
就不再需要每次手动构造:
sockaddr_in
24.1 为什么客户端保存服务器IP和Port?
因为在最简单的字典客户端中:
所有请求都发给同一个服务器
所以可以在构造客户端对象时保存:
ip_
port_
以后直接调用:
SendTo(word);
即可。
二十五、封装版英译汉服务器与客户端
25.1 dict_server.cc
#include <iostream>
#include <string>
#include "udp_server.hpp"
#include "Dict.hpp"
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cout
<< "Usage: "
<< argv[0]
<< " ip port"
<< std::endl;
return 1;
}
Dict dict("./dict.txt");
UdpServer server;
server.Start(
argv[1],
static_cast<uint16_t>(std::stoi(argv[2])),
[&dict](
const std::string& req,
std::string* resp
)
{
*resp = dict.Translate(req);
}
);
return 0;
}
25.2 dict_client.cc
#include <iostream>
#include <string>
#include "udp_client.hpp"
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cout
<< "Usage: "
<< argv[0]
<< " server_ip server_port"
<< std::endl;
return 1;
}
UdpClient client(
argv[1],
static_cast<uint16_t>(std::stoi(argv[2]))
);
while (true)
{
std::string word;
std::cout << "请输入单词# ";
if (!(std::cin >> word))
{
break;
}
if (word == "quit")
{
break;
}
if (!client.SendTo(word))
{
std::cerr << "send failed\n";
break;
}
std::string result;
if (!client.RecvFrom(&result))
{
std::cerr << "recv failed\n";
break;
}
std::cout
<< word << " 的意思是:"
<< result
<< std::endl;
}
return 0;
}
25.3 编译
g++ dict_server.cc \
-std=c++11 \
-Wall \
-Wextra \
-o dict_server
g++ dict_client.cc \
-std=c++11 \
-Wall \
-Wextra \
-o dict_client
25.4 启动服务器
因为当前:
UdpSocket::Bind()
接收的是 IP 字符串,所以使用:
./dict_server 0.0.0.0 8888
25.5 启动客户端
./dict_client 127.0.0.1 8888
输入:
apple
返回:
apple 的意思是:苹果
输入:
teacher
返回:
teacher 的意思是:老师

二十六、为什么要逐步封装,而不是一开始就设计完整框架?
观察整个开发过程。
第一阶段:直接使用系统调用
socket
bind
recvfrom
sendto
此阶段主要解决:
怎样让两台程序交换UDP数据报?
第二阶段:封装UdpServer
UdpServer
|
v
封装socket和bind
|
v
封装接收循环
此阶段主要解决:
怎样把服务器的网络处理流程组织起来?
第三阶段:引入回调函数
UdpServer
|
v
Handler
|
v
Dict::Translate
此阶段主要解决:
怎样把网络通信和具体业务解耦?
第四阶段:封装UdpSocket
业务程序
|
v
UdpServer / UdpClient
|
v
UdpSocket
|
v
Linux Socket API
此阶段主要解决:
怎样复用客户端和服务器共同依赖的UDP基础操作?
因此,封装并不是为了增加类的数量。
它的目的是:
让不同层次的代码承担清晰的职责,减少重复并提高复用性。
总结
本文通过两个版本的 UDP 服务器,逐步完成了 Linux Socket 编程入门。
V1 实现:
Echo Server
掌握:
socket()
bind()
recvfrom()
sendto()
close()
同时学习:
sockaddr_in
IP地址
Port
INADDR_ANY
网络字节序
V2 实现:
DictServer
通过:
std::unordered_map
保存英译汉字典。
再通过:
std::function
将网络通信与业务处理分离。
最后进一步封装:
UdpSocket
UdpServer
UdpClient
建立了比较清晰的代码结构。
整个学习过程可以概括为:
Linux Socket API
|
v
UDP Echo Server
|
v
InetAddr地址封装
|
v
UDP客户端
|
v
Dict字典业务
|
v
回调函数解耦
|
v
UdpSocket通用封装
|
v
可复用的UDP服务器与客户端
下一篇将继续在这些基础上实现:
UDP多人聊天室。
并进一步学习如何维护在线用户、使用 Route 转发消息、引入线程池,以及如何让客户端同时收发消息。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)