哈喽大家好,我是咸鱼
之前咸鱼在《Linux 网络收包流程》一文中介绍了 Linux 是如何实现网络接收数据包的
简单回顾一下:
- 数据到达网卡之后,网卡通过 DMA 将数据放到内存分配好的一块
ring buffer
中,然后触发硬中断 - CPU 收到硬中断之后简单的处理了一下(分配
skb_buffer
),然后触发软中断 - 软中断进程
ksoftirqd
执行一系列操作(例如把数据帧从ring ruffer
上取下来)然后将数据送到三层协议栈中 - 在三层协议栈中数据被进一步处理发送到四层协议栈
- 在四层协议栈中,数据会从内核拷贝到用户空间,供应用程序读取
- 最后被处在应用层的应用程序去读取
当 Linux 要发送一个数据包的时候,这个包是怎么从应用程序再到 Linux 的内核最后由网卡发送出去的呢?
那么今天咸鱼将会为大家介绍 Linux 是如何实现网络发送数据包
发包流程
假设我们的网卡已经启动好(分配和初始化 RingBuffer) 且 server 和 client 已经建立好 socket
这里需要注意的是,网卡在启动过程中申请分配的 RingBuffer 是有两个:
igb_tx_buffer
数组:这个数组是内核使用的,用于存储要发送的数据包描述信息,通过vzalloc
申请的e1000_adv_tx_desc
数组:这个数组是网卡硬件使用的,用于存储要发送的数据包,网卡硬件可以通过 DMA 直接访问这块内存,通过dma_alloc_coherent
分配
igb_tx_buffer
数组中的每个元素都有一个指针指向e1000_adv_tx_desc
这样内核就可以把要发送的数据填充到
e1000_adv_tx_desc
数组上然后网卡硬件会直接从
e1000_adv_tx_desc
数组中读取实际数据,并将数据发送到网络上
拷贝到内核
- socket 系统调用将数据拷贝到内核
应用程序首先通过 socket 提供的接口实现系统调用
我们在用户态使用的 send
函数和 sendto
函数其实都是 sendto
系统调用实现的
send/sendto
函数 只是为了用户方便,封装出来的一个更易于调用的方式而已
/* sendto 系统调用 省略了一些代码 */
SYSCALL_DEFINE6(sendto, int, fd, void __user *, buff, size_t, len,
unsigned int, flags, struct sockaddr __user *, addr,
int, addr_len)
{
...
sock = sockfd_lookup_light(fd, &err, &fput_needed);
...
err = sock_sendmsg(sock, &msg, len);
...
}
在 sendto
系统调用内部,首先 sockfd_lookup_light
函数会查找与给定文件描述符(fd)关联的 socket
接着调用 sock_sendmsg
函数(sock_sendmsg ==> __sock_sendmsg ==> __sock_sendmsg_nosec
)
其中 sock->ops->sendmsg
函数实际执行的是 inet_sendmsg
协议栈函数
/*
__sock_sendmsg_nosec 函数
iocb:指向与 I/O 操作相关的结构体 kiocb
sock: 指向要执行发送操作的套接字结构体
msg: 指向存储要发送数据的消息头结构体 msghdr
size: 要发送的数据大小
*/
static inline int __sock_sendmsg_nosec(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t size)
{
...
return sock->ops->sendmsg(iocb, sock, msg, size);
}
这时候内核会去找 socket 上对应的具体协议发送函数
以 TCP 为例,具体协议发送函数为 tcp_sendmsg
tcp_sendmsg
会去申请一个内核态内存 skb(sk_buff)
,然后挂到发送队列上(发送队列是由 skb 组成的一个链表)
接着把用户待发送的数据拷贝到 skb 中,拷贝之后会触发【发送】操作
这里说的发送是指在当前上下文中,待发送数据从 socket 层发送到传输层
需要注意的是,这时候不一定开始真正发送,因为还要进行一些条件判断(比如说发送队列中的数据已经超过了窗口大小的一半)
只有满足了条件才能够发送,如果没有满足条件这次系统调用就可能直接返回了
网络协议栈处理
- 传输层处理
接着数据来到了传输层
传输层主要看 tcp_write_xmit
函数,这个函数处理了传输层的拥塞控制、滑动窗口相关的工作
该函数会根据发送窗口和最大段大小等因素计算出本次发送的数据大小,然后将数据封装成 TCP 段并发送出去
如果满足窗口要求,设置 TCP 头然后将数据传到更低的网络层进行处理
在传输层中,内核主要做了两件事:
- 复制一份数据(skb)
为什么要复制一份出来呢?因为网卡发送完成之后,skb 会被释放掉,但 TCP 协议是支持丢失重传的
所以在收到对方的 ACK 之前必须要备份一个 skb 去为重传做准备
实际上一开始发送的是 skb 的拷贝版,收到了对方的 ACK 之后系统才会把真正的 skb 删除掉
- 封装 TCP 头
系统会根据实际情况添加 TCP 头封装成 TCP 段
这里需要知道的是:每个 skb 内部包含了网络协议中的所有头部信息,例如 MAC 头、IP 头、TCP/UDP 头等
在设置这些头部时,内核会通过调整指针的位置来填充相应的字段,而不是频繁申请和拷贝内存
比如说在设置 TCP 头的时候,只是把指针指向 skb 的合适位置。后面再设置 IP 头的时候,在把指针挪一挪就行
这种方式利用了 skb 数据结构的链表特性可以避免内存分配和数据拷贝所带来的性能开销,从而提高数据传输的效率
- 网络层处理
数据离开了传输层之后,就来到了网络层
网络层主要做下面的事情:
- 路由项查找:
根据目标 IP 地址查找路由表,确定数据包的下一跳( ip_queue_xmit
函数)
- IP 头设置:
根据路由表查找的结果,设置 IP 头中的源和目标 IP 地址、TTL(生存时间)、IP 协议等字段
- netfilter 过滤:
netfilter 是 Linux 内核中的一个框架,用于实现数据包的过滤和修改
在网络层,netfilter 可以用于对数据包进行过滤、NAT(网络地址转换)等操作
- skb 切分:
如果数据包的大小超过了 MTU(最大传输单元),需要将数据包进行切分成多个片段,以适应网络传输,每个片段会被封装成单独的 skb
- 数据链路层处理
当数据来到了数据链路层之后,会有两个子系统协同工作,确保数据包在发送和接收过程中能够正确地对数据进行封装、解析和传输
- 邻居子系统
管理和维护主机或路由器与其它设备之间的邻居关系
邻居子系统里会发送 arp 请求找邻居,然后把邻居信息存在邻居缓存表里,用于存储目标主机的 MAC 地址
当需要发送数据包到某个目标主机时,数据链路层会首先查询邻居缓存表,以获取目标主机的 MAC 地址,从而正确地封装数据包(封装 MAC 头)
- 网络设备子系统
网络设备子