网络通信Socket(Linux)



网络通信之套接字Socket

预备知识根据自身基础选择性查看

文章目录

  • 网络通信之套接字Socket
    • 预备知识:
      • IP 及 端口
      • 网络模型
      • 字节序
      • IP地址转换
      • 虚拟地址空间
      • 文件描述符
      • sockaddr结构体 / sockaddr_in
    • socket 编程
      • TCP通信流程
      • socket常用函数详解
      • 服务器端代码实现
      • 客户端代码实现

预备知识:

IP 及 端口

IP: 本质为一个整型数,表示计算机在网络中的地址,IP协议有两个:IPv4 和 IPv6

IPv4协议:(目前用的最多)32位整型数表示,4个字节 ,也可使用点分十进制描述: 192.168.255.65

IPv4可是使用的最大地址有 2^32个,数量较少所有开始发展IPv6

IPv6协议:128位整型数表示,16字节,

​ 使用字符串描述IP地址 :2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b

​ *IPv4可是使用的最大地址有 2^128个

端口 : 用于定位到主机上的某一个进程 ,通过这个端口进程进行通信

端口是一个无符号短整型(unsigned short),有效端口(0 ~ 2^16 -1)

注意:一个端口只能给一个进程使用

简单理解: IP地址理解为 酒店的地址,端口理解为 具体房间的通信电话号码

网络模型

网络分成模型

网络通信Socket(Linux)_第1张图片

网络各层的作用:

  1. 物理层: 负责最后将信息编码成电流脉冲或其他信号用于传输
  2. 数据链路层: 通过物理网络提供数据传输,确定网络数据包形式
  3. 网络层: 负责源和终点之间连接(通过 IPv4 或 IPv6 查找对应主机)
  4. 传输层 : 向高层提供端对端的网络数据流服务 (TCP / UDP 协议)
  5. 会话层 : 会话层建立、管理、终止表示层与实体之间的通信会话
  6. 表示层: 对应用层数据编码和转化,确保另一个应用层识别

字节序

各计算机体系结构中,对存储机制有所不用,为了正常通信,双方应同一规则避免通信失败。

顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,也就是说对于单字符来说是没有字节序问题的,字符串是单字符的集合,因此字符串也没有字节序问题。

目前主要两种字节存储机制:大端 、小端

  • 大端 :低位字节存储到内存的高地址位高字节存储到内存低地址位

其中网络通信使用大端存储

  • 小端:低位字节存储到内存的低地址位高字节存储到内存高地址位

平时主机使用小端存储

示例:

// 有一个16进制的数, 有32位 (int): 0xab5c01ff
// 字节序, 最小的单位: char 字节, int 有4个字节, 需要将其拆分为4份
// 一个字节 unsigned char, 最大值是 255(十进制) ==> ff(16进制) 
                 内存低地址位                内存的高地址位
--------------------------------------------------------------------------->
小端:         0xff        0x01        0x5c        0xab
大端:         0xab        0x5c        0x01        0xff

Socket提供好了封装接口,用与在主机 ->网络 -> 主机之间转换字节序的函数 主->网(htons \ htonl) // 网->z主(ntohs\ntohl)

//主机字节序 ->  网络字节序    (短整型)
uint16_t  htons(uint16_t hostshort);
//主机字节序 ->  网络字节序    (长整型)
uint32_t  htonl(uint16_t hostlong);

//网络字节序 -> 主机字节序 (短整型)
uint16_t ntohs(uint16_t netshort);
//网络字节序 ->  主机字节序    (长整型)
uint32_t  ntohl(uint16_t hostlong);

注意:在传输数据时,记得转换字节序

IP地址转换

虽然IP的本质是整型数据,但是使用过程中通常通过字符串描述

IP地址使用时也需要转换大小端

//主机字节序  -> 网络字节序
//主机字节序的IP地址为 字符串 , 网络字节序的IP地址为整型
int inet_pton(int af, const char* src , void* dst);

//参数
af :地址族协议   (IPv4协议 : AF_INET) (IPv6协议: AF_INET6)
src: 输入参数,(点分十进制) 192.168.255.45
dst: 输出参数 , (大端整型)

//大端整型 ->小端点分十进制IP地址
const char* inet_ntop(int af , const char* src , char *dest , socklen_t size);

//参数
af :地址族协议   (IPv4协议 : AF_INET) (IPv6协议: AF_INET6)
src: 输入参数,(大端整型)
dst: 输出参数 , (点分十进制) 192.168.255.45
size: 标记dst指向内存最多储存多少字节

虚拟地址空间

虚拟地址空间 :操作系统知识补充

当我们运行磁盘上的一段可执行程序,就会得到一个进程(进程是一段程序的执行过程,是动态概念) ,内核会给每一个运行的进程分配一块属于自己的虚拟地址空间,并将应用程序数据装载在对应的虚拟内存地址上。

进程运行过程中处理的数据从物理内存中加载,进程中的数据通过CPU的内存管理单元从虚拟地址空间中映射过去。

网络通信Socket(Linux)_第2张图片

虚拟地址的意义

为什么操作系统不直接从物理内存上获取数据?如果直接读取会发生什么?

网络通信Socket(Linux)_第3张图片

进程A需要100m内存 ,直接在物理内存上从0地址开始分配置100 , 同理进程B分配250m内存,并且进程A和进程B占用的内存为连续的。会发生如下问题:

  1. 每个进程的地址不隔离,有安全风险:程序直接访问物理内存可能存在bug的程序修改其他程序的内存结构
  2. 内存效率低: 直接物理内存,进程对应整块内存,如果内存不足时数据量大,内存和磁盘拷贝时间会很长,效率低
  3. 进程中数据的地址不确定,每次都会有变化: 物理内存使用是动态的,无法确定内存使用情况,起始地址都不同,加载数据效率低

文件描述符

文件描述符

在Linux系统中,一切皆是文件。如何将应用程序和文件对应?

解决方案是使用文件描述符(fd),当在进程中打开一个现有文件或创建一个新文件时,内核会向进程返回一个文件描述符用于对应这个打开/新建的文件。这些文件描述符存储在内核为维护进程的一个文件描述符表中。

文件描述符的概念往往只适用于 Linux 或 Unix 系统

文件描述符表

前面讲到启动一个进程就会得到一个对应的虚拟地址空间,这个虚拟地址空间分为两大部分,Linux 的进程控制块 PCB (pcb简单理解为一个进程所有基本信息),里边包括管理进程所需的各种信息,其中有一个结构体叫做 file ,我们将它叫做文件描述符表里边有一个整形索引表,用于存储文件描述符

网络通信Socket(Linux)_第4张图片

文件描述符表性质 1: 每个进程对应的文件描述符表默认支持打开的最大文件数为 1024,可以修改
文件描述符表性质 2: 每个进程的文件描述符表中都已经默认分配了三个文件描述符,对应的都是当前终端文件
文件描述符表性质3:.每打开新的文件,内核会从进程的文件描述符表中找到一个空闲的没有别占用的文件描述符与其进行关联
文件描述符表性质4 : 每个进程文件描述符表中的文件描述符值是唯一的,不会重复

sockaddr结构体 / sockaddr_in

struct sockaddr 是一个通用地址结构 , 是为了同一地址结构的表示方式,统一接口函数,使不同的地址结构可以被 bind() —connect() 等函数调用;

sockaddr 结构体的缺陷 : sa_data把目标地址和端口信息混在一起了

struct sockaddr{
     
  unsigned short sa_family;
  char sa_data[14];
};
  1. sa_family : 通信类型,地址族协议(IPv4/IPv6)
  2. sa_data : 14字节 ,包含套接字中的 目标地址 和 端口信息

sockaddr_in 结构体:struct sockaddr_in中的in 表示internet,就是网络地址,这只是我们比较常用的地址结构,属于AF_INET地址族
sockaddr_in结构体解决了sockaddr的缺陷,把port和addr 分开储存在两个变量中

struct in_addr{
     
  unsigned long s_addr;
}

struct sockaddr_in {
     
  short int sinfamily;
  unsigned short int sin_port ;
  struct in_addr sin_addr;
  unsigned char sin_zero[8];
}

sin_port / s_addr 一般为主机字节序(小端),使用时注意初始化转化为网络字节序(htons()函数)

使用方式

一般先把sockaddr_in 变量赋值后 ,强制转换后传入用于sockaddr做参数

  1. sockaddr_in 用于socked 定义和赋值
  2. sockaddr 用于函数参数

socket 编程

为了将TCP/IP 协议相关软件移植UNIX类系统中,设计者开发一个接口以便于应用程序能简单的调用接口通信,最终形成了Socket套接字。Linux系统采用套接字通信,因此广泛使用,其头文件包含在 sys/socket.h中

TCP通信流程

TCP是一个 面向连接(双向连接)安全(数据校验)流式传输(发送/接受数据速度,数据量可以不一致)传输层协议

网络通信Socket(Linux)_第5张图片

服务器端通信流程

//创建用于监听的套接字 ,这个套接字是一个文件描述符
int lfd = socket();

//绑定 监听的文件描述符 的 IP地址 和 端口号
bind();

//设置监听是否有客户端发起连接请求
listen();

//等待客户端连接 ,当有客户端连且请求会创建用于通信的文件描述符 ,没有连接便阻塞
int cfd = accept();

//通信 :读、写 默认都是阻塞的
write();   //send();
read();   //recv();

//断开连接 、 关闭套接字
close();

注意:服务器端有两种文件描述符,一类是用于监听客户端连接请求(只存在一个),检测到连接请求后会调用accept创建新连接, 另一类文件描述符用于和客户端通信(N个),每个客户端和服务器都对应一个通信的文件描述符 简化理解:服务器文件描述符 (用于监听客户端连接)、(用于和已连接客户端通信)

此处文件描述符内存结构

网络通信Socket(Linux)_第6张图片

对应两块内存: 读缓冲区 、 写缓冲区

读缓冲区 :通过文件描述符将内存中的数据读出

写缓冲区 :通过文件描述符将数据写入内存中

客户端通信流程

单线程情况下,只有一个通信的文件描述符

//创建一个通信的套接字
int cfd = socket();

//连接服务器 ,需要知道 IP地址 和 端口号
connect();

//通信 :读、写 
write();   //send();
read();  //recv();

//断开连接 ,关闭套接字(文件描述符)
close();

socket常用函数详解

//创建一个套接字
int socked(int domain ,int type ,int protocol);  //创建失败 返回 -1

domain : 使用的地址族协议(IPv4 、IPv6)

  • IPv4 :AF_INET
  • IPv6 :AF_INET6

type: 传输方式 (流式 、报式)

  • SOCK_STREAM: 使用流式的传输协议
  • SOCK_DGRAM: 使用报式 (报文) 的传输协议

protocol : 默认 0

  • 选择流式传输 使用TCP
  • 选择报式传输 使用UDP
//绑定用于监听文件描述符  IP 、端口
int bind(int sockfd , const struct sockaddr* addr , socklen_t  addrlen);  //创建失败 返回 -1
  • sockfd: 监听的文件描述符,通过 socket () 调用得到的返回值
  • addr: 传入参数,要绑定的 IP 和端口信息需要初始化到这个结构体中,IP和端口要转换为网络字节序
  • addrlen: 参数 addr 指向的内存大小,sizeof (struct sockaddr)
//给监听的套接字设置监听
int listen(int sockfd ,int backlog);  //创建失败 返回 -1
  • sockfd: 文件描述符,可以通过调用 socket () 得到,在监听之前必须要绑定 bind ()
  • backlog: 同时能处理的最大连接要求,最大值为 128
//等待并接受客户端的连接,如果或连接请求创建新的用于通信的套接字 ,没有连接便会阻塞
int accept(int sockfd , struct sockaddr *addr ,socklen_t *addrlen);  //成功返回文件描述符,失败 -1
  • sockfd: 监听的文件描述符
  • addr: 传出参数,里边存储了建立连接的客户端的地址信息
  • addrlen: 传入传出参数,用于存储 addr 指向的内存大小

这个函数是一个阻塞函数,当没有新的客户端连接请求的时候,该函数阻塞;当检测到有新的客户端连接请求时,阻塞解除,新连接就建立了,得到的返回值也是一个文件描述符,基于这个文件描述符就可以和客户端通信了

//接受数据
ssize_t read(int socket , void* buf,size_t len);    //失败 -1

//发送数据
ssize_t write(int socket , void* buf,size_t len);     //失败 -1
  • fd: 通信的文件描述符,accept () 函数的返回值
  • buf: 传入参数,要发送的字符串
  • len: 要发送的字符串的长度
//成功连接后,客户端随机绑定一个端口
//服务器端调用 accept() ,其中第二个参数存储客户端 IP \端口
int connect(int sockfd ,struct sockaddr *addr ,socklen_t *addrlen);  //成功返回 0,失败 -1

服务器端代码实现

#include
#include
#include
#include
#include


int main()
{
     
	//创建用于监听的套接字
	int fd = socket(AF_INET, SOCK_STREAM, 0);
	if (fd == -1)
	{
     
		perror("socket() fail");
		return -1;
	}

	//绑定本地ip port
	//使用sockaddr_in 初始化避免IP和端口混乱
	struct sockaddr_in saddr;  
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(8888);//转化为网络字节序
	saddr.sin_addr.s_addr = INADDR_ANY; // 0 = 0.0.0.0 对于0,没有大小端差别

	//使用时在强转为sockaddr类型
	int ret = bind(fd, (struct sockaddr*)&saddr, sizeof(saddr));
	if (ret == -1)
	{
     
		perror("bind() fail");
		return -1;
	}

	//设置监听
	ret = listen(fd, 128);
	if (ret == -1)
	{
     
		perror("listen() fail");
		return -1;
	}

	//阻塞并等待客户端的连接
	struct sockaddr_in caddr;
	int addrlen = sizeof(caddr);
	int cfd = accept(fd, (struct sockaddr*)&caddr, &addrlen);
	if (cfd == -1)
	{
     
		perror("accept() fail");
		return -1;
	}

	//新连接成功,打印客户端的 IP 和 Port 信息
	char ip[32];
	printf("客户端的 IP :%s  Port :%d \n",
		inet_ntop(AF_INET, &caddr.sin_addr.s_addr, ip, sizeof(ip)),
		ntohs(caddr.sin_port));

	//通信
	while (1)
	{
     
		//接受客户端数据
		char buff[1024];
		int len = recv(cfd, buff, sizeof(buff), 0);
		if (len > 0) {
     
			printf("client say :%s", buff);
			send(cfd, buff, len, 0);//发送消息给客户端
		} 
		else if (len == 0)
		{
     
			printf("客户端已经断开连接");
			break;
		}
		else
		{
     
			perror("recv() fail");
			break;
		}
	}
	//关闭文件描述符(两类)
	close(fd);
	close(cfd);

	return 0;
}

客户端代码实现

// client.c
#include 
#include 
#include 
#include 
#include 

int main()
{
     
    // 1. 创建通信的套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1)
    {
     
        perror("socket");
        exit(0);
    }

    // 2. 连接服务器
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(10000);   // 大端端口
    inet_pton(AF_INET, "192.168.110.138 ", &addr.sin_addr.s_addr);

    int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
    if (ret == -1)
    {
     
        perror("connect");
        exit(0);
    }

    // 3. 和服务器端通信
    int number = 0;
    while (1)
    {
     
        // 发送数据
        char buf[1024];
        sprintf(buf, "你好, 服务器...%d\n", number++);
        write(fd, buf, strlen(buf) + 1);

        // 接收数据
        memset(buf, 0, sizeof(buf));
        int len = read(fd, buf, sizeof(buf));
        if (len > 0)
        {
     
            printf("服务器say: %s\n", buf);
        }
        else if (len == 0)
        {
     
            printf("服务器断开了连接...\n");
            break;
        }
        else
        {
     
            perror("read");
            break;
        }
        sleep(1);   // 每隔1s发送一条数据
    }

    close(fd);

    return 0;
}

推荐大家多了解一些底层知识
推荐大丙老师的教程

你可能感兴趣的:(网络编程,操作系统,网络,tcp/ip,udp)