在上篇文章中,我们已经铺垫了一些前置知识,这一篇文章我们就来实现UDP网络编程,实现一个Echo Server,就是客户端给服务端发送一条消息,服务端接收后,再转发给客户端,回显出来
在前面的文章中,我们说过网络通信本质其实就是两个不同主机之间的进程间通信,我们知道在一个主机内进程间通信的方式有很多,例如:管道,共享内存,消息队列等等,那两个不同主机之间可以怎么通信呢?这里我们就来介绍一下最基础且通用的Socket通信
socket到底是什么?
上篇文章我们在理解socket时说了,把 IP + Port 叫做**套接字Socket**。
其实说白了,socket就是通信端点。为什么这么说?
我们知道两个不同主机进行进程间通信,其实就是通过网络协议栈来进行数据交互(将数据进行封装和分用);并且网络属于操作系统,操作系统不相信任何用户,所以用户需要访问网络功能,就只能通过系统调用。而套接字Socket就是操作系统在给应用程序(进程)访问网络服务时的接口和端点,这样应用程序(进程)就可以通过调用这套接口(也就是通过这个端点),来利用网络协议栈进行数据交互了

所以说到底,一次网络连接是由两个端点组成。而每个端点就是一个套接字Socket,由 IP地址 和 端口号 唯一标识。那也就不难理解,通信的本质就是两个Socket之间的数据交换。
我们说过,Linux下一切皆文件,Socket也是一种特殊的文件类型,我们也可以像操作普通文件一样,通过文件描述符来操作它
具体表现为,当应用程序调用socket()函数时,操作系统内核会:
不是说Socket是通信端点吗?怎么一会又接口,一会又特殊文件的呢?
用一句话来概括,Socket(套接字)是网络通信的端点,是操作系统提供给应用程序的一组编程接口(API),应用程序通过调用这套接口,就可以利用网络协议栈(如TCP/IP)进行网络通信。
我们先来实现一下服务端
#pragma once
#include <iostream>
#include <string>
#include "Log.hpp"
using namespace LogModule;
class UdpServer
{
public:
UdpServer(const std::string& ip, uint16_t port)
:_socketfd(-1), _ip(ip), _port(port)
{}
~UdpServer() {}
private:
int _socketfd;
std::string _ip; // 用的是字符串风格,点分十进制
uint16_t _port; // 端口号
};我们需要 ip + port 来标识唯一进程,同时引入之前实现的日志
创建 Socket 是网络通信的第一步,怎么创建呢?使用 socket()系统调用。
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);功能:创建一个通信端点(套接字),返回一个文件描述符。 参数:
返回值:成功返回套接字文件描述符,失败返回 -1
注意:参数protocol设为0,表示使用默认协议的意思就是通过前面两个参数来判断选择哪个协议(TCP还是UDP)
这里我们直接创建(使用udp协议),如果失败就直接退出,成功就打印一条正常信息的日志
void Init()
{
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_socketfd < 0)
{
LOG(LogLevel::FATAL) << "socket error";
exit(1);
}
LOG(LogLevel::INFO) << "socket success, socketfd: " << _socketfd;
}接下来我们需要绑定socket信息,ip和port。 下面我们先来介绍一下bind():
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);功能:将套接字与一个本地地址(IP地址和端口号)绑定。
参数:
返回值:成功返回 0,失败返回 -1
这里我们需要绑定一个IPv4的地址结构
那么我们就详细认识一下struct sockaddr_in结构体,上篇文章中我们就介绍过sockaddr_in用于网络通信,它的结构主要分为三部分:地址类型、端口号、IP地址
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family; // 地址族(Address Family):AF_INET(表示IPv4)
in_port_t sin_port; // 16位的端口号(Port number),需要使用网络字节序
struct in_addr sin_addr; // 32位的IPv4地址
char sin_zero[8]; // 填充字段,通常置为零(用于保持与 `struct sockaddr` 大小一致)
};
// in_addr 结构体的定义(用于存储IPv4地址)
struct in_addr {
uint32_t s_addr; // 32位的IPv4地址(以网络字节序存储)
};
成员详细解释
sin_family:sin_port:sin_addr:sin_zero[8]:那要如何填充服务端的sockaddr_in地址结构呢?
sockaddr_in 的目的是绑定 (bind) 自己的网络接口和端口,宣告自己将在哪里"监听"客户端的连接请求。sin_family,我们使用IPv4的地址格式,所以直接设为AF_INETsin_port,需要填充端口号,需要使用网络字节序,所以要使用主机字节序转网络字节数的函数,即htons()函数sin_addr,就是填充ip地址,同样也需要网络字节序来存储,但是我们习惯使用点分十进制来表示ip,而不管是网络字节序中存储的,还是主机字节序中存储的,都不是以点分十进制的方式,点分十进制是ip地址的字符串表示形式,而字节序是以二进制的形式存储的,所以我们也需要转换,这个工作我们可以自己做,但是没必要,已经有现成的函数供我们使用,我们可以使用inet_addr()函数#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp); // 字符串 → 网络字节序整数初始化代码如下:
void Init()
{
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_socketfd < 0)
{
LOG(LogLevel::FATAL) << "socket error";
exit(1);
}
LOG(LogLevel::INFO) << "socket success, socketfd: " << _socketfd;
// 填充sockaddr_in结构体
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // TODO
// 绑定IPv4地址结构
int n = bind(_socketfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
LOG(LogLevel::INFO) << "bind success, sockfd : " << _socketfd;
}绑定ip这里我们还有一个小问题,未来我们可以有多个客户端访问我们的服务端,不论哪个客户端向我们服务端发送消息,我们都应该转发回去,但是服务器可能有多个ip,我们这里服务器直接绑死了一个ip地址,那客户端就只能与该ip进行发送数据,其他ip不行(服务器一般都会有公网ip和内网ip)。那应该怎么做呢?
没错,我们上面已经提到过,可以将 sin_addr 设置为宏 INADDR_ANY(其值为 0),这表示服务器愿意接受从任何可用网络接口 (网卡) 发来的连接,如果服务器有多个ip,使用 INADDR_ANY 可以同时监听所有IP,只有在服务器只想监听特定网络接口时,才会指定具体的IP地址,所以我们不需要特意去绑定ip,那么成员变量_ip也就不需要了
所以现在服务端代码如下:
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
using namespace LogModule;
class UdpServer
{
public:
UdpServer(uint16_t port)
:_socketfd(-1), _port(port)
{}
void Init()
{
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_socketfd < 0)
{
LOG(LogLevel::FATAL) << "socket error";
exit(1);
}
LOG(LogLevel::INFO) << "socket success, socketfd: " << _socketfd;
// 填充sockaddr_in结构体
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
//local.sin_addr.s_addr = inet_addr(_ip.c_str()); // TODO
local.sin_addr.s_addr = INADDR_ANY;
// 绑定IPv4地址结构
int n = bind(_socketfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(2);
}
LOG(LogLevel::INFO) << "bind success, sockfd : " << _socketfd;
}
~UdpServer() {}
private:
int _socketfd;
// std::string _ip; // 用的是字符串风格,点分十进制
uint16_t _port; // 端口号
};那端口号绑定多少呢?
首先我们知道0~1023是固定端口,所以这里我们需要有一个所有人已知的端口号,这样才能让客户端找到我,所以我们可以选择一个1024到65535的端口进行绑定,这里我们可以绑定一个8080端口,因为该端口广为人知且不易冲突,比较适合开发、测试、代理及内部服务等场景
我们服务端运行,肯定是死循环在运行,没有特殊情况是不会退出的,所以这里我们还是使用一个标志位 _isrunning 来表示是否运行的状态,所以需要新增一个成员变量
服务端运行起来之后,得先接收客户端发来的数据,然后再转发回去,怎么接收,怎么转发呢?使用系统调用来进行接收和转发
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);参数详解
int sockfdvoid *bufsize_t lenint flags我们这里直接设为0就可以了,默认阻塞
struct sockaddr *src_addrsocklen_t *addrlen返回值
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);参数和recvfrom差不多
参数详解
int sockfdconst void *bufsize_t lennt flags**同样这里我们也直接填0就行了,默认阻塞发送
const struct sockaddr *dest_addrsocklen_t addrlen返回值
收到客户端发来的信息之后,我们可以拿到客户端的地址结构信息(IP、端口号等信息),等转发回去时,就需要目的地址结构(转发到哪个客户端),这个时候就可以使用拿到的客户端地址结构
为什么可以拿到客户端的地址结构信息呢?
简单来说,服务端通过 recvfrom这个系统调用,不仅能收到客户端发来的数据,还能自动获取到客户端的地址和端口信息,然后它就可以用 sendto再把这个信息作为目标地址,将数据转发回客户端。
自动获取?到底怎么拿到的呢?
那这里就不得不提一下recvfrom的两个关键参数了:
src_addr: 这是一个 指向 struct sockaddr 的指针。当这个参数不是一个空指针时,recvfrom函数会在接收到数据后,自动将发送方(即客户端)的协议族、IP地址和端口号等信息填充到这个结构体中。addrlen: 这是一个输入输出型参数。在调用时,你需要将它初始化为 src_addr所指向结构体的实际长度。当函数返回时,它会被设置为实际存储在 src_addr中的地址信息的长度。代码如下:
void Start()
{
_isrunning = true;
while(_isrunning)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_socketfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
// 服务端需要知道客户端的ip和端口号
uint16_t peer_port = ntohs(peer.sin_port); // 从网络中拿到的数据
std::string peer_ip = inet_ntoa(peer.sin_addr); // 网络字节序转点分十进制
buffer[n] = 0;
LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port<< "]# " << buffer; // 客户端发送的消息内容
// 转发回去
std::string result = "Server echo# ";
result += buffer;
ssize_t m = sendto(_socketfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, len);
if(n < 0)
{
LOG(LogLevel::FATAL) << "sendto error";
exit(3);
}
}
}
}我们肯定需要通过命令行来输入我们要绑定的端口号信息,所以就需要用到命令行参数。
#include "UdpServer.hpp"
// ./udpserver port
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]);
Enable_Console_Log_Strategy();
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port);
usvr->Init();
usvr->Start();
return 0;
}我们运行测试一下:
ltx@qsy:~/gitLinux/Linux_network/SocketUDP/EchoServer$ ./udpserver
Usage: ./udpserver port
ltx@qsy:~/gitLinux/Linux_network/SocketUDP/EchoServer$ ./udpserver 8080
[2025-09-22 23:25:22] [INFO] [1349557] [UdpServer.hpp] [28] - socket success, socketfd: 3
[2025-09-22 23:25:22] [INFO] [1349557] [UdpServer.hpp] [45] - bind success, sockfd : 3
^C由于我们客户端代码还没写,所以会阻塞等待接收数据。
我们客户端就不单独封装一个类了,直接在主程序中实现。
首先客户端访问目标服务器需要知道什么?
那肯定需要知道服务器的ip和端口号
那我怎么知道服务器的ip和端口?
因为客户端和服务端肯定是一家公司写的,他自己就知道,就像我们现在实现Echo Server,客户端和服务端都是我自己写的,我能不知道服务器的ip和端口嘛!!!
代码如下:
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
using namespace LogModule;
// ./udpclient server_ip server_port
int main(int argc, char* argv[])
{
// 客户端需要绑定服务器的ip和port
if(argc != 3)
{
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
Enable_Console_Log_Strategy();
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)
{
LOG(LogLevel::FATAL) << "socket error";
return 2;
}
return 0;
}客户端创建套接字之后,需不需要绑定呢?
首先肯定需要绑定,但是不需要我们手动绑定,操作系统会自己给我们绑定,在客户端第一次发送数据时,操作系统自动为它分配一个可用的临时端口(Ephemeral Port),这个过程称为“隐式绑定”或“动态绑定”。
为什么不需要我们手动绑定呢?而是让操作系统来给我们自动绑定?有啥说法吗?
首先一个端口号,只能被一个进程bind,所以为了避免client端口冲突,我们首次调用 sendto()(UDP) 时,操作系统会自动为我们从临时端口范围(通常是 1024 到 65535)中选择一个可用的端口号,与客户端的ip地址进行绑定。如果是我们自己去绑定的话,我们不知道哪个端口有没有被绑定,这样就可能造成端口冲突。所以,客户端的端口号是几,不重要,只要是唯一的就行!
那我们客户端只需要填充 sockaddr_in 地址结构就可以了,因为我们的目的是给服务器发送数据,所以就需要知道服务器的ip和端口,那我们要把这些信息填入到地址结构 sockaddr_in 中
代码如下:
// 填写服务器信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server)); // 这里使用memset
server.sin_family = AF_INET;
server.sin_port = htons(server_port); // 转成网络字节序
server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 字符串->网络字节序知道服务器的ip和端口了,就可以给服务器发送数据了,然后再接收服务器转发回来的数据,在终端上回显出来
客户端完整代码如下:
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
using namespace LogModule;
// ./udpclient server_ip server_port
int main(int argc, char* argv[])
{
// 客户端需要绑定服务器的ip和port
if(argc != 3)
{
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
Enable_Console_Log_Strategy();
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)
{
LOG(LogLevel::FATAL) << "socket error";
return 2;
}
// 不需要手动绑定ip和端口,操作系统会分配一个临时端口与ip进行绑定
// 填写服务器信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server)); // 这里使用memset
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 input;
LOG(LogLevel::INFO) << "Client Enter# ";
std::getline(std::cin, input);
// 发送数据给服务器
ssize_t n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));
if(n < 0)
{
LOG(LogLevel::FATAL) << "sendto error";
return 3;
}
// 接收服务器转发回来的数据并回显在控制台上
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(m > 0)
{
buffer[m] = 0;
LOG(LogLevel::INFO) << buffer;
}
}
return 0;
}我们登录云服务器的ip一般都是公网ip,我们也可以通过ifconfig来查看自己的私有ip,下面我们来介绍一个ip——本地环回

运行结果:

客户端也可以使用服务器的其他ip进行收发数据,这里就不再演示了
不过这里要提一下,我们无法bind公网ip,因为公网ip其实没有配置到我们的ip上。
今天我们是通过网络通信来实现Echo Server,后面我们还可以对这段代码进行层状设计,我们客户端把数据发送给服务端,让服务端在接收数据后,回调交给上层去处理,然后再把处理后的结果再发送给客户端。这里就能体会到一个简易的分层设计,UDP只负责网络通信,数据怎么处理交给上层,上层处理好之后再把结果回调回来,再由UDP把结果转发回去就可以了。