网络编程 - OSI和TCP/IP协议模型及TCP、UDP协议

一、OSI七层协议模型及TCP/IP协议模型

OSI七层协议模型与TCP/IP协议模型的对应

1. OSI七层协议模型

层级作用
应用层为应用数据提供服务
表示层数据格式化,数据加密
会话层建立、维护和管理会话
传输层建立、维护和管理端到端的连接,控制数据传输的方式
网络层数据传输线路选择,IP地址及路由选择
数据链路层物理通路的发送和数据包的划分,附加Mac地址到数据包
物理层01比特流的转换
**数据传输由顶向下,下层为上层提供服务**

2. TCP/IP四层协议模型

层级作用
应用层负责处理特定的应用程序细节,如ftp、smtp、ssh等
运输层主要为两台主机上的应用提供端到端的通信,如TCP、UDP
网络层(互联网层)处理分组在网络中的活动,如分组的选路
链路层(数据链路层/网络接口层)包括操作系统中的设备驱动程序、计算机中对应的 网络接口卡,01比特流的转换

3. 协议封装

下层协议通过封装为上层协议提供服务。应用程序数据在发送到物理网络上之前,将沿着协议栈从上往下依次传递。每层协议都将在上层数据的基础上加上自己的头部信息(有时也包括尾部信息),以实现该层的功能。
协议的封装示意图

二、TCP协议 - TCP协议头部

TCP/IP协议头部示意图

字段名说明
源端口号和目的端口号发送端的端口号和目的端的端口号。和IP首部的源IP地址和目的IP地址可以唯一确定一个TCP连接
数据序号表示在这个报文段中的第一个数据字节序号
确认序号仅当ACK标志为1时有效。确认号表示期望收到的下一个字节的序号(这个下面再详细分析)
偏移就是头部长度,有4位,跟IP头部一样,以4字节为单位。最大是60个字节
保留位6位,必须为0
标志位见表:TCP/IP 标志位
窗口大小16位,代表的是窗口的字节容量,也就是TCP的标准窗口最大为2^16 - 1 = 65535个字节
校验和源机器基于数据内容计算一个数值,收信息机要与源机器数值结果完全一样,从而证明数据的有效性。检验和覆盖了整个的TCP报文段:这是一个强制性的字段,一定是由发送端计算和存储,并由接收端进行验证的
紧急指针是一个正偏移量,与序号字段中的值相加表示紧急数据最后一个字节的序号。TCP的紧急方式是发送端向另一端发送紧急数据的一种方式
选项与填充(必须为4字节整数倍,不够补0)最常见的可选字段的最长报文大小MSS(Maximum Segment Size),每个连接方通常都在一个报文段中指明这个选项。它指明本端所能接收的最大长度的报文段。该选项如果不设置,默认为536(20+20+536=576字节的IP数据报)
表:TCP/IP 标志位
标志位名称说明
URG紧急指针有效
ACK确认序号有效
PSH接收方应尽快将这个报文交给应用层
RST连接重置
SYN同步序号用来发起一个连接
FIN终止一个连接

三、TCP协议 - 三次握手

TCP三次握手示意图

  1. 首先客户端向服务器端发送一段TCP报文,其中:标记位为SYN,表示“请求建立新连接”,序号为seq=x(x一般为1),随后客户端进入SYN-SENT阶段。
  2. 服务器端接收到来自客户端的TCP报文后,结束LISTEN阶段。并返回一段TCP报文,其中:标志位为SYN和ACK,表示"确认客户端的报文seq序号有效,服务器能正常接收客户端发送的数据,并同意创建新连接"(即告诉客户端,服务器收到了你的数据);序号为seq=y,确认号为ack=x+1,表示收到客户端的序号seq并将其值加1作为自己确认号ack的值;随后服务器端进入SYN-RCVD阶段。
  3. 客户端接收到来自服务器端的确认收到数据的TCP报文之后,明确了从客户端到服务器的数据传输是正常的,结束SYN-SENT阶段。并返回最后一段TCP报文。其中:标志位为ACK,表示“确认收到服务器端同意连接的信号”(即告诉服务器,我知道你收到我发的数据了);序号为seq=x+1,表示收到服务器端的确认号ack,并将其值作为自己的序号值;确认号为ack=y+1,表示收到服务器端序号seq,并将其值加1作为自己的确认号ack的值;随后客户端进入ESTABLISHED阶段。服务器收到来自客户端的“确认收到服务器数据”的TCP报文之后,明确了从服务器到客户端的数据传输是正常的。结束SYN-SENT阶段,进入ESTABLISHED阶段。
  4. 在客户端与服务器端传输的TCP报文中,双方的确认号ack和序号seq的值,都是在彼此ack和seq值的基础上进行计算的,这样做保证了TCP报文传输的连贯性。一旦出现某一方发出的TCP报文丢失,便无法继续"握手",以此确保了"三次握手"的顺利完成。
  • 使用Wireshark抓包工具查看TCP三次握手过程
  1. 在cmd下输入:nslookup smtp.163.com 获取smtp邮件服务的IP地址
  2. 回到Wireshark,在使用这个过滤器后的编辑框内输入:ip.addr == 220.181.12.16
  3. 选择WLAN
  4. 点击左上角开始捕获分组按钮开始捕获
  5. 在cmd下输入:telnet 220.181.12.16 25
    220.181.12.16 - smtp邮件服务的IP地址
    25 - 端口号
    捕获结果如下:
    使用Wireshark转包工具查看三次握手过程

四、TCP协议 - 滑动窗口

维互发送方/接收方缓冲区,缓冲区是用来解决网络之间数据不可靠的问题,例如丢包,重复包,出错,乱序。在TCP协议中,发送方和接受方通过各自维护自己的缓冲区。通过商定包的重传机制等一系列操作,来解决不可靠的问题。

  1. 为保证数据包依次序传输,使用发送<=>确认机制 发送确认机制示意图

  2. 为解决发送<=>确认机制带来的效率上的弊端(数据包在网络上传输需要时间),采用了如下方案:一次发送多个包,同时确认多个 一次发送多个包,同时确认多个包机制示意图

  3. 那么一次发送多少个包过去呢?一次发送多少包是最优解呢?

我们能不能把第一个和第二个包发过去后,收到第一个确认包就把第三个包发过去呢?而不是去等到第二个包的确认包才去发第三个包。这样就很自然的产生了我们"滑动窗口"的实现。

1. 正常情况

滑动窗口 - 正常情况A

  • 在上图中,我们可看出灰色1号2号3号包已经发送完毕,并且已经收到ack。这些包就已经是过去式。4、5、6、7号包是黄色的,表示已经发送了。但是并没有收到对方的ack,所以也不知道接收方有没有收到。8、9、10号包是绿色的。是我们还没有发送的。这些绿色也就是我们接下来马上要发送的包。 可以看出我们的窗口正好是7格。后面的11-16还没有被读进内存。要等4号-10号包有接下来的动作后,我们的包才会继续往下发送。
    滑动窗口 - 正常情况B
  • 从上图可以看到4号包对方已经被接收到,所以被涂成了灰色。“窗口”就往右移一格,这里只要保证“窗口”是7格的。 我们就把11号包读进了我们的缓存。进入了“待发送”的状态。8、9号包已经变成了黄色,表示已经发送出去了。接下来的操作就是一样的了,确认包后,窗口往后移继续将未发送的包读进缓存,把“待发送“状态的包变为“已发送”。

2. 丢包情况

  • 有可能我们包发过去,对方的ack丢了。也有可能我们的包并没有发送过去。从发送方角度看就是我们没有收到ack。
    滑动窗口 - 丢包情况
  • 一般情况:一直在等ack。如果一直等不到的话,我们也会把读进缓存的待发送的包也一起发过去。但是,这个时候我们的窗口已经发满了。所以并不能把12号包读进来,而是始终在等待5号包的Ack。

如果我们这个Ack始终不来怎么办呢? 采用超时重传机制解决:
发送端每发送一个报文段,就启动一个定时器并等待确认信息;接收端成功接收新数据后返回确认信息。若在定时器超时前数据未能被确认,TCP就认为报文段中的数据已丢失或损坏,需要对报文段中的数据重新组织和重传。

滑动窗口 - 超时重传

五、TCP协议 - 四次挥手

四次挥手示意图

  1. 客户端发送断开TCP连接请求的报文,其中报文中包含seq序列号,是由发送端随机生成的,并且还将报文中的FIN字段置为1,表示需要断开TCP连接。(FIN=1,seq=x,x由客户端随机生成)
  2. 服务端会回复客户端发送的TCP断开请求报文,其包含seq序列号,是由回复端随机生成的,而且会产生ACK字段,ACK字段数值是在客户端发过来的seq序列号基础上加1进行回复,以便客户端收到信息时,知晓自己的TCP断开请求已经得到验证。(FIN=1,ACK=x+1,seq=y,y由服务端随机生成)
  3. 服务端在回复完客户端的TCP断开请求后,不会马上进行TCP连接的断开,服务端会先确保断开前,所有传输到A的数据是否已经传输完毕,一旦确认传输数据完毕,就会将回复报文的FIN字段置1,并且产生随机seq序列号。(FIN=1,ACK=x+1,seq=z,z由服务端随机生成)
  4. 客户端收到服务端的TCP断开请求后,会回复服务端的断开请求,包含随机生成的seq字段和ACK字段,ACK字段会在服务端的TCP断开请求的seq基础上加1,从而完成服务端请求的验证回复。(FIN=1,ACK=z+1,seq=h,h为客户端随机生成)
  • 至此TCP断开连接的四次挥手过程完毕。

六、TCP协议 - 分包和粘包

1. TCP分包

  1. 场景
    发送方发送字符串"helloworld",接收方却分别接收到两个数据包字符串,分别是字符串"hello"和字符串"world".
    当发送端发送数量较多的数据时,接收端读取数据时会分批到达,造成一次发送多次读取(分包)
  2. 造成分包的原因
    TCP是以段(Segment)为单位发送数据的,建立TCP链接后,有一个最大消息长度(MSS)。如果应用层数据包超过MSS,就会把应用层数据包拆分,分成两个段来发送。
    这个时候接收端的应用层就要拼接这两个TCP包,才能正确处理数据。
    相关的,路由器有一个最大传输单元(MTU),一般是1500字节,除去IP头部20字节,留给TCP的就只有MTU-20字节。所以一般TCP的MSS为MTU-20=1460字节。当应用层数据超过1460字节时,TCP会分多个数据包来发送。

2. TCP粘包

  1. 场景
    发送方发送两个字符串,分别是"HELLO" 和 “WORLD”,接收端却只收到了一个数据包字符串"HELLOWORLD"。
    发送端发送了几次数据,接收端一次性读取了所有数据,造成多次发送一次读取;这通常是网络流量优化,把多个小的数据段集满达到一定的数据量,从而减少网络链路中的传输次数。
  2. 造成粘包的原因
    TCP为了提高网络的利用率,会使用一个叫做Nagle的算法。该算法是指发送端即使有要发送的数据,如果很少的话,会延迟发送。如果应用层给TCP传送数据很快的话,就会把两个应用层数据包"粘"在一起,TCP最后只发一个TCP数据包给接收端。

3. 分包和粘包的解决方案

发送数据前,给数据附加一定字节的长度位。

表1:上图参数说明
字段名说明
包标识包头部的特殊标识,用来标识包的开始
数据长度数据包的大小,固定长度,2、4 或者8字节
数据内容数据内容,长度为数据头定义的长度大小
  • 实际操作如下:
  1. 发送端:先发送包表示和长度,再发送数据内容
  2. 接收端:先解析本次数据包的大小N,再读取N个字节,这N个字节就是一个完整的数据内容
  3. 接收端接收具体流程如下:
    接收端具体接收流程

4. 分包和粘包的解决方案示例代码

  1. server.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <ctype.h>
#include <arpa/inet.h>

#define SERVER_PORT     6666
#define SERVER_IP       "127.0.0.1"
#define DATA_LENGTH     4

const char* TAG         = "FBEB";

int read_package(int sock, char* buf, unsigned int bufSize) {
        int tag_len = strlen(TAG);
        int read_len = read(sock, buf, tag_len + DATA_LENGTH);

        // 对TAG进行检查
        if (strncmp(buf, TAG, tag_len) != 0) {
                return -1;
        }

        int data_len = *((int*)(buf + tag_len));
        printf("data length: %d\n", data_len);

        // 读取数据
        int count = 0;          // 当前已读取数据长度
        read_len = 0;
        while (count < data_len) {
                read_len = read(sock, buf + count, data_len - count);
                if (read_len < 1) {
                        fprintf(stderr, "read() - failed!\n");
                        return -1;
                }
                printf("readlen: %d\n", read_len);
                count += read_len;
        }

        return data_len;
}

int main(void) {
        int sock = 0;
        struct sockaddr_in server_addr;

        // 1.创建信箱
        sock = socket(AF_INET, SOCK_STREAM, 0);
        // 2.清空标签,写上地址和端口号
        bzero(&server_addr, sizeof(server_addr));
        server_addr.sin_family = AF_INET;                                       // 选择协议族 IPV4
        server_addr.sin_addr.s_addr = htonl(INADDR_ANY);        // 监听本地所有IP地址
        server_addr.sin_port = htons(SERVER_PORT);                      // 绑定端口号

        // 3.将标签贴至收信箱
        bind(sock, (struct sockaddr*)(&server_addr), sizeof(server_addr));

        // 4.将信箱挂至传达室,即可收信
        listen(sock, 128);

        // 万事俱备,只欠信封
        printf("Wait for client connection...\n");

        int done = 1;
        while (done) {
                struct sockaddr_in client;
                int client_socket = 0;
                char client_ip[64] = { 0 };

                socklen_t client_addr_len;
                client_addr_len = sizeof(client);
                client_socket = accept(sock, (struct sockaddr*)(&client), &client_addr_len);

                // 打印客户端IP地址和端口号
                printf("cliend ip:%s\t port:%d\n",
                        inet_ntop(AF_INET, &client.sin_addr.s_addr, client_ip, sizeof(client_ip)),
                        ntohs(client.sin_port));

                // 读取客户端发送的数据
                char buf[256] = { 0 };
                int len = read_package(client_socket, buf, 256);
                if (len < 0) {
                        fprintf(stderr, "read_package() - sock[%d] error!\n", client_socket);
                        close(client_socket);
                        continue;
                }

                buf[len] = '\0';
                printf("server receive[%d]: %s\n", len, buf);

                // 转成大写
                for (int i = 0; i < len; ++i) {
                        buf[i] = toupper(buf[i]);
                }

                write(client_socket, buf, len);
                close(client_socket);
        }

        return 0;
}

  1. client.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <stdlib.h>

#define SERVER_PORT     6666
#define SERVER_IP       "127.0.0.1"
#define DATA_LENGTH     4

const char* TAG = "FBEB";

int main(void) {
        int sock = 0;
        struct sockaddr_in in;

        sock = socket(AF_INET, SOCK_STREAM, 0);
        memset(&in, 0, sizeof(in));
        in.sin_family = AF_INET;                                        // 选择协议族 IPV4
        inet_pton(AF_INET, SERVER_IP, &in.sin_addr);
        in.sin_port = htons(SERVER_PORT);                       // 绑定端口号

        connect(sock, (struct sockaddr*)(&in), sizeof(in));

        char message[] = "To do all the ordinary things well is extraordinary, and to do all the simple things well is not simple.";
        int ms_len = strlen(message);
        int tag_len = strlen(TAG);

        // 组装数据包
        int mem_size = ms_len + tag_len + DATA_LENGTH;
        char* buf = (char*)malloc(mem_size);
        strcpy(buf, TAG);
        *((int*)(buf + tag_len)) = ms_len;
        memcpy(buf + tag_len + DATA_LENGTH, message, ms_len);

        write(sock, buf, tag_len + DATA_LENGTH);
        sleep(1);
        write(sock, buf + tag_len + DATA_LENGTH, ms_len);

        int n = read(sock, buf, mem_size - 1);
        if (n < 0) {
                fprintf(stderr, "read() - failed!\n");
        }
        else {
                buf[n] = '\0';
                printf("%s\n", buf);
        }


        return 0;
}

七、UDP通信协议

1. 概述

UDP 是User Datagram Protocol的简称, 中文名是用户数据报协议,是OSI(Open System Interconnection,开放式系统互联) 参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务,IETF RFC768是UDP的正式规范。UDP在IP报文的协议号是17。
UDP协议与TCP协议一样用于处理数据包,在OSI模型中,两者都位于传输层,处于IP协议的上一层。UDP有不提供数据包分组、组装和不能对数据包进行排序的缺点,也就是说,当报文发送之后,是无法得知其是否安全完整到达的。UDP用来支持那些需要在计算机之间传输数据的网络应用。包括网络视频会议系统在内的众多的客户/服务器模式的网络应用都需要使用UDP协议。UDP协议从问世至今已经被使用了很多年,虽然其最初的光彩已经被一些类似协议所掩盖,但即使在今天UDP仍然不失为一项非常实用和可行的网络传输层协议。

2. TCP和UDP的区别

  1. TCP通信是有序的、可靠的、面向连接的
  2. UDP通信是不保证有序到达的数据报服务(在局域网内UDP已很可靠)
  3. 在使用上:UDP不需要listen,也不需要先建立连接(TCP客户端和服务器端分别使用connect和receive建立连接)

3. UPD常用函数

  1. 头文件
    #include <sys/types.h>
    #include <sys/socket.h>
  1. 创建套接字:socket
/*************************************************************************************************************************
* 函数:int socket(int domain, int type, int protocol);
* 功能:创建一个套接字
* 参数:
* 		domain			- 地址描述。仅支持AF_INET格式
* 		type			- 指定socket类型。此处使用SOCK_DGRAM(即UDP通信)。如果要是有TCP通信可使用SOCK_STREAM
* 		protocol		- 协议。一般取0。常用的协议有:IPPROTO_TCP、IPPROTO_UDP、IPPROTO_STCP、IPPROTO_TIPC等
* 返回:
* 		成功			- 返回创建的套接字描述符
* 		失败			- 返回-1,并设置errno
**************************************************************************************************************************/
  1. 发送数据:sendto
/*************************************************************************************************************************
* 函数:ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
* 功能:UDP发送数据
* 参数:
* 		sockfd			- 套接字
* 		buf				- 待发送的数据首地址
* 		len				- 待发送的数据长度
* 		flags			- 标志。一般取0
* 		dest_addr		- 目的主机地址
* 		addrlen			- 目的主机地址的长度
* 返回:
* 		成功			- 返回发送的字节数
* 		失败			- 返回-1,并设置errno
**************************************************************************************************************************/
  1. 接收数据:recvfrom
/*************************************************************************************************************************
* 函数:ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
* 功能:用于从(已连接)套接口上接收数据,并捕获数据发送源的地址
* 参数:
* 		sockfd			- 套接字
* 		buf				- 接收缓冲区的首地址
* 		len				- 接收缓冲区的长度
* 		flags			- 标志。一般取0
* 		src_addr		- 源主机地址
* 		addrlen			- 源主机地址的长度。必须初始化为对应的地址长度
* 返回:
* 		成功			- 返回接收的字节数
* 		失败			- 返回-1,并设置errno
**************************************************************************************************************************/
  1. 接收数据:recv
/*************************************************************************************************************************
* 函数:ssize_t recv(int sockfd, void *buf, size_t len, int flags);
* 功能:用于从(已连接)套接口上接收数据,并捕获数据发送源的地址
* 参数:
* 		sockfd			- 套接字
* 		buf				- 接收缓冲区的首地址
* 		len				- 接收缓冲区的长度
* 		flags			- 标志。一般取0
* 返回:
* 		成功			- 返回接收的字节数
* 		失败			- 返回-1,并设置errno
* 说明:
* 		该调用的参数不需要指定地址。因为当使用udp时,对应的套接字被自动绑定在一个短暂的动态的端口上。   
**************************************************************************************************************************/

步骤总结:

  • 基于UDP的网络套接字通信
  • 服务器端
  1. 创建一个网络套接字
  2. 设置服务器地址
  3. 绑定该套接字,使得该套接字和对应的端口关联起来
  4. 循环处理客户端请求。使用recvfrom等待接收客户端发送的数据。使用sendto发送数据至客户端
  • 客户端
  1. 创建一个套接字
  2. 设置服务器地址
  3. 使用sendto向服务器端(接收端)发送数据
  4. 使用recv接受数据

4. 使用示例

示例代码分为两个文件,分别是:客户端(client.c)和服务器端(server.c)

  1. 客户端:client.c
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/socket.h>

#define BUFFER_SIZE     1024

#define SERVER_PORT     9000
#define SERVER_ADDRESS "127.0.0.1"

int main(int argc, char *argv[]) {

    // 创建套接字
    int sock = socket(AF_INET, SOCK_DGRAM, 0);

    // 设置服务器地址
    struct sockaddr_in in;
    bzero(&in, sizeof(in));
    in.sin_family = AF_INET;
    in.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
    in.sin_port = htons(9000);

    // 向服务器发送数据
    char buffer[BUFFER_SIZE] = { 0 };
    strcpy(buffer, "Hello World!");
    int ret = sendto(sock, buffer, strlen(buffer) + 1, 0, (struct sockaddr*)(&in), sizeof(in));
    if(ret == -1) {
        fprintf(stderr, "sendto() - failed! reason: %s\n", strerror(errno));
        exit(1);
    }

    fprintf(stdout, "send success! data: %s\n", buffer);

    return 0;
}
  1. 服务器端(server.c)
#include <stdio.h>
#include <errno.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>

#define BUFFER_SIZE  1024

#define SERVER_PORT  9000

int main(int argc, char *argv[]) {
    // 用于Unix系统内部通信的地址, struct sockaddr_un

    // 创建套接字
    int server_sock = socket(AF_INET, SOCK_DGRAM, 0);

    // 设置服务器地址
    struct sockaddr_in server;
    server.sin_family = AF_INET;   // 地址域,相当于地址的类型,AF_UNIX表示地址位于UNIX系统内部
    server.sin_addr.s_addr = INADDR_ANY;
    server.sin_port = htons(SERVER_PORT);

    // 绑定套接字,使得该套接字和对应的系统套接字文件关联起来
    int ret = bind(server_sock, (struct sockaddr*)(&server), sizeof(server));
    if(ret == -1) {
        fprintf(stderr, "bind() - failed! reason: %s\n", strerror(errno));
        exit(1);
    }

    // 创建套接字队列
    ret = listen(server_sock, 5);

    // 循环处理客户端请求
    while(1) {
        //printf("server waiting\n");
        char buffer[BUFFER_SIZE] = { 0 };

        // 等待并接收客户端请求
        struct sockaddr_in client_addr;
        bzero(&client_addr, sizeof(client_addr));
        int client_size = sizeof(client_addr);
        //int client = accept(server_sock, (struct sockaddr*)(&client_addr), &client_size);  // 此行可保留也可删除
        int recv_len = recvfrom(server_sock, buffer, sizeof(buffer), 0, (struct sockaddr*)(&client_addr), &client_size);
        if(recv_len < 0) {
            fprintf(stderr, "recvfrom() - failed! reason: %s\n", strerror(errno));
            exit(1);
        }

        printf("recv data: %s\n", buffer);

        close(client);
    }

    close(server_sock);

    return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值