网络编程学习:TCP/IP协议
TCP/IP协议简介
TCP/IP协议包含了一系列的协议,也叫TCP/IP协议族(TCP/IP Protocol Suite,或TCP/IP Protocols),简称TCP/IP。
分层结构
为了能够实现不同类型的计算机和不同类型的操作系统之间进行通信,引入了分层的概念最早的分层体系结构是OSI开放系统互联模型,由国际化标准组织(ISO)指定的,由于OSI过于复杂,所以到现在为止也没有适用,而使用的是TCP/IP协议族
OSI一共分为7层,TCP/IP协议族一共四层,简介如下,
各层完成的任务简介如下,
- 应用层:应用程序间沟通的层,应用层的协议有FTP、Telnet、HTTP等。
- 传输层:提供进程间的数据传送服务,负责传送数据,并且提供应用程序端到端的逻辑通信。传输层的协议有TCP、UDP等。
- 网络层:提供基本的数据封包传送功能,最大可能的让每个数据包都能够到达目的主机。网络层的协议有IP、ICMP等。
- 链路层:负责数据帧的发送和接收,链路层的协议有ARP、RARP等。
数据包传输
数据包在网络中从发送端到接收端的传输过程,涉及到从应用层到链路层的逐层封装和解封装,过程如下图所示,
数据包从发送端的应用进程开始逐层向下传递,最终通过物理网络传输到接收端,每一层都会对数据进行一定的封装,以便于传输和接收。具体的流程如下:
-
应用层:应用层负责生成数据,数据可能是来自应用程序(如HTTP请求、FTP传输等)的实际内容。应用层的数据会传递到传输层。
-
传输层(TCP/UDP):数据在传输层会将TCP/UDP协议头部附加在应用层数据之前(图中标识为 “TCP/UDP头部”),无论TCP和UDP都用一个16位的端口号来表示不同的应用程序,并且都会将源端口和目的端口存入报文首部中。封装后的数据包被传递到网络层。
-
网络层:网络层负责将传输层数据封装到IP数据报中,IP首部会标识处理数据的协议类型(上层数据类型),IP首部会存入一个长度为8位的数值,称作协议域:6表示为TCP协议、17表示为UDP协议等。IP首部还会标识发送方地址(源IP)和接收方地址(目标IP)。经过封装的数据包被传递到数据链路层。
-
链路层(数据链路):链路层将IP数据报进一步封装到帧中,准备通过物理网络传输。以太网头部包含了源MAC地址、目标MAC地址、以太网帧类型(如0800代表IPv4)等信息。数据链路层帧通过物理介质(如以太网、Wi-Fi等)传输到目的地。
备注:数据封装和分用的过程大致为,发送端每通过一层会增加该层的首部,接收端每通过一层则删除该层的首部。
IP协议
IP协议简介
IP协议也称之为网际协议,特指为实现在一个相互连接的网络系统上从源地址到目的地传输数据包(互联网数据包)所提供必要功能的协议。
IP协议的特点如下:
- 不可靠:它不能保证IP数据包能成功地到达它的目的地,仅提供尽力而为的传输服务。
- 无连接:IP并不维护任何关于后续数据包的状态信息,每个数据包的处理是相互独立的,IP数据包可以不按发送顺序接收。
备注:IP数据包中含有发送它主机的IP地址(源地址)和接收它主机的IP地址(目的地址)
在IP协议中,有几个概念需要我们进行了解,包括MAC地址,IP地址,子网掩码,端口,以下我们一一介绍。
MAC地址
MAC地址我们可以简单理解成是每个计算机的身份证号,在理论上MAC每一台计算机的MAC地址都是唯一的。
因为每一台网络设备要和其他设备通信都要使用网络适配器或者叫网卡,而每一个网卡在出厂时都会被分配一个编号,这个编号就是MAC地址,用来标识网络设备的,所以是不是很像身份证。
MAC地址在以太网内通常是一个48bit的值,人为识别的时侯是通过16进制数来识别的,以两个十六进制数为一组,一共分为6组,每组之间通过“:”隔开,前三组称之为厂商ID,后三组称之为设备ID。
大家有兴趣可以查查自己电脑的MAC地址,如下,
IP地址
上述的MAC地址属于是硬件地址,那么IP地址就是属于软件层面的地址,也就是说IP地址是可以进行修改的,此外IP地址是任意一台主机在网络中的唯一标识。
IP地址目前有两种,分别是ipv4,占32位和ipv6,占128位。
以下我们介绍以下ipv4,
ipv4由32bit组成,一般使用点分十进制字符串来标识,比如192.168.3.103
ipv4是由{网络ID,主机ID}两部分组成,
- 子网ID:IP地址中由子网掩码中1覆盖的连续位
- 主机ID:IP地址中由子网掩码中0覆盖的连续位
上述所讲的子网掩码是什么,简单来说子网掩码其实就是用来保证几个主机只能在一个子网下进行通信的。
比如上图的子网掩码为 255.255.255.0,换算成二进制其实就是前面的24bit都为1,而最后的8bit为0,然后我们通过原IP地址和子网掩码进行与运算,如果运算结果相等,那就表示在一个子网下了。
通过上述描述我们可以总结出IP地址的特点如下,
- 子网ID不同的网络不能直接通信,如果要通信则需要路由器转发
- 主机ID全为0的IP地址表示网段地址
- 主机ID全为1的IP地址表示该网段的广播地址
比如,192.168.3.10和192.168.3.111可以直接通信
如果192.168.3.x网段而言,192.168.3.0表示网段,192.168.3.255表示广播地址。
ipv4地址目前有五种类别,都是依据前八位来分的,如下,
- A类地址:默认8bit子网ID,第一位为0,前八位00000000 - 01111111,范围0.x.x.x - 127.x.x.x
- B类地址:默认16bit子网ID,前两位为10,前八位10000000 - 10111111,范围128.x.x.x-191.x.x.x
- C类地址:默认24bit子网ID,前三位为110,前八位11000000 - 11011111,范围192.x.x.x-223.x.x.x
- D类地址:前四位为1110,组播地址,前八位11100000-11101111,范围224.x.x.x- 239.x.x.x
- E类地址: 前五位为11110,保留为今后使用,前八位11110000-11111111,范围240.x.x.x-255.x.x.x
备注:在上述五类中,A,B,C三类地址是最常用的
在ipv4地址中,还有一个比较特殊的地址,叫做回环ip地址,地址为127.0.0.1 。
回环地址的功能主要是测试本机的网络配置,能ping通127.0.0.1说明本机的网卡和IP协议安装都没有问题。
并且127.0.0.1~127.255.255.254中的任何地址都将回环到本地主机中不属于任何一个有类别地址类,它代表设备的本地虚拟接口。
端口
TCP/IP协议采用的是端口标识通信的进程,端口用于区分一个系统里的多个进程。
端口有如下特点,
- 对于同一个端口,在不同系统中对应着不同的进程
- 对于同一个系统,一个端口只能被一个进程拥有
- 一个进程拥有一个端口后,传输层送到该端口的数据全部被该进程接收,同样,进程送交传输层的数据也通过该端口被送出
在网络程序中,用端口号(port)来标识一个运行的网络程序
端口号的特点如下,
- 端口号是无符号短整型的类型
- 每个端口都拥有一个端口号
- TCP、UDP维护各自独立的端口号
- 网络应用程序,至少要占用一个端口号,也可以占有多个端口号
备注:端口号就类似于进程号,同一个时刻只能标志一个端口号,且可以重复使用。
UDP协议
UDP协议简介
UDP协议是传输层协议的一种,也叫用户数据报协议。
UDP是一种面向无连接的传输层通信协议,可以通过不同主机上的进程间通信。
UDP协议有如下几个特点,
- 发送数据之前不需要建立链接
- 不对数据包的顺序进行检查
- 没有错误检测和重传机制
- 相比TCP速度稍快些
- 简单的请求/应答应用程序可以使用UDP
- 对于海量数据传输不应该使用UDP
- 广播和多播应用必须使用UDP
UDP协议主要应用于 DNS(域名解析)、NFS(网络文件系统)、RTP(流媒体)等领域,一般语音和视频通话都是使用UDP来通信的
UDP编程基础
在了解UDP协议的编程前,我们先了解以下几个概念,C/S架构,字节序,地址转换和socket编程。
C/S架构
C/S架构简单来说就是客户端(client)和服务器(server)的通信,通常客户端都是主动的一方,主动向服务器发送请求,服务器接收到后给予相应的服务。
字节序
字节序就是指多个字节数据的存储顺序,分为两种存储方式,大端存储和小端存储。
- 大端格式:将高位字节数据存储在低地址
- 小端格式:将低位字节数据存储在低地址
两种存储方式示意图如下,
备注:我们可以直接认为,离0x近的是高位字节,远的是低位字节。
如果要判断当前主机的字节序的话,可以通过以下程序判断,
#include <stdio.h>
//判断当前系统的字节序
union un
{
int a;
char b;
};
int main(int argc, char const *argv[])
{
//由于union联合体的元素是共享内存,所以可以通过判断元素b所在的地址所存储的字节判断字节序
union un myun;
myun.a = 0x12345678;
printf("a = %#x\n", myun.a);
printf("b = %#x\n", myun.b);
if(myun.b == 0x78)
{
printf("小端存储\n");
}
else
{
printf("大端存储\n");
}
return 0;
}
当两台计算机进行通信时,可能存在字节序不相同的问题,这就会导致通信也出现问题,于是我们可以通过字节序转换函数将主机的字节序转换成网络字节序解决这个问题。
字节序转换函数有四个,分别是htonl(),htons(),ntohl(),ntohs(),其中的h表示host,n表示network,后面的l和s区分转换字节序是long还是short类型。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostint32);
// 功能:
// 将32位主机字节序数据转换成网络字节序数据
// 参数:
// hostint32:待转换的32位主机字节序数据
// 返回值:
// 成功:返回网络字节序的值
uint16_t htons(uint16_t hostint16);
// 功能:
// 将16位主机字节序数据转换成网络字节序数据
// 参数:
// hostint16:待转换的16位主机字节序数据
// 返回值:
// 成功:返回网络字节序的值
uint32_t ntohl(uint32_t netint32);
// 功能:将32位网络字节序数据转换成主机字节序数据
// 参数:
// netint32:待转换的32位网络字节序数据
// 返回值:
// 成功:返回主机字节序的值
uint16_t ntohs(uint16_t netint16);
// 功能:将16位网络字节序数据转换成主机字节序数据
// 参数:
// netint16:待转换的16位网络字节序数据
// 返回值:
// 成功:返回主机字节序的值
备注:网络协议指定了通讯字节序为大端方式,并且在需要字节序转换的时候一般调用特定字节序转换函数。
地址转换函数
由于我们人为识别的IP地址是点分十进制的字符串形式,但是计算机或者网络中的识别的IP地址是整型数据,所以需要使用地址转换函数进行转换。
地址转换函数有inet_pton(),inet_ntop(),inet_addr()和inet_ntoa(),详细介绍如下,
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
// 功能:将32位无符号整数转换成点分十进制数串
// 参数:
// family:协议族
// addrptr:32位无符号整数
// strptr:点分十进制数串
// len:strptr缓存区长度
// len的宏定义
#define INET_ADDRSTRLEN 16 // for IPv4
#define INET6_ADDRSTRLEN 46 // for IPv6
// 返回值:
// 成功:返回字符串的首地址
// 失败:返回NULL
in_addr_t inet_addr(const char *cp);
// 功能:将点分十进制IP地址转化为整形数据
// 参数:
// cp:点分十进制的IP地址
// 返回值:
// 成功:整形数据
char *inet_ntoa(struct in_addr in);
// 功能:将整形数据转化为点分十进制的IP地址
// 参数:
// in:保存IP地址的结构体
// 返回值:
// 成功:点分十进制的IP地址
网络编程接口socket
在进行网络编程时,socket套接字编程是必要的编程接口,这里简单介绍一下,详细建议自己取学习一下。
socket的作用是提供不同主机上的进程之间的通信。
socket特点包括以下几个,
- socket也称“套接字”
- socket是一种文件描述符,代表了一个通信管道的一个端点
- socket的操作类似于对文件的操作一样,可以使用read、write、close等函数对socket套接字进行网络
- socket可以进行数据的收取和发送等操作
- 创建socket套接字(描述符)的方法调用socket()
socket有以下三种分类,
- SOCK_STREAM,流式套接字,用于TCP
- SOCK_DGRAM,数据报套接字,用于UDP
- SOCK_RAW,原始套接字,对于其他层次的协议操作时需要使用这个类型
UPD协议编程
在了解UDP编程之前,我们可以直接看UDP的C/S架构,如下,
由C/S架构,可以知道UDP的服务器和客户端的流程如下,
服务器:
- 创建套接字 socket( )
- 将服务器的ip地址、端口号与套接字进行绑定 bind( )
- 接收数据 recvfrom()
- 发送数据 sendto()
客户端:
- 创建套接字 socket()
- 发送数据 sendto()
- 接收数据 recvfrom()
- 关闭套接字 close()
依据上述流程,可以直接进行UDP的编程,以下是各个函数的描述。
创建socket()套接字
int socket(int domain, int type, int protocol);
// 功能:创建一个套接字,返回一个文件描述符
// 参数:
// domain:通信域,协议族
// AF_UNIX 本地通信
// AF_INET IPv4网络协议
// AF_INET6 IPv6网络协议
// AF_PACKET 底层接口
// type:套接字的类型
// SOCK_STREAM 流式套接字(TCP)
// SOCK_DGRAM 数据报套接字(UDP)
// SOCK_RAW 原始套接字(用于链路层)
// protocol:附加协议,如果不需要,则设置为0
// 返回值:
// 成功:文件描述符
// 失败:-1
可以看到,创建socket函数的三个参数,由于我们选择UDP和ipv4通信,所以参数选择为 AF_INET和SOCK_DGRAM,后续函数可以自行对照参数,不做介绍了。
备注:再创建套接字时,系统不会分配端口;并且创建的套接字默认属性是主动的,即主动发起服务的请求;当作为服务器时,往往需要修改为被动的。
发送数据sendto()
#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
// 功能:发送数据
// 参数:
// sockfd:文件描述符,socket的返回值
// buf:要发送的数据
// len:buf的长度
// flags:标志位
// 0 阻塞
// MSG_DONTWAIT 非阻塞
// dest_addr:目的网络信息结构体(需要自己指定要给谁发送)
// addrlen:dest_addr的长度
// 返回值:
// 成功:发送的字节数
// 失败:-1
在以上函数中,可以看到其中我们需要填入一个参数为dest_addr这个结构体,这个结构体的类型为sockaddr*,是一个通用的地址结构体,用于存储各种协议的地址信息,结构体定义如下,
struct sockaddr {
sa_family_t sa_family; // 2字节,地址族,例如 AF_INET, AF_INET6
char sa_data[14]; // 地址数据,具体含义取决于地址族
};
而针对不同的网络协议,我们在定义结构体时的类型都不一样,对于我们要使用的ipv4协议,需要定义的结构体类型为sockaddr_in,定义如下,
struct sockaddr_in {
sa_family_t sin_family; // 地址族,必须是 AF_INET
in_port_t sin_port; // 16位的端口号(网络字节序)
struct in_addr sin_addr; // 32位的IPv4地址(网络字节序)
char sin_zero[8]; // 填充字段,保持与sockaddr大小一致
};
可以看到,在 sockaddr_in中提供了明确的字段来存储IPv4的地址和端口信息。
而由于sendto函数(包括后续的函数)中传入的结构体类型为sockaddr类型,所以在传入是需要进行强制类型转换,如下,
struct sockaddr_in my_addr;
bind(sockfd,(struct sockaddr*)&my_addr,sizeof(my_addr));
绑定ip和port号bind()
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 功能:将套接字与网络信息结构体绑定
// 参数:
// sockfd:文件描述符,socket的返回值
// addr:网络信息结构体
// 通用结构体(一般不用)
// struct sockaddr
// 网络信息结构体 sockaddr_in
// #include <netinet/in.h>
// struct sockaddr_in
// addrlen:addr的长度
// 返回值:
// 成功:0
// 失败:-1
绑定函数适用于固定服务器的ip地址和port号,这是因为服务器是被动的,客户端是主动的,一般先运行服务器,后运行客户端,所以服务器需要固定自己的信息(ip地址和端口号),这样客户端才可以找到服务器并与之通信。
接收数据recvfrom()
1 #include <sys/types.h>
2 #include <sys/socket.h>
3 4
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,5 struct sockaddr *src_addr, socklen_t *addrlen);
6 功能:接收数据
7 参数:
8 sockfd:文件描述符,socket的返回值
9 buf:保存接收的数据
10 len:buf的长度
11 flags:标志位
12 0 阻塞
13 MSG_DONTWAIT 非阻塞
14 src_addr:源的网络信息结构体(自动填充,定义变量传参即可)
15 addrlen:src_addr的长度
16 返回值:
17 成功:接收的字节数
18 失败:‐1
UDP编程示例
以上已经介绍了所有的UDP编程的相关函数,下面是一个客户端和一个服务器的编程示例,
客户端
// udp客户端的实现
#include <stdio.h> // printf
#include <stdlib.h> // exit
#include <sys/types.h>
#include <sys/socket.h> // socket
#include <netinet/in.h> // sockaddr_in
#include <arpa/inet.h> // htons, inet_addr
#include <unistd.h> // close
#include <string.h>
int main(int argc, char const *argv[])
{
if(argc < 3)
{
fprintf(stderr, "Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}
int sockfd; // 文件描述符
struct sockaddr_in serveraddr; // 服务器网络信息结构体
socklen_t addrlen = sizeof(serveraddr);
// 第一步:创建套接字
if((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0)
{
perror("fail to socket");
exit(1);
}
// 客户端自己指定自己的ip地址和端口号,一般不需要,系统会自动分配
#if 0
struct sockaddr_in clientaddr;
clientaddr.sin_family = AF_INET;
clientaddr.sin_addr.s_addr = inet_addr(argv[3]); // 客户端的ip地址
clientaddr.sin_port = htons(atoi(argv[4])); // 客户端的端口号
if(bind(sockfd, (struct sockaddr *)&clientaddr, addrlen) < 0)
{
perror("fail to bind");
exit(1);
}
#endif
// 第二步:填充服务器网络信息结构体
// inet_addr:将点分十进制字符串ip地址转化为整形数据
// htons:将主机字节序转化为网络字节序
// atoi:将数字型字符串转化为整形数据
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(argv[1]);
serveraddr.sin_port = htons(atoi(argv[2]));
// 第三步:进行通信
char buf[32] = "";
while(1)
{
fgets(buf, sizeof(buf), stdin);
buf[strlen(buf) - 1] = '\0';
if(sendto(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) < 0)
{
perror("fail to sendto");
exit(1);
}
char text[32] = "";
if(recvfrom(sockfd, text, sizeof(text), 0, (struct sockaddr *)&serveraddr, &addrlen) < 0)
{
perror("fail to recvfrom");
exit(1);
}
printf("from server: %s\n", text);
}
// 第四步:关闭文件描述符
close(sockfd);
return 0;
}
备注:在客户端的代码中,我们只设置了目的IP、目的端口,客户端的本地ip、本地port是我们调用sendto的时候系统底层自动给客户端分配的;分配端口的方式为随机分配,即每次运行系统给的port不一样
服务器
// udp服务器的实现
#include <stdio.h> // printf
#include <stdlib.h> // exit
#include <sys/types.h>
#include <sys/socket.h> // socket
#include <netinet/in.h> // sockaddr_in
#include <arpa/inet.h> // htons, inet_addr
#include <unistd.h> // close
#include <string.h>
int main(int argc, char const *argv[])
{
if(argc < 3)
{
fprintf(stderr, "Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}
int sockfd; // 文件描述符
struct sockaddr_in serveraddr; // 服务器网络信息结构体
socklen_t addrlen = sizeof(serveraddr);
// 第一步:创建套接字
if((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0)
{
perror("fail to socket");
exit(1);
}
// 第二步:填充服务器网络信息结构体
// inet_addr:将点分十进制字符串ip地址转化为整形数据
// htons:将主机字节序转化为网络字节序
// atoi:将数字型字符串转化为整形数据
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(argv[1]);
serveraddr.sin_port = htons(atoi(argv[2]));
// 第三步:将套接字与服务器网络信息结构体绑定
if(bind(sockfd, (struct sockaddr *)&serveraddr, addrlen) < 0)
{
perror("fail to bind");
exit(1);
}
while(1)
{
// 第四步:进行通信
char text[32] = "";
struct sockaddr_in clientaddr;
if(recvfrom(sockfd, text, sizeof(text), 0, (struct sockaddr *)&clientaddr, &addrlen) < 0)
{
perror("fail to recvfrom");
exit(1);
}
printf("[%s - %d]: %s\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port), text);
strcat(text, " *_*");
if(sendto(sockfd, text, sizeof(text), 0, (struct sockaddr *)&clientaddr, addrlen) < 0)
{
perror("fail to sendto");
exit(1);
}
}
// 第四步:关闭文件描述符
close(sockfd);
return 0;
}
备注:服务器之所以要bind是因为它的本地port需要是固定,而不是随机的,服务器也可以主动地给客户端发送数据
TCP协议
TCP协议简介
TCP协议也是传输层协议的一种,也叫传输控制协议。
TCP是一种面向连接的且可靠的传输层通信协议。
TCP协议有如下几个特点,
- 每一次完整的数据传输都要经过建立连接、使用连接、终止连接的过程
- TCP数据包中包含序号和确认序号
- 对包进行排序并检错,可靠,而损坏的包可以被重传且每收到一个数据都要给出相应的确认
- 服务器被动链接,客户端是主动链接
TCP协议的服务对象是需要高度可靠性且面向连接的服务如HTTP、FTP、SMTP等。
TCP协议报文格式
传输层的TCP协议的TCP头的数据帧格式如下,
下面是每一个字段的分析,
- 源端口号: 表示报文的发送端口,占16位。
- 目的端口号:表示报文的接收端口,占16位。
- 序号:表示报文中第一个字节的序号,占32位,发起方发送数据时,都需要标记序号。
- 确认序号:表示期望收到对方的下一个报文段数据的第一个字节的序号,占32位。
- 头部长度:表示TCP头的长度,也就是数据开始的位置,占4位。
- 保留:4位值域,这些位必须是0,为了将来定义新的用途所保留。
- 窗口大小:表示自己接收方的窗口,告诉对方我还允许发送方发送多少字节,占16位。
- 校验位:检验数据正确性,占16位。
- 优先指针:指示报文段中紧急数据的第一位,通过紧急指针判断哪些数据是紧急数据,占16位,要和表示为URG位一起使用。
- 选项:长度不定,但长度必须以是32bits的整数倍。
- 标志位:总共由8位的标志位,下面介绍6位。
- URG:URG置1时表示紧急指针字段有效,告诉系统报文段中有紧急数据。
- ACK:ACK置1时确认号字段才能生效。
- PSH:该标志置1时,一般是表示发送端缓存中已经没有待发送的数据,接收端不将该数据进行队列处理,而是尽可能快将数据转由应用处理,简单理解就是接收端插队。
- RST:用于复位相应的TCP连接。
- SYN:SYN置1时表示这是一个连接请求或连接接收报文。
- FIN:用来释放一个连接,置1时表明发送端数据完毕。
TCP三次握手
TCP协议中,客户端和服务器建立连接时都要进行三次握手,图示如下,
三次握手的流程如下,
-
第一次握手:客户端首先给服务端发一个 SYN 报文,并指明客户端的初始化序列号 ISN。此时客户端处于同步等待的状态。首部的同步位SYN=1,初始序号seq=x,SYN=1的报文段不能携带数据,但是要消耗掉一个序号。
-
第二次握手:服务器在收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且也是指定了自己的初始化序列号 ISN(s)。同时会把客户端的 ISN + 1 作为ACK 的值,表示自己已经收到了客户端的 SYN。在确认报文段中SYN=1,ACK=1,确认号ack=x+1,初始序号seq=y。
-
第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 ISN + 1 作为 ACK 的值,表示已经收到了服务端的 SYN 报文。服务器收到 ACK 报文之后,双方已建立起了连接。确认报文段ACK=1,确认号ack=y+1,序号seq=x+1(初始为seq=x,第二个报文段所以要+1),ACK报文段可以携带数据,不携带数据则不消耗序号。
为什么一定要是三次握手而不能是两次握手?
假设两次握手建立连接,客户端向服务器发送SYN1包请求建立连接,但是由于出现问题导致SYN1在中间存在滞留,此时客户端会重发SYN2包,数据包正常送达后服务器恢复SYN2+ACK通过两次握手建立连接,但是如果此时SYN1阻塞的网络节点恢复,这时候SYN1会送达到服务器,那服务器就会误以为客户端又发起了一个新连接,这样的话服务器在两次握手后就会再次等待客户端发送数据,但是客户端自己知道只发送了一个连接此时就出现了服务器和客户端状态不一致。
但是上述场景如果是三次握手的场景下,最后的SYN1包发送给服务器时,此时服务器如果收不到客户端的ACK应答的话就不会认为与客户端第二次建立连接成功。
总结:三次握手本质上就是为了解决网络信道不可靠的问题,为了在不可靠的信道上建立可靠的连接。
TCP四次挥手
处于连接状态的客户端和服务端,都可以发起关闭连接请求,此时需要进行四次挥手进行关闭连接,图示如下,
四次挥手流程如下,
- 第一次挥手:主动的断开方既可以是客户端,也可以是服务器端,向对方发送一个FIN数据包,表示要关闭连接,并且自己进入终止等待1(FIN_WAIT_1)状态。
- 第二次挥手:正常情况下,在收到了主动断开方发送的FIN断开请求报文后,被动断开方会发送一个ACK响应数据包,表示自己进入了关闭等待(CLOSE-WAIT)状态,且主动断开方会进入终止等待2(FIN_WAIT_2)状态。
- 第三次挥手:在发送完成ACK报文后,被动断开方还可以继续完成未数据的发送,待剩余数据发送完成后,被动断开方会向主动断开方发送一个FIN数据包,表示被动断开方的数据都发送完了,然后,被动断开方进入最终确认(LAST_ACK)状态。
- 第四次挥手:主动断开方收在到FIN断开响应数据包后,还需要进行最后的确认,向被动断开方发送一个ACK确认数据包,然后,自己就进入超时等待(TIME_WAIT)状态,等待超时后最终关闭连接。被动断开方在收到主动断开方的最后的ACK报文以后,就会关闭了连接。
为什么主动断开方要等待超时时间后才关闭连接?
这是因为主动断开方需要等待最后发送的ACK包顺利到达被动断开方。
总结就是为了在不可靠的链路中建立可靠的连接断开确认。
TCP协议编程
TCP协议的C/S架构示意图如下,
从上述的TCP架构中,我们可以知道TCP编程的流程如下,
服务器:
- 创建套接字 socket()
- 将套接字与服务器网络信息结构体绑定 bind()
- 将套接字设置为监听状态 listen()阻塞等待客户端的连接请求 accept()进行通信 recv()/send()
- 关闭套接字 close()
客户端:
- 创建套接字 socket()
- 发送客户端连接请求 connect()进行通信 send()/recv()
- 关闭套接字 close()
由于上面已经讲述了关于socket()编程的基础,所以这里不再赘述。
创建socket()套接字
创建套接字的socket()函数,在UDP编程时已经编写,详情请看UPD编程部分。
建立连接connect()
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 功能:给服务器发送客户端的连接请求
// 参数:
// sockfd:文件描述符,socket函数的返回值
// addr:要连接的服务器的网络信息结构体(需要自己设置)
// addrlen:addr的长度
// 返回值:
// 成功:0
// 失败:-1
备注:connect建立连接之后不会产生新的套接字,并且连接成功后才可以开始传输TCP数据。
发送数据send()
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 功能:发送数据
// 参数:
// sockfd:文件描述符
// 客户端:socket函数的返回值
// 服务器:accept函数的返回值
// buf:发送的数据
// len:buf的长度
// flags:标志位
// 0 阻塞
// MSG_DONTWAIT 非阻塞
// 返回值:
// 成功:发送的字节数
// 失败:-1
备注:不能用TCP协议发送0长度的数据包
接收数据recv()
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// 功能:接收数据
// 参数:
// sockfd:文件描述符
// 客户端:socket函数的返回值
// 服务器:accept函数的返回值
// buf:保存接收到的数据
// len:buf的长度
// flags:标志位
// 0 阻塞
// MSG_DONTWAIT 非阻塞
// 返回值:
// 成功:接收的字节数
// 失败:-1
// 如果发送端关闭文件描述符或者关闭进程,则recv函数会返回0
绑定ip和port号bind()
绑定函数bind(),在UDP编程时已经编写,详情请看UPD编程部分。
监听函数listen()
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
// 功能:将套接字设置为被动监听状态,这样做之后就可以接收到连接请求
// 参数:
// sockfd:文件描述符,socket函数返回值
// backlog:允许通信连接的主机个数,一般设置为5、10
// 返回值:
// 成功:0
// 失败:-1
阻塞等待accept()
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 功能:阻塞等待客户端的连接请求
// 参数:
// sockfd:文件描述符,socket函数的返回值
// addr:接收到的客户端的信息结构体(自动填充,定义变量即可)
// addrlen:addr的长度
// 返回值:
// 成功:新的文件描述符(只要有客户端连接,就会产生新的文件描述符,这个新的文件描述符专门与指定的客户端进行通信的)
// 失败:-1
以上就是所TCP网络编程会使用到的函数,现在我们根据上述的C/S架构编写一个TCP的服务器和客户端通信的基础示例。
TCP编程示例
客户端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <string.h>
#define N 128
int main(int argc, char const *argv[])
{
if(argc < 3)
{
fprintf(stderr, "Usage: %s [ip] [port]\n", argv[0]);
exit(1);
}
// 第一步:创建套接字
int sockfd;
if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("fail to socket");
exit(1);
}
// 第二步:发送客户端连接请求
struct sockaddr_in serveraddr;
socklen_t addrlen = sizeof(serveraddr);
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(argv[1]);
serveraddr.sin_port = htons(atoi(argv[2]));
if(connect(sockfd, (struct sockaddr *)&serveraddr, addrlen) == -1)
{
perror("fail to connect");
exit(1);
}
// 第三步:进行通信
// 发送数据
char buf[N] = "";
fgets(buf, N, stdin);
buf[strlen(buf) - 1] = '\0';
if(send(sockfd, buf, N, 0) == -1)
{
perror("fail to send");
exit(1);
}
// 接收数据
char text[N] = "";
if(recv(sockfd, text, N, 0) == -1)
{
perror("fail to recv");
exit(1);
}
printf("from server: %s\n", text);
// 第四步:关闭套接字文件描述符
close(sockfd);
return 0;
}
服务器
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <string.h>
#define N 128
int main(int argc, char const *argv[])
{
if(argc < 3)
{
fprintf(stderr, "Usage: %s [ip] [port]\n", argv[0]);
exit(1);
}
// 第一步:创建套接字
int sockfd;
if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("fail to socket");
exit(1);
}
// 第二步:将套接字与服务器网络信息结构体绑定
struct sockaddr_in serveraddr;
socklen_t addrlen = sizeof(serveraddr);
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(argv[1]);
serveraddr.sin_port = htons(atoi(argv[2]));
if(bind(sockfd, (struct sockaddr *)&serveraddr, addrlen) == -1)
{
perror("fail to bind");
exit(1);
}
// 第三步:将套接字设置为被动监听状态
if(listen(sockfd, 10) == -1)
{
perror("fail to listen");
exit(1);
}
// 第四步:阻塞等待客户端的连接请求
int acceptfd;
struct sockaddr_in clientaddr;
if((acceptfd = accept(sockfd, (struct sockaddr *)&clientaddr, &addrlen)) == -1)
{
perror("fail to accept");
exit(1);
}
// 打印连接的客户端的信息
printf("ip:%s, port:%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
// 第五步:进行通信
// TCP服务器与客户端通信时,需要使用accept函数的返回值
char buf[N] = "";
if(recv(acceptfd, buf, N, 0) == -1)
{
perror("fail to recv");
}
printf("from client: %s\n", buf);
strcat(buf, " *_*");
if(send(acceptfd, buf, N, 0) == -1)
{
perror("fail to send");
exit(1);
}
// 关闭套接字文件描述符
close(acceptfd);
return 0;
}
备注:作为TCP服务器,必须具备一个可以明确知道的地址,并且要让操作系统知道是一个服务器,而不是客户端,最后对于面向连接的TCP协议来说,连接的建立才真正意味着数据通信的开始。