Linux从网卡接收到内核软中断处理整体流程

Linux网络收报原理

Linux启动阶段

软中断处理线程启动

创建内核线程ksoftirqd。

c 复制代码
static struct smp_hotplug_thread softirq_threads = {
    .store              = &ksoftirqd,
    .thread_should_run  = ksoftirqd_should_run,
    .thread_fn          = run_ksoftirqd,
    .thread_comm        = "ksoftirqd/%u"
}
static __init int spawn_ksoftirqd(void)
{
    register_cpu_notifier(&cpu_nfb);
    BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
}
early_initcall(spawn_ksoftirqd);

网络子系统初始化

网络子系统初始化将做的事情
  • 为每个CPU初始化softnet_data;
  • 为软中断RX_SOFTIRQ和TX_SOFTIRQ注册处理函数;
具体执行

Linux内核初始化通过调用subsys_initcall实现,目前关心的网络子系统初始化通过函数net_dev_init实现。

c 复制代码
static int __init net_dev_init(void)
{
    ......
    for_each_possible_cpu(i){
        struct softnet_data *sd = &per_cup(softnet_data,i);
        memset(sd, 0, sizeof(*sd));
        skb_queue_head_init(&sd->input_pkt_queue);
        skb_queue_head_init(&sd->process_queue);
        sd->completion_queue = NULL;
        INIT_LIST_HEAD(&sd->poll_list);
        ......
    }
    ......
    open_softirq(NET_TX_SOFTIRQ, net_tx_action);
    open_softirq(NET_RX_SOFTIRQ, net_rx_action);
}
subsys_initcall(net_dev_init);

这个函数的功能是为每个cpu申请一个softnet_data数据。每个softnet_data里面都有一个poll_list,网卡驱动在起来以后会将网卡的poll函数注册进来。14和15行的open_softirq会为每个软中断注册一个处理函数(理解起来感觉就像是用户空间的信号处理函数)。

c 复制代码
void open_softirq(int nr, void (*action)(struct softirq_action*))
{
    softirq_vec[nr].action = action;
}

软中断的枚举在include/linux/interrupt.h中有定义,如下:

c++ 复制代码
enum
{
    HI_SOFTIRQ=0,
    TIMER_SOFTIRQ,
    NET_TX_SOFTIRQ,
    NET_RX_SOFTIRQ,
    BLOCK_SOFTIRQ,
    BLOCK_IOPOLL_SOFTIRQ,
    TASKLET_SOFTIRQ,
    SCHED_SOFTIRQ,
    HRTIMER_SOFTIRQ,
    RCU_SOFTIRQ,
    NR_SOFTIRQS
}

我们就可以看到open_softirq的实现就很简单,就是在softirq_vec这个数组中信号对应序号的位置写上软中断处理函数的指针,仅此而已。

协议栈注册

Linux系统的协议栈注册是通过fs_initcall(inet_init)实现的。这个fs_initcall调用传入的inet_init函数进行协议栈初始化,将协议注册到inet_protos和ptype_base数据结构中。

c 复制代码
static struct packet_type ip_packet_type __read_mostly ={
    .type = cpu_to_be16(ETH_P_IP),
    .func = ip_rcv,
};
static const struct net_protocol udp_protocol = {
    .handler = udp_rcv,
    .err_handler = udp_err,
    .no_policy = 1,
    .netns_ok = 1,
};
static const struct net_protocol tcp_protocol = {
    .early_demux = tcp_v4_early_demux,
    .handler = tcp_v4_rcv,
    .err_handler = tcp_v4_err,
    .no_policy = 1,
    .netns_ok = 1,
};
static int __init inet_init(void)
{
    ......
    if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)
        pr_crit("%s: Cannot add ICMP protocol\n", __func__);
    if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
        pr_crit("%s: Cannot add UDP protocol\n", __func__);
    if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
        pr_crit("%s: Cannot add TCP protocol\n", __func__);
    ......
    dev_add_pack(&ip_packet_type);
}

看到static struct packet_type ip_packet_type __read_mostly这个变量声明我也是很懵逼的。后来查了资料,__read_mostly实际是一个关键字,表示这个变量主要是用来被读取的。

!tip

这里就有一个不太理解的,系统是怎么知道先调用ip的解包函数ip_rcv,然后再根据需要调用udp或者tcp的解包函数的咧?

应用协议注册函数inet_add_protocol

以上那些协议解包函数都是通过inet_add_protocol这个函数注册进去的,这个函数实现如下:

c 复制代码
int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol)
{
    if (!prot->netns_ok){
        pr_err("Protocol %u is not namespace aware, cannot register.\n", protocol);
        return -EINVAL;
    }
    return !cmpxchg((const struct net_protocol**)&inet_protos[protocol], NULL, prot)?0:-1;
}

这里面就是用了一个原子操作将inet_protos下标为对应协议号的结构体指针替换为传入的指针,很简洁。

IP注册函数

ip是通过注册函数dev_add_pack注册进去的,看一下实现:

c 复制代码
void dev_add_pack(struct packet_type* pt)
{
    ......
    struct list_head *head = ptype_head(pt);
}
static inline struct list_head *ptype_head(const struct packet_type *pt)
{
    if (pt->type == htons(ETH_P_ALL))
        return &ptype_all;
    else
        return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}

实际就是调用了ptype_head并且返回了head,这个ptype_head主要做的就是在ptype_base中找到packet_type.type字段(IP协议传入的是cpu_to_be16(ETH_P_IP))这个转换成本机short类型的序号。

!tip

总结一下,IP协议的处理函数会放到ptype_base这个数组中,索引和类型ETH_P_IP相关;应用协议会放到inet_protos数组中,索引就是IPPROTO_TCP/IPPROTO_UDP/IPPROTO_ICMP这种宏。

网卡驱动

网卡驱动初始化

每个驱动程序(不仅仅是网卡驱动)会使用module_init向内核注册一个初始化函数,当驱动程序被加载时,内核会调用这个函数。比如igb网卡驱动程序的代码:

c 复制代码
static struct pci_driver igb_driver = {
    .name = igb_driver_name,
    .id_table = igb_pci_tbl,
    .probe = igb_probe,
    .remove = igb_remove,
    ......
};
static int __init igb_init_module(void)
{
    ......
    ret = pci_register_driver(&igb_driver);
    return ret;
}

网卡设备被识别之后就会调用注册的probe函数,也就是igb_probe。这个函数主要做以下一些事情来使网卡进入ready状态:

  • 获取硬件的MAC地址;
  • DMA初始化;
  • 注册ethtool实现函数(相当于ethtool命令对客户提供虚函数,由网卡驱动进行了实现);
  • 注册net_device_ops、netdev等变量;(ops变量包含igb_open函数,用于在网卡启动时调用)
  • NAPI初始化,注册poll函数;

net_device_ops对于igb网卡来说是实例化了一个全局变量igb_netdev_ops如下:

c 复制代码
static const struct net_device_ops igb_netdev_ops = {
    .ndo_open = igb_open,
    .ndo_stop = igb_close,
    .ndo_start_xmit = igb_xmit_frame,
    .ndo_get_stats64 = igb_get_stats64,
    .ndo_set_rx_mode = igb_set_rx_mode,
    .ndo_set_mac_address = igb_set_mac,
    .ndo_change_mtu = igb_change_mtu,
    .ndo_do_ioctl = igb_ioctl,......
};

igb_open函数在初始化的过程中会调用igb_alloc_q_vector函数,这个函数用来注册一个NAPI机制必须的poll函数,对于igb网卡来讲就是igb_poll。

c 复制代码
static int igb_alloc_q_vector(...)
{
    ......
    netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);
}

网卡启动

在igb_probe这一步中,设置了net_device_ops变量。这个变量包含启动、发包、设置MAC地址等回调函数。启动算法时候会调用net_device_ops.ndo_open方法,对于igb网卡来讲就是igb_open函数:

c 复制代码
static int __igb_open(struct net_device *netdev, bool resuming)
{
    // 分配传输描述符数组
    err = igb_setup_all_tx_resources(adapter);
    // 分配接收描述符数组
    err = igb_setup_all_rx_resources(adapter);
    // 注册中断处理函数
    err = igb_request_irq(adapter);
    if (err)
        goto err_req_irq;
    // 启用NAPI
    for (i = 0; i < adapter->num_q_vectors; i++)
        napi_enable(&(adapter->q_vector[i]->napi));
    ......
}

igb_setup_all_rx_resources这一步会分配RingBuffer并且建立内存和RX队列的映射关系:

c 复制代码
static int igb_setup_all_rx_resources(struct igb_adapter *adapter)
{
    ...
     for (i = 0; i < adapter->num_rx_queues; i++){
         err = igb_setup_rx_resources(adapter->rx_ring[i]);
         ...
     }
    return err;
}

对adapter(适配器)中的rx_ring中的每一个,调用igb_setup_rx_resources进行分配资源。每个rx_ring的元素是一个环形接收对接。下面来看一下分配资源的函数igb_setup_rx_resources:

c 复制代码
int igb_setup_rx_resources(struct igb_ring *tx_ring)
{
    //1. 申请igb_rx_buffer数组内存
    size = sizeof(struct igb_rx_buffer) * rx_ring->count;
    rx_ring->rx_buffer_info = vzalloc(size);
    
    //2. 申请e1000_adv_rx_desc DMA数组内存
    rx_ring->size = rx_ring->count * sizeof(union e1000_adv_rx_desc);
    rx_ring->size = ALIGN(rx_ring->size, 4096);
    rx_ring->desc = dma_alloc_coherent(dv, rx_ring->size, &rx_ring->dma, GFP_KERNEL);
    
    //3. 初始化队列成员
    rx_ring->next_to_alloc = 0;
    rix_ring->next_to_clean = 0;
    rx_ring->next_to_user = 0;
    
    return 0;
}

可以看到一个igb_ring中即有rx_buffer_info,同时也有dma数组。下面看看__igb_open中的下一步igb_request_irq(注释是"注册中断处理函数",这里是驱动,注册的是硬中断的处理函数):

c 复制代码
static int igb_request_irq(struct igb_adapter *adapter)
{
    if (adapter->msix_entries)
    {
        err = igb_request_msix(adapter);
        if (!err)
            goto request_done;
        ......
    }
}

static int igb_request_msix(struct igb_adapter *adapter)
{
    ......
    for (i = 0; i < adapter->num_q_vectors; i++)
    {
        ......
        err = request_irq(adapter->msix_entries[vector].vector, igb_msix_ring, 0, q_vector->name,)
    }
}

adapter->num_q_vectors是个老演员了,__igb_open函数最后一步启用NAPI也用到了这个。那么这个注册到底干了什么事情,就是给硬中断增加了一个处理函数igb_msix_ring吗?

网卡接收数据

网卡驱动干活

网卡在接收到数据帧以后会将数据DMA到系统内存,然后触发硬中断。网卡驱动注册了硬中断的处理函数是igb_msix_ring,那么这个硬中断的处理细节到底是如何呢?如下:

c 复制代码
static irqreturn_t igb_msix_ring(int irq, void * data)
{
    struct igb_q_vector *q_vector = data;
    /* write the ITR value calculated from the previous interrupt. */
    igb_write_itr(q_vector);
    napi_schedule(&q_vector->napi);
    return IRQ_HANDLED;
}
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);
}

第二个函数____napi_schedule中我们看到了老演员softnet_data.poll_list,这个变量是per-cpu的,是在网络子系统初始化时候调用net_dev_init函数初始化的。

ksoftirqd内核线程处理软中断

ksoftirqd会运行函数run_ksoftirqd,如下:

c 复制代码
static void run_ksoftirqd(unsigned int cpu)
{
    local_irq_disable();
    if (local_softirq_pending()){
        __do_softirq();
        ...
    }
    local_irq_enable();
}

asmlinkage void __do_softirq(void)
{
    do{
        if (pending & 1){
            unsigned int vec_nr = h - softirq_vec;
            int pre_count = preempt_count();
            ...
            trace_softirq_entry(vec_nr);
            h->action(h);
            trace_softirq_exit(vec_nr);
            ...
        }
        h++;
        pending >>= 1;
    }while (pending);
}

看到第19行大家应该还记得在net_dev_init函数中,我们调用了open_softirq为每个信号注册了处理的函数,其中就包含软中断NET_RX_SOFTIRQ的处理函数net_rx_action,就是放在softirq_vec数组NET_RX_SOFTIRQ对应索引的action函数指针中的。

c 复制代码
static void net_rx_action(struct softirq_action *h)
{
    struct softnet_data *sd = &__get_cpu_var(softnet_data);
    unsigned long time_limit = jiffies + 2;
    int budget = netdev_budget;
    void *have;
    local_irq_disable();
    while (!list_empty(&sd->poll_list)){
        ......
        n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
        work = 0;
        if (test_bit(NAPI_STATE_SCHED, &n->state)){
            work = n->poll(n, weight);
            trace_napi_poll(n);
        }
        budget -= work;
        ...
    }
}

在这个接收消息的处理函数中我们看到了很多老演员,复习一下:

  • softirq_data.poll_list:在Linux网络子系统初始化时候通过net_dev_init函数创建的,是一个per-cpu的变量;在接收到报文的时候通过硬中断处理函数igb_msix_ring-->____napi_schedule-->list_add_tail添加的变量;

这个net_rx_action的poll函数实际是在网卡启动时候的igb_open-->igb_alloc_q_vector-->netif_napi_add增加进去的igb_poll函数。读到这里,我也能够理解____napi_schedule函数的第二个入参是哪里来的了,实际就是q_vector->napi,也就是adapter->q_vector[i]->napi。

接收函数会调用每个网卡的poll函数即n->poll(n,weight)这句。对于igb网卡来讲,就是igb_poll函数:

c 复制代码
static int igb_poll(struct napi_struct *napi, int budget)
{
    ...
    if (q_vector->tx.ring)
        clean_complete = igb_clean_tx_irq(q_vector);
    if (q_vector->rx.ring)
        clean_complete &= igb_clean_rx_irq(q_vector, budget);
    ...
}

读取操作中,igb_poll的重点工作是对igb_clean_rx_irq的调用。

c 复制代码
static bool igb_clean_rx_irq(struct igb_q_vector *q_vector, const int budget){
    ...
    do{
        /* retrieve a buffer from the ring */
        skb = igb_fetch_rx_buffer(rx_ring, rx_desc, skb);
        /* fetch next buffer in frame if non-eop */
        if (igb_is_non_eop(rx_ring, rx_desc))
            continue;
        /* verify the packet layout is correct */
        if (igb_cleanup_headers(rx_ring, rx_desc, skb))
        {
            skb = NULL;
            continue;
        }
        /* populate checksum, timestamp, VLAN, and protocol */
        igb_process_skb_fields(rx_ring, rx_desc, skb);
        napi_gro_receive(&q_vector->napi, skb);
        ...
    } while (likely(total_packets < budget));
}

这个函数通过igb_fetch_rx_buffer和igb_is_non_eop来收取完整的数据帧。这里出于的考虑是一个数据帧可能会包含多个RingBuffer,所以需要每次通过igb_is_non_eop来判断一下是不是已经到了数据帧的结尾了,如果没有就continue,不进行以下的操作。

当数据帧收取完毕之后会进行一个头校验,然后是通过igb_process_skb_fields函数设置skb的timestamp、VLAN id、protocol等字段。然后是关键的igb_process_skb_fields函数了:

c 复制代码
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb)
{
    skb_gro_reset_offset(skb);
    return napi_skb_finish(dev_gro_receive(napi, skb), skb);
}

static gro_result_t napi_skb_finish(gro_result_t ret, struct sk_buff *skb)
{
    switch (ret) {
        case GRO_NORMAL:
            if (netif_receive_skb(skb))
                ret = GRO_DROP;
            break;
            ......
    }
}

在以上函数的netif_receive_skb函数中,数据包将被发送到协议栈中进行处理。以UDP来举例,netif_receive_skb函数会首先调用ip_rcv函数进行IP层的解包(这个是在协议栈注册的inet_init函数中写进ptype_base数组的);然后再通过udp_rcv函数解析udp协议。看一下代码,书接上文netif_receive_skb实现如下:

c 复制代码
int netif_receive_skb(struct sk_buff *skb)
{
    // RPS处理逻辑,先忽略
    ......
    return __netif_receive_skb(skb);
}
static int __netif_receive_skb(struct sk_buff *skb)
{
    ......
    ret = __netif_receive_skb_core(skb, false);
}
static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
{
    ......
    // pcap逻辑,这里会将数据送入抓包点。tcpdump就是从这个入口取包
    list_for_each_entry_rcu(ptype, &ptype_all, list){
        if (!ptype->dev || ptype->dev == skb->dev){
            if (pt_prev)
                ret = deliver_skb(skb, pt_prev, orig_dev);
            pt_prev = ptype;
        }
    }
    ......
    list_for_each_entry_rcu(ptype, &ptype_base[ntohs(type) & PTYPE_HASH_MASK], list){
        if (ptype->type == type 
            && (
                ptype->dev == null_or_dev 
                || ptype->dev == skb->dev 
                || type->dev == orig_dev
            )
           ){
            if (pt_prev)
                ret = deliver_skb(skb, pt_prev, orig_dev);
            pt_prev = ptype;
        }
    }
}

tcpdump命令会把抓包函数以虚拟协议的方式挂到ptype_all上。所以说tcpdump可以抓到ip层的头。

来个急转弯,讨论一下tcpdump。tcpdump会执行到packet_create:

c 复制代码
static int packet_create(struct net *net, struct socket *sock, ...)
{
    ...
    po->prot_hook.func = packet_rcv;
    if (sock->type == SOCK_PACKET)
        po->prot_hook.func = packet_rcv_spkt;
    po->prot_hook.af_packet_priv = sk;
    register_prot_hook(sk);
}

register_prot_hook函数会把tcpdump用到的"协议"挂到ptype_all上。至此急转弯结束,回归正常协议解析过程。

还是看__netif_receive_skb_core函数,33行调用了deliver_skb是个关键:

c 复制代码
static inline int deliver_skb(struct sk_buff *skb, struct packet_type *pt_prev, struct net_device *orig_dev)
{
    ......
    return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}

可以看到这个函数调用了pt_prev->func。这个pt_pre是ptype_base上面取下来的。我们在协议栈注册时候通过inet_init-->dev_add_pack将ip层协议解包的结构体加进去了,其中func就是ip_rcv函数指针。也就是说deliver_skb的4行就是在调用ip_rcv这个函数进行ip层的解包!

IP层的处理

我们都知道了ip_rcv是用来处理IP协议的,那么我们从关注流程的角度看一下ip_rcv函数的实现:

c 复制代码
int ip_rcv(struct sk_buff *skb, ...)
{
    ......
    return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL, ip_rcv_finish);
}

NF_HOOK是一个钩子函数,ip_rcv在解析完成后就会把结果挂在钩子上,告诉操作系统发生了NFPROTO_IPV4-->NF_INET_PRE_ROUTING事件。这个时候操作系统就可以执行一些和这个事件相关的操作了。在注册完钩子之后呢,就会执行ip_rcv_finish函数:

c 复制代码
static int ip_rcv_finish(struct sk_buff *skb)
{
    ......
    if (!skb_dst(skb)){
        int err = ip_route_input_noref(skb, iph->daddr, iph->saddr, iph->tos, skb->dev);
        ...
    }
    ......
    return dst_input(skb);
}

跟踪ip_route_input_noref函数就会发现,它又调用了ip_route_input_mc。在ip_route_input_mc中,函数ip_local_deliver被赋值给了dst.input。

c 复制代码
static int ip_route_input_mc(struct sk_buff *skb, __be32 daddr, __be32 saddr, u8 tos, struct net_device *dev, int our)
{
    if (our) {
        rth->dst.input = ip_local_deliver;
        rth->rt_flags |= RTCF_LOCAL;
    }
}

回到ip_rcv_finish函数,最后执行了dst_input(skb):

c 复制代码
static inline int dst_input(struct sk_buff *skb)
{
    return skb_dst(skb)->input(skb);
}

skb_dst(skb)->input调用的input方法就是路由子系统赋予的ip_local_deliver。

c 复制代码
int ip_local_deliver(struct sk_buff *skb)
{
    if (ip_is_fragment(ip_hdr(skb))){
        if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
            return 0;
    }
    return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL, ip_local_deliver_finish);
}

static int ip_local_deliver_finish(struct sk_buff *skb)
{
    ......
    int protocol = ip_hdr(skb)->protocol;
    const struct net_protocol *ipprot;
    ipprot = rcv_dereference(inet_protos[protocol]);
    if (ipprot != NULL) {
        ret = ipprot->handler(skb);
    }
}

到了这里,我们又看到了一位人民艺术家"inet_protos"。这个数组是以协议为下表的数组,那么连起来就是找到了对应的协议,并且调用了协议结构体的handler方法。以udp举例,那么调用的就是udp_rcv函数。从这里也可以看出,这个就是使用函数指针+结构体在C语言的基础上实现了多态。

总结

Linux启动阶段

  1. 启动软中断处理内核线程ksoftirqb;
  2. 注册软中断回调函数;
    1. 发送软中断回调net_tx_action
    2. 接收软中断回调net_rx_action;
  3. 注册协议栈;
    1. IP协议放在ptype_base数组中;
    2. 应用层协议放在inet_protos数组中;

其余阶段

其余阶段包含网卡初始化、硬中断触发处理、软中断触发处理、IP解包、应用层解包。这部分画图太复杂了。有兴趣可以按照之前的文字描述自己进行整理。

引用:

《深入理解Linux网络》 张彦飞

相关推荐
charlie11451419121 分钟前
Linux内核深入学习(4)——内核常见的数据结构之链表
linux·数据结构·学习·链表·内核
Big__Star44 分钟前
Windows 上配置 Docker,Docker 的基本原理和用途,以及如何在 Docker 中运行程序
windows·docker·容器
L汐1 小时前
05 部署Nginx反向代理
运维·nginx·github
南方以南_1 小时前
CentOS相关操作hub(更新中)
linux·运维·centos
Clownseven2 小时前
[安全清单] Linux 服务器安全基线:一份可以照着做的加固 Checklist
linux·服务器·安全
熊猫在哪3 小时前
野火鲁班猫(arrch64架构debian)从零实现用MobileFaceNet算法进行实时人脸识别(一)conda环境搭建
linux·人工智能·python·嵌入式硬件·神经网络·机器学习·边缘计算
斯普润布特3 小时前
Centos系统资源镜像配置
linux·运维·centos
雨笋情缘3 小时前
【2025-05-22】XXL-JOB 的 8810 端口添加到 CentOS 6.5 的防火墙白名单
linux·运维·centos
xzl043 小时前
centos yum源,docker源
linux·docker·centos
力软快速开发平台3 小时前
流程自动化引擎:重塑企业数字神经回路
运维·自动化