文章目录
网络编程套接字
源IP地址和目的IP地址
因特网上的每一台计算机都有一个唯一的Mac地址,如果一台主机想要传输数据到另一台主机上,那么对端主机的IP地址就应该作为数据传输的目的IP地址。对端主机同样也需要直到该计算机的源IP地址。目的IP地址表明该数据传输的目的地,源IP地址作为对端主机响应时的目的IP地址
IPv4 协议下 IP地址32位 4字节 IPv6 协议下 128位 16字节
源MAC地址和目的MAC地址
大部分数据传输是跨局域网的,数据传输过程会经过若干个路由器。Mac地址是包含在数据链路层的报头中的,而Mac地址只在当前局域网内有效,当数据跨网络到达另一个局域网时,其源Mac地址和目的Mac地址需要发生变化
Mac 地址 48位 6字节
源端口号和目的端口号
两台主机进行通信不仅仅是为了将数据发送给对端主机,而是为了将数据交付给对端主机上的某个服务(进程)。
Socket通信的本质就是两个进程之间在进行通信,只不过这里是跨网络通信,数据的发送者是主机上的某个进程
端口号(port)的实际作用就是用来标识一台主机上的一个进程的
- 端口号是传输层协议的内容
- 端口号是一个两字节的16位整数
- 端口号用来标识一个进程,告诉操作系统,当前的数据要交给哪一个进程进行处理
- 一个端口号只能被一个进程占用
IP地址用于唯一标识公网内一台主机,端口号唯一标识主机上的一个进程,因此IP+Port可以唯一标识网络中一台主机的一个进程
网络字节序
- 大端模式:数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址出
- 小端模式:数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处
TCP/IP规定网络数据流采用大端字节序,即低地址高字节,无论是打断及还是小端机都需要按照TCP/IP协议规定的网络字节序来发送和接收数据
为什么网络字节序采用大端字节序
TCP在Unix时代就有了,Unix机器都是大端机,大端也更符合现代人的读写习惯,因此网络字节序也就采用大端。后来人们发现小端可以简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。
网络字节序转换接口
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
Socket编程接口
创建套接字: 客户端 + 服务器
int socket(int domain, int type, int protocol);
绑定端口号: 服务器
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
监听套接字: 服务器
int listen(int socket, int backlog);
接收请求: 服务器
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
建立连接: 客户端
int connect(int socket, const struct sockaddr *address, socklen_t address_len);
sockaddr
结构
套接字不仅支持网络间进程通信,还支持本地的进程通信(域间套接字)。在跨网络通信时我们呢需要传递IP和端口号,而本地通信并不需要。所以套接字接口提供了sockaddr_in
(用于跨网络通信)和sockaddr_un
(用于本地通信)
grep -ER 'struct sockaddr_in {' /usr/include # 可以使用grep指令查看sockaddr_in结构体结构
struct sockaddr_in {
__kernel_sa_family_t sin_family; /* Address family */
__be16 sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
/* Pad to size of `struct sockaddr'. */
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};
#define sin_zero __pad /* for BSD UNIX comp. -FvK */
#endif
// 可以提炼出 struct sockaddr_in 当中成员如下
// sin_family: 协议家族
// sin_port: 标识端口号(16位整数)
// struct in_addr sin_addr: 一个结构体,但结构体内只有一个成员s_addr 是一个三十二位整数即IP地址
/* Internet address. */
struct in_addr {
__be32 s_addr;
};
为了能让套接字的网络通信和本地通信能够公用一套函数接口,就出现了struct sokeaddr
结构体,该结构体和其它两个结构都不相同,但是这三个结构体头部的16个比特位都是一样的,前16个比特位构成一个字段,叫做协议家族,地址类型
在调用socket编程接口时,统一传入struct sockaddr
结构体,这些API内部可以提取struct sockaddt
结构头部16位进行识别,进而得出到底是要进行网络还是本地通信,然后执行响应的操作。因此我们就通过sockaddr
结构将网络通信和本地通信的参数进行了统一
为什么有这么多本地进程通信方式
本地进程通信方式有管道、消息队列、共享内存、信号量等方式了,现在套接字中又有用于本地通信的域间套接字。实际是因为早期就有好多实验室在研究通信方式,其中最经典的就有System V标准的通信方式和POSIX标准的通信方式
- IPv4和IPv6的地址格式定义在
netinet/in.h
中,地址类型分别位常熟AF_INET, AF_INET6
为什么不适用void* 类型作为形参呢??
因为当时C语言还不支持void* 于是设计出了struct sockaddr
这样的解决方案,因为这些接口都是系统接口,是所有上层软件的基石不可以随意更改。
实现简单的UDP Server
创建套接字
int socket(int domain, int type, int protocol);
- domain:创建套接字的域就是协议家族,也就是指定套接字用于通信的协议类型。也就是该参数指定
struct sockaddr
结构前16位。如果是本地通信设置为AF_UNIX
, 如果是网络通信就设置为AF_INET
或者AF_INET6
- type: 创建套接字所需要的服务类型,常见的就是
SOCK_STREAM
(流式服务) 和SOCK_DGRAM
(数据报式服务 Datagram) - protocol: 创建套接字的协议类别,可以指定TCP或者UDP。该字段一般设置为0即可,API会通过前两个参数自动推导出你使用的协议
- 套接字创建成功返回一个文件描述符,失败返回-1,同时设置错误码
socket 函数属于什么类型的接口
系统调用接口,我们在应用层编写代码,因此我们调用的实际是下三层接口,而传输层和网络层都是在操作系统内完成的。
socket 函数底层做了什么
socket 函数被进程调用,而每一个进程在系统层面上都有PCB、文件描述符表、进程地址空间。当进程调用socket函数创建套接字时。相当于打开了一个网络文件,内核层面创建一个struct file
结构体来描述这个网络文件,并将这个结构体连入进程对应的文件双链表(组织),并将该结构体的地址按顺序填入文件描述符表中的fd_array
(文件描述符数组)中,然后将该文件地址填入的数组下标返回给用户
struct file
结构体内部包含文件的属性信息、操作方法、以及文件缓冲区等。文件属性在内核中是由struct inode
结构体来维护的,文件对应的操作方法实际就是一对函数指针,在内核当中由struct file_operations
结构体来进行维护,文件缓冲区输出的位置对于打开的普通文件对应的一般是磁盘,对于网络文件来说一般指的就是网卡
当用户把数据写入普通文件的到文件缓冲区后,操作系统会定期将数据刷到磁盘上,如果是网络文件,则会刷到网卡中。
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
using namespace std;
class UdpServer{
public:
UdpServer(){};
~UdpServer(){ if (sockfd > 0) close(sockfd); };
bool InitServer() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << sockfd << std::endl;
return true;
}
private:
int sockfd; // 网络文件的文件描述符
};
int main() {
UdpServer* svr = new UdpServer();
svr->InitServer();
return 0;
}
服务器绑定
套接字已经创建完毕,但是对于进程来说,这仅仅只是在系统层面打开了一个文件而已,操作系统并不知道之后要将数据写入磁盘还是网卡,是网卡的话又应该写到哪张网卡中
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
-
socket :需要参与绑定的文件描述符
-
addr
: 网络相关属性信息,包括协议家族、IP地址、端口号等 -
addrlen
传入addr
结构体的长度 -
绑定成功返回0,失败返回-1,同时设置错误码
绑定的底层操作
绑定将主机的IP地址和进程想要使用的端口号告诉操作系统,操作系统就可以改变网络文件的操作函数的指向,将对应的网络文件操作函数改为对应网卡的操作函数。此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来
bool InitServer() {
// 1 创建套接字
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << sockfd << std::endl;
// 2 服务器绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = inet_addr(ip.c_str()); // 后文有说明
if (bind(sockfd, (struct sockaddr*)&local, sizeof(struct sockaddr)) < 0) {
std::cerr << "bind error" << std::endl;
return false;
}
std::cout << "bind success" << std::endl;
return true;
}
字符串IP和整数IP的转换方式
可以定义一个位段A,位段A中设置四个成员,每个8比特位,然后再定义一个联合体IP,联合体中包含两个成员,一个是32位整数一个是位段A(字符串IP)
struct A{
uint32_t p1 : 8;
uint32_t p2 : 8;
uint32_t p3 : 8;
uint32_t p4 : 8;
};
union IP{
uint32_t ip;
struct A p;
};
void ip_test(){
IP ip1;
ip1.ip = 123141251;
cout << ip1.p.p1 << "." << ip1.p.p2 << "." << ip1.p.p3 << "." << ip1.p.p4 << endl;
}
// 可以看到整数IP成功转换称字符串ip了
[clx@VM-20-6-centos udp]$ ./udp_server
131.252.86.7
操作系统内部实际就是使用位段和联合体,来完成字符串IP和整数IP之间的转换的
inet_addr
函数
实际再进行字符串IP和整数IP转换时,系统已经为我们提供了相应的转换函数,我们直接调用即可
in_addr_t inet_addr(const char *cp); // 字符串IP转换成整数IP
char *inet_ntoa(struct in_addr in); // 整数IP转换成字符串IP
- 使用
inet_nota
只需要将struct sockaddr_in
中的sin_addr
就可以了
运行服务器
UDP服务器的初始化只需要创建和绑定就可以了,完毕后我们就可以操纵服务器收取和发送数据了。
接收数据
UDP服务器读取数据函数叫做recvfrom
ssize_t recvfrom(int socket, void *restrict buffer, size_t length,
int flags, struct sockaddr *restrict address,
socklen_t *restrict address_len);
-
socket : 服务器绑定的文件描述符
-
buffer : 读取数据的存放位置
-
length :期望读取的字节数
-
flags : 读取方式,一般设置为0,表示阻塞读取
-
src_addr
: 输出型参数,用于获取对端网络属性信息 -
addrlen
:src_addr
调用时传入期望读取到src_addr
结构体的长度,返回时代表实际读取到的str_addr
结构体的长度,是一个输入输出型参数 -
读取成功返回实际读取到的字节数,失败返回-1,同时设置错误码
由于UDP不是面向连接的,我们除了获取数据以外还需要获取对端网关的属性信息,包括IP地址和端口号等,在调用recvfrom
读取数据时,必须将addrlen
设置为你要读取的结构体对应的大小。由于recvfrom
提供的参数也是struct sockaddr*
类型的,因此我们传入结构体地址时需要将struct sockaddr_in*
类型强转
void Start() {
#define COM_SIZE 128
char buffer[COM_SIZE];
for (; ;) {
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
cout << sockfd << endl;
ssize_t size = recvfrom(sockfd, buffer, COM_SIZE - 1, 0, (struct sockaddr*)&peer, &len);
if (size > 0) {
buffer[size] = 0;
int peer_port = ntohs(peer.sin_port);
std::string peer_ip = inet_ntoa(peer.sin_addr);
std::cout << peer_ip << ":" << peer_port << "#" << buffer << std::endl;
}
else {
std::cerr << "recvfrom error" << endl;
}
sleep(1);
}
}
写完这个接口我们就可以进行简单的本地环回测试了
int main(int argc, char* argv[]) {
// local_loopback_test(argc, argv);
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
std::string ip = "127.0.0.1";
int port = atoi(argv[1]);
UdpServer* svr = new UdpServer(ip, port);
// ip_test();
svr->InitServer();
svr->Start();
return 0;
}
使用指令netstat -nlup就可以观察到UPP服务器的网络信息
[clx@VM-20-6-centos udp]$ ./udp_server 8888
socket create success, sockfd: 3
127.0.0.1:8888:3
bind success
3
[clx@VM-20-6-centos udp]$ netstat -nlup
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
udp 0 0 0.0.0.0:8888 0.0.0.0:* 17015/./udp_server
在netstat 命令显示的信息中,Proto
表示协议类型,Recv-Q
表示网络接收队列,Send-Q
表示网络发送队列,PID
表示该进程的进程ID, Program name
表示进程的程序名称
其中Foreign Address 携程0.0.0.0:*表示任意IP地址、任意端口号的程序都可以访问当前进程
实现简单的 UDP Client
创建套接字
#include <iostream>
#include <memory.h>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h> // 格式转换函数 inet_addr()
using namespace std;
class UdpClient
{
public:
UdpClient(std::string& _ip, short _port)
:sockfd(-1), server_port(_port), server_ip(_ip){};
~UdpClient() {
if (sockfd >= 0) close(sockfd);
}
bool InitClient() {
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "socket create error" << std::endl;
return false;
}
return true;
}
private:
int sockfd; //文件描述符
int server_port; //服务端端口号
std::string server_ip; //服务端IP地址
};
客户端绑定
在网络通信中,客户端不需要进行端口号的绑定。因为服务器就是为了向他人提供服务的,所以服务器必须让别人知道自己的IP地址和端口号。IP地址一般对应域名,端口号一般没有显式指定,所以端口号一定要是一个众所周知的不能轻易改变的,否则客户端无法得知服务端的端口号。
当然客户端在进行通信时虽然也需要端口号,但是客户端一般不需要用户手动绑定。而是在进行网络通信时计算机自动分配一个端口,只要保证端口号是唯一的即可,不需要将特定客户端端口和进程强关联
如果客户端绑定了某个端口号,那么这个端口号只能给这一个客户端使用,如果客户端没有启动也无法分配给他人。而且如果这个端口号被人使用了,客户端也就没法启动了。所以客户端端口只要保证唯一性,在这个进程调用类似于sendto
这样的接口时,操作系统会自动分配一个进行通信即可。
启动客户端
void Start()
{
std::string msg;
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(server_port);
peer.sin_addr.s_addr = inet_addr(server_ip.c_str());
for (;;){
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
}
}
int main(int argc, char* argv[])
{
if (argc != 3){
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
UdpClient* clt = new UdpClient(server_ip, server_port);
clt->InitClient();
clt->Start();
return 0;
}
sendto
函数
UDP客户端发送数据的函数叫做sendto
ssize_t sendto(int socket, const void *message, size_t length,
int flags, const struct sockaddr *dest_addr,
socklen_t dest_len);
-
socket : 服务器绑定的文件描述符
-
buffer : 待写入数据的存放位置
-
length :期望写入数据的字节数
-
flags : 写入方式,一般设置为0,表示阻塞读取
-
dest_addr
: 输入型参数,用于提供对端网络属性信息 -
addrlen
:dest_addr
调用时传入期望读取到dest_addr
结构体的长度,返回时代表实际读取到的str_addr
结构体的长度,是一个输入输出型参数 -
写入成功返回实际写入的字节数,写入失败返回-1,并设置错误码
由于UDP并不是面向连接的,因此传入待发送数据还需要知名对端的网络相关信息,包括IP地址和端口号等
UDP服务器本地测试
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-21J6eQ0T-1688688070661)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230707070938410.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MfN1f8ef-1688688070662)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230707071034343.png)]
使用netstat 命令查看网络信息,可以看到服务端端口是8888,客户端端口是33476,双方正在使用udp协议进行网络通信
INADDR_ANY
目前我们已经通过了网络测试,,接下来就要进行网络测试了,但是由于使用的是云服务器,IP地址并非真正的公网IP,这个IP地址不能直接被绑定而需要绑定INADDR_ANY,此时我们的服务器才能被外网访问
当一台服务器的带宽足够大时,一台机器的接收能力就约束了这台机器的IO效率,我们可以通过增加网卡数量以提高服务器的接收能力。如果一个服务配备多张网卡,这个服务绑定的是INADDR_ANY那么不管发送给那一张网卡的8888端口,服务都可以收到请求。因此,服务器绑定INADDR_ANY这种方案是非常推荐的,所有服务器在具体操作使用的时候用的也就是这种方案
INADDR_ANY的值本质是0,不存在大小端问题,在设置时不需要进行网络字节序的转换
简易的回声服务器
在上文的UDP服务器中,客户端发送数据给客户端,服务端会对数据进行打印,但是客户端并不知道对端是否已经收到自己发送的数据。为了让客户端知道自己的数据被对端收到了,服务器将收到的数据返回给客户端
// 修改服务器代码
void Start() {
#define COM_SIZE 128
char buffer[COM_SIZE];
for (; ;) {
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t size = recvfrom(sockfd, buffer, COM_SIZE - 1, 0, (struct sockaddr*)&peer, &len);
if (size > 0) {
buffer[size] = 0;
int peer_port = ntohs(peer.sin_port);
std::string peer_ip = inet_ntoa(peer.sin_addr);
std::cout << peer_ip << ":" << peer_port << "#" << buffer << std::endl;
// 将数据返回给客户端
if (sendto(sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len) < 0) {
std::cerr << "sendto error" << std::endl;
}
else {
std::cout << "send success" << endl;
}
}
else {
std::cerr << "recvfrom error" << endl;
}
}
}
// 修改客户端代码
void Start()
{
std::string msg;
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(server_port);
peer.sin_addr.s_addr = inet_addr(server_ip.c_str());
for (;;){
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
// 接收服务器回声
#define BUFF_SIZE 128
char buffer[BUFF_SIZE];
struct sockaddr_in server_net;
socklen_t len = sizeof(server_net);
ssize_t size = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&server_net, &len);
if (size > 0) {
cout << "server say # " << buffer << endl;
}
else {
cerr << "recvfrom failed" << endl;
}
}
}
网络测试
静态编译客户端
我们可以将生成的客户端的可执行程序发送给其它机器,进行网络级别测试,为了保证程序在其它机器上也可以运行,可以在编译的时候携带-static
选项进行静态编译,然后将程序传输到其它机器上进行运行,这样它们就可以访问你的服务器了
参考文章
CSDN博主「2021dragon」的网络套接字(一)
原文链接:https://blog.csdn.net/chenlong_cxy/article/details/124533429