
🎬 个人主页:HABuo
📖 个人专栏:《C++系列》 《Linux系列》《数据结构》《C语言系列》《Python系列》《YOLO系列》
⛰️ 如果再也不能见到你,祝你早安,午安,晚安

目录
前言:
本篇博客是在上篇博客的基础上,进行代码逐步编写,通过多业务场景,我们会对网络协议栈有深刻的了解,对上篇博客所涉及的接口也更加得心应手!
📚一、UDP服务端
所用接口:socket、bind、recvfrom、inet_addr、htons、ntohs、inet_ntoa
接口使用:
cpp
socket(AF_INET, SOCK_DGRAM, 0)
AF_INET针对IPv4(常用),AF_INET6针对IPv6
SOCK_DGRAM针对数据报,SOCK_STREAM针对字节流
bind(_sockfd, (struct sockaddr *)&local, (socklen_t)sizeof(local))
_sockfd:socket的返回值
local:绑定到对应的IP地址和端口(而这些信息我们已经初始化这个结构体中)
recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len)
_sockfd:socket的返回值(这是服务端(接收信息端)创建的套接字的返回值不要理解错误)
buffer:用来接收数据存储的缓冲区
sizeof(buffer) - 1:文件字符串的读取不默认带'\0',-1为了下面的代码在结尾处+0
0:阻塞式接收
(struct sockaddr *)&peer:输出型参数,给一个空的结构体,去拿接收客户端的IP和端口
&len:输入输出型参数:拿对应填充结构体的大小
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server))
_sockfd:socket的返回值(这是客户端(发送信息端)创建的套接字的返回值不要理解错误)
message.c_str():发送的信息,这里.c_str()是string里的成员函数,只是转成c式的字符串,后面带'\0'而已
message.size():发送信息的大小
0:常用参数为0
(struct sockaddr*)&server:这里不是输出型参数,是为了告诉服务端发送信息的这一端的IP和端口
sizeof(server):这个结构体的字段长度,用sizeof不计算后面'\0'
补充接口:
| 函数名 | 全称含义 | 方向 | 数据宽度 | 典型应用 |
|---|---|---|---|---|
htons |
Host to Network Short | 主机 → 网络 | 16位 | 端口号、协议号 |
htonl |
Host to Network Long | 主机 → 网络 | 32位 | IPv4地址 (其他情况下较少使用,通常用 inet_addr 等函数直接处理) |
ntohs |
Network to Host Short | 网络 → 主机 | 16位 | 接收到的端口号 |
ntohl |
Network to Host Long | 网络 → 主机 | 32位 | 接收到的IPv4地址 |
cpp
in_addr_t inet_addr(const char *cp);
功能:将点分十进制字符串转换为网络字节序的 32 位整数
char *inet_ntoa(struct in_addr in);
功能:将网络字节序的 IPv4 地址(struct in_addr)转换为点分十进制字符串。
了解上述任务之后我们就可以编写服务端的代码了
cpp
namespace Server
{
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
const int defaultsockfd = -1;
const std::string defaultip = "0.0.0.0";
const uint16_t defaultport = 8080;
const int gnum = 1024;
typedef function<void(string, uint16_t, string)> func_t;
class udpServer
{
public:
udpServer(func_t handler, const uint16_t &port = defaultport, const std::string &ip = defaultip)
: _callback(handler), _sockfd(defaultsockfd), _ip(ip), _port(port), _isrunning(false)
{}
void initServer()
{
// 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(SOCKET_ERR);
}
cout << "socket success: " << " : " << _sockfd << endl;
// 本地属性初始化
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// local.sin_addr.s_addr = htonl(INADDR_ANY);//这是接收所有IP发来的信息的写法,上述写法是指定IP,一般服务器是这种写法,但是演示通过上述写法演示
local.sin_port = htons(_port);
// 套接字绑定
if (bind(_sockfd, (struct sockaddr *)&local, (socklen_t)sizeof(local)) < 0)
{
cerr << "bind error: " << errno << " : " << strerror(errno) << endl;
exit(BIND_ERR);
}
}
void start()
{
// 服务器的本质其实就是一个死循环
char buffer[gnum];
for (;;)
{
// 读取数据
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 必填
ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
string clientip = inet_ntoa(peer.sin_addr); // 1. 网络序列 2. int->点分十进制IP
uint16_t clientport = ntohs(peer.sin_port);
string message = buffer;
cout << clientip << "[" << clientport << "]# " << message << endl;
_callback(clientip, clientport, message);
}
}
}
~udpServer()
{
if (_sockfd > 0)
close(_sockfd);
}
private:
int _sockfd;
std::string _ip;
uint16_t _port;
func_t _callback; //回调
bool _isrunning;
};
}
代码解释:
initServer()成员函数主要式完成服务器的创建工作:即套接字的创建以及将套接字绑定到内核
start()成员函数主要完成服务端的启动工作:即将服务器一直保持死循环状态,有数据你就读没数据就阻塞在那里!
对于接口如何融入到整体代码请看最上面接口的使用部分
📚二、UDP客户端
所用接口:socket、recvfrom、inet_addr、htons
客户端完整代码:
cpp
namespace Client
{
using namespace std;
class udpClient
{
public:
udpClient(const string &serverip, const uint16_t &serverport)
: _serverip(serverip),_serverport(serverport), _sockfd(-1), _quit(false)
{}
void initClient()
{
// 创建socket
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd == -1)
{
cerr << "socket error: " << errno << " : " << strerror(errno) << endl;
exit(2);
}
cout << "socket success: " << " : " << _sockfd << endl;
// 2. client要不要bind[必须要的],client要不要显示的bind,需不需程序员自己bind?不需要!!!
// 写服务器的是一家公司,写client是无数家公司 -- 由OS自动形成端口进行bind!-- OS在什么时候,如何bind
}
void run()
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
server.sin_port = htons(_serverport);
string message;
while(!_quit)
{
cout << "Please Enter# ";
cin >> message;
sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
//size计算方式是统计'\0'之前的字符个数,也正好符合我们的需求,因为文件中的字符串仅仅是一个一个的字符,文件中的字符串并不需要'\0'结尾
}
}
~udpClient()
{}
private:
int _sockfd;
string _serverip;
uint16_t _serverport;
bool _quit;
};
}
这客户端为什么没有进行bind?
- 客户端必须有一个端口和IP才能通信。无论是 TCP 还是 UDP,一个套接字要想发送数据,它必须有一个源IP地址 和源端口号。这两个信息会出现在数据包的 IP 头和 UDP/TCP 头中,用于标识"这个数据包是谁发的",也便于服务器把回复包发回来。
IP地址:从哪个网卡发出(或者使用哪个源IP)。
端口号:客户端程序的身份标识,服务器回复时会作为目标端口。
所以,客户端不是不需要绑定,而是不需要你手动绑定 ------ 内核会在你第一次发送数据(或连接)时自动替你完成绑定。
bind的作用是"固定"或"提前指定"
bind的作用是把一个套接字显式绑定到一个特定的IP和端口上。对于服务器 来说,它必须
bind到一个众所周知(well-known)的端口(例如 HTTP 的 80),否则客户端无法知道该往哪个端口发起连接。对于客户端来说,它没有"被其他人已知"的需求。客户端只需要一个临时的、唯一的端口用于本次通信即可,用完即弃。
- 内核自动绑定的过程
当你创建一个客户端套接字(
socket)后,没有调用bind,而是直接调用了:
TCP 客户端 :
connectUDP 客户端 :第一次
sendto(或connect+send)内核会在这些时机检查:如果这个套接字还没有绑定到本地地址(IP + 端口),就自动执行一次隐式绑定:
选择源IP地址:根据路由表,选择出站网卡上最合适的IP地址(例如去往目标服务器路径上指定的源IP)。
选择临时端口 :从系统配置的临时端口范围 (通常
32768~60999,可通过sysctl net.ipv4.ip_local_port_range查看)中分配一个当前未被使用的端口。这个临时端口会在连接关闭或发送结束后被释放(TCP 需等待
TIME_WAIT一定时间)。所以客户端为什么没有进行bind,并不是说客户端不需要绑定,而是操作系统帮我们做了,因为只有绑定才能将客户端的IP和端口号送入内核,也才能标识"数据包是谁发的",从而便于服务器把回复包发回来
📚三、本主机通信
上述代码既可以进行本主机的互相通信,下面我们讲述一个有具体场景的,即解析命令行指令的本主机通信:
在前面进程程序替换章节,我们也写过类似的代码,当初我们又fork、又exec等等一堆代码,今天再来写稍稍麻烦些,今天我们介绍一个方便的接口:
popen与pclose
popen() 和 pclose() 是标准 C 库函数(在 <stdio.h> 中声明),用于方便地执行外部命令并与其进行数据通信 。它们本质上封装了 fork()、exec()、pipe() 等系统调用,让程序员能够像读写文件一样读写一个子进程的标准输入或标准输出。
cpp
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
作用:
-
popen():创建一个管道,启动一个 shell 执行command,并返回一个FILE *指针,用于读取或写入该进程的输入/输出。 -
pclose():关闭由popen打开的流,等待子进程结束,并返回子进程的退出状态。
popen 参数
-
command:要执行的命令字符串(例如"ls -l"、"grep error")。popen会调用/bin/sh -c command来执行,所以可以使用 shell 的通配符、管道等特性。 -
type:指定数据流向:-
"r":读取 。子进程的标准输出连接到返回的FILE *,你的程序可以从中读取子进程的输出。 -
"w":写入 。子进程的标准输入连接到返回的FILE *,你的程序可以向其写入数据,作为子进程的输入。
-
返回值 :成功返回一个 FILE * 指针,失败返回 NULL。
pclose 参数
stream:由popen返回的FILE *指针。
返回值 :返回子进程的退出状态(如同 waitpid 获得的 status)。如果 waitpid 失败或发生其他错误,返回 -1。
工作原理
-
popen内部调用fork()创建一个子进程。 -
子进程调用
exec执行/bin/sh -c command。 -
父子进程之间建立一条管道:
-
若
type为"r":父进程读取子进程的标准输出。 -
若
type为"w":父进程写入子进程的标准输入。
-
-
父进程通过返回的
FILE *进行fread/fgets(读模式)或fwrite/fprintf(写模式)等标准 I/O 操作。 -
pclose关闭文件指针,并调用wait等待子进程结束,返回其退出状态。
业务代码:
cpp
void execCommand(int sockfd, string clientip, uint16_t clientport, string cmd)
{
//1. cmd解析,ls -a -l //2. 如果必要,可能需要fork, exec*
if(cmd.find("rm") != string::npos || cmd.find("mv") != string::npos || cmd.find("rmdir") != string::npos)
{
cerr << clientip << ":" << clientport << " 正在做一个非法的操作: " << cmd << endl;
return;
}
string response;
FILE *fp = popen(cmd.c_str(), "r");//就是popen里面将我们客户端发给服务端的指令字符串,再经
//服务端回调该方法经参数传递给该函数,而该函数就相当于我们之前实现的shell里面执行指令之后,将
//执行指令的内容以字符串的方式保存在fp指向的空间中
if(fp == nullptr) response = cmd + " exec failed";
char line[1024];
while(fgets(line, sizeof(line), fp)) response += line;
pclose(fp);
// 开始返回,返回给客户端
struct sockaddr_in client;
bzero(&client, sizeof(client));
client.sin_family = AF_INET;
client.sin_port = htons(clientport);
client.sin_addr.s_addr = inet_addr(clientip.c_str());
sendto(sockfd, response.c_str(), response.size(), 0, (struct sockaddr*)&client, sizeof(client));
}
代码解读:
整体的流程是:客户端发送指令字符串 (保证合法,不合法我们就在回调函数中进行拦截),服务端recvfrom接收到对应内容之后,将内容以参数的形式传递给回调方法,而回调方法就是上述业务代码(而这个回调方法,我们在udpServer.cc中udpServer初始化时就已经保存在function包装器中,因此我们才可以在类内使用该回调方法)。至于业务代码逻辑很简单,大家看看即可!
本主机通信:主要就是使用IP地址为127.0.0.1,这个IP地址叫做本地环回地址,向"127.0.0.1"这个IP地址发送数据的进程都会将这个数据自上而下贯穿网络协议栈,但是不向网络中进行发送。因此所谓本主机通信,也就是两个进程互相通信,没有这个业务代码依然可以,只不过我们建立一个业务逻辑而已,顺便写了一份业务与底层架构的解耦!以后想要写什么业务,只需将这个回调方法更改即可!
除了本主机通信,还有跨主机通信,本质原理和操作完全一样,下面将另一个多人聊天系统的业务场景代码贴在下面有兴趣的可以了解一下:
📚四、总结
本篇博客我们认真的编写了udp服务器的服务端和客户端,基本上是固定套路,我们编写的时候理解的过程中稍加记忆,这些代码也就迎刃而解,整体可以概括为以下流程:
-
服务器:socket -> bind -> recvfrom-> close
-
客户端:socket -> sendto -> close
用于测试的.cc文件,我将其放置在了我的代码库,有需要的可以去查阅:
