一、网络分层
OSI 参考模型(七层)基本存在于教科书中,而TCP/IP 协议栈(四层)大行其道,图1 是两个模型的对照关系。
下面分别聊聊 TCP/IP 协议栈的各层的理论、经验和实践。
二、网络接口层(以太网)
2.1 协议体
下面详细介绍每个参数的含义:
目的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头结构体定义如下:
structethhdr{unsignedchar h_dest[ETH_ALEN];unsignedchar h_source[ETH_ALEN];
__be16 h_proto;}__attribute__((packed));
抓包现场:
MTU 和 MSS
三、网络层(IP、ICMP)
3.1 IP
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 个字节不等。
Linux 中 IP 头结构体定义如下(经修改方便阅读):
structiphdr{
__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;};
抓包现场:
3.2 ICMP
互联网控制消息协议(英语:InternetControlMessageProtocol,缩写:ICMP)是互联网协议族的核心协议之一。它用于网际协议(IP)中发送控制消息,提供可能发生在通信环境中的各种问题反馈。通过这些信息,使管理者可以对所发生的问题作出诊断,然后采取适当的措施解决。
ICMP 报头从 IP 报头的第20字节开始(除非使用了 IP 报头的选项部分):
类型(8位):标识生成的错误报文;
代码(8位):进一步划分 ICMP 的类型,该字段用来查找产生错误的原因;例如,ICMP 的目标不可达类型可以把这个位设为 1 至 15 等来表示不同的意思;
校验和(16位):Internet 校验和(RFC 1071),用于进行错误检查,该校验和是从 ICMP 头和以该字段替换为 0 的数据计算得出的;
其余部分(4字节):报头的其余部分,内容根据 ICMP 类型和代码而有所不同。
Linux 中 ICMP 头结构体定义如下:
structicmphdr{
__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
抓包现场:目标端口不可达
四、传输层(TCP、UDP)
五、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 服务端地址
structsockaddr_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,(structsockaddr*)&server_addr,sizeof(server_addr));
listen函数
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);
accept函数,recv_addr 是客户端的地址
structsockaddr_in recv_addr;
socklen_t in_len =sizeof(structsockaddr_in);int socket_fd =accept(listen_fd,(structsockaddr*)&recv_addr,&in_len);
connect函数,server_addr 服务端地址
int fd =socket(AF_INET, SOCK_STREAM,0);int ret =connect(fd,(structsockaddr*)&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 接收数据
structiphdr{0
__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;};
sendto函数,通过 fd 向 server_addr 发送数据,通常 UDP 使用,直接发送数据,MSG_DONTWAIT 表示非阻塞
structiphdr{1
__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;};
recvfrom函数,fd 已经 bind 到监听端口,通过 fd 接收来自 recv_addr 地址的数据
structiphdr{2
__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;};
阻塞/非阻塞,创建的文件描述符默认是阻塞,阻塞就是调用accept、connect、send、recv、sendto和recvfrom函数时会阻塞线程,直到有返回结果或超时;非阻塞就是不阻塞当前线程,调用立刻返回。设置 fd 为非阻塞代码如下:
structiphdr{3
__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;};
5.3 多路复用
5.3.1 select
函数原型
structiphdr{4
__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;};
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 函数就会返回。
structiphdr{5
__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;};
操作 fd_set 的宏
structiphdr{6
__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;};
代码示例
structiphdr{7
__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;};
上面代码输出
structiphdr{8
__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;};
当把上面做下面修改
structiphdr{9
__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;};
输出结果为
structicmphdr{0
__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;};
经测试发现几点注意事项
select 函数第一个参数必须要大于等于「最大文件描述符 + 1」;
FD_SET(fd, &readfds) 中 fd 大于等于 1024 时,结果就不准了,select 后,FD_ISSET(fd, &readfds) 一直大于 0,所以 fd 必需要小于等于 1023;
5.3.2 poll
函数原型
structicmphdr{1
__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;};
fds:是一个结构体(struct pollfd)指针,也就是 poll 函数同时监控的一个或多个文件描述符上的事件;
nfds:其实就是 int 型,指明 poll 同时监控文件描述符的个数,不限于 1024,可以很大;
timeout:超时参数,-1:永久等待;0:立即返回;大于 0:等待超时时间,以毫秒为单位;
返回值:fds 中有多少个文件描述符有监控的事件发生;
代码示例
structicmphdr{2
__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;};
输出结果
structicmphdr{3
__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;};
5.3.3 epoll
创建 epoll 函数,返回 epfd 文件描述符,size 在 Linux 2.6.8 之后被忽略,但是必须大于 0。
structicmphdr{4
__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;};
epoll 使用的结构体
structicmphdr{5
__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;};
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 函数
structicmphdr{6
__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;};
epfd:epoll 文件描述符,epoll 实例;
op:操作选项
EPOLL_CTL_ADD:向 epoll 实例中添加文件描述符和 epoll_event;
EPOLL_CTL_MOD:修改文件描述符对应新的 epoll_event;
EPOLL_CTL_DEL:删除文件描述符,对应的 event 参数被忽略,可填 NULL;
fd:待监视的文件描述符;
event:待监视的事件结构体;
获取 epoll 实例中已经就绪的文件描述符及其事件
structicmphdr{7
__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;};
epfd:epoll 文件描述符,epoll 实例;
events:事件就绪文件描述符会存入事先提供好的 epoll_event 数组;
maxevents:是个数值,获取至多该数值的就绪的文件描述符;
timeout:超时参数,-1:永久等待;0:立即返回;大于 0:等待超时时间,以毫秒为单位;
返回值:有多少个文件描述符上有事件发生了;
代码示例
structicmphdr{8
__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;};
输出结果
structicmphdr{9
__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;};
LT(水平触发)模式和 ET(边缘触发)模式区别在于:
当一个新的事件到来时,ET 模式下可以从 epoll_wait 调用中获取到这个事件,可如果这次没有把这个事件处理完,在没有新的事件再次到来时,是无法再次从 epoll_wait 调用中获取这个事件的;
而 LT 模式则相反,只要一个事件未处理完,就总能从 epoll_wait 中获取到这个事件;
因此,在 LT 模式下要简单一些,不容易出错,而在 ET模式下事件发生时,如果 socket 有可读事件,但没有一次性地将缓冲区数据处理完,则会导致缓冲区中的用户请求得不到响应,直到下次 socket 有可读事件。
epoll 原理
六、虚拟网卡(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;
反向路由校验(net.ipv4.conf.xxx.rp_filter)内核参数
当一个网卡收到数据包后,把源地址和目的地址互换后,查找反向路由出口:
0:关闭反向路由校验;
1:开启严格的反向路由校验。对每个进来的数据包,校验其反向路由是否是最佳路由,如果不是,则直接丢弃该数据包;
2:开启松散的反向路由校验。对每个进来的数据包,校验其源地址是否可达,即反向路由是否能通(通过任意网口),如果不通,则直接丢弃该数据包;
示例:修改参数为2,允许来自同一个 IP 的数据包进出走不同网卡
int fd =socket(AF_INET, SOCK_STREAM,0);0
6.2 Tap(网络接口层)
与 Tun 原理和用途类似,只不过 Tap 工作在网络接口层,Tun 处理 IP 报文,而 Tap 处理以太帧。
6.3 创建代码
创建 Tun/Tap 虚拟网卡代码如下:
int fd =socket(AF_INET, SOCK_STREAM,0);1
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 代码如下:
int fd =socket(AF_INET, SOCK_STREAM,0);2
七、网络抓包
7.1 tcpdump
7.1.1 网卡相关
抓包命令,后台执行,抓所有网卡(-i any)的所有数据,抓包文件保存到 packet.pcap (-w),日志保存到 tcpdump.log,以太帧显示 Linux cooked capture v1
int fd =socket(AF_INET, SOCK_STREAM,0);3
抓 eth0 网卡所有数据,以太帧显示 Ethernet II
int fd =socket(AF_INET, SOCK_STREAM,0);4
7.1.2 IP相关
抓所有来自或者发送给固定 IP 的数据包
int fd =socket(AF_INET, SOCK_STREAM,0);5
抓所有来自固定 IP 的数据包
int fd =socket(AF_INET, SOCK_STREAM,0);6
抓所有发给固定 IP 的数据包
int fd =socket(AF_INET, SOCK_STREAM,0);7
7.1.3 Port相关
抓所有来自或者发送给固定 Port 的数据包
int fd =socket(AF_INET, SOCK_STREAM,0);8
抓所有来自或者发送给不是指定 Port 的数据包
int fd =socket(AF_INET, SOCK_STREAM,0);9
抓所有来自固定 Port 的数据包
int fd =socket(AF_INET, SOCK_DGRAM,0);0
抓所有发给固定 Port 的数据包
int fd =socket(AF_INET, SOCK_DGRAM,0);1
7.1.4 协议相关
抓 TCP/UDP/ICMP 协议的数据包
int fd =socket(AF_INET, SOCK_DGRAM,0);2
7.1.5 复杂组合
int fd =socket(AF_INET, SOCK_DGRAM,0);3
7.1.6 结束抓包,pid 是抓包后台进程 id
int fd =socket(AF_INET, SOCK_DGRAM,0);4
7.1.7 抓包文件切片,100MB 一个文件
int fd =socket(AF_INET, SOCK_DGRAM,0);5
7.2 Wireshark
7.2.1 IP相关
过滤规则:收发的 IP 是 172.17.0.3,ip.addr == 172.17.0.3
过滤规则:源 IP 是 172.17.0.3,ip.src == 172.17.0.3
过滤规则:目的 IP 是 172.17.0.3,ip.dst == 172.17.0.3
7.2.2 Port相关
过滤规则:udp 的端口是 20000,udp.port == 20000
过滤规则:udp 的目的端口是 20000,udp.dstport == 20000(源端口:udp.srcport == 20000)
7.2.3 协议相关
过滤规则:udp[0:2]==856B && udp[6:1]==58
八、网络工具
Linux 内核接收网络包的过程大致可分为四个阶段:接收到 RingBuffer、硬中断处理、ksoftirqd 软中断处理和送到协议栈的处理。
8.1 ethtool
-i:显示网卡驱动的信息,如驱动的名称、版本等;
-S:查看网卡收发包的统计情况;
-g/-G:查看或者修改RingBuffer的大小;
-l/-L:查看或者修改网卡队列数;
-c/-C:查看或者修改硬中断合并策略;
8.2 ifconfig
网络管理工具 ifconfig 不只是可以为网卡配置 ip,启动或者禁用网卡,也包含了一些网卡的统计信息。
int fd =socket(AF_INET, SOCK_DGRAM,0);6
RX bytes:接收的总字节数;
RX errors:收到的总错误包数量;
RX dropped:数据包已经进入了 Ring Buffer,但是由于其它原因(内存不足等)导致的丢包;
RX overruns:由于 Ring Buffer 空间不足导致的丢包数量;
8.3 伪文件
8.3.1 伪文件 /proc/interrupts:记录硬中断的情况
int fd =socket(AF_INET, SOCK_DGRAM,0);7
网卡的队列 eth0-TxRx-2 的中断号是 60, 名称和数字都不是固定的,因机器而异;
60 号中断都是由 CPU2 来处理的,总的中断次数是 12625827;
硬中断的总次数不代表网络收包总数。原因有二:
网卡可以设置中断合并,多个网络帧可以只发起一次中断;
NAPI 运行的时候会关闭硬中断,通过 poll 来收包。
8.3.2 伪文件 /proc/irq/x/smp_affinity:保存 CPU 亲和性,与 x 硬中断的绑定
int fd =socket(AF_INET, SOCK_DGRAM,0);8
4 的二进制是 100,第 3 位为 1,代表的就是第 3 个 CPU 核心,也就是 CPU2;
8.3.3 伪文件 /proc/net/dev:记录网卡的统计信息
int fd =socket(AF_INET, SOCK_DGRAM,0);9
bytes:发送或接收的数据的总字节数;
packets:接口发送或接收的数据包总数;
errs:由设备驱动程序检测到的发送或接收错误的总数;
drop:设备驱动程序丢弃的数据包总数;
fifo:FIFO 缓冲区错误的数量;
frame:分组帧错误的数量;
colls:接口上检测到的冲突数;
8.3.4 伪文件 /proc/softirqs:统计的所有软中断信息
structsockaddr_in server_addr;0
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,(structsockaddr*)&server_addr,sizeof(server_addr));
NET_RX:收包时触发的 softirq,主要用于观察 softirq 在每个 CPU 上分布是否均匀。如果不均匀,可能原因是 NIC 不支持 RSS,没有多个 Ring Buffer,开启 RPS 后就均匀多了。
九、网络调优(进阶)
9.1 Ring Buffer 调优
查看网卡的Ring Buffer 大小
structsockaddr_in server_addr;1
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,(structsockaddr*)&server_addr,sizeof(server_addr));
Ring Buffer 就是一个生产者消费者对列。对于接收过程来讲,网卡收到数据包往 Ring Buffer 中写入,ksoftirqd 内核进程从中取走处理。只要 ksoftirqd 内核进程消费的足够快,Ring Buffer 就不会满。假如 CPU 繁忙导致 ksoftirqd 消费慢时,Ring Buffer 可能瞬间被填满,导致网卡直接丢弃后面再来的数据包,不做任何处理!
查看网卡统计信息
structsockaddr_in server_addr;2
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,(structsockaddr*)&server_addr,sizeof(server_addr));
rx_fifo_errors 如果不为 0(在 ifconfig 中体现为 overruns 指标增长),就表示有包因为 Ring Buffer 装不下而被丢弃了。增大 Ring Buffer 的大小可以缓解丢包问题。修改命令如下:
structsockaddr_in server_addr;3
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,(structsockaddr*)&server_addr,sizeof(server_addr));
增大队列长度可以解决偶发的瞬时丢包问题。不过会引入新的问题,那就是排队的包过多会导致网络包的延时增加。
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 命令:
structsockaddr_in server_addr;4
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,(structsockaddr*)&server_addr,sizeof(server_addr));
表示网卡 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,以获取更好的性能。开启命令:
structsockaddr_in server_addr;5
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,(structsockaddr*)&server_addr,sizeof(server_addr));
这个值依赖于系统期望的同时活跃的连接数,这个连接数正常会远小于系统能承载的最大连接数,因为大部分连接不会同时活跃。该值建议是 32768,能覆盖大多数情况,每个活跃连接会分配一个 entry。除了这个之外还要配置 rps_flow_cnt,这个值是每个队列负责的 Flow 最大数量,如果只有一个队列,则 rps_flow_cnt 一般是跟 rps_sock_flow_entries 的值一致,但是有多个队列的时候 rps_flow_cnt 值就是 rps_sock_flow_entries / N, N 是队列数量。
structsockaddr_in server_addr;6
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,(structsockaddr*)&server_addr,sizeof(server_addr));
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工具可以查看网卡的队列情况。
structsockaddr_in server_addr;7
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,(structsockaddr*)&server_addr,sizeof(server_addr));
上述结果表示当前网卡支持的最大队列数是 63,当前开启的队列数是 20,最多同时可以有 20 个核心收包。如果有 40 个核心,增加队列数到 40 可以提高收包的并发量,这比增加 Ring Buffer 大小更为有用。其实这里的队列指的就是 Ring Buffer,修改队列数量方法如下:
structsockaddr_in server_addr;8
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,(structsockaddr*)&server_addr,sizeof(server_addr));
一般处理到这里,网络包的接收就没有大问题了。但如果你有更高的追求,或者是说你并没有更多的CPU核心可以参与进来了,那怎么办?放心,我们也还有方法提高单核的处理网络包的接收速度。
需要注意的是,设置了 smp_affinity 的话不能开启 irqbalance 或者需要为 irqbalance 设置 –banirq 列表,将设置了 smp_affinity 的 IRQ 排除。不然 irqbalance 机制运作时会忽略你设置的 IRQ smp_affinity 配置。
调整 Ring Buffer 队列的权重
NIC 如果支持 mutiqueue 的话 NIC 会根据一个 Hash 函数对收到的数据包进行分发。能调整不同队列的权重,用于分配数据。
structsockaddr_in server_addr;9
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,(structsockaddr*)&server_addr,sizeof(server_addr));
我的 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 。
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);0
设置 8 个队列的权重。加起来不能超过 128 。128 是 indirection table 大小,每个 NIC 可能不一样。
更改 Ring Buffer Hash Field
分配数据包的时候是按照数据包内的某个字段来进行的,这个字段能进行调整。
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);1
查看 tcp4 的 Hash 字段。
也可以设置 Hash 字段:
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);2
sdfn 需要查看 ethtool 看其含义,还有很多别的配置值。
9.4 硬中断合并
发生硬中断时,CPU 会消耗一部分性能来处理上下文切换,以便处理完中断后恢复原来的工作,如果网卡每收到一个包就触发硬中断,频繁中断会使 CPU 工作效率变低。如果能适当降低中断的频率,多攒几个包一起发出硬中断,会使 CPU 的工作效率提升。虽然降低中断频率能使得收包并发量提高,但是会使一些包的延迟增大。
查看硬中断合并策略
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);3
Adaptive RX:自适应中断合并,网卡驱动自己判断啥时候合并;
rx-usecs:每当过这么长时间过后,触发一个 RX interrupt 硬中断;
rx-frames:每当累计收到这么多个帧后,触发一个 RX interrupt 硬中断;
修改硬中断合并策略
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);4
9.5 软中断调优
硬中断之后,接下来就是 ksoftirqd 内核进程处理软中断了。硬中断和其后续的软中断在同一个 CPU 核心上处理。因此,前面硬中断分散到多核上处理的时候,软中断的优化其实也就跟着做了,也会被多核处理。不过软中断也还有自己的可优化选项。
9.5.1 软中断 budget 调整
一旦 ksoftirqd 内核线程被硬中断触发开始处理软中断了,它会集中精力处理很多网络包,然后再去做别的事情。由内核参数 net.core.netdev_budget 控制:
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);5
上面表示 ksoftirqd 一次最多处理 300 个包,处理完会把 CPU 主动让出来。想要提高内核处理网络包的效率,就可以让 ksoftirqd 内核进程一次多处理几个包,再让出 CPU。直接修改这个参数就可以了:
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);6
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 是否开启命令:
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);7
开启 GRO 命令:
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);8
十、网络收发过程(进阶)
十一、eBPF
https://github.com/csioza/eBPF
十二、参考
Linux source code (v6.0)
https://zh.wikipedia.org/wiki/IPv4
网络编程实战 - 极客时间
趣谈网络协议 - 极客时间
ip头、tcp头、udp头详解及定义,结合Wireshark抓包看实际情况 - 白羊沈歌 - 博客园
TCP/IP报文头部结构整理_ythunder的博客-CSDN博客_ip报文头部
从TCP头看TCP协议 - 掘金
https://tonydeng.github.io/sdn-handbook/basic/tcpip.html
Tun/Tap接口使用指导 - charlieroro - 博客园
https://zhuanlan.zhihu.com/p/57518857
腾讯技术工程:十个问题理解 Linux epoll 工作原理
qiya:深入理解 Linux 的 epoll 机制
Linux 网络包接收过程的监控与调优
ethtool原理介绍和解决网卡丢包排查思路 - 个人文章 - SegmentFault 思否
Linux 网络协议栈收消息过程-Ring Buffer
作业部落 Cmd Markdown 编辑阅读器
Linux 网络协议栈收消息过程-Per CPU Backlog
Linux 网络协议栈收消息过程-TCP Protocol Layer
千夜同学 | Linux 网络接收数据包流程
千夜同学 | Linux 网络发送数据包流程
网卡的RX Ring和TX Ring
[译] 深入理解 iptables 和 netfilter 架构
网络丢包分析 - CNHK19 - 博客园
Linux服务器丢包故障的解决思路及引申的TCP/IP协议栈理论 | SDNLAB | 专注网络创新技术
https://upload.wikimedia.org/wikipedia/commons/3/37/Netfilter-packet-flow.svg
int listen_fd =socket(AF_INET, SOCK_STREAM,0);int ret =listen(listen_fd, SOMAXCONN);9推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...