十年码农内功:网络篇

本文基于 Linux 系统,编程语言 C/C++

一、网络分层

OSI 参考模型(七层)基本存在于教科书中,而 TCP/IP 协议栈(四层)大行其道,图1 是两个模型的对照关系。

图1 网络模型

下面分别聊聊 TCP/IP 协议栈的各层的理论、经验和实践。

二、网络接口层(以太网)

2.1 协议体

图2 以太帧

下面详细介绍每个参数的含义:

  • 目的MAC (6字节):目的网卡地址;
  • 源MAC (6字节):源网卡地址;
  • 类型 (2字节):0x0800 表示 IP 或 ICMP、0x0806 表示 ARP、0x0835 表示 RARP;
  • 数据 (46~1500字节):以太网帧的整体大小必须在 64~1518 字节之间,除去目的 MAC、源 MAC、类型和 CRC (一共18字节),得到数据的长度在 46~1500之间;一帧能传输的最大长度就是 MTU,通常是 1500;
  • Padding:如果数据长度小于 46 字节,需要补充 0 来达到最少数据长度 46字节;
  • CRC (4字节):以太网帧的 CRC 校验;

Linux 中 ETH 头结构体定义如下:

struct ethhdr {
    unsigned char   h_dest[ETH_ALEN];
    unsigned char   h_source[ETH_ALEN];
    __be16          h_proto;
} __attribute__((packed));

抓包现场:

图3 ICMP的以太帧

图4 ARP的以太帧

图5 TCP的以太帧

MTU 和 MSS

图6

三、网络层(IP、ICMP)

网络层主要有两个协议:IP 和 ICMP。

3.1 IP

IP 是一种无连接的协议,操作在使用分组交换的链路层(如以太网)上。此协议会尽最大努力交付数据包,意即它不保证任何数据包均能送达目的地,也不保证所有数据包均按照正确的顺序无重复地到达。

图7 IP

下面详细介绍每个参数的含义:

  • 版本(4位):通信双方使用的版本必须一致。对于IPv4,字段的值是4;对于IPv6,字段的值是6;
  • 首部长度(4位):首部长度说明首部有多少个「4字节」。由于 IPv4 首部可能包含数目不定的选项,这个字段也用来确定数据的偏移量。最小值是 5,相当于 5*4=20 字节(RFC 791),最大值是 15;大于 5 时表示选项字段存在;
  • 区分服务(4位):最初被定义为「服务类型」字段,实际上并未使用,但1998年被IETF重定义为区分服务(RFC 2474)。只有在使用区分服务时,这个字段才起作用,一般的情况下都不使用这个字段。例如需要实时数据流的技术会应用这个字段,一个例子是 VoIP;
  • 显式拥塞通告(2位):在 RFC 3168 中定义,允许在不丢弃报文的同时通知对方网络拥塞的发生。ECN是一种可选的功能,仅当两端都支持并希望使用,且底层网络支持时才被使用;
  • 总长度(16位):报文总长,包含首部和数据,单位为字节。这个字段的最小值是 20(20字节首部 + 0字节数据),最大值是 2^16-1=65535。IP 规定所有主机都必须支持最小 576 字节的报文,这是假定上层数据长度512 字节,加上最长 IP 首部 60 字节,加上 4 字节富裕量,得出 576 字节,但大多数现代主机支持更大的报文。当下层的数据链路协议的最大传输单元(MTU)字段的值小于 IP 报文长度时,报文就必须被分片,详细见「标识」;
  • 标识(4位):这个字段主要被用来唯一地标识一个报文的所有分片,因为分片不一定按序到达,所以在重组时需要知道分片所属的报文。每产生一个数据报,计数器加 1,并赋值给此字段。一些实验性的工作建议将此字段用于其它目的,例如增加报文跟踪信息以协助探测伪造的源地址;
  • 标志(3位):这个 3 位字段用于控制和识别分片,它们是:
    • 位0:保留,必须为0;
    • 位1:禁止分片(Don’t Fragment,DF),当DF=0时才允许分片;如果DF标志被设置为1,但路由要求必须分片报文,此报文会被丢弃;
    • 位2:更多分片(More Fragment,MF),MF=1代表后面还有分片,MF=0 代表已经是最后一个分片。
  • 片偏移量(13位):这个字段指明了每个分片相对于原始报文开头的偏移量,以 8 字节作单位。
  • 生存时间(8位):这个字段避免报文在互联网中永远存在(例如陷入路由环路)。这实际上是一个跳数计数器:报文经过的每个路由器都将此字段减 1,当此字段等于 0 时,报文不再向下一跳传送并被丢弃,最大值是 255。常规地,一份 ICMP 报文被发回报文发送端说明其发送的报文已被丢弃,这也是 traceroute 的核心原理;
  • 协议(8位):这个字段定义了该报文数据区使用的协议。常用的有 0x01(ICMP)、0x06(TCP) 和 0x11(UDP);IP协议号列表
  • 首部校验和(16位):这个检验和字段只对首部查错,不包括数据部分。在每一跳,路由器都要重新计算出的首部检验和并与此字段进行比对,如果不一致,此报文将会被丢弃。重新计算的必要性是因为每一跳的一些首部字段(如TTL、Flag、Offset等)都有可能发生变化,不检查数据部分是为了减少工作量。数据区的错误留待上层协议处理(UDP 和 TCP 都有校验和字段)。此处的校验计算方法不使用CRC。
  • 源IP(32位):一个 IPv4 地址由 4 字节构成。因为NAT的存在,这个地址并不总是报文的真实发送端,因此发往此地址的报文会被送往 NAT 设备,并由它被翻译为真实的地址;
  • 目的IP(32位):与源地址格式相同,但指出报文的接收端;
  • 选项(0~40字节):附加的首部字段可能跟在目的地址之后,但这并不被经常使用,从 0 到 40 个字节不等。

图8 IP选项

Linux 中 IP 头结构体定义如下(经修改方便阅读):

struct iphdr {
    __u8    version:4, ihl:4;
    __u8    tos;
    __be16  tot_len;
    __be16  id;
    __be16  frag_off;
    __u8    ttl;
    __u8    protocol;
    __sum16 check;
    __be32  saddr;
    __be32  daddr;
};

抓包现场:

图9 TCP下的IP

3.2 ICMP

互联网控制消息协议(英语:InternetControlMessageProtocol,缩写:ICMP)是互联网协议族的核心协议之一。它用于网际协议(IP)中发送控制消息,提供可能发生在通信环境中的各种问题反馈。通过这些信息,使管理者可以对所发生的问题作出诊断,然后采取适当的措施解决。

图10 ICMP协议

ICMP 报头从 IP 报头的第20字节开始(除非使用了 IP 报头的选项部分):

  • 类型(8位):标识生成的错误报文;
  • 代码(8位):进一步划分 ICMP 的类型,该字段用来查找产生错误的原因;例如,ICMP 的目标不可达类型可以把这个位设为 1 至 15 等来表示不同的意思;
  • 校验和(16位):Internet 校验和(RFC 1071),用于进行错误检查,该校验和是从 ICMP 头和以该字段替换为 0 的数据计算得出的;
  • 其余部分(4字节):报头的其余部分,内容根据 ICMP 类型和代码而有所不同。

表1

Linux 中 ICMP 头结构体定义如下:

struct icmphdr {
    __u8    type;
    __u8    code;
    __sum16 checksum;
    union {
        struct {
            __be16  id;
            __be16  sequence;
        } echo;
        __be32  gateway;
        struct {
            __be16  __unused;
            __be16  mtu;
        } frag;
        __u8    reserved[4];
    } un;
};

抓包现场:Ping

图11 Ping请求

图12 Ping应答

抓包现场:目标端口不可达

图13

四、传输层(TCP、UDP)

万字长文,详细剖析,一篇就够系列

图14 TCP 状态机

五、Socket 编程

5.1 套接字

创建 TCP 套接字

int fd = socket(AF_INET, SOCK_STREAM, 0);

创建 UDP 套接字

int fd = socket(AF_INET, SOCK_DGRAM, 0);

5.2 函数

bind函数,server_addr 服务端地址

struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(20000);





int listen_fd  = socket(AF_INET, SOCK_STREAM, 0);
int ret = bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

listen函数

int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int ret = listen(listen_fd, SOMAXCONN);

accept函数,recv_addr 是客户端的地址

struct sockaddr_in recv_addr;

socklen_t in_len = sizeof(struct sockaddr_in);

int socket_fd = accept(listen_fd, (struct sockaddr *)&recv_addr, &in_len);

connect函数,server_addr 服务端地址

int fd  = socket(AF_INET, SOCK_STREAM, 0);
int ret = connect(fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

send函数,通过 fd,向对端发送数据

std::string data = "1234567890";
int ret = send(fd, data.c_str(), data.size(), MSG_NOSIGNAL);

recv函数,通过 socket_fd 接收数据

unsigned char buff[1024] = {0};
int count = recv(socket_fd, buff, 1024, 0);

sendto函数,通过 fd 向 server_addr 发送数据,通常 UDP 使用,直接发送数据,MSG_DONTWAIT 表示非阻塞

std::string data = "123";
int ret = sendto(fd, data.c_str(), data.size(), MSG_DONTWAIT, (struct sockaddr*)&server_addr, sizeof(server_addr));

recvfrom函数,fd 已经 bind 到监听端口,通过 fd 接收来自 recv_addr 地址的数据

struct sockaddr_in recv_addr;

socklen_t in_len = sizeof(struct sockaddr_in);


unsigned char buff[1024] = {0};
ssize_t count = recvfrom(fd, buff, 1024, MSG_DONTWAIT, (struct sockaddr *)&recv_addr, &in_len);

阻塞/非阻塞,创建的文件描述符默认是阻塞,阻塞就是调用accept、connect、send、recv、sendto和recvfrom函数时会阻塞线程,直到有返回结果或超时;非阻塞就是不阻塞当前线程,调用立刻返回。设置 fd 为非阻塞代码如下:

int NonBlock(int fd) {
    int flags;
    flags = fcntl(fd, F_GETFL, 0); //得到文件状态标志
    if (flags < 0)
        return -1;
    flags |= O_NONBLOCK; //设置文件状态标志
    if (fcntl(fd, F_SETFL, flags) < 0)
        return -1;
    return 0;
}

5.3 多路复用

5.3.1 select

函数原型

int select(int maxfd, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • fd_set:是一个位图,被监视的文件描述符的值对应的第几位置为 1,在 Linux系统中,最多 1024 位;
  • maxfd:是一个整数,是指 fd_set 集合中的「最大文件描述符的值 + 1」,最大值为 1024。这个值的作用是为了不用每次都轮询 1024 个文件描述符,假设只有几个套接字,只需监视 「最大文件描述符的值 + 1」 个文件描述符,这样可以减少轮询时间以及系统的开销,如图14 所示;
  • readfds:是一个位图,里面最多可以容纳 1024 个文件描述符,把需要监视可读的文件描述符放入其中,当有文件描述符可读时,select 就会返回一个大于 0 的值,表示有多少个文件描述符可读;
  • writefds:和 readfds 类似,把需要监视可写的文件描述符放入其中,当有文件描述符可写时,select 就会返回一个大于 0 的值,表示有多少个文件描述符可写;
  • errorfds:同上面两个参数,用来监视文件描述符是否有错误异常;
  • timeout:超时参数
    • 当将 timeout 设置为 NULL 时,表明此时 select 是阻塞的;
    • 当将 timeout 设置为 timeout->tv_sec = 0,timeout->tv_usec = 0时,表明这个函数为非阻塞;
    • 当将 timeout 设置为非 0 的时间,表明 select 有超时时间,当这个时间走完,select 函数就会返回。
struct timeval {      
    long tv_sec;  //秒
    long tv_usec; //微秒 
}

图15

操作 fd_set 的宏

FD_ZERO(fd_set *fd);              // 清空该组文件描述符集合
FD_CLR(inr fd, fd_set *fd);       // 清除该组文件描述符集合中的指定文件描述符
FD_ISSET(int fd, fd_set *fd);     // 测试指定的文件描述符是否在该文件描述符集合中
FD_SET(int fd, fd_set *fd);       // 向该文件描述符集合中添加文件描述符

代码示例

int main(int argc, char **argv)


{


    int max_fd_num = 1021;
    int write_time = 1;







    int fds[2000] = {0};
    int fds_num = 0;
    int maxfd = 0;
    for ( ; fds_num < max_fd_num; ++fds_num) {
        int fd = eventfd(0, EFD_NONBLOCK);
        fds[fds_num] = fd;
        maxfd = std::max(fd, maxfd);
    }

    thread t([&]() {
        for (int i = 0; i < write_time; i++) {
            usleep(500000);
            uint64_t val = 1;
            int res = write(maxfd, &val, sizeof(uint64_t));
            printf("thread1 write, res = %d, event_fd = %d, value = %ld\n", res, maxfd, val);
        }
    });

    fd_set readfds, writefds, exceptfds;
    struct timeval timeout = {0};
    while(1) {
        FD_ZERO(&readfds);                      /* 清空文件描述符集合 */
        
        for (int i = 0; i < fds_num; ++i)
            FD_SET(fds[i], &readfds);           /* 添加文件描述符到集合 */

        int num = select(maxfd + 1, &readfds, &writefds, &exceptfds, &timeout);
        printf("thread2 select, num = %d, fds_num = %d, maxfd = %d\n", num, fds_num, maxfd);
        if (FD_ISSET(maxfd, &readfds) > 0) {    /* 测试 event_fd2 是否可读 */
            uint64_t val;
            int res = read(maxfd, &val, sizeof(uint64_t));
            printf("thread2 read, res = %d, event_fd = %d, value = %ld\n", res, maxfd, val);
        }
        usleep(1000000);
    }

    t.join();
    return 0;
}

上面代码输出

thread2 select, num = 0, fds_num  = 1021, maxfd = 1023
thread1 write,  res = 8, event_fd = 1023, value = 1
thread2 select, num = 1, fds_num  = 1021, maxfd = 1023
thread2 read,   res = 8, event_fd = 1023, value = 1
thread2 select, num = 0, fds_num  = 1021, maxfd = 1023
thread2 select, num = 0, fds_num  = 1021, maxfd = 1023
thread2 select, num = 0, fds_num  = 1021, maxfd = 1023
··· ···

当把上面做下面修改

int max_fd_num = 1026;
int write_time = 0;

输出结果为

thread2 select, num =  5, fds_num  = 1026, maxfd = 1028
thread2 read,   res = -1, event_fd = 1028, value = 104
thread2 select, num =  5, fds_num  = 1026, maxfd = 1028
thread2 read,   res = -1, event_fd = 1028, value = 104
thread2 select, num =  5, fds_num  = 1026, maxfd = 1028
thread2 read,   res = -1, event_fd = 1028, value = 104
thread2 select, num =  5, fds_num  = 1026, maxfd = 1028
··· ···

经测试发现几点注意事项

  • select 函数第一个参数必须要大于等于「最大文件描述符 + 1」;
  • FD_SET(fd, &readfds) 中 fd 大于等于 1024 时,结果就不准了,select 后,FD_ISSET(fd, &readfds) 一直大于 0,所以 fd 必需要小于等于 1023;

5.3.2 poll

函数原型

struct pollfd {
    int fd;        /* 文件描述符 */
    short events;  /* 监视的发生事件类型 */
    short revents; /* 实际返回的事件类型 */
};

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:是一个结构体(struct pollfd)指针,也就是 poll 函数同时监控的一个或多个文件描述符上的事件;
  • nfds:其实就是 int 型,指明 poll 同时监控文件描述符的个数,不限于 1024,可以很大;
  • timeout:超时参数,-1:永久等待;0:立即返回;大于 0:等待超时时间,以毫秒为单位;
  • 返回值:fds 中有多少个文件描述符有监控的事件发生;

代码示例

int main(int argc, char **argv)


{


    int fds_num = 15000;

    int write_time = 1;







    struct pollfd fds[20000] = {0};
    for (int i = 0; i < fds_num; i++) {
        fds[i].fd = eventfd(0, EFD_NONBLOCK);
        fds[i].events = POLLIN; /* 监视可读事件 */
    }

    thread t([&]() {
        for (int i = 0; i < write_time; i++) {
            usleep(500000);
            uint64_t val = 1;
            int res1 = write(fds[0].fd, &val, sizeof(uint64_t));
            int res2 = write(fds[fds_num - 1].fd, &val, sizeof(uint64_t));
            printf("thread1 write, res1 = %d, res2 = %d, event_fd1 = %d, event_fd2 = %d, value = %ld\n", res1, res2, fds[0].fd, fds[fds_num - 1].fd, val);
        }
    });

    for (int i = 0; i < 10; i++) {
        int num = poll(fds, fds_num, 1000); /* 超时时间是1秒 */
        printf("thread2 poll, num = %d, fds_num = %d, maxfd = %d\n", num, fds_num, fds[fds_num - 1].fd);
        for (int i = 0, index = 0; i < fds_num && index < num; i++) {
            if (fds[i].revents & POLLIN) {
                uint64_t val;
                int res = read(fds[i].fd, &val, sizeof(uint64_t));
                printf("thread2 read, res = %d, event_fd = %d, value = %ld, index = %d\n", res, fds[i].fd, val, index);
                index++;
            }
        }
    }

    t.join();
    return 0;
}

输出结果

thread1 write, res1 = 8, res2 = 8, event_fd1 = 3, event_fd2 = 15002, value = 1
thread2 poll, num = 2, fds_num  = 15000,  maxfd = 15002
thread2 read, res = 8, event_fd = 3,      value = 1, index = 0
thread2 read, res = 8, event_fd = 15002,  value = 1, index = 1
thread2 poll, num = 0, fds_num  = 15000,  maxfd = 15002
thread2 poll, num = 0, fds_num  = 15000,  maxfd = 15002
thread2 poll, num = 0, fds_num  = 15000,  maxfd = 15002

5.3.3 epoll

创建 epoll 函数,返回 epfd 文件描述符,size 在 Linux 2.6.8 之后被忽略,但是必须大于 0。

int epoll_create(int size);

epoll 使用的结构体

typedef union epoll_data {
    void *ptr; /* 事件触发后回调用的自定义的任意指针 */
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;


struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
};

events 可以是以下几个宏的集合:

  • EPOLLIN:文件描述符可读(包括对端SOCKET正常关闭);
  • EPOLLOUT:文件描述符可写;
  • EPOLLRDHUP (since Linux 2.6.17):流式 socket 的对端被关闭或者关闭写操作;
  • EPOLLPRI:文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR:文件描述符发生错误;
  • EPOLLHUP:文件描述符被挂住;
  • EPOLLET: 将 epoll 设为边缘触发(Edge Triggered)模式,相对于水平触发(Level Triggered)模式;
  • EPOLLONESHOT (since Linux 2.6.2):只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket 的话,需要再次把这个 socket 加入到 epoll 里;
  • EPOLLWAKEUP (since Linux 3.5) :如果系统通过 /sys/power/autosleep 进入 autosleep 模式,并且发生事件把设备从睡眠中唤醒,设备驱动仅仅保持设备唤醒到那个事件进入队列。要保持设备唤醒到事件被处理,必须使用该标志;
  • EPOLLEXCLUSIVE (since Linux 4.5) :解决同一个文件描述符同时被添加到多个 epoll 实例中造成的“惊群”问题。 这个标志的设置有一些限制条件,比如只能是在 EPOLL_CTL_ADD 操作中设置,而且对应的文件描述符本身不能是一个 epoll 实例;

添加修改删除文件描述符的控制函数 epoll_ctl 函数

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfd:epoll 文件描述符,epoll 实例;
  • op:操作选项如下
    • EPOLL_CTL_ADD:向 epoll 实例中添加文件描述符和 epoll_event;
    • EPOLL_CTL_MOD:修改文件描述符对应新的 epoll_event;
    • EPOLL_CTL_DEL:删除文件描述符,对应的 event 参数被忽略,可填 NULL;
  • fd:待监视的文件描述符;
  • event:待监视的事件结构体;

获取 epoll 实例中已经就绪的文件描述符及其事件

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epfd:epoll 文件描述符,epoll 实例;
  • events:事件就绪文件描述符会存入事先提供好的 epoll_event 数组;
  • maxevents:是个数值,获取至多该数值的就绪的文件描述符;
  • timeout:超时参数,-1:永久等待;0:立即返回;大于 0:等待超时时间,以毫秒为单位;
  • 返回值:有多少个文件描述符上有事件发生了;

代码示例

int main(int argc, char **argv)


{


    int fds_num = 15000;

    int write_time = 1;







    int epoll_fd = epoll_create(1024);


    struct epoll_event events[20000] = {0};
    for (int i = 0; i < fds_num; i++) {
        int fd             = eventfd(0, EFD_NONBLOCK);
        events[i].events   = EPOLLIN | EPOLLET; /* 监视可读事件 */
        events[i].data.fd  = fd;
        int result         = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &events[i]);
    }

    thread t([&]() {
        for (int i = 0; i < write_time; i++) {
            usleep(500000);
            uint64_t val = 1;
            int res1 = write(events[0].data.fd, &val, sizeof(uint64_t));
            int res2 = write(events[fds_num - 1].data.fd, &val, sizeof(uint64_t));
            printf("thread1 write, res1 = %d, res2 = %d, event_fd1 = %d, event_fd2 = %d, value = %ld\n", 
                res1, res2, events[0].data.fd, events[fds_num - 1].data.fd, val);
        }
    });

    for (int i = 0; i < 10; i++) {
        int ret = 0;
        struct epoll_event wait_events[20000] = {0};
        int num = epoll_wait(epoll_fd, wait_events, fds_num, 1000); /* 超时时间是1秒 */
        printf("thread2 wait, num = %d, fds_num = %d, epoll_fd = %d\n", num, fds_num, epoll_fd);
        for (int i = 0; i < num; i++) {
            uint64_t val;
            int res = read(wait_events[i].data.fd, &val, sizeof(uint64_t));
            printf("thread2 read, res = %d, event_fd = %d, value = %ld\n", res, wait_events[i].data.fd, val);
        }
    }

    t.join();
    return 0;
}

输出结果

thread1 write, res1 = 8, res2 = 8, event_fd1 = 4, event_fd2 = 15003, value = 1
thread2 wait, num = 2, fds_num  = 15000, epoll_fd = 3
thread2 read, res = 8, event_fd = 4,     value = 1
thread2 read, res = 8, event_fd = 15003, value = 1
thread2 wait, num = 0, fds_num  = 15000, epoll_fd = 3
thread2 wait, num = 0, fds_num  = 15000, epoll_fd = 3
thread2 wait, num = 0, fds_num  = 15000, epoll_fd = 3

LT(水平触发)模式和 ET(边缘触发)模式区别在于:

  • 当一个新的事件到来时,ET 模式下可以从 epoll_wait 调用中获取到这个事件,可如果这次没有把这个事件处理完,在没有新的事件再次到来时,是无法再次从 epoll_wait 调用中获取这个事件的;
  • 而 LT 模式则相反,只要一个事件未处理完,就总能从 epoll_wait 中获取到这个事件;
  • 因此,在 LT 模式下要简单一些,不容易出错,而在 ET模式下事件发生时,如果 socket 有可读事件,但没有一次性地将缓冲区数据处理完,则会导致缓冲区中的用户请求得不到响应,直到下次 socket 有可读事件。

epoll 原理

图16

六、虚拟网卡(Tun/Tap)

6.1 Tun(网络层)

  • Tun 虚拟网卡工作在网络层,创建 Tun 虚拟网卡会返回一个文件描述符 fd,处理 IP 报文;
  • App(业务服务)通过 Socket API(TCP/UDP/RAW)发的数据包,被路由表路由到了 Tun 虚拟网卡;
  • fd 可读,Tun 虚拟网卡把接收到的 IP 包交给 Proxy 代理服务处理;
  • Proxy 代理服务可以通过 Socket API(TCP/UDP/RAW)把 IP 报文经过 eth0 物理网卡发送出去;
  • 当 eth0 物理网卡收到消息后,发给 Proxy 代理服务,然后 Proxy 写入 Tun 虚拟网卡的 fd,Tun 把数据发给 App;

图17 Tun虚拟网卡

反向路由校验(net.ipv4.conf.xxx.rp_filter)内核参数

  • 当一个网卡收到数据包后,把源地址和目的地址互换后,查找反向路由出口:
  • 0:关闭反向路由校验;
  • 1:开启严格的反向路由校验。对每个进来的数据包,校验其反向路由是否是最佳路由,如果不是,则直接丢弃该数据包;
  • 2:开启松散的反向路由校验。对每个进来的数据包,校验其源地址是否可达,即反向路由是否能通(通过任意网口),如果不通,则直接丢弃该数据包;
  • 示例:修改参数为2,允许来自同一个 IP 的数据包进出走不同网卡
sysctl -w net.ipv4.conf.all.rp_filter=2
sysctl -w net.ipv4.conf.eth0.rp_filter=2
sysctl -w net.ipv4.conf.tun0.rp_filter=2

图18 反向路由校验

6.2 Tap(网络接口层)

与 Tun 原理和用途类似,只不过 Tap 工作在网络接口层,Tun 处理 IP 报文,而 Tap 处理以太帧。

6.3 创建代码

创建 Tun/Tap 虚拟网卡代码如下:

struct ifreq ifr;
int fd = open("/dev/net/tun", O_RDWR);
if (fd < 0)
    return errno;





memset(&ifr, 0, sizeof(ifr));
ifr.ifr_flags = IFF_TUN | IFF_NO_PI; //IFF_TAP
std::string name = “tun0“;
strncpy(ifr.ifr_name, name.c_str(), name.size()); //设置设备名称

if (ioctl(fd, TUNSETIFF, (void *)&ifr) < 0) {
    close(fd);
    return errno;
}

int flags:

  • IFF_TUN:使用 IFF_TUN 来指定一个 TUN 设备(报文不包括以太头);
  • IFF_TAP:使用 IFF_TAP 来指定一个 TAP 设备(报文包含以太头);
  • IFF_NO_PI:可以与 IFF_TUN 或 IFF_TAP 执行 OR 配合使用;
    • 设置 IFF_NO_PI 会告诉内核不需要提供报文信息,即告诉内核仅需要提供「纯」IP 报文;
    • 不设置 IFF_NO_PI,会在报文开始处添加 4 个额外的字节(2字节的标识和2字节的协议);

配置虚拟网卡的 IP 和 MTU 代码如下:

std::string name = “tun0“;
std::string ip = "127.0.0.8";
char cmd[64];
sprintf(cmd, "ifconfig %s %s mtu %d up", name.c_str(), ip.c_str(), 1500);
system(cmd);

七、网络抓包

7.1 tcpdump

7.1.1 网卡相关

抓包命令,后台执行,抓所有网卡(-i any)的所有数据,抓包文件保存到 packet.pcap (-w),日志保存到 tcpdump.log,以太帧显示 Linux cooked capture v1

nohup tcpdump -Xvvvnnttt -s 0 -i any -w ./packet.pcap > tcpdump.log 2>&1 &

图19

抓 eth0 网卡所有数据,以太帧显示 Ethernet II

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 -w ./packet.pcap > tcpdump.log 2>&1 &

图20

7.1.2 IP相关

抓所有来自或者发送给固定 IP 的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 host 192.168.0.2 -w ./packet.pcap > tcpdump.log 2>&1 &

抓所有来自固定 IP 的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 src host 192.168.0.2 -w ./packet.pcap > tcpdump.log 2>&1 &

抓所有发给固定 IP 的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 dst host 192.168.0.2 -w ./packet.pcap > tcpdump.log 2>&1 &

7.1.3 Port相关

抓所有来自或者发送给固定 Port 的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 port 10000 -w ./packet.pcap > tcpdump.log 2>&1 &

抓所有来自或者发送给不是指定 Port 的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 not port 10000 -w ./packet.pcap > tcpdump.log 2>&1 &

抓所有来自固定 Port 的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 src port 10000 -w ./packet.pcap > tcpdump.log 2>&1 &

抓所有发给固定 Port 的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 dst port 10000 -w ./packet.pcap > tcpdump.log 2>&1 &

7.1.4 协议相关

抓 TCP/UDP/ICMP 协议的数据包

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 udp -w ./packet.pcap > tcpdump.log 2>&1 &

7.1.5 复杂组合

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 'dst host (192.168.0.2 or 192.168.0.3) and dst port 10000 and udp[8:2]=0x0a21 and (udp[10:2]=0x8000 or udp[10:1]=0x81)' -w ./packet.pcap > tcpdump.log 2>&1 &

7.1.6 结束抓包,pid 是抓包后台进程 id

kill -2 pid

7.1.7 抓包文件切片,100MB 一个文件

nohup tcpdump -Xvvvnnttt -s 0 -i eth0 -B 10240 -C 100M -Z root -w ./packet.pcap > tcpdump.log 2>&1 &

7.2 Wireshark

Wireshark 主要用于显示并分析 8.1 中抓到的数据文件 packet.pcap。

7.2.1 IP相关

过滤规则:收发的 IP 是 172.17.0.3,ip.addr == 172.17.0.3

图21

过滤规则:源 IP 是 172.17.0.3,ip.src == 172.17.0.3

图22

过滤规则:目的 IP 是 172.17.0.3,ip.dst == 172.17.0.3

图23

7.2.2 Port相关

过滤规则:udp 的端口是 20000,udp.port == 20000

图24

过滤规则:udp 的目的端口是 20000,udp.dstport == 20000(源端口:udp.srcport == 20000)

图25

7.2.3 协议相关

过滤规则:udp[0:2]==856B && udp[6:1]==58

图26

八、网络工具

Linux 内核接收网络包的过程大致可分为四个阶段:接收到 RingBuffer、硬中断处理、ksoftirqd 软中断处理和送到协议栈的处理。

8.1 ethtool

  • -i:显示网卡驱动的信息,如驱动的名称、版本等;
  • -S:查看网卡收发包的统计情况;
  • -g/-G:查看或者修改RingBuffer的大小;
  • -l/-L:查看或者修改网卡队列数;
  • -c/-C:查看或者修改硬中断合并策略;

8.2 ifconfig

网络管理工具 ifconfig 不只是可以为网卡配置 ip,启动或者禁用网卡,也包含了一些网卡的统计信息。

$ ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.3  netmask 255.255.0.0  broadcast 172.17.255.255
        ether 02:99:ac:15:00:03  txqueuelen 0  (Ethernet)
        RX packets 223355  bytes 268483170 (256.0 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 133664  bytes 8815261 (8.4 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
  • RX bytes:接收的总字节数;
  • RX errors:收到的总错误包数量;
  • RX dropped:数据包已经进入了 Ring Buffer,但是由于其它原因(内存不足等)导致的丢包;
  • RX overruns:由于 Ring Buffer 空间不足导致的丢包数量;

8.3 伪文件

8.3.1 伪文件 /proc/interrupts:记录硬中断的情况

$ cat /proc/interrupts
            CPU0       CPU1       CPU2         
  58:   11697205          0          0    IR-PCI-MSI-edge      eth0-TxRx-0
  59:          0   13296501          0    IR-PCI-MSI-edge      eth0-TxRx-1
  60:          0          0   12625827    IR-PCI-MSI-edge      eth0-TxRx-2
  • 网卡的队列 eth0-TxRx-2 的中断号是 60, 名称和数字都不是固定的,因机器而异;
  • 60 号中断都是由 CPU2 来处理的,总的中断次数是 12625827;
  • 硬中断的总次数不代表网络收包总数。原因有二:
  • 网卡可以设置中断合并,多个网络帧可以只发起一次中断;
  • NAPI 运行的时候会关闭硬中断,通过 poll 来收包。

8.3.2 伪文件 /proc/irq/x/smp_affinity:保存 CPU 亲和性,与 x 硬中断的绑定

$ cat /proc/irq/60/smp_affinity
4

4 的二进制是 100,第 3 位为 1,代表的就是第 3 个 CPU 核心,也就是 CPU2;

8.3.3 伪文件 /proc/net/dev:记录网卡的统计信息

$ cat /proc/net/dev | column -t
Inter-|   Receive    |        Transmit
face      |bytes     packets  errs      drop  fifo  frame  compressed  multicast|bytes  packets  errs    drop  fifo  colls  carrier  compressed
lo:       266978     1320     0         0     0     0      0           0                266978   1320    0     0     0      0        0           0
eth0:     268483170  223355   0         0     0     0      0           0                8815261  133664  0     0     0      0        0           0
  • bytes:发送或接收的数据的总字节数;
  • packets:接口发送或接收的数据包总数;
  • errs:由设备驱动程序检测到的发送或接收错误的总数;
  • drop:设备驱动程序丢弃的数据包总数;
  • fifo:FIFO 缓冲区错误的数量;
  • frame:分组帧错误的数量;
  • colls:接口上检测到的冲突数;

8.3.4 伪文件 /proc/softirqs:统计的所有软中断信息

$ cat /proc/softirqs
                    CPU0       CPU1       CPU2
          HI:          0          0          0
       TIMER:  213600952  238182536  197809105
      NET_TX:       3068       2897       2716
      NET_RX:  183758868  207187711  175961293
       BLOCK:    3193309     632121          0
BLOCK_IOPOLL:          0          0          0
     TASKLET:   10932625     328213     160999
       SCHED:  221105331  119746350   76116897
     HRTIMER:          0          0          0
         RCU:   96902678  107325602   92810065

NET_RX:收包时触发的 softirq,主要用于观察 softirq 在每个 CPU 上分布是否均匀。如果不均匀,可能原因是 NIC 不支持 RSS,没有多个 Ring Buffer,开启 RPS 后就均匀多了。

九、网络调优(进阶)

9.1 Ring Buffer 调优

查看网卡的Ring Buffer 大小

$ ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:

RX:             8192
RX Mini:        0
RX Jumbo:       0
TX:             8192
Current hardware settings:

RX:             1024
RX Mini:        0
RX Jumbo:       0
TX:             1024

Ring Buffer 就是一个生产者消费者对列。对于接收过程来讲,网卡收到数据包往 Ring Buffer 中写入,ksoftirqd 内核进程从中取走处理。只要 ksoftirqd 内核进程消费的足够快,Ring Buffer 就不会满。假如 CPU 繁忙导致 ksoftirqd 消费慢时,Ring Buffer 可能瞬间被填满,导致网卡直接丢弃后面再来的数据包,不做任何处理!

查看网卡统计信息

$ ethtool -S eth0
...
rx_fifo_errors: 0
tx_fifo_errors: 0
...

rx_fifo_errors 如果不为 0(在 ifconfig 中体现为 overruns 指标增长),就表示有包因为 Ring Buffer 装不下而被丢弃了。增大 Ring Buffer 的大小可以缓解丢包问题。修改命令如下:

$ ethtool -G eth0 rx 4096 tx 4096

增大队列长度可以解决偶发的瞬时丢包问题。不过会引入新的问题,那就是排队的包过多会导致网络包的延时增加。

9.2 网卡单队列

9.2.1 RPS(Receive Packet Steering)

RPS 是网卡(NIC)在不支持 RSS 时,在软件中实现类似 RSS 的机制。好处是任何 NIC 都能支持 RPS,但缺点是 NIC 收到数据后 DMA 将数据存入的还是一个 Ring Buffer,NIC 触发的 IRQ 还是发到一个 CPU,还是由这一个 CPU 处理软中断,调用驱动(driver)注册的轮询函数(poll)来将 Ring Buffer 的数据取出来,然后 RPS 才开始起作用,它会为每个 Packet 计算 Hash 之后将 Packet 发到对应 CPU 的 backlog 中,并通过 IPI(Inter-processor Interrupt)告知目标 CPU 来处理 backlog。后续 Packet 的处理流程就由这个目标 CPU 来完成。从而实现将负载分散到多个 CPU 的目的。

RPS 默认是关闭的,当机器有多个 CPU 并且通过 softirqs 的统计 /proc/softirqs 发现 NET_RX 在 CPU 上分布不均匀或者发现网卡不支持多队列(mutiqueue)时,就可以考虑开启 RPS。开启 RPS 命令:

$ echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus

表示网卡 eth0 的 rx-0 队列的数据均匀发给 15(0xf)个 CPU 核心处理。

注意:如果 NIC 不支持对队列,RPS 并不是无脑开启,因为开启后会加重所有 CPU 的负载,在一些场景(比如 CPU 密集型)上并不一定能带来好处,所以得测试下才能用。

9.2.2 RFS(Receive Flow Steering)

RFS 一般和 RPS 配合使用。RPS 是将收到的数据包分发到不同 CPU 以实现负载均衡,但是可能同一个 Flow 的多个数据包被分发到多个不同的 CPU 上,会降低 CPU 缓存命中率,并且会使同一个 Flow 的数据包拷贝到同一个 CPU 上处理。

RFS 就是保证同一个 Flow 的 数据包都会被打到正在处理当前 Flow 数据的 CPU 上,从而提高 CPU 缓存命中率。基本上就是收到数据后根据数据的一些信息做个 Hash 在这个 table 的 entry 中找到当前正在处理这个 Flow 的 CPU 信息,从而将数据发给这个正在处理该 Flow 数据的 CPU 上,从而做到提高缓存命中率,避免数据在不同 CPU 之间拷贝。

RFS 同样默认是关闭的。正常来说开启了 RPS 都要再开启 RFS,以获取更好的性能。开启命令:

$ sysctl -w net.core.rps_sock_flow_entries=32768

这个值依赖于系统期望的同时活跃的连接数,这个连接数正常会远小于系统能承载的最大连接数,因为大部分连接不会同时活跃。该值建议是 32768,能覆盖大多数情况,每个活跃连接会分配一个 entry。除了这个之外还要配置 rps_flow_cnt,这个值是每个队列负责的 Flow 最大数量,如果只有一个队列,则 rps_flow_cnt 一般是跟 rps_sock_flow_entries 的值一致,但是有多个队列的时候 rps_flow_cnt 值就是 rps_sock_flow_entries / N, N 是队列数量。

$ echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

9.2.3 aRFS(Accelerated Receive Flow Steering)

aRFS 是由硬件协助完成这个类似 RFS 的工作。aRFS 对于 RFS 就和 RSS 对于 RPS 一样,就是把 CPU 的工作挪到了硬件来做,从而不用浪费 CPU 时间,直接由 NIC 完成 Hash 值计算并将数据发到目标 CPU,所以快一点。NIC 必须暴露出来一个 ndo_rx_flow_steer 的函数用来实现 aRFS。

9.3 网卡多队列

NIC 收到数据的时候产生的 IRQ 只可能被一个 CPU 处理,从而只有一个 CPU 会执行 napi_schedule 来触发 softirq,触发的这个 softirq 的 handler 也还是会在这个产生 softirq 的 CPU 上执行。所以 driver 的 poll 函数也是在最开始处理 NIC 发出 IRQ 的那个 CPU 上执行。于是一个 Ring Buffer 上同一个时刻只有一个 CPU 在拉取数据。

现在的主流网卡基本上都支持多队列,每一个队列有一个中断号,可以独立向某个 CPU 核心发起硬中断请求,让CPU 来 poll 包。网卡将接收的包放到不同的内存队列里,多个 CPU 就可以同时分别向各自的队列发起消费了。这个特性叫做 RSS(Receive Side Scaling,接收端扩展)。通过ethtool工具可以查看网卡的队列情况。

$ ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:

RX:             0
TX:             0
Other:          1
Combined:       63
Current hardware settings:

RX:             0
TX:             0
Other:          1
Combined:       20

上述结果表示当前网卡支持的最大队列数是 63,当前开启的队列数是 20,最多同时可以有 20 个核心收包。如果有 40 个核心,增加队列数到 40 可以提高收包的并发量,这比增加 Ring Buffer 大小更为有用。其实这里的队列指的就是 Ring Buffer,修改队列数量方法如下:

$ ethtool -L eth0 combined 40

一般处理到这里,网络包的接收就没有大问题了。但如果你有更高的追求,或者是说你并没有更多的CPU核心可以参与进来了,那怎么办?放心,我们也还有方法提高单核的处理网络包的接收速度。

需要注意的是,设置了 smp_affinity 的话不能开启 irqbalance 或者需要为 irqbalance 设置 –banirq 列表,将设置了 smp_affinity 的 IRQ 排除。不然 irqbalance 机制运作时会忽略你设置的 IRQ smp_affinity 配置。

调整 Ring Buffer 队列的权重

NIC 如果支持 mutiqueue 的话 NIC 会根据一个 Hash 函数对收到的数据包进行分发。能调整不同队列的权重,用于分配数据。

$ ethtool -x eth0
RX flow hash indirection table for eth0 with 8 RX ring(s):
    0:      0     0     0     0     0     0     0     0
    8:      0     0     0     0     0     0     0     0
   16:      1     1     1     1     1     1     1     1
   ......
   64:      4     4     4     4     4     4     4     4
   72:      4     4     4     4     4     4     4     4
   80:      5     5     5     5     5     5     5     5
   ......
  120:      7     7     7     7     7     7     7     7

我的 NIC 一共有 8 个队列,一个有 128 个不同的 Hash 值,上面就是列出了每个 Hash 值对应的队列是什么。最左侧 0 8 16 是为了能让你快速的找到某个具体的 Hash 值。比如 Hash 值是 76 的话我们能立即找到 72 那一行:”72: 4 4 4 4 4 4 4 4”,从左到右第一个是 72 数第 5 个就是 76 这个 Hash 值对应的队列是 4 。

ethtool -X eth0 weight 6 2 8 5 10 7 1 5

设置 8 个队列的权重。加起来不能超过 128 。128 是 indirection table 大小,每个 NIC 可能不一样。

更改 Ring Buffer Hash Field

分配数据包的时候是按照数据包内的某个字段来进行的,这个字段能进行调整。

$ ethtool -n eth0 rx-flow-hash tcp4
TCP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA
L4 bytes 0 & 1 [TCP/UDP src port]
L4 bytes 2 & 3 [TCP/UDP dst port]

查看 TCPv4 的 Hash 字段,也可以设置 Hash 字段:

ethtool -N eth0 rx-flow-hash udp4 sdfn

sdfn 需要查看 ethtool 看其含义,还有很多别的配置值。

9.4 硬中断合并

发生硬中断时,CPU 会消耗一部分性能来处理上下文切换,以便处理完中断后恢复原来的工作,如果网卡每收到一个包就触发硬中断,频繁中断会使 CPU 工作效率变低。如果能适当降低中断的频率,多攒几个包一起发出硬中断,会使 CPU 的工作效率提升。虽然降低中断频率能使得收包并发量提高,但是会使一些包的延迟增大。

查看硬中断合并策略

$ ethtool -c eth0
Coalesce parameters for eth0:
Adaptive RX: on  TX: on
rx-usecs: 32
rx-frames: 64
tx-usecs: 8
tx-frames: 128
  • Adaptive RX:自适应中断合并,网卡驱动自己判断啥时候合并;
  • rx-usecs:每当过这么长时间过后,触发一个 RX interrupt 硬中断;
  • rx-frames:每当累计收到这么多个帧后,触发一个 RX interrupt 硬中断;

修改硬中断合并策略

ethtool -C eth0 adaptive-rx on

9.5 软中断调优

硬中断之后,接下来就是 ksoftirqd 内核进程处理软中断了。硬中断和其后续的软中断在同一个 CPU 核心上处理。因此,前面硬中断分散到多核上处理的时候,软中断的优化其实也就跟着做了,也会被多核处理。不过软中断也还有自己的可优化选项。

9.5.1 软中断 budget 调整

一旦 ksoftirqd 内核线程被硬中断触发开始处理软中断了,它会集中精力处理很多网络包,然后再去做别的事情。由内核参数 net.core.netdev_budget 控制:

$ sysctl -a | grep net.core.netdev_budget
net.core.netdev_budget = 300

上面表示 ksoftirqd 一次最多处理 300 个包,处理完会把 CPU 主动让出来。想要提高内核处理网络包的效率,就可以让 ksoftirqd 内核进程一次多处理几个包,再让出 CPU。直接修改这个参数就可以了:

$ sysctl -w net.core.netdev_budget=600

9.5.2 软中断 GRO 合并

GRO(Generic Receive Offloading)是 LGO(Large Receive Offload,多数是在 NIC 上实现的一种硬件优化机制)的一种软件实现,从而能让所有 NIC 都支持这个功能。网络上大部分 MTU 都是 1500 字节,开启 Jumbo Frame 后能到 9000 字节,如果发送的数据超过 MTU 就需要切割成多个数据包。通过合并「足够类似」的包来减少传送给网络协议栈的包数,有助于减少 CPU 的使用量。GRO 使协议层只需处理一个 header,而将包含大量数据的整个大包送到用户程序。如果用 tcpdump 抓包看到机器收到了不现实的、非常大的包,这很可能是系统开启了 GRO。

GRO 和「硬中断合并」的思想类似,不过阶段不同。「硬中断合并」是在中断发起之前,而 GRO 已经在处理软中断中了。

napi_gro_receive 就是在收到数据包的时候合并多个数据包用的,如果收到的数据包需要被合并,napi_gro_receive 会很快返回。当合并完成后会调用 napi_skb_finish ,将因为数据包合并而不再用到的数据结构释放。最终会调用到 netif_receive_skb 将数据包交到上层网络栈继续处理。netif_receive_skb 就是数据包从 Ring Buffer 出来后到上层网络栈的入口。

查看 GRO 是否开启命令:

$ ethtool -k eth0 | grep generic-receive-offload
generic-receive-offload: on

开启 GRO 命令:

ethtool -K eth0 gro on

GRO 只是对包的接收阶段的优化方式,对于发送来说是GSO。

十、网络收发过程(进阶)

新出炉的干货 Linux 网络收包过程(详细到爆)

图27

十一、丢包排查

完整的数据收发流程图,容易丢包的点及查看方法。

十二、eBPF

可以运行的 XDP 和 TC 实验环境

github.com/csioza/eBPF…

十三、参考

创作不易,你的点赞、转发、收藏是我创作的最大动力!

更多分享尽在微信公众号(科英)!

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYlQq2Zl' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片