MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TCP/IP协议栈详解

2022-10-225.3k 阅读

TCP/IP 协议栈基础概念

协议栈概述

TCP/IP 协议栈是一个分层的网络通信模型,它定义了网络中不同设备之间如何进行数据交换。这个模型由多个层次组成,每个层次负责特定的功能,各层次之间相互协作,共同完成数据从源端到目的端的传输。与传统的 OSI(开放系统互联)七层模型不同,TCP/IP 协议栈通常被简化为四层或五层模型,我们这里主要介绍五层模型,从下到上依次为物理层、数据链路层、网络层、传输层和应用层。

物理层负责处理物理介质上的信号传输,例如电缆、光纤中的电信号或光信号。数据链路层则负责将物理层传来的信号转换为数据帧,并处理错误检测和纠正,同时还负责介质访问控制,决定哪个设备可以在共享介质上传输数据。网络层的主要功能是进行逻辑地址寻址,将数据包从源节点通过网络路由到目的节点,IP 协议就处于这一层。传输层为应用程序提供端到端的可靠或不可靠的数据传输服务,常见的协议有 TCP 和 UDP。应用层则是直接面向用户应用程序的一层,例如 HTTP、FTP、SMTP 等协议都工作在这一层。

IP 协议

IP(网际协议)是网络层的核心协议,它负责将数据包从源主机发送到目的主机。IP 协议提供了一种无连接、不可靠的数据报传输服务。所谓无连接,是指在发送数据之前,源主机和目的主机之间不需要建立像电话线路那样的连接;不可靠则意味着 IP 协议不保证数据包一定能成功到达目的主机,也不保证数据包的顺序与发送顺序一致。

IP 地址是 IP 协议中用于标识网络中设备的地址。目前广泛使用的是 IPv4 地址,它是一个 32 位的二进制数,通常以点分十进制的形式表示,例如 192.168.1.1。IPv4 地址分为网络地址和主机地址两部分,通过子网掩码可以区分这两部分。子网掩码也是一个 32 位的二进制数,与 IP 地址进行按位与运算,可以得到网络地址。例如,对于 IP 地址 192.168.1.1 和子网掩码 255.255.255.0,进行按位与运算后得到网络地址 192.168.1.0。

随着互联网的发展,IPv4 地址逐渐枯竭,IPv6 应运而生。IPv6 采用 128 位的地址长度,相比 IPv4 大大增加了地址空间。IPv6 地址通常以冒号十六进制的形式表示,例如 2001:0db8:85a3:0000:0000:8a2e:0370:7334。IPv6 还引入了一些新的特性,如简化的首部格式、更好的路由选择效率、内置的安全性等。

TCP 协议

TCP(传输控制协议)是传输层的重要协议,它为应用程序提供面向连接、可靠的字节流传输服务。面向连接意味着在数据传输之前,源端和目的端需要通过三次握手建立一条连接,数据传输完成后,通过四次挥手释放连接。可靠传输是通过序列号、确认号、重传机制等手段来保证的。

TCP 首部包含了许多重要的字段,例如源端口号和目的端口号,用于标识发送方和接收方的应用程序;序列号用于标识每个字节在数据流中的位置;确认号用于告诉发送方已经成功接收的数据的下一个字节的序列号;窗口字段用于告知对方自己当前的接收缓冲区大小,从而实现流量控制。

TCP 的三次握手过程如下:

  1. 客户端向服务器发送一个 SYN 包(同步包),其中包含客户端的初始序列号(ISN),此时客户端处于 SYN_SENT 状态。
  2. 服务器接收到 SYN 包后,回复一个 SYN + ACK 包,其中包含服务器的初始序列号,同时确认号为客户端的 ISN + 1,此时服务器处于 SYN_RCVD 状态。
  3. 客户端接收到 SYN + ACK 包后,再发送一个 ACK 包,确认号为服务器的 ISN + 1,此时客户端和服务器都进入 ESTABLISHED 状态,连接建立成功。

TCP 的四次挥手过程如下:

  1. 主动关闭方(通常是客户端)发送一个 FIN 包(结束包),表示自己已经没有数据要发送了,此时主动关闭方处于 FIN_WAIT_1 状态。
  2. 被动关闭方接收到 FIN 包后,回复一个 ACK 包,确认号为接收到的 FIN 包的序列号 + 1,此时被动关闭方处于 CLOSE_WAIT 状态,主动关闭方处于 FIN_WAIT_2 状态。
  3. 被动关闭方在处理完剩余的数据后,发送一个 FIN 包,此时被动关闭方处于 LAST_ACK 状态。
  4. 主动关闭方接收到 FIN 包后,回复一个 ACK 包,确认号为接收到的 FIN 包的序列号 + 1,此时主动关闭方进入 TIME_WAIT 状态,经过 2MSL(最长报文段寿命)时间后,主动关闭方进入 CLOSED 状态,而被动关闭方在接收到 ACK 包后直接进入 CLOSED 状态。

UDP 协议

UDP(用户数据报协议)也是传输层的协议,与 TCP 不同,UDP 提供无连接、不可靠的数据报传输服务。UDP 首部相对简单,只包含源端口号、目的端口号、长度和校验和字段。UDP 没有像 TCP 那样的连接建立和释放过程,也没有序列号、确认号和重传机制。

UDP 适用于一些对实时性要求较高、对数据准确性要求相对较低的应用场景,例如视频流、音频流传输、DNS 查询等。在这些场景中,少量的数据丢失可能不会对整体的服务质量产生太大影响,而实时性则更为关键。

网络编程中的 TCP/IP 应用

Socket 编程基础

Socket(套接字)是网络编程中用于实现不同主机之间进程通信的一种机制,它是应用层与 TCP/IP 协议栈之间的接口。Socket 可以看作是两个进程之间通信的端点,通过这个端点,进程可以发送和接收数据。

在 UNIX 系统中,Socket 被视为一种特殊的文件描述符,像操作文件一样对 Socket 进行读写操作。在 Windows 系统中,也有类似的概念,通过 Windows Sockets 规范(简称 Winsock)来进行网络编程。

Socket 有多种类型,常见的有流式套接字(SOCK_STREAM)和数据报套接字(SOCK_DGRAM)。流式套接字基于 TCP 协议,提供可靠的、面向连接的字节流传输服务;数据报套接字基于 UDP 协议,提供无连接、不可靠的数据报传输服务。

创建一个 Socket 需要指定地址族、套接字类型和协议。例如,在 C 语言中使用 Berkeley Sockets 进行编程时,创建一个基于 IPv4 的 TCP 套接字的代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

#define PORT 8080
#define SERVER_IP "192.168.1.100"
#define BUFFER_SIZE 1024

int main(int argc, char const *argv[]) {
    int sockfd;
    struct sockaddr_in servaddr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(servaddr.sin_zero, '\0', sizeof(servaddr.sin_zero));

    // 设置服务器地址信息
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    servaddr.sin_addr.s_addr = inet_addr(SERVER_IP);

    // 连接服务器
    if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("Connect failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    char buffer[BUFFER_SIZE];
    // 发送数据
    const char *msg = "Hello, server!";
    send(sockfd, msg, strlen(msg), 0);

    // 接收数据
    int n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
    buffer[n] = '\0';
    printf("Received from server: %s\n", buffer);

    close(sockfd);
    return 0;
}

在上述代码中,首先通过 socket 函数创建了一个基于 IPv4(AF_INET)的流式套接字(SOCK_STREAM)。然后设置服务器的地址信息,包括地址族、端口号和 IP 地址。接着使用 connect 函数连接到服务器。连接成功后,通过 send 函数向服务器发送数据,再通过 recv 函数接收服务器返回的数据。最后关闭套接字。

TCP 服务器端编程

编写一个 TCP 服务器端程序,需要以下几个步骤:

  1. 创建套接字:与客户端类似,通过 socket 函数创建一个套接字,指定地址族为 AF_INET,套接字类型为 SOCK_STREAM
  2. 绑定地址:使用 bind 函数将套接字绑定到一个特定的 IP 地址和端口号,这样客户端就可以通过这个地址和端口号连接到服务器。
  3. 监听连接:调用 listen 函数,使服务器处于监听状态,等待客户端的连接请求。listen 函数的第二个参数指定了等待连接队列的最大长度。
  4. 接受连接:当有客户端连接请求到达时,使用 accept 函数接受连接。accept 函数会返回一个新的套接字,用于与该客户端进行通信,而原来的套接字继续用于监听其他客户端的连接请求。
  5. 数据传输:通过新的套接字进行数据的发送和接收操作。
  6. 关闭套接字:通信完成后,关闭与客户端通信的套接字,以及监听套接字。

以下是一个简单的 TCP 服务器端示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

#define PORT 8080
#define BACKLOG 10
#define BUFFER_SIZE 1024

int main(int argc, char const *argv[]) {
    int listenfd, connfd;
    struct sockaddr_in servaddr, cliaddr;

    // 创建监听套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

    // 设置服务器地址信息
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);

    // 绑定地址
    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("Bind failed");
        close(listenfd);
        exit(EXIT_FAILURE);
    }

    // 监听连接
    if (listen(listenfd, BACKLOG) < 0) {
        perror("Listen failed");
        close(listenfd);
        exit(EXIT_FAILURE);
    }

    printf("Server is listening on port %d...\n", PORT);

    socklen_t len = sizeof(cliaddr);
    // 接受连接
    connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &len);
    if (connfd < 0) {
        perror("Accept failed");
        close(listenfd);
        exit(EXIT_FAILURE);
    }

    char buffer[BUFFER_SIZE];
    // 接收数据
    int n = recv(connfd, buffer, sizeof(buffer) - 1, 0);
    buffer[n] = '\0';
    printf("Received from client: %s\n", buffer);

    // 发送数据
    const char *msg = "Hello, client!";
    send(connfd, msg, strlen(msg), 0);

    close(connfd);
    close(listenfd);
    return 0;
}

在这个代码中,服务器首先创建监听套接字并绑定到指定的端口号。然后开始监听连接,当有客户端连接时,接受连接并通过新的套接字与客户端进行数据交互。

UDP 编程

UDP 编程与 TCP 编程有一些不同之处。由于 UDP 是无连接的,所以不需要像 TCP 那样进行连接建立和释放的过程。

编写 UDP 客户端程序的步骤如下:

  1. 创建套接字:使用 socket 函数创建一个数据报套接字(SOCK_DGRAM)。
  2. 设置服务器地址:与 TCP 类似,设置服务器的地址族、端口号和 IP 地址。
  3. 发送数据:使用 sendto 函数向服务器发送数据,sendto 函数需要指定目标地址。
  4. 接收数据:使用 recvfrom 函数从服务器接收数据,recvfrom 函数会返回数据以及发送方的地址信息。

以下是一个 UDP 客户端示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

#define PORT 8080
#define SERVER_IP "192.168.1.100"
#define BUFFER_SIZE 1024

int main(int argc, char const *argv[]) {
    int sockfd;
    struct sockaddr_in servaddr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(servaddr.sin_zero, '\0', sizeof(servaddr.sin_zero));

    // 设置服务器地址信息
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    servaddr.sin_addr.s_addr = inet_addr(SERVER_IP);

    char buffer[BUFFER_SIZE];
    // 发送数据
    const char *msg = "Hello, server!";
    sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr *)&servaddr, sizeof(servaddr));

    socklen_t len = sizeof(servaddr);
    // 接收数据
    int n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&servaddr, &len);
    buffer[n] = '\0';
    printf("Received from server: %s\n", buffer);

    close(sockfd);
    return 0;
}

编写 UDP 服务器端程序的步骤如下:

  1. 创建套接字:同样创建一个数据报套接字。
  2. 绑定地址:将套接字绑定到指定的 IP 地址和端口号。
  3. 接收数据:使用 recvfrom 函数接收客户端发送的数据,并获取客户端的地址信息。
  4. 发送数据:使用 sendto 函数向客户端发送数据,根据接收到的客户端地址进行回复。

以下是一个 UDP 服务器端示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main(int argc, char const *argv[]) {
    int sockfd;
    struct sockaddr_in servaddr, cliaddr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

    // 设置服务器地址信息
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);

    // 绑定地址
    if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("Bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    char buffer[BUFFER_SIZE];
    socklen_t len = sizeof(cliaddr);
    // 接收数据
    int n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&cliaddr, &len);
    buffer[n] = '\0';
    printf("Received from client: %s\n", buffer);

    // 发送数据
    const char *msg = "Hello, client!";
    sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr *)&cliaddr, len);

    close(sockfd);
    return 0;
}

通过以上代码示例,可以看到 TCP 和 UDP 在网络编程中的具体应用方式,以及它们之间的区别。

TCP/IP 协议栈的性能优化

TCP 性能优化

  1. 窗口机制优化:TCP 的窗口机制对于性能有重要影响。发送窗口大小决定了发送方可以在未收到确认的情况下发送的数据量。可以通过调整窗口大小来提高传输效率。例如,在网络带宽较高、延迟较低的情况下,可以适当增大发送窗口,以充分利用网络带宽。在 Linux 系统中,可以通过修改 /proc/sys/net/ipv4/tcp_wmem/proc/sys/net/ipv4/tcp_rmem 来调整发送和接收窗口的大小。这两个文件分别定义了 TCP 发送和接收缓冲区的最小值、默认值和最大值。例如,将 tcp_wmem 设置为 “4096 87380 16777216”,表示发送缓冲区的最小值为 4096 字节,默认值为 87380 字节,最大值为 16777216 字节。
  2. 拥塞控制优化:TCP 的拥塞控制算法对网络性能也起着关键作用。拥塞控制算法包括慢启动、拥塞避免、快速重传和快速恢复等阶段。不同的拥塞控制算法在不同的网络环境下表现不同。例如,在高速网络环境中,CUBIC 拥塞控制算法相对其他算法可能具有更好的性能。可以通过修改系统参数来选择不同的拥塞控制算法,在 Linux 系统中,可以通过 sysctl 命令来设置 net.ipv4.tcp_congestion_control 参数,例如 sysctl -w net.ipv4.tcp_congestion_control=cubic
  3. 延迟确认优化:TCP 的延迟确认机制是指接收方在接收到数据后,并不立即发送确认包,而是等待一段时间,看是否有更多的数据需要一起确认,以减少确认包的数量。然而,如果延迟时间过长,可能会导致发送方重传数据,影响性能。可以根据网络情况适当调整延迟确认的时间。在 Linux 系统中,可以通过 sysctl 命令修改 net.ipv4.tcp_delack_time 参数来调整延迟确认时间,默认值为 200 毫秒。

UDP 性能优化

  1. 校验和优化:UDP 的校验和计算会消耗一定的 CPU 资源。在一些对性能要求极高的场景下,如果可以确保网络环境比较可靠,不需要严格的校验和,可以通过设置套接字选项 IP_HDRINCL 来禁用 UDP 校验和计算,从而提高性能。例如在 C 语言中,可以通过以下代码禁用 UDP 校验和:
int on = 1;
setsockopt(sockfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on));
  1. 缓冲区优化:与 TCP 类似,UDP 也可以通过调整接收和发送缓冲区的大小来提高性能。在 Linux 系统中,可以通过修改 /proc/sys/net/core/rmem_max/proc/sys/net/core/wmem_max 来设置 UDP 接收和发送缓冲区的最大值。例如,将 rmem_max 设置为一个较大的值,可以提高 UDP 接收大数据包的能力。
  2. 多线程和并发处理:由于 UDP 是无连接的,多个 UDP 数据报的处理可以并行进行。可以通过多线程技术,让不同的线程处理不同的 UDP 数据报,提高整体的处理效率。例如,在 C++ 中可以使用 std::thread 来创建多个线程处理 UDP 数据:
#include <iostream>
#include <thread>
#include <vector>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>

#define PORT 8080
#define BUFFER_SIZE 1024

void handle_udp(int sockfd) {
    char buffer[BUFFER_SIZE];
    struct sockaddr_in cliaddr;
    socklen_t len = sizeof(cliaddr);
    int n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&cliaddr, &len);
    buffer[n] = '\0';
    std::cout << "Received from client: " << buffer << std::endl;
    // 处理数据并回复
    const char *msg = "Hello, client!";
    sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr *)&cliaddr, len);
}

int main() {
    int sockfd;
    struct sockaddr_in servaddr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_DUDP, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));

    // 设置服务器地址信息
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);

    // 绑定地址
    if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("Bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.emplace_back(handle_udp, sockfd);
    }

    for (auto &th : threads) {
        th.join();
    }

    close(sockfd);
    return 0;
}

在上述代码中,创建了多个线程来处理 UDP 数据报,提高了 UDP 服务器的并发处理能力。

TCP/IP 协议栈的安全问题

TCP 安全问题

  1. TCP 端口扫描:攻击者可以通过扫描目标主机的 TCP 端口,来发现开放的服务。常见的端口扫描技术有全连接扫描(通过 connect 函数尝试与目标端口建立完整的 TCP 连接)、半连接扫描(发送 SYN 包,不完成三次握手,以避免被目标主机记录完整连接)等。为了防范端口扫描,可以使用防火墙来限制外部对内部主机端口的访问,只允许必要的端口对外提供服务。同时,一些入侵检测系统(IDS)可以检测到异常的端口扫描行为,并发出警报。
  2. TCP 会话劫持:攻击者在 TCP 连接建立后,通过获取连接的序列号等信息,冒充其中一方与另一方进行通信。例如,攻击者可以嗅探网络流量,获取 TCP 连接的序列号和确认号,然后伪造数据包,劫持会话。防范 TCP 会话劫持可以采用加密技术,如使用 SSL/TLS 协议对 TCP 连接进行加密,使得攻击者无法获取到有效的序列号等信息。此外,还可以通过设置合理的 TCP 选项,如时间戳选项(TCP_TS),增加序列号的随机性,提高会话劫持的难度。
  3. TCP 拒绝服务攻击(DoS):常见的 TCP DoS 攻击包括 SYN 泛洪攻击,攻击者发送大量的 SYN 包,但不完成三次握手,导致服务器的半连接队列被填满,无法处理正常的连接请求。为了防范 SYN 泛洪攻击,可以采用 SYN 缓存、SYN Cookie 等技术。SYN 缓存是在收到 SYN 包时,不立即分配完整的连接资源,而是先在缓存中记录相关信息,待完成三次握手后再分配资源。SYN Cookie 则是在接收到 SYN 包时,根据一定的算法生成一个 Cookie 作为确认号,在后续的 ACK 包中验证这个 Cookie,从而避免半连接队列被填满。

UDP 安全问题

  1. UDP 端口扫描:与 TCP 类似,攻击者也可以对 UDP 端口进行扫描。由于 UDP 是无连接的,扫描方式略有不同,通常是向目标 UDP 端口发送探测包,根据是否收到回复来判断端口是否开放。防范 UDP 端口扫描同样可以使用防火墙,限制外部对 UDP 端口的访问。
  2. UDP 放大攻击:攻击者利用一些 UDP 协议(如 DNS、NTP 等)的特性,构造伪造源 IP 地址(目标受害者的 IP 地址)的请求包发送给这些协议的服务器,服务器会向伪造的源 IP 地址(受害者)返回大量的响应数据,从而达到放大攻击流量的目的,使受害者网络带宽耗尽。防范 UDP 放大攻击需要网络管理员对 DNS、NTP 等服务器进行严格配置,限制其对外的响应,只允许合法的请求。同时,互联网服务提供商(ISP)也可以通过流量清洗等技术来检测和阻止异常的 UDP 流量。
  3. UDP 数据篡改:由于 UDP 没有像 TCP 那样严格的可靠性机制,攻击者有可能篡改 UDP 数据报的内容。为了确保 UDP 数据的完整性,可以在应用层加入校验和机制,对数据进行完整性校验。例如,在 DNS 协议中,就有自己的校验和机制来防止数据被篡改。

通过对 TCP/IP 协议栈各方面的深入理解,包括基础概念、网络编程应用、性能优化以及安全问题等,开发人员可以更好地设计和实现高效、安全的网络应用程序。在实际应用中,需要根据具体的场景和需求,综合运用这些知识,以达到最佳的效果。无论是构建大规模的网络服务,还是开发小型的网络工具,对 TCP/IP 协议栈的掌握都是后端开发网络编程中至关重要的一环。