文章目录
-
- [UDP Socket编程实战(一):Echo Server从零到一](#UDP Socket编程实战(一):Echo Server从零到一)
- 一、项目结构与辅助模块
-
- [1.1 代码结构](#1.1 代码结构)
- [1.2 nocopy.hpp:禁止拷贝](#1.2 nocopy.hpp:禁止拷贝)
- [1.3 Comm.hpp:公共枚举](#1.3 Comm.hpp:公共枚举)
- [1.4 InetAddr.hpp:地址封装](#1.4 InetAddr.hpp:地址封装)
- 二、服务器代码拆解
-
- [2.1 看UdpServer之前先理思路](#2.1 看UdpServer之前先理思路)
- [2.2 类定义和成员变量](#2.2 类定义和成员变量)
- [2.3 Init():创建socket和绑定端口](#2.3 Init():创建socket和绑定端口)
-
- [2.3.1 socket() 的三个参数](#2.3.1 socket() 的三个参数)
- [2.3.2 sockaddr_in 的填充](#2.3.2 sockaddr_in 的填充)
- [2.3.3 bind() 的强制转换](#2.3.3 bind() 的强制转换)
- [2.4 Start():收消息和回显](#2.4 Start():收消息和回显)
-
- [2.4.1 recvfrom 逐参数解析](#2.4.1 recvfrom 逐参数解析)
- [2.4.2 sendto 逐参数解析](#2.4.2 sendto 逐参数解析)
- [2.4.3 buffer[n] = 0 是干嘛的](#2.4.3 buffer[n] = 0 是干嘛的)
- 三、客户端代码拆解
-
- [3.1 客户端的整体流程](#3.1 客户端的整体流程)
- [3.2 启动和参数解析](#3.2 启动和参数解析)
- [3.3 创建socket和填充服务器地址](#3.3 创建socket和填充服务器地址)
- [3.4 发送和接收循环](#3.4 发送和接收循环)
- 四、客户端为什么不用显式bind
-
- [4.1 这个问题很多新手都会问](#4.1 这个问题很多新手都会问)
- [4.2 为什么要让OS自动分配](#4.2 为什么要让OS自动分配)
- [4.3 自动bind发生在哪里](#4.3 自动bind发生在哪里)
- 五、地址转换函数详解
-
- [5.1 为什么需要地址转换](#5.1 为什么需要地址转换)
- [5.2 字符串 → in_addr](#5.2 字符串 → in_addr)
-
- [5.2.1 inet_addr(简单版)](#5.2.1 inet_addr(简单版))
- [5.2.2 inet_pton(严格版)](#5.2.2 inet_pton(严格版))
- [5.3 in_addr → 字符串](#5.3 in_addr → 字符串)
-
- [5.3.1 inet_ntoa(简单版,但有坑)](#5.3.1 inet_ntoa(简单版,但有坑))
- [5.3.2 inet_ntop(线程安全版)](#5.3.2 inet_ntop(线程安全版))
- [5.4 四个函数对比总结](#5.4 四个函数对比总结)
- [六、INADDR_ANY 专题](#六、INADDR_ANY 专题)
-
- [6.1 为什么服务器要用 INADDR_ANY](#6.1 为什么服务器要用 INADDR_ANY)
- 七、本篇总结
-
- [7.1 核心要点](#7.1 核心要点)
- [7.2 容易混淆的点](#7.2 容易混淆的点)
UDP Socket编程实战(一):Echo Server从零到一
💬 开篇:前三篇讲清楚了协议分层、数据传输流程和Socket的基础概念。从这篇开始,我们正式动手写代码。第一个项目是Echo Server------客户端发什么,服务器就原原本本地回来什么。听起来很简单,但这个过程中会涉及socket创建、地址绑定、数据收发、地址转换等核心操作,把这些写得熟练了,后面所有UDP项目都是在此基础上扩展。
👍 点赞、收藏与分享:这篇会逐行拆解Echo Server和Client的代码,还会讲清楚地址转换函数的细节。如果对你有帮助,请点赞收藏!
🚀 循序渐进:从代码结构到逐行分析,从服务器到客户端,从常规用法到易错点,一步步把UDP编程的基础打稳。
一、项目结构与辅助模块
1.1 代码结构
正式写Echo Server之前,先把项目的文件结构理清楚。这个项目用了几个辅助头文件,理解它们的作用,后面看主代码就不会迷。
项目文件列表:
bash
.
├── nocopy.hpp // 禁止拷贝的基类
├── Log.hpp // 日志模块(已有,不重复)
├── Comm.hpp // 公共枚举和常量
├── InetAddr.hpp // 地址封装类
├── UdpServer.hpp // UDP服务器核心
└── UdpClient.cpp // UDP客户端(main在这里)
1.2 nocopy.hpp:禁止拷贝
cpp
#pragma once
#include <iostream>
class nocopy
{
public:
nocopy(){}
nocopy(const nocopy &) = delete; // 删除拷贝构造
const nocopy& operator = (const nocopy &) = delete; // 删除拷贝赋值
~nocopy(){}
};
这个类的唯一目的就是禁止拷贝。UdpServer会继承它,这样如果你不小心写了 UdpServer s2 = s1;,编译器直接报错。
为什么要禁止拷贝?服务器对象内部持有一个 _sockfd(文件描述符),如果被拷贝,两个对象会共享同一个fd,关闭一个的时候另一个就崩了。这是网络编程中非常常见的一个坑,用nocopy预防。
1.3 Comm.hpp:公共枚举
cpp
#pragma once
enum{
Usage_Err = 1,
Socket_Err,
Bind_Err
};
用枚举定义退出码。socket失败退出码是2,bind失败退出码是3。这样看进程退出码就能马上知道哪一步出问题了,比全都返回-1要方便排查。
1.4 InetAddr.hpp:地址封装
cpp
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class InetAddr
{
public:
InetAddr(struct sockaddr_in &addr):_addr(addr)
{
_port = ntohs(_addr.sin_port); // 网络序 → 主机序
_ip = inet_ntoa(_addr.sin_addr); // in_addr → 点分十进制字符串
}
std::string Ip() {return _ip;}
uint16_t Port() {return _port;};
std::string PrintDebug()
{
std::string info = _ip;
info += ":";
info += std::to_string(_port); // 拼接成 "127.0.0.1:4444" 的格式
return info;
}
~InetAddr(){}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
这个类做的事很简单:把 sockaddr_in 转成人类可以看懂的格式。构造函数里就完成了转换,后面只需要调 Ip()、Port() 或者 PrintDebug() 就行。
注意构造函数里的两个转换:
ntohs:把端口号从网络字节序转回主机序,这样打印出来的才是正确的端口号inet_ntoa:把in_addr结构体转成点分十进制字符串,比如"192.168.1.100"
关于 inet_ntoa 的线程安全问题,后面在地址转换函数的章节会专门讲。
二、服务器代码拆解
2.1 看UdpServer之前先理思路
UDP服务器的工作流程很直线:创建socket → 绑定端口 → 进入循环收消息 → 把消息原封不动发回去。没有连接、没有握手,就是这么简单。但每一步里的细节都值得仔细看一遍。
2.2 类定义和成员变量
cpp
const static uint16_t defaultport = 8888;
const static int defaultfd = -1;
const static int defaultsize = 1024;
class UdpServer : public nocopy // 继承nocopy,禁止拷贝
{
private:
uint16_t _port; // 监听的端口号
int _sockfd; // socket文件描述符,-1表示未创建
};
_sockfd 初始化为 -1,这是一个约定:-1表示"还没创建"。后面每次用之前都可以先判断是否为-1来知道状态。
2.3 Init():创建socket和绑定端口
cpp
void Init()
{
// 第一步:创建socket
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
lg.LogMessage(Fatal, "socket errr, %d : %s\n", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Info, "socket success, sockfd: %d\n", _sockfd);
// 第二步:绑定端口
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 清零,养成习惯
local.sin_family = AF_INET; // IPv4
local.sin_port = htons(_port); // 端口号转网络序
local.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n != 0)
{
lg.LogMessage(Fatal, "bind errr, %d : %s\n", errno, strerror(errno));
exit(Bind_Err);
}
}
逐行看:
2.3.1 socket() 的三个参数
cpp
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
AF_INET:IPv4地址族SOCK_DGRAM:数据报类型,对应UDP。如果是TCP就写SOCK_STREAM0:让系统根据前两个参数自动选择协议,对于AF_INET + SOCK_DGRAM,系统会自动选UDP
返回值是一个文件描述符(fd),本质上就是一个整数。操作系统用这个fd来标识这个网络连接,后面所有收发操作都围绕着这个fd展开。
2.3.2 sockaddr_in 的填充
cpp
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
这里有几个要注意的点:
bzero 为什么要清零? sockaddr_in 有一个8字节的填充区域 sin_zero,如果不清零,里面是随机数据。虽然正常情况下不影响功能,但养成清零的习惯可以避免一些奇怪的问题。
htons 是干嘛的? 把端口号从主机字节序转为网络字节序。前篇讲过,网络传输统一用大端。如果你的机器是小端(x86),不转的话端口号就错了。
INADDR_ANY 是什么意思? 值是0,表示监听所有网卡上的所有IP地址。如果服务器有多个网卡(比如既有内网又有外网),用 INADDR_ANY 就不用去纠结要监听哪个IP,所有的都收。
2.3.3 bind() 的强制转换
cpp
int n = ::bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
bind 的第二个参数类型是 struct sockaddr *(通用地址),但我们填的是 sockaddr_in(IPv4地址)。所以要强制转换。这个转换在前篇解释过,是C语言实现多态的方式。
注意前面的 ::bind,加了全局作用域限定符。这是为了明确调用系统调用的 bind,而不是类自身可能有的同名函数。养成这个习惯,避免名字冲突。
2.4 Start():收消息和回显
cpp
void Start()
{
char buffer[defaultsize]; // 接收缓冲区,1024字节
for (;;) // 永远循环,服务器不退出
{
struct sockaddr_in peer; // 存储发送方的地址
socklen_t len = sizeof(peer); // 地址长度
// 接收数据
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
InetAddr addr(peer); // 把地址封装起来
buffer[n] = 0; // 手动加'\0',变成C字符串
std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;
// 把数据原封不动发回去
sendto(_sockfd, buffer, strlen(buffer), 0,
(struct sockaddr *)&peer, len);
}
}
}
2.4.1 recvfrom 逐参数解析
cpp
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&peer, &len);
| 参数 | 含义 |
|---|---|
_sockfd |
从哪个socket收 |
buffer |
收进哪个缓冲区 |
sizeof(buffer) - 1 |
最多收多少字节。留1个字节给'\0' |
0 |
flags,一般写0 |
&peer |
收到数据后,把发送方的地址存到这里 |
&len |
传入时是地址结构体的大小,返回时是实际填充的大小 |
recvfrom 最重要的特点就是会把发送方的地址记录下来。这正好是UDP Echo Server需要的:收到消息,知道是谁发的,然后回复给他。
len 为什么不能乱写? 如果你写的值比 sizeof(sockaddr_in) 小,内核写地址的时候会截断或者报错。写 sizeof(peer) 就对了,别图省事。
2.4.2 sendto 逐参数解析
cpp
sendto(_sockfd, buffer, strlen(buffer), 0,
(struct sockaddr *)&peer, len);
| 参数 | 含义 |
|---|---|
_sockfd |
用哪个socket发 |
buffer |
发什么数据 |
strlen(buffer) |
发多少字节 |
0 |
flags,一般写0 |
&peer |
发给谁(之前recvfrom记录的地址) |
len |
目的地址的长度 |
注意这里用的 strlen(buffer) 而不是 sizeof(buffer)。因为 sizeof 是缓冲区的大小(1024),而实际数据长度是 n(或者说是 strlen 的结果)。发的时候只发实际有内容的部分。
2.4.3 buffer[n] = 0 是干嘛的
recvfrom 不会自动在收到的数据后面加'\0'。如果你想把 buffer 当C字符串用(比如打印、传给 strlen),必须手动加一个'\0'。这是C语言处理字符串时的经典细节,别忘了。
三、客户端代码拆解
3.1 客户端的整体流程
客户端比服务器简单一点:创建socket → 填充服务器地址 → 循环发消息、收回复。注意客户端不需要 bind,这个问题后面单独讲。
3.2 启动和参数解析
cpp
// 用法:./udp_client server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return 1;
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// ...
}
客户端从命令行参数里拿到服务器的IP和端口。这样就不用硬编码,方便测试不同环境。
3.3 创建socket和填充服务器地址
cpp
// 1. 创建socket
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
std::cerr << "socket error: " << strerror(errno) << std::endl;
return 2;
}
// 2. 填充服务器地址信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
和服务器一样,socket创建完全相同。区别在于后面填的不是本地地址,而是服务器的地址。这个地址结构体会一直用到发送数据的时候。
inet_addr 把点分十进制字符串转成 in_addr 结构体,同时也完成了字节序转换。关于它的详细用法,后面地址转换函数的部分会讲。
3.4 发送和接收循环
cpp
while (true)
{
std::string inbuffer;
std::cout << "Please Enter# ";
std::getline(std::cin, inbuffer); // 读取用户输入
// 发送给服务器
ssize_t n = sendto(sock, inbuffer.c_str(), inbuffer.size(),
0, (struct sockaddr*)&server, sizeof(server));
if(n > 0)
{
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
// 收服务器的回复
ssize_t m = recvfrom(sock, buffer, sizeof(buffer)-1,
0, (struct sockaddr*)&temp, &len);
if(m > 0)
{
buffer[m] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
else break;
}
else break;
}
close(sock);
流程很清晰:输入 → 发送 → 等待回复 → 打印回复 → 下一轮。这里用了 getline 而不是 cin >> 读输入,好处是能处理带空格的字符串。
注意 recvfrom 的第五个参数传了 &temp 而不是 &server。因为这里只是收数据,不需要用到发送方地址,所以用一个临时变量就行。
四、客户端为什么不用显式bind
4.1 这个问题很多新手都会问
服务器需要 bind 端口,这能理解------客户端要知道服务器在哪里。但客户端自己的端口呢?服务器回复的时候也需要知道客户端的地址和端口,那客户端的端口是怎么确定的?
答案是:客户端在第一次调用 sendto 的时候,操作系统会自动为它分配一个端口并绑定。
4.2 为什么要让OS自动分配
两个原因:
端口号要唯一。 同一台机器上可能同时跑几十个客户端程序,如果每个都自己写死一个端口号,很容易冲突。让OS从动态端口范围(49152-65535)里随机分配,就不会冲突。
客户端数量可以非常多。 服务器端口必须固定,因为所有客户端都要知道去哪里连接。但客户端的端口只有服务器需要知道(用来回复),它用哪个端口客户端自己不在乎。所以让OS帮忙分配就行。
4.3 自动bind发生在哪里
具体来说,当客户端调用 sendto 的时候:
- 内核看到这个socket还没有绑定本地地址
- 自动从动态端口池里选一个空闲端口,把这个端口绑定到socket上
- 然后才真正发送数据
从这一刻开始,服务器收到数据后,recvfrom 记录的就是客户端自动绑定的那个端口,回复的时候就用这个端口。
记住:客户端一定会bind,只是不需要你显式写出来。
五、地址转换函数详解
5.1 为什么需要地址转换
人看的IP地址是字符串:"192.168.1.100"。但内核处理的是32位整数(in_addr 结构体)。网络传输用的是网络字节序(大端)。三种表示之间需要来回转换,这就是地址转换函数的作用。
5.2 字符串 → in_addr
有两个函数可以做这件事:
5.2.1 inet_addr(简单版)
cpp
in_addr_t inet_addr(const char *cp);
输入点分十进制字符串,直接返回网络字节序的32位整数。使用很简单:
cpp
server.sin_addr.s_addr = inet_addr("192.168.1.100");
一步到位,字符串转换和字节序转换都帮你做了。但它有个问题:错误时返回 INADDR_NONE(也就是0xFFFFFFFF),而 255.255.255.255 的合法值也是这个数,分不清楚是错误还是合法地址。一般场景不受影响,但严格的代码应该用下面这个。
5.2.2 inet_pton(严格版)
cpp
int inet_pton(int af, const char *src, void *dst);
af:地址族,AF_INET(IPv4)或AF_INET6(IPv6)src:输入字符串dst:输出的in_addr结构体指针- 返回值:成功返回1,失败返回0或-1
cpp
struct in_addr addr;
int ret = inet_pton(AF_INET, "192.168.1.100", &addr);
if (ret != 1) {
// 转换失败,处理错误
}
server.sin_addr = addr;
inet_pton 的优势是:支持IPv4和IPv6,返回值能明确区分成功和失败。多线程环境推荐用这个。
5.3 in_addr → 字符串
5.3.1 inet_ntoa(简单版,但有坑)
cpp
char *inet_ntoa(struct in_addr in);
把 in_addr 转成点分十进制字符串,返回一个 char*。
cpp
char *ip = inet_ntoa(peer.sin_addr); // "192.168.1.100"
看起来很方便,但这里有个隐蔽的问题:返回的 char* 指向函数内部的静态存储区。意味着:
- 不需要你手动
free - 但如果你调用两次
inet_ntoa,第二次的结果会覆盖第一次
cpp
char *ip1 = inet_ntoa(addr1.sin_addr); // "192.168.1.1"
char *ip2 = inet_ntoa(addr2.sin_addr); // "10.0.0.1"
printf("%s\n", ip1); // 打印的是 "10.0.0.1"!!ip1被覆盖了
这是一个经典的坑。如果要保留结果,必须马上复制出来:
cpp
char *ip = inet_ntoa(peer.sin_addr);
std::string saved_ip(ip); // 立刻复制,后面用 saved_ip
多线程环境下千万不要用 inet_ntoa。 多个线程同时调用,结果会互相覆盖,数据乱得不行。
5.3.2 inet_ntop(线程安全版)
cpp
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
af:地址族src:输入的in_addr结构体指针dst:你自己提供的输出缓冲区size:缓冲区大小
cpp
char ip[INET_ADDRSTRLEN]; // INET_ADDRSTRLEN = 16,IPv4地址字符串的最大长度
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
结果写到你自己的缓冲区里,不存在静态存储区覆盖的问题。多线程环境优先选这个。
5.4 四个函数对比总结
| 函数 | 方向 | 支持IPv6 | 线程安全 | 推荐场景 |
|---|---|---|---|---|
inet_addr |
字符串 → in_addr | ✗ | ✓ | 简单场景,单线程 |
inet_pton |
字符串 → in_addr | ✓ | ✓ | 严格场景,多线程 |
inet_ntoa |
in_addr → 字符串 | ✗ | ✗ | 简单场景,单线程 |
inet_ntop |
in_addr → 字符串 | ✓ | ✓ | 严格场景,多线程 |
记住一个规律:pton 和 ntop 是新版本,支持IPv6和线程安全;addr 和 ntoa 是旧版本,简单但有限制。 如果对场景没特别要求,用新版本养成习惯。
六、INADDR_ANY 专题
6.1 为什么服务器要用 INADDR_ANY
很多新手第一次写服务器,会把bind的IP写成自己机器的IP地址,比如 "192.168.1.100"。这样确实能用,但在生产环境有问题。
服务器通常有多个网卡:内网卡、外网卡、回环网卡(127.0.0.1)。如果你只bind一个IP,那么从其他网卡进来的请求就收不到。
INADDR_ANY 的值是0,意思是"监听所有网卡上的所有IP"。用它bind之后,无论客户端从哪个网卡连过来,服务器都能收到。
cpp
local.sin_addr.s_addr = INADDR_ANY; // 推荐
// local.sin_addr.s_addr = inet_addr("192.168.1.100"); // 不推荐
还有一个实际问题:云服务器通常不允许你直接bind公有IP 。因为云平台的公有IP经过了NAT映射,你bind的应该是内网IP或者 INADDR_ANY。所以养成用 INADDR_ANY 的习惯,省得在本地能用但放到云上就不行。
七、本篇总结
7.1 核心要点
服务器流程:
socket()创建fd →bind()绑定端口 →recvfrom()收数据 →sendto()回复INADDR_ANY监听所有网卡,这是生产环境的标准写法- 服务器永远循环,不主动退出
客户端流程:
socket()创建fd → 填充服务器地址 →sendto()发数据 →recvfrom()收回复- 客户端不需要显式bind,第一次sendto时OS自动分配端口
- 客户端的端口是动态分配的,每次启动可能不同
地址转换:
inet_addr/inet_pton:字符串 → in_addrinet_ntoa/inet_ntop:in_addr → 字符串- 多线程环境用
inet_pton和inet_ntop inet_ntoa的静态存储区坑:结果要马上复制,不能保存指针
辅助类:
nocopy禁止拷贝,防止fd被复制导致双重关闭InetAddr封装地址转换,打印调试用
7.2 容易混淆的点
-
sizeof(buffer) - 1而不是sizeof(buffer):留一个字节给'\0'。如果不留,收满的时候加'\0'会越界。 -
len必须初始化为sizeof(peer):recvfrom用它来知道你给了多大的地址缓冲区。如果写错了,轻则报错,重则写越界。 -
sendto用的是strlen(buffer)不是sizeof(buffer):只发实际数据的长度,不是整个缓冲区。 -
客户端的
bind是隐式的:不是没有bind,是OS帮你做了。服务器回复的时候用的就是这个自动绑定的端口。 -
inet_ntoa的坑是静态存储区,不是内存泄漏:不需要free,但结果会被下一次调用覆盖。多线程更危险。 -
::bind前面的:::全局作用域限定符,确保调用的是系统的bind函数,而不是可能同名的类方法。
💬 总结:这一篇把Echo Server的每一行代码都拆解清楚了。从socket创建到数据收发,从地址填充到转换函数,这些是UDP编程的基础操作。下一篇我们会在此基础上引入回调机制,把Echo Server改成一个网络字典服务器,同时讲清楚封装版UdpSocket的设计思路。
👍 点赞、收藏与分享:如果这篇帮你理清了UDP编程的基本操作,请点赞收藏!下一篇会有更多实战代码,敬请期待!