【计算机网络】网络编程套接字

文章目录

  • 网络编程套接字
    • 源IP地址和目的IP地址
    • 源MAC地址和目的MAC地址
    • 源端口号和目的端口号
    • 网络字节序
      • 网络字节序转换接口
    • Socket编程接口
      • `sockaddr` 结构
    • 实现简单的UDP Server
      • 创建套接字
      • 服务器绑定
      • 运行服务器
    • 实现简单的 UDP Client
      • 创建套接字
      • 客户端绑定
      • 启动客户端
    • UDP服务器本地测试
      • INADDR_ANY
    • 简易的回声服务器
    • 网络测试

网络编程套接字

源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 
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 
#include 
#include 
#include 
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 
#include 
#include 
#include 
#include 
#include 
#include 
#include    // 格式转换函数 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

你可能感兴趣的:(网络编程,网络,计算机网络,php)