目录
网络基础
TCP/IP网络模型
应用层
应用进程间通信和交互的规则。进程则是主机中正在运行的程序,应用层交互的数据单元称为报文。
应用层不关注数据传输,只关注进程间的交互,例如使用HTTP\FTP\TELNET\DNS等。应用层深处计算机的用户态中,传输层往下面则全是计算机的内核态。应用层做的事情类似于我们使用手机发送消息,不需要关注的消息的底层是怎么传输,只要关注对方是否回复我的消息以及对方是否收到消息即可。
运输层
运输层主要负责向两台主机中进程之间的通信提供通用的数据传输服务。即所有进程都可以使用运输层进行数据传输。
传输控制协议TCP:提供面向连接的、可靠的数据传输服务,数据传输的基本单位是报文段。
用户数据报协议UDP:提供无连接尽最大努力的数据传输服务,数据传输的单位是用户数据报。
TCP比UDP多一些功能,例如流量控制、超时重传、拥塞控制等。UDP的优点在于实时性好传输效率高。应用如果传输的的数据量较大,数据包的大小超过了MSS(TCP的最大报文长度)的时候,就要对数据包进行分块处理然后发送,这样处理的好处在于如果其中部分信息丢失,不需要整块数据包重新发送,只需要发送丢失的块即可。TCP协议中每个分块则称为TCP段。
传输层使用端口分辨数据应该发送给哪个进程使用,端口信息存储在报文中,根据报文中的信息找到目标端口。
网络层
网络层的主要任务是为分组互联网上不同主机提供通信服务。网络层在发送数据的时候将运输层的报文段或者用户数据报封装成包的形式进行传送。网络层主要使用IP协议进行数据的传输,所以封装的包也就是ip数据报。
网络层的两个具体任务分析
- 通过特定算法,在互联网中每个每个路由器生成一个用来转发分组的转发表
- 每个路由器根据自己收到的ip数据报根据转发表转发给下一个路由器即可
IP协议将传输层的报文作为数据部分,然后封装上自己IP报文组成IP数据报。如果IP数据报的大小超过MTU(1500字节)则要对IP数据报再次分片发送。
网络层通过网络号和主机号找到数据报要发给哪个目标主机。根据网络号找到主机属于哪个子网,然后根据主机号确认是这个子网中的哪台主机。
总结:IP协议的作用类似于直到自己的目的地,但是不知道到达目的地具体的道路,所以不停的在路上询问别人到达目的地的路是怎么走,路上问到的每一个人也就近似于网络中的每一个路由。
数据链路层
数据链路层将网络层交付下来的IP数据报转换成帧,在相邻的两个节点上传输帧。具体来说就是在IP头部加上MAC(物理地址)然后封装成数据帧,发送到网络上。
MAC头部是供以太网进行使用的,其中包含着发送端和接收端的MAC地址等信息,在实际的发送中,使用ARP协议获取对方MAC地址。数据链路层主要的任务就是为网络层提供在以太网这类底层网络上发送原始数据包。
协议总结
源Ip和目的Ip
源Ip和目的Ip简单可以理解成为数据包从哪里来和数据包到哪里去的问题。
网络通信的本质还是进程间的通信,数据包通过在不同主机中进行传送,然后将处理好的数据传输回来。操作系统从底层拿到数据包后,对数据包层层解包,最终通过端口号将数据分配到指定进程。
端口号
两个进程如何确认位置,Ip+端口号可以标记网络中唯一的进程,从而实现发送和接收都是目标进程。任何报文中都会包含一个Ip和端口号,通过标记找到特定进程,将数据通过网络的传到特定进程中。一个端口只可以被一个进程使用。
端口号和进程的关系。一个进程可以绑定多个端口号,但是一个端口号只可以绑定一个进程。
网络字节序
确定网络数据流的地址
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出
- 接收主机把从网络上接收到字节依次保存在接收缓冲区上,按照内存地址从低到高的顺序保存
- TCP/IP协议规定,网络数据应采用大端字节序,低地址高字节。无论主机是大端机还是小端机都按照大端字节序。
- 如果发送主机是小端,则需要将数据转换成为大端,否则就忽略,直接发送数据即可
#include <arpa/inet.h>
//host to net long
uint32_t htonl(uint32_t hostlong);
//host to net short
uint16_t htons(uint16_t hostshort);
//net to host long
uint32_t ntohl(uint32_t netlong);
//net to host short
uint16_t ntohs(uint16_t netshort);
1. 记忆方法:h主机、n网络
2. 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回。如果主机是大端字节序,这些函数则不做转换,将参数原封不动的返回。
大小端
内存地址从小到大,先存储多字节数最高有效位,称为大端序;先存储多字节整数的最低有效位,称为小端序。
socket编程
struct sockaddr_in 处理Ipv4
struct sockaddr_in {
sa_family_t sin_family; // 地址族 (Address Family)
in_port_t sin_port; // 端口号 (Port Number)
struct in_addr sin_addr; // IP地址 (IP Address)
char sin_zero[8];// 填充字节 (Padding Bytes)
};
struct in_addr {
unsigned long s_addr; // 32位IPV4地址打印的时候可以调用inet_ntoa()函数将其转换为char *类型.
};
参数分析
- sin_family:执行协议族,使用IPV4还是IPV6
- sin_port:
- 指定端口号,必须使用 htons 函数将主机字节序转换为网络字节序
- 例如:address.sin_port = htons(8888)
- sin_addr
- 指定IP地址
- 可以通过
INADDR_ANY
指定所有本地地址,或者使用inet_pton
函数将字符串形式的 IP 地址转换为in_addr
结构体- sin_zero
- 填充字节,目的是让sockaddr_in结构体的大小与sockaddr结构体大小相同,一般设置为0
socket
- 功能:创建一个新的套接字,并返回一个文件描述符,该文件描述符用于后续的网络操作
- 参数:
- domain:指明使用协议类型
AF_INET
:IPv4 网络协议。AF_INET6
:IPv6 网络协议。AF_UNIX
或AF_LOCAL
:本地通信(UNIX 域协议)- type:何种传输方式,TCP/UDP
SOCK_STREAM
:提供面向连接的稳定数据传输(TCP)。SOCK_DGRAM
:提供数据报服务(UDP)。SOCK_RAW
:提供原始网络协议访问。- protocal
0
:一般情况下设置为 0,以选择默认协议。IPPROTO_TCP
:TCP 协议。IPPROTO_UDP
:UDP 协议返回值
- 成功:返回一个文件描述符,该描述符时一个非负整数,表示新创建的套接字
- 失败:返回-1,同时设置errno指示错误
套接字创建常见错误总结
EACCES
:尝试创建套接字的操作被禁止。EAFNOSUPPORT
:指定的地址族不可用。EINVAL
:提供的参数无效。EMFILE
:进程已经打开了太多文件。ENFILE
:系统范围内打开的文件数达到限制。ENOMEM
:内存不足。EPROTONOSUPPORT
:指定的协议不可用
void Socket()
{
listen_sock = socket(AF_INET,SOCK_STREAM,0);
if(listen_sock<0)
{
LOG(FATAL,"socket error");
exit(1);//套接字创建失败
}
}
bind
将套接字与特定的IP地址和端口号绑定,从而套接字监听特定的地址和端口
- 返回值:成功0,错误-1。
- 参数
- socketfd:socket函数返回的描述符
- addr:使用sockaddr_in对端口号和Ip地址初始化,主要初始化其IP地址和端口号
- addrlen:通常设置为sizeof(struct sockaddr)
void Bind()
{
struct sockaddr_in local;
memset(&local,0,sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;//云服务器是无法直接绑定公网IP的
if(bind(listen_sock,(struct sockaddr*)&local,sizeof(local))<0)
{
LOG(FATAL,"bind error");
exit(2);//绑定失败
}
LOG(INFO,"bind socket ... success");
}
listen
用于服务器套接字的系统调用,将套接字设置为被动监听状态,等待客户端的连接请求
- 参数:
- sockfd:socket()函数返回的描述符,该套接字已经绑定到一个地址(通过bind绑定)
- backlog:指定全连接和半连接队列的最大长度。
- 返回值:成功0,错误-1。
void Listen()
{
if(listen(listen_sock,BACKLOG)<0)
{
LOG(FATAL,"listen socket error");
exit(3);//监听失败
}
LOG(INFO,"listen socket ... success");
}
accept
- 功能:TCP服务器程序调用,从全连接队列中返回下一个建立成功的连接。如果全队列为空,线程则进入阻塞态睡眠状态。
- 返回值:
- 成功时,内核会自动生成一个全新的socket描述符,用它引用于客户端TCP连接。通常accept第一个参数的套接字称为监听套接字(listening socket),accept返回的套接字称为已连接套接字(connected socket)
- 错误时,返回-1
- 参数:
- sockfd:socket函数返回的描述符,该套接字已经绑定到一个地址并正在监听连接(通过listen)
- addr:输出一个sockaddr_in变量地址,用来存放发起连接请求的客户端的协议地址
- addrlen:指明缓冲区的长度
static int Accept(int listensock, std::string *ip, uint16_t *port)
{
struct sockaddr_in src;
socklen_t len = sizeof(src);
int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
if (servicesock < 0)
{
return -1;
}
if(port) *port = ntohs(src.sin_port);
if(ip) *ip = inet_ntoa(src.sin_addr);
return servicesock;
}
connect
客户端套接字与服务端套接字建立连接
- 参数
- sockfd:由socket()函数返回的套接字文件描述符
- addr:指向sockaddr结构体指针,该结构体包含了服务器IP地址和端口号
- addrlen:sockaddr结构体大小
- 返回值
- 成功:0;失败 -1
static bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0) return true;
else return false;
}
Ip地址转换函数
网络编程中,IP地址一般是按照字符串的形式表示,例如“127.0.0.1”。但是在网络编程接口中要求IP地址以二进制的形式存储在结构体中,例如存储为sockaddr_in 和 sin_addr类型。
inet_pton()
- 作用:将点分十进制表示的IPV4地址或者冒号分割的IPV6地址转换为二进制形式
- 参数
- af:协议族,AF_INET(4),AF_INET6(6)
- src:以字符串形式表示的IP地址
- dst: 指向保存转换后地址的缓冲区,通常是一个
struct in_addr
或struct in6_addr
结构体- 返回值
- 成功1 无效0 失败-1
TCP连接内核理解
创建socket后,用户层面看到是返回一个文件描述符,但是内核创建了一系列socket相关的对象。
socket系统调用
内核执行流程
- 分配套接字结构
- 内核首先分配一个套接字结构(sock结构体),用于存储套接字的各种属性和状态信息
- 初始化套接字结构
- 根据传递的参数,初始化套接字结构的各个字段
- 设置套接字的协议族、类型和协议
- 初始化套接字的状态为未连接状态
- 分配文件描述符
- 内核分配一个文件描述符(file结构体),用于引用新创建的套接字结构
- 将文件描述符添加到进程的文件描述符表中,以便于后续操作可以通过该文件描述符访问套接字
- 初始化文件描述符结构
- 将文件描述符与套接字结构关联起来
- 设置文件描述符的操作方法(读写关闭等),该方法指向套接字的相关函数
- 返回文件描述符
- 内核返回文件描述符供进程使用
文件描述符存储信息概述
- 文件描述符表条目
- 指向file结构体的指针
- 记录文件描述符的引用计数
- 文件结构(file结构)
- 包含指向套接字结构体(sock结构)的指针
- 记录文件描述符读写、关闭等操作方法
- 记录文件描述符的状态标志(设置阻塞或非阻塞)
- 套接字结构(sock结构体)
- 套接字协议族、类型
- 套接字的各种属性和状态信息
- 套接字的缓冲区、发送和接收队列
listen系统调用
总结
- 验证套接字
- 确保套接字已经保定到一个特定的IP地址和端口号上
- 设置套接字的状态为监听
- 内核将套接字的状态标记为监听状态,此时套接字不再是主动连接,而是用于被动的等待客户端的连接请求
- 分配队列
- listen中backlog指定了内核应该为套接字维护的半连接和全连接的最大长度
- 半连接队列:用于存放已经收到SYN请求但是尚未完成三次握手的连接请求
- 全连接队列:用于存放已经完成三次握手,等待被accept系统调用处理的连接请求
- 初始化队列
- 处理连接请求
- 套接字进入连接状态后,内核开始监听指定端口的连接请求
- 事件通知
- 如果套接字是非阻塞的,或者使用的事件驱动机制,内核会在新的连接请求到来的时候通知应用程序
主要工作总结:申请和初始化接收队列(未完成连接队列、已完成连接队列)。已完成连接队列的底层使用链表数据结构。未完成连接队列中使用哈希表。
首先设置套接字为监听状态,具体实现方法通过tcp_sock结构体的强转完成。能够实现实现强制转换的原因主要存在嵌套关系。
其次设置两个连接队列的长度,这两个队列是接收客户端请求的主要数据结构。其中已完成连接队列中存储的都是已经完成TCP三次握手过程的连接。
connect系统调用
connect系统调用后Linux底层主要流程
- 检查套接字状态
- 首先检查connect()传入的套接字是否有效且未连接。如果该套接字已经被连接或者无效,则该系统调用直接返回错误
- 检查地址和端口
- 检查传递给connect()的服务器地址和端口信息,确保其是有效的。如果地址无效,connect()则返回错误
- 设置目的地址
- 内核将服务器的端口和地址信息存储在套接字结构中,以便后续使用
- 启动三次握手
- 更新套接字状态
- 三次握手后,内核将客户端套接字的状态更新已连接状态,此时connect()调用返回,并且应用程序可以使用该套接字进行数据传输
- 错误处理
- 如果三次握手过程中出现任意错误,则会返回一个内核错误码,connect()调用将返回-1,同时设置errno
非阻塞模式:如果套接字被设置成非阻塞模式,connect()调用会立即返回,不会等待三次握手完成。在该情况下,内核在三次握手完成或者失败的时候通知应用程序,例如在EPOLL中检测套接字何时变的可写,从而确定连接何时完成。
主要工作总结:将本地的socket状态设置成TCP_SYN_SENT,然后选一个可用端口,接着发出SYN握手请求,开始TCP三次握手流程。
三次握手内核级别理解
- listen
- 申请并初始化两个连接队列(已完成连接队列和未完成连接队列)
- connect
- 客户端:选择本地可用的端口;发出SYN握手请求;启动重传计时器
- 服务端:判断接收队列是否已经满了,如果满了抛弃该请求,如果没满发送SYN ACK,申请request_sock添加到半连接队列中,同时启动定时器。
- 客户端:清除重传计时器;设置为已连接;发送ACK确认
- 服务端:创建新的sock,从半连接队列中删除,然后将其加入到全连接队列中。
- accept
- 从全队列中拿走socket
- 补充:如果程序中实现了线程池,那么建立好的队列不是放在全连接队列中,而是提前放到了线程池中,所以获取连接的时候也是直接通过线程池去获得。