本来想继续写 socket
实现,发现网络栈是一个整体,不搞懂网卡与内核的交互,就缺少了最重要的一环。参考内核 4.4, 只梳理了大致框架,忽略很多细节,比如 sriov
, xdp
网卡中断逻辑
网卡接到数据后,触发中断,内核回调中断处理程序 ISR
. 一般中断都会分成上半部和下半部 (bh), 上半部执行时间短,不允许程序休眠,并且此时中断处于禁止状态。下半部有多种实现,网卡使用软中断,由 ksoftirqd
处理,耗时较长。
在石器时代,网卡中断只由一个 cpu 处理,但是在大数据高吐吞时,就会把某个核(一般是 cpu0) 拖跨,一直频繁的响应中断,严重影响网卡吞吐。所以就有了硬件层面的 RSS
概念,receive side scaling
,网卡实现多个队列,每个队列一个中断并且绑定到不同 cpu, 将中断分摊到多个 cpu. 如果硬件不支持硬件队列呢?就有了 RPS
概念,在软件层面模拟一个队列,如果有 RSS
,就没必要用 RPS
读取网卡数据有两种方式:中断和轮循。只有中断会使 cpu 负载变得很高,一直忙于响应中断。只用轮循时延得不到保证,会使其它进程恶心。为了高效的使用 cpu,现代操作系统都是采用 中断 + 轮循 的方式,这就是下面会提到的 NAPI
操作系统网络初始化
在网卡正式工作前,先由内核为网络做准备工作。内核调用 net/dev/core.c net_dev_init
初始化网络,主要做如下工作:
-
dev_proc_init
在操作系统/proc/net
目录下生成相关文件,/proc/net/dev
,/proc/net/ptype
,/proc/net/dev_mcast
存放一些统计及状态信息 -
netdev_kobject_init
创建操作系统目录/sys/class/net
, 当网卡启动后都会在这里进行注册,方便查看 - 初始化全局
ptype_all
结构,这里存放不同协义族如何处理接收数据包的回调。打印/proc/net/ptype
文件可以看到全部,比如ip_rcv
, 这一块很重要 -
for_each_possible_cpu
初始化每个 cpu 的局部变量,per_cpu
是一个宏,表示他所引用的变量,每个 cpu 都有一个私有的。
for_each_possible_cpu(i) {
struct work_struct *flush = per_cpu_ptr(&flush_works, i);
struct softnet_data *sd = &per_cpu(softnet_data, i);
INIT_WORK(flush, flush_backlog);
skb_queue_head_init(&sd->input_pkt_queue);
skb_queue_head_init(&sd->process_queue);
INIT_LIST_HEAD(&sd->poll_list);
sd->output_queue_tailp = &sd->output_queue;
#ifdef CONFIG_RPS
sd->csd.func = rps_trigger_softirq;
sd->csd.info = sd;
sd->cpu = i;
#endif
sd->backlog.poll = process_backlog;
sd->backlog.weight = weight_p;
}
work_struct
每个 cpu 都会有一个,如果网卡消失了,那么回调 flush_backlog
释放属于这个网卡的 skb
. 接下来最重要的就是 softnet_data
结构体,非常重要,所有网卡的数据包都是挂载到某个 cpu, 就是放在这个结构体里。如果开启了 RPS
,设置软中断回调。最后设置 backlog.poll
回调 process_backlog
,这个就是 NAPI Poll
-
open_softirq
设置软中断传输发送回调分别是net_tx_action
,net_rx_action
-
cpuhp_setup_state_nocalls
注册通知事件,如果 cpu 坏掉,那么dev_cpu_dead
将坏掉 cpu 的本地网络包,转移到其它 cpu
网卡初始化
分为两个阶段,在驱动加载时调用 ixgbe_probe
初始化硬件一次,当网卡激活,也就是 if up 时调用 ixgbe_open
初始化与操作系统相关工作。这里以 intel
网卡举例,老的是 igb
,新的驱动是 igbxe
, 代码路径 drivers/net/ethernet/intel/ixgbe
. 有的商用服务器会用 broadcom
网卡,价格便宜一些,驱动原理相近。
代码有些涉及硬件知识,不影响分析的情况下忽略。这里涉及几个重要结构体,网卡本身是一个 pci 设备,所以自然是 pci_dev
, 并且有 pci_dev_id
. 所有网卡在内核都用 net_device
来抽象表示。
ixgbe_probe
函数声明如下,传入 pci_dev
设备
int ixgbe_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
- 激活网卡内存,配置设备
DMA
, 在内核虚拟地址空间申请地址,保留网卡 io, memory 地址 - 最重要的是调用
alloc_etherdev_mq
在内核分配空间,生成net_device
结构并初始化。这里重点关注indices
用来初始化RSS
队列,默认 64 个队列。 -
ioremap
将网卡 pci 地址重新映射,赋值给hw_addr
,io_addr
-
ixgbe_set_ethtool_ops
设置ethtool
操作接口,这样将网卡操作抽象出来。 -
ixgbe_sw_init
初始化网卡私有数据,这里看到设置RSS
队列个数逻辑min_t(int, ixgbe_max_rss_indices(adapter), num_online_cpus())
,取硬件队列和 cpu 的最小值。 -
ixgbe_enable_sriov
打开sriov
硬件虚拟化 -
eth_platform_get_mac_address
设置网卡硬件mac
地址和mac filter
-
ixgbe_init_interrupt_scheme
申请中断,优先判断硬件是否支持MSI-X
, 检查中断向量num_q_vector
个数,取 cpu 核数和硬件队列最小值,分配中断向量空间msix_entries
. 查看测试机,由于 cpu 总核数 40,所以num_q_vector
也是 40. 如果不支持MSI-X
那么设置num_q_vectors
为 1. -
ixgbe_alloc_q_vectors
在内核申请空间,可以看到队列结构体ixgbe_ring
是和ixgbe_q_vector
一起分配的,将vector
中断和队列绑定,rx
和tx
两个队列同时绑定到一个vector
中断,理想情况下一对队列拥有一个中断,如果中断不够用,那么会有多个共用。这里还涉及到 cpu 亲缘性和 numa. 最后netif_napi_add
给每个中断设置NAPI_POLL
回调。 -
ixgbe_cache_ring_rss
将队列ixgbe_ring
与硬件RSS
关联对应 -
register_netdev
该网卡设备注册到名字空间
ixgbe_probe
主要功能就是生成 net_device
结构, 读取硬件 mac
地址,将网卡注册到系统中。由于支持 msi-x
根据 cpu 个数配置硬件中断和队列,设置 ixgbe_poll
回调。
ixgbe_open
第二个初始化的函数,在网卡激活时调用。其本功能很简单,申请所有 rx
, tx
队列资源,申请中断,配置 msi-x
.
-
ixgbe_setup_all_tx_resources
分配发送队列,每个队列申请自己的DMA
地址,总长度sizeof(struct ixgbe_tx_buffer) * tx_ring->count
, 其中count
就是大家常说的网卡队列ring buffer
个数,默认 512,一般都要调大,防止丢包。 -
ixgbe_setup_all_rx_resources
同理,分配接收队列,我的测试机是 40 个列队。 -
ixgbe_configure
设置网卡虚拟化,接收模式,flow director
等等,最后调用ixgbe_configure_tx
,ixgbe_configure_rx
网卡硬件配置接收发送队列。 -
ixgbe_request_irq
向操作系统申请irq
硬中断,当前网卡支持msi-x
,最终调用ixgbe_request_msix_irqs
申请中断向量,并配置中断回调ixgbe_msix_clean_rings
网卡硬中断处理流程
这个图非常简单,大致流程如下:
┌────────────────┐ 4.Call ┌───────────────┐ 5.Raise ┌─────────────────┐
│ │ Back │ │ softirq │ │
│ CPU │─────────────▶ │ NIC DRIVER │──────────────▶│ KSOFTSWAPD │
│ │ │ │ │ │
└────────────────┘ └───────────────┘ └─────────────────┘
▲ │
3.Raise │ │
IRQ │ │
│ │
│ │
┌────────────────┐ ▼
│ │
│ │
│ │
│ NIC │
1. packet │ │
──────────▶ │ │
│ │
│ │ ┌────┬────┬────┬────┬────┬────┬────┐
│ ┌────────┐ │ 2. DMA │ │ │ │ │ │ │ │
│ │ DMA │ ──┼─────────▶ │ │ │ │ │ │ │ │
│ └────────┘ │ │ │ │ │ │ │ │ │
└────────────────┘ └────┴────┴────┴────┴────┴────┴────┘
Ring buffer
- 网卡接收数据包,如果没有开启混杂模式,那么
filter
过滤掉不属于自己mac
的包。 - 由
DMA
控制器将数据包写到内存。 - 网卡通知
cpu
产生硬中断,触发 callbackixgbe_msix_clean_rings
, 此时q_vector
网卡硬中断处于关闭状态。
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
将等待轮循列表添加到 cpu 私有变量 softnet_data
里,然后触发软中断 NET_RX_SOFTIRQ
- 每个 cpu 都有一个软中断守护进程
KSOFTIRQD
, 由他负责回调处理。
其实这里有个疑问,ring buffer
每个队列有一个,那数据包到来后如何选择写到哪个呢?另外,直到此时网卡硬中断还处于关闭状态。
软中断处理数据
在操作系统初始化得知,软中断回调 net_rx_action
来完成数据包的轮循,但不是死循环,有两个退出条件:处理数据超过 budget
配额或是超时一定时间,代码写写 300个和 2000us. 如果有未处理数据,再次触发软中断,等待调度。最后打开硬中断。
这个逻辑在函数 ixgbe_poll
里,当前 cpu 不止一个队列,所以会将配额尽可能得分配。对于发送,接收队列分别调用 ixgbe_clean_tx_irq
, ixgbe_clean_rx_irq
处理。
ixgbe_clean_rx_irq
: 每个 ixgbe_ring
保存一个环形数组,ring buffer
,读出 skb
数据后调用 ixgbe_rx_skb
开始处理,如果开启了 GRO
那么合并小包,然后再查看是否有 RPS
逻辑,如果有就走,没有就调用 __netif_receive_skb
根据上文提到的 ptype_all
,来选择是 ip_rcv
, 还是 ipv6_rcv
再继续上层逻辑。调用 ip_rcv
之后会回调 NF_INET_PRE_ROUTING
hook, 也就是大家都熟悉的 iptables
pre_routing
链。
ixgbe_clean_tx_irq
: 每次限制发送 q_vector->tx.work_limit
个报文,底层网卡逻辑暂时不看了。
关于接收数据时 RPS
ixgbe_rx_skb
处理数据包时,先判断是否开启 RPS
, 如果 get_rps_cpu
返回 cpu, 那么将 skb
数据包入队列 softnet_data.input_pkt_queue
, 最后触发软中断 net_rx_action
,那么这个软中断回调是哪个函数呢?是 process_backlog
,在 net_dev_init
里指定。最终还是走到 __netif_receive_skb
逻辑。
其间队列可能满,会丢弃包,通过 netdev_max_backlog
来调节 backlog 队列大小。