【Linux网络】Socket编程:UDP网络编程实现Echo Server
在上篇文章中,我们已经铺垫了一些前置知识,这一篇文章我们就来实现UDP网络编程,实现一个Echo Server,就是客户端给服务端发送一条消息,服务端接收后,再转发给客户端,回显出来
文章目录
- 1. 再识Socket
- 2. 框架
- 3. 初始化
- 3.1 socket系统调用——创建套接字
- 3.2 bind系统调用——绑定地址
- 4. 运行服务端
- 4.1 recvfrom系统调用——接收数据
- 4.2 sendto系统调用——发送数据
- 5. UdpServer.cc——服务端主程序
- 6. UdpClient.cc——客户端程序
1. 再识Socket
在前面的文章中,我们说过网络通信本质其实就是两个不同主机之间的进程间通信,我们知道在一个主机内进程间通信的方式有很多,例如:管道,共享内存,消息队列等等,那两个不同主机之间可以怎么通信呢?这里我们就来介绍一下最基础且通用的Socket通信
socket到底是什么?
上篇文章我们在理解socket时说了,把 IP + Port
叫做**套接字Socket
**。
其实说白了,socket就是通信端点。为什么这么说?
我们知道两个不同主机进行进程间通信,其实就是通过网络协议栈来进行数据交互(将数据进行封装和分用);并且网络属于操作系统,操作系统不相信任何用户,所以用户需要访问网络功能,就只能通过系统调用。而套接字Socket就是操作系统在给应用程序(进程)访问网络服务时的接口和端点,这样应用程序(进程)就可以通过调用这套接口(也就是通过这个端点),来利用网络协议栈进行数据交互了
所以说到底,一次网络连接是由两个端点组成。而每个端点就是一个套接字Socket,由 IP地址
和 端口号
唯一标识。那也就不难理解,通信的本质就是两个Socket之间的数据交换。
我们说过,Linux下一切皆文件,Socket也是一种特殊的文件类型,我们也可以像操作普通文件一样,通过文件描述符来操作它
具体表现为,当应用程序调用socket()函数时,操作系统内核会:
-
在内部创建一套数据结构,用于管理连接、缓冲区、状态等。
-
返回一个文件描述符(File Descriptor) 给应用程序。
不是说Socket是通信端点吗?怎么一会又接口,一会又特殊文件的呢?
-
在本质上:我们说它就是网络通信的端点,由IP地址和端口号唯一标识,这没毛病
-
在实现上:它是操作系统创建和管理的内核对象,应用程序通过文件描述符来操作它,所以说是一种特殊文件也没毛病
-
在功能上:操作系统内核实现了复杂的TCP/IP协议栈,用户不能直接操作这些协议栈,所以我们说它是一组API,是应用程序与网络协议栈之间的编程接口。这么说也没有毛病
-
在哲学上:我们认为它是一种抽象和解耦,它隐藏了网络协议的所有复杂性,让程序员可以用“打开-读-写-关闭”这种类似文件操作的简单模式来进行网络编程。那还说啥了,更没毛病了
用一句话来概括,Socket(套接字)是网络通信的端点,是操作系统提供给应用程序的一组编程接口(API),应用程序通过调用这套接口,就可以利用网络协议栈(如TCP/IP)进行网络通信。
2. 框架
我们先来实现一下服务端
#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 来标识唯一进程,同时引入之前实现的日志
3. 初始化
3.1 socket系统调用——创建套接字
创建 Socket 是网络通信的第一步,怎么创建呢?使用 socket()系统调用。
#include <sys/types.h>
#include <sys/socket.h>int socket(int domain, int type, int protocol);
功能:创建一个通信端点(套接字),返回一个文件描述符。
参数:
-
domain:协议族/地址族
-
AF_INET:IPv4 网络协议
-
AF_INET6:IPv6 网络协议
-
AF_UNIX 或 AF_LOCAL:本地进程间通信
-
-
type:套接字类型
-
SOCK_STREAM:面向连接的可靠字节流(TCP)
-
SOCK_DGRAM:无连接不可靠数据报(UDP)
-
SOCK_RAW:原始套接字,允许直接访问底层协议
-
-
protocol:通常设为 0,表示使用默认协议
返回值:成功返回套接字文件描述符,失败返回 -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;}
3.2 bind系统调用——绑定地址
接下来我们需要绑定socket信息,ip和port。
下面我们先来介绍一下bind():
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能:将套接字与一个本地地址(IP地址和端口号)绑定。
参数:
-
sockfd:socket() 返回的套接字描述符
-
addr:指向要绑定的地址结构体的指针
-
addrlen:地址结构体的长度
返回值:成功返回 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
:
-
作用:指定地址族(Address Family)。
-
取值:对于IPv4,必须设置为常量 AF_INET。就等于是告诉操作系统这个结构体使用的是IPv4的地址格式。
sin_port
:
-
作用:存储端口号。
-
类型:in_port_t 通常是 uint16_t(一个16位的无符号整数)。
-
关键点:端口号必须使用网络字节序(大端序)。我们通常写的端口号(如 80, 8080)是主机字节序(可能是大端或小端),所以需要用 htons() 函数进行转换。关于网络字节序在上篇文章中做过介绍
sin_addr
:
-
作用:存储IPv4地址。
-
类型:是一个 struct in_addr,它内部只有一个重要的成员 s_addr(一个32位的无符号整数)。
-
关键点:IP地址也必须以网络字节序存储。我们习惯用点分十进制字符串(如 “192.168.1.1”)表示IP地址,需要先用 inet_addr() 或 inet_pton() 函数将其转换为32位的网络字节序整数。
-
也可以设置为宏 INADDR_ANY(其值为 0),表示绑定所有可用的网络接口(网卡)。注意:这个特性我们下面会详细介绍
sin_zero[8]
:
-
作用:填充物,没有实际用途。
-
原因:struct sockaddr 是一个更通用、更抽象的网络地址结构体,许多Socket API函数(如 bind)的参数类型是 struct sockaddr *。为了兼容各种类型的地址结构(如 sockaddr_in for IPv4, sockaddr_in6 for IPv6),这些结构体的大小必须相同。sockaddr_in 结构体的大小是16字节,而前三个成员加起来只有 2 + 2 + 4 = 8 字节,所以需要8字节的 sin_zero 来凑数。
-
注意:在使用 sockaddr_in 之前,通常会用 memset() 或 bzero() 将整个结构体清零,这顺便就把 sin_zero 填充为0了。后面这两个函数我们都可以使用一下
那要如何填充服务端的sockaddr_in地址结构呢?
- 我们得要知道,服务端填充
sockaddr_in
的目的是绑定 (bind) 自己的网络接口和端口,宣告自己将在哪里"监听"客户端的连接请求。 - 首先
sin_family
,我们使用IPv4的地址格式,所以直接设为AF_INET - 对于
sin_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()); // TODOlocal.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端口,因为该端口广为人知且不易冲突,比较适合开发、测试、代理及内部服务等场景
4. 运行服务端
我们服务端运行,肯定是死循环在运行,没有特殊情况是不会退出的,所以这里我们还是使用一个标志位 _isrunning 来表示是否运行的状态,所以需要新增一个成员变量
服务端运行起来之后,得先接收客户端发来的数据,然后再转发回去,怎么接收,怎么转发呢?使用系统调用来进行接收和转发
4.1 recvfrom系统调用——接收数据
#include <sys/socket.h>ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
参数详解
int sockfd
-
作用:已经创建并绑定的套接字描述符
-
要求:首先必须是有效的套接字,通常已经通过 socket() 创建,对于服务端可能已经 bind(),这些工作我们已经做好了
void *buf
-
作用:指向接收数据缓冲区的指针
-
注意:缓冲区必须足够大以容纳接收的数据
size_t len
-
作用:缓冲区的最大长度(字节数)
-
注意:如果接收的数据大于此长度,多余的数据会被丢弃(UDP)或保留在协议栈中(TCP)
int flags
-
作用:控制接收行为的标志位
-
常用值:
-
0:默认行为,阻塞接收
-
MSG_DONTWAIT:非阻塞模式,如果没有数据立即返回
-
MSG_PEEK:窥视数据,数据会被复制到缓冲区但不从接收队列中移除
-
MSG_WAITALL:等待所有请求的数据到达(主要用于TCP)
-
我们这里直接设为0就可以了,默认阻塞
struct sockaddr *src_addr
-
作用:指向存放发送方地址信息的结构体指针
-
类型:通常是 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)
-
特殊值:如果设为 NULL,表示不关心发送方地址
socklen_t *addrlen
-
作用:输入输出参数
-
输入时:指向的值表示 src_addr 缓冲区的长度
-
输出时:会被设置为实际写入的地址结构体长度
-
重要:调用前必须初始化为 src_addr 缓冲区的实际大小
返回值
-
成功:返回接收到的字节数
-
失败:返回 -1,并设置 errno
-
连接关闭(TCP):返回 0
-
无数据(非阻塞模式):返回 -1,errno 为 EAGAIN 或 EWOULDBLOCK
4.2 sendto系统调用——发送数据
#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 sockfd
-
作用:已经创建的套接字描述符
-
要求:必须是有效的套接字,对于UDP通常已经创建但不需要连接
const void *buf
-
作用:指向要发送数据缓冲区的指针
-
类型:可以是任意类型数据的指针
-
注意:数据在发送过程中不会被修改
size_t len
-
作用:要发送数据的长度(字节数)
-
注意:对于UDP,数据长度不应超过MTU(通常1500字节),否则可能被分片或丢弃
- i**
nt flags
**
-
作用:控制发送行为的标志位
-
常用值:
-
0:默认行为,阻塞发送
-
MSG_DONTWAIT:非阻塞模式,如果发送缓冲区满立即返回
-
MSG_OOB:发送带外数据(TCP紧急数据)
-
MSG_NOSIGNAL:发送失败时不产生SIGPIPE信号
-
同样这里我们也直接填0就行了,默认阻塞发送
const struct sockaddr *dest_addr
-
作用:指向目标地址信息的结构体指针
-
类型:通常是 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)
-
重要:必须正确填写目标地址和端口
socklen_t addrlen
- 作用:目标地址结构体的长度
返回值
-
成功:返回实际发送的字节数(可能小于请求的 len)
-
失败:返回 -1,并设置 errno
-
注意事项:UDP发送成功只表示数据已交给协议栈,不保证对方收到
收到客户端发来的信息之后,我们可以拿到客户端的地址结构信息(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);}}}}
5. UdpServer.cc——服务端主程序
我们肯定需要通过命令行来输入我们要绑定的端口号信息,所以就需要用到命令行参数。
#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
由于我们客户端代码还没写,所以会阻塞等待接收数据。
6. UdpClient.cc——客户端程序
我们客户端就不单独封装一个类了,直接在主程序中实现。
首先客户端访问目标服务器需要知道什么?
那肯定需要知道服务器的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和portif(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)); // 这里使用memsetserver.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和portif(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)); // 这里使用memsetserver.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把结果转发回去就可以了。