此处主要讲的是从数据来到,中断到最终数据包被处理的过程。


0:首先来介绍一下IO端口访问问题,内核提供了这样一组函数处理:  // kernel/io.c中

>: inb( )、inw( )、inl( )函数

       分别从I/O端口读取1、2或4个连续字节。

       后缀“b”、“w”、“l”分别代表一个字节(8位)、一个字(16位)以及一个长整型(32 位)。

       

>: inb_p( )、inw_p( )、inl_p( )

       分别从I/O端口读取1、2或4个连续字节,然后执行一条 “空指令” 使CPU暂停。 p 可以理解成pause

       

>: outb( )、outw( )、outl( )

       分别向一个I/O端口写入1、2或4个连续字节。


>: outb_p( )、outw_p( )、outl_p( )

       分别向一个I/O端口写入1、2或4个连续字节,然后执行一条“空指令”指令使CPU暂停。


>:  insb( )、insw( )、insl( )

        分别从I/O端口读入以1、2或4个字节为一组的连续字节序列。字节序列的长度由该函数的参数给出。


>: outsb( )、outsw( )、outsl( )

       分别向I/O端口写入以1、2或4个字节为一组的连续字节序列。

  

1:当一个中断来到,首先响应 net_interrupt   函数

/*
 * The typical workload of the driver:
 * Handle the network interface interrupts.
 */
static irqreturn_t net_interrupt(int irq, void *dev_id)  // 注意参数是:中断号和设备id
 {
         struct net_device *dev = dev_id;
         struct net_local *np;
         int ioaddr, status;
         int handled = 0;

         ioaddr = dev->base_addr;       // 设备的IO地址 
 
         np = netdev_priv(dev);         // 得到dev私有数据
         status = inw(ioaddr + 0);      // 从端口读两个字节
 
         if (status == 0)
                 goto out;
         handled = 1;
 
         if (status & RX_INTR) {
                 /* Got a packet(s). */
                 net_rx(dev);          // 使用这个函数net_rx来获取一个数据包 -----> receive
         }                             // 这个函数下面会说到
 #if TX_RING
         if (status & TX_INTR) {       // 发送数据
                 /* Transmit complete. */
                 net_tx(dev);             // 发送数据使用net_tx ------> transmit
                 np->stats.tx_packets++;  // 计数 
                 netif_wake_queue(dev);   // 处理结束,唤醒下一个队列中等待者                                                        }
#endif 
         if (status & COUNTERS_INTR) 
         {                                /* Increment the appropriate 'localstats' field. */ 
              np->stats.tx_window_errors++; 
         } 
out: 
         return IRQ_RETVAL(handled); // 返回中断 
}


2:下面 需要看一下接收数据包函数net_rx

/* We have a good packet(s), get it/them out of the buffers. */
static void
net_rx(struct net_device *dev)        // 所谓接收数据包,其实就是构造skb数据结构 ^_^
{
         struct net_local *lp = netdev_priv(dev);
         int ioaddr = dev->base_addr;
         int boguscount = 10;
 
         do { // 下面是循环接收数据么
                 int status = inw(ioaddr);     // 获取状态
                 int pkt_len = inw(ioaddr);    // 获取包大小
 
                 if (pkt_len == 0)               /* 全部接收 */
                         break;                  /* 可以结束 */
 
                 if (status & 0x40) {    /* There was an error. */
                         lp->stats.rx_errors++;
                         if (status & 0x20) lp->stats.rx_frame_errors++;
                         if (status & 0x10) lp->stats.rx_over_errors++;
                         if (status & 0x08) lp->stats.rx_crc_errors++;
                         if (status & 0x04) lp->stats.rx_fifo_errors++;
                 } else {
                         /* Malloc up new buffer. */
                         struct sk_buff *skb;
 
                         lp->stats.rx_bytes+=pkt_len;     // 接收的字节数+pkt_len
 
                         skb = dev_alloc_skb(pkt_len);    // 需要接收多少bytes就分配多少空间给sk_buff
                         if (skb == NULL) {               // 需要丢包
                                 printk(KERN_NOTICE "%s: Memory squeeze, dropping packet.\n",
                                            dev->name);
                                 lp->stats.rx_dropped++;  // 丢包数++
                                 break;
                         }
                         skb->dev = dev;                  // 现在开始构建skb包
 
                         /* 'skb->data' points to the start of sk_buff data area. */
                         memcpy(skb_put(skb,pkt_len), (void*)dev->rmem_start,    // 注意开始从dev向skb中放入数据,大小pkt_len
                                    pkt_len);
                         /* or */
                         insw(ioaddr, skb->data, (pkt_len + 1) >> 1);
 
                         netif_rx(skb);                   // 这个函数很重要,下面会具体说~
                         dev->last_rx = jiffies;          // 上一次rx的时间
                         lp->stats.rx_packets++;          // 接收包数量++
                         lp->stats.rx_bytes += pkt_len;   // 接收字节数+pkt_len
                 }
         } while (--boguscount);
 
         return;
 }


3:显然我们知道现在要分析netif_rx函数了


先看几个函数:

local_irq_disable() , local_irq_enable() , local_irq_save() 和 local_irq_restore() 为中断处理函数,

主要是在要进入临界区时禁止中断和在出临界区时使能中断。

local_irq_disable() 和 local_irq_enable() 配对使用;

 local_irq_save() 则和  local_irq_restore()  配对使用。

 /**
  *      netif_rx        -       post buffer to the network code
  *      @skb: buffer to post
  *
  *      This function receives a packet from a device driver and queues it for
  *      the upper (protocol) levels to process.  It always succeeds. The buffer
  *      may be dropped during processing for congestion control or by the
  *      protocol layers.
  *
  *      return values:
  *      NET_RX_SUCCESS  (no congestion)
  *      NET_RX_DROP     (packet was dropped)
  *
  */
 //  需要注意的是:这里是非NAPI方式下的函数
 int netif_rx(struct sk_buff *skb) // 注意接收数据后将数据进行排队,然后给上层协议处理,不过也有可能因为拥塞之类丢包!
 {
         struct softnet_data *queue;   // 每个cpu结构都有这样一个队列,这样在SMP之间就避免了枷锁操作,提高并发度
         unsigned long flags;
 
         /* if netpoll wants it, pretend we never saw it */
         if (netpoll_rx(skb))          // 关于netpoll机制以后在讨论
                 return NET_RX_DROP;
 
         if (!skb->tstamp.tv64)
                 net_timestamp(skb);   // 设置包到达时间
 
         /*
          * The code is rearranged so that the path is the most
          * short when CPU is congested, but is still operating.
          */
         local_irq_save(flags);        // 关中断,禁止中断
         queue = &__get_cpu_var(softnet_data);  // 取得当前CPU输入队列(得到CPU参数数据队列   softnet_data)
 
         __get_cpu_var(netdev_rx_stat).total++; // 更新当前CPU接收到的帧的数量,包括接收的和丢弃的
         if (queue->input_pkt_queue.qlen <= netdev_max_backlog) {  // 每个CPU都有输入队列的最大长度,如果超过,则丢弃该数据帧
                 if (queue->input_pkt_queue.qlen) {   // 如果队列中有元素
 enqueue:
                         dev_hold(skb->dev);          // 网络设备引用值++
                         __skb_queue_tail(&queue->input_pkt_queue, skb);  // 将skb添加到队列的末尾(注意这里产生软中断NET_RX_SOFTIRQ,进一步处理包)
                         local_irq_restore(flags);    // 开中断   // 同时需要知道:NET_RX_SOFTIRQ 是由net_rx_action函数处理
                         return NET_RX_SUCCESS;       // 返回接收数据成功
                 }
 
                 napi_schedule(&queue->backlog); // 如果qlen=0,说明queue->backlog可能已经当前CPU的poll-list中移除了,要重新加入
                 goto enqueue;                  // list_add_tail(&n->poll_list, &__get_cpu_var(softnet_data).poll_list);
         }                                     // 其实就是让后面action中循环能够找到这个设备,,,然后goto到上面重新将包放入队列
 
         __get_cpu_var(netdev_rx_stat).dropped++;     // 如果上面的没有执行成功,那么丢包数量++
         local_irq_restore(flags);                    // 开中断  允许中断
 
         kfree_skb(skb); // 因为丢包才能才第到此处,所以将skb free掉 return NET_RX_DROP; // 返回丢包 }


注意一个问题:上面在将包放进队列的过程中,是关了中断的,完成后开中断,但是在接收包的数据的时候并没有禁止中断,即收包的IRQ是不需要被禁用的。因为将包放入到cpu的等待队列不会耗时太长。这也说明,传统API只能适用与低速设备。


关于NAPI问题,请看:点击打开链接

简介:在没有NAPI的时候,都是通过中断系统来处理包的到达,这就才造成一个问题,当有很多很多短包蜂拥到达的时候,中断系统将会忙死,所以为了优化这种情况,加入NAPI,其实采用的是一种轮询方式。非NAPI方式是将数据放进CPU的队列中,而NAPI是有自己的私有队列的,可以说是自己的私有缓冲区!!!


下面来理清一下思路,在内核初始化的时候,对于每个CPU中的softnet_data都初始化了

static int __init net_dev_init(void)
{
         int i, rc = -ENOMEM;
 
         BUG_ON(!dev_boot_phase);
 
         if (dev_proc_init())                      // 不管
                 goto out;
 
         if (netdev_kobject_init())                // 不管
                 goto out; 
 
         INIT_LIST_HEAD(&ptype_all);
         for (i = 0; i < PTYPE_HASH_SIZE; i++)     // 不管
                 INIT_LIST_HEAD(&ptype_base[i]);
 
         if (register_pernet_subsys(&netdev_net_ops))        // 不管
                 goto out;
 
         if (register_pernet_device(&default_device_ops))    // 不管
                 goto out;
 
         /*
          *      Initialise the packet receive queues.初始化话数据包的接收队列
          */
 
         for_each_possible_cpu(i) {            // 对于每一个CPU都会进行处理
                 struct softnet_data *queue;   // 每个CPU中都有这样一个结构
 
                 queue = &per_cpu(softnet_data, i);   // 获得这个iCPU上面的softnet_data结构
                 skb_queue_head_init(&queue->input_pkt_queue);  // 初始化接收数据队列
                 queue->completion_queue = NULL;      // 暂无完成
                 INIT_LIST_HEAD(&queue->poll_list);   // 初始化设备队列(注意poll_list在处理数据的时候会被遍历)
 
                 queue->backlog.poll = process_backlog; // 这个很重要!在以后的处理这个设备上的数据的时候使用这个函数,,,看下面
                 queue->backlog.weight = weight_p;
         }
 
         netdev_dma_register();             // 下面忽略
 
         dev_boot_phase = 0;
 
         open_softirq(NET_TX_SOFTIRQ, net_tx_action, NULL);
         open_softirq(NET_RX_SOFTIRQ, net_rx_action, NULL);
 
         hotcpu_notifier(dev_cpu_callback, 0);
         dst_init();
         dev_mcast_init();
         rc = 0;
 out:
         return rc;
 }

看看process_backlog函数:

static int process_backlog(struct napi_struct *napi, int quota)  // 注意需要在下面更详细地说
{
         int work = 0;
         struct softnet_data *queue = &__get_cpu_var(softnet_data);
         unsigned long start_time = jiffies;
 
         napi->weight = weight_p;
         do {
                 struct sk_buff *skb;
                 struct net_device *dev;
 
                 local_irq_disable();
                 skb = __skb_dequeue(&queue->input_pkt_queue);   // 从队里获取一个skb
                 if (!skb) {
                         __napi_complete(napi);      // 如果队列已经空了,那么其实就是将napi的poll_list从CPU的那个结构中移除
                         local_irq_enable();
                         break;
                 }
 
                 local_irq_enable();
 
                 dev = skb->dev;
 
                 netif_receive_skb(skb);    // 下面处理接收数据(当然这里需要在下面更详细地说)
 
                 dev_put(dev);
         } while (++work < quota && jiffies == start_time);// 需要注意的是:退出有两情况:当处理完所有skb 或者 分配时间达到 。
 
         return work;
}


// 看一下softnet_data结构体

 struct softnet_data
 {
         struct net_device       *output_queue;        // 网络设备发送队列的头
         struct sk_buff_head     input_pkt_queue;      // 接收缓冲区的sk_buff队列
         struct list_head        poll_list;            // poll设备队列头
         struct sk_buff          *completion_queue;    // 完成发送数据包,等待释放的队列
 
         struct napi_struct      backlog;              // NAPI结构
 #ifdef CONFIG_NET_DMA
         struct dma_chan         *net_dma;
 #endif
 };


4:放进队列之后该怎么处理呢?是不是要开始处理数据了。。。net_rx_action现在出现!

注意接收到的数据在两个地方等待net_rx_action来处理:

1:对于非NAPI方式来说,我们需要从CPU的softnet_data->input_pkt_queue中取得数据。

2:对于NAPI方式,前面说过有自己的缓冲区,那么poll函数从设备缓存读取数据。

下面看代码:

static void net_rx_action(struct softirq_action *h)
{
         struct list_head *list = &__get_cpu_var(softnet_data).poll_list; // 获取设备列表
         unsigned long start_time = jiffies;   // 获取当前时间戳
         int budget = netdev_budget;
         void *have;
 
         local_irq_disable();                   // 禁止中断
 
         while (!list_empty(list)) {            // 对每一个设备进行循环处理一次,看是否有设备等待轮询取得数据
                 struct napi_struct *n;
                 int work, weight;
 
                 /* If softirq window is exhuasted then punt.
                  *
                  * Note that this is a slight policy change from the
                  * previous NAPI code, which would allow up to 2
                  * jiffies to pass before breaking out.  The test
                  * used to be "jiffies - start_time > 1".
                  */
                 if (unlikely(budget <= 0 || jiffies != start_time)) // 保证当前的 POLL 过程的时间不超过一个时间片,这样不至于被软中断占用太多的时间
                         goto softnet_break;
 
                 local_irq_enable();    // 开中断
 
                 /* Even though interrupts have been re-enabled, this
                  * access is safe because interrupts can only add new
                  * entries to the tail of this list, and only ->poll()
                  * calls can remove this head entry from the list.
                  */
                 n = list_entry(list->next, struct napi_struct, poll_list); // 从softnet_data 数据结构中的轮循队列上获得等待轮循的napi_struct结构
 
                 have = netpoll_poll_lock(n);   // 锁定该 struct napi_struct ,并且记录当前调度的CPU
 
                 weight = n->weight;
 
                 /* This NAPI_STATE_SCHED test is for avoiding a race
                  * with netpoll's poll_napi().  Only the entity which
                  * obtains the lock and sees NAPI_STATE_SCHED set will
                  * actually make the ->poll() call.  Therefore we avoid
                  * accidently calling ->poll() when NAPI is not scheduled.
                  */
                 work = 0;
                 if (test_bit(NAPI_STATE_SCHED, &n->state)) // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
                         work = n->poll(n, weight);         // !!!这里相当重要!根据weight调用想要的poll函数!
                                                            // 之前说过如果低非NAPI,那么使用的是初始化时候的即process_backlog函数
                 WARN_ON_ONCE(work > weight);               // 如果是NAPI函数,那么就是自己的poll函数处理      
                                                            // 那么又要返回上面看process_backlog函数(往下看~~~~有重写)
                 budget -= work;
 
                 local_irq_disable();
 
                 /* Drivers must not modify the NAPI state if they
                  * consume the entire weight.  In such cases this code
                  * still "owns" the NAPI instance and therefore can
                  * move the instance around on the list at-will.
                  */
                 if (unlikely(work == weight)) {
                         if (unlikely(napi_disable_pending(n)))
                                 __napi_complete(n);
                         else
                                 list_move_tail(&n->poll_list, list);
                 }
 
                 netpoll_poll_unlock(have);
         }
 out:
         local_irq_enable();
 
 #ifdef CONFIG_NET_DMA
         /*
          * There may not be any more sk_buffs coming right now, so push
          * any pending DMA copies to hardware
          */
         if (!cpus_empty(net_dma.channel_mask)) {
                 int chan_idx;
                 for_each_cpu_mask(chan_idx, net_dma.channel_mask) {
                         struct dma_chan *chan = net_dma.channels[chan_idx];
                         if (chan)
                                 dma_async_memcpy_issue_pending(chan);
                 }
         }
 #endif
 
         return;
 
 softnet_break:
         __get_cpu_var(netdev_rx_stat).time_squeeze++;
         __raise_softirq_irqoff(NET_RX_SOFTIRQ);
         goto out;
 }

注意看下面的部分代码:基本的意思就是从CPU这个softnet_data的字段input_pkt_queue队列中不断的取和当前napi_struct相关的数据包,每次获取一个数据包那么就使用函数netif_receive_skb函数处理!这个函数也是非常重要的!下面再说...如果没有的话,那么就是__napi_complete函数将这个napi_struct移除polllist,以免下次被循环到没有数据。

do {
	struct sk_buff *skb;
        struct net_device *dev;
 
        local_irq_disable();
        skb = __skb_dequeue(&queue->input_pkt_queue);    // 出来一个数据
        if (!skb) {                            // 如果是null,那么队列空,移除设备
                   	__napi_complete(napi);
                        local_irq_enable();
                        break;
                 }
 
        local_irq_enable();
 
        dev = skb->dev;                 // 获取这个包对应的设备
 
        netif_receive_skb(skb);         // 这个函数最重要!下面分析!!!!!!!!
 
        dev_put(dev);
} while (++work < quota && jiffies == start_time);


看netif_receive_skb函数!netif_receive_skb是链路层接收数据报的最后一站!!!

/**
 *      netif_receive_skb - process receive buffer from network
 *      @skb: buffer to process
 *
 *      netif_receive_skb() is the main receive data processing function.
 *      It always succeeds. The buffer may be dropped during processing
 *      for congestion control or by the protocol layers.
 *
 *      This function may only be called from softirq context and interrupts
 *      should be enabled.
 *
 *      Return values (usually ignored):
 *      NET_RX_SUCCESS: no congestion
 *      NET_RX_DROP: packet was dropped
 */
int netif_receive_skb(struct sk_buff *skb)   // 注意这个函数可能要被很多人处理,因为可以注册多个协议进行处理
{
         struct packet_type *ptype, *pt_prev;
         struct net_device *orig_dev;
         int ret = NET_RX_DROP;
         __be16 type;
 
         /* if we've gotten here through NAPI, check netpoll */
         if (netpoll_receive_skb(skb))
                 return NET_RX_DROP;
 
         if (!skb->tstamp.tv64)
                 net_timestamp(skb);     // 更新时间
 
         if (!skb->iif)                  // 设备的(idx)编号
                 skb->iif = skb->dev->ifindex;
 
         orig_dev = skb_bond(skb);       // 可以展开成  orig_dev = skb->dev;skb->dev = skb->dev->master;
                                         // 不是很懂~  (处理路由聚合问题)
         if (!orig_dev)
                 return NET_RX_DROP;
 
         __get_cpu_var(netdev_rx_stat).total++;   // cpu统计
 
         skb_reset_network_header(skb);              // 网络层头(校准头指针)
         skb_reset_transport_header(skb);            // 传输层头(校准头指针)
         skb->mac_len = skb->network_header - skb->mac_header;   // 注意mac层长度就是网络层的头---->mac层头之间部分!
 
         pt_prev = NULL;
 
         rcu_read_lock();
 
 #ifdef CONFIG_NET_CLS_ACT
         if (skb->tc_verd & TC_NCLS) {
                 skb->tc_verd = CLR_TC_NCLS(skb->tc_verd);
                 goto ncls;
         }
 #endif     // 下面类似于协议嗅探器,因为是ETH_p_all类型
            // 这位部分代码是核心代码哦!  以下的代码用于在协议链上寻找匹配的协议(在ptype_all中找)
         list_for_each_entry_rcu(ptype, &ptype_all, list) {   // 这里需要先理解一下packet_type结构体,goto到下面先看看!!!!
                 if (!ptype->dev || ptype->dev == skb->dev) { // 这个地方在下面有解释
                         if (pt_prev)
                                 ret = deliver_skb(skb, pt_prev, orig_dev); // 此处找到的是ETH_P_ALL类型协议(如果有注册)
                         pt_prev = ptype;
                 }
         }
 
 #ifdef CONFIG_NET_CLS_ACT
         skb = handle_ing(skb, &pt_prev, &ret, orig_dev);
         if (!skb)
                 goto out;
 ncls:
 #endif
         // 若编译内核时选上BRIDGE,下面会执行网桥模块
         skb = handle_bridge(skb, &pt_prev, &ret, orig_dev);  // 进入桥进行二层处理,如果返回skb == NULL,说明skb 被直接二层转发走了,不用再送网络层了,函数直接返回
          
         if (!skb)    // 包是否被桥转发走了   ( 具体的后来在分析 )
                 goto out;
         skb = handle_macvlan(skb, &pt_prev, &ret, orig_dev); // 编译内核时选上MAC_VLAN模块,下面才会执行
         if (!skb)                        // 同样如果被vlan消耗,那么无需往上面协议层传递了~!直接退出返回
                 goto out;
         // 注意哦:如果数据包在上面没有被处理掉,那么说明要传递到上面一层即ip层进行处理                                                                           // 注意在I派层处理有两种情况:还要往上面一层即TCP层传递,或者直接ARP处理
         // 这位部分代码是核心代码哦!  以下的代码用于在协议链上寻找匹配的协(在ptype_base  hash表中找)
         type = skb->protocol;
         list_for_each_entry_rcu(ptype,
                         &ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {   // 这里面匹配的类型就是ip层一些协议的类型
                 if (ptype->type == type &&
                     (!ptype->dev || ptype->dev == skb->dev)) {
                         if (pt_prev)
                                 ret = deliver_skb(skb, pt_prev, orig_dev);     // 进行处理~~~~
                         pt_prev = ptype;
                 }
         }
 
         if (pt_prev) {
                 ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
         } else {
                 kfree_skb(skb);
                 /* Jamal, now you will not able to escape explaining
                  * me how you were going to use this. :-)
                  */
                 ret = NET_RX_DROP;
         }
 
 out:
         rcu_read_unlock();
         return ret;
 }

packet_type 结构体看看

struct packet_type {
         __be16      type;   /* This is really htons(ether_type). */ // 成员保存了二层协议类型,ETH_P_IP、ETH_P_ARP,ETH_P_ALL
         struct net_device  *dev;   /* NULL is wildcarded here           */
         int                (*func) (struct sk_buff *,          // 成员就是钩子函数了,如 ip_rcv()、arp_rcv()等等
                                     struct net_device *,
                                     struct packet_type *,
                                     struct net_device *);
         struct sk_buff    *(*gso_segment)(struct sk_buff *skb,
                                                 int features);
         int               (*gso_send_check)(struct sk_buff *skb);
         void              *af_packet_priv;
         struct list_head   list;
};


注意:所有协议的packet_type存放在两条协议链中,ptype_base和ptype_all,ptype_base 为哈希链表,ptype_all为双向链.

系统使用dev_add_pack函数将指定协议类型的packet_type添加到这两个表中。

》 对于ETH_P_ALL类型的数据报文将在ptype_all表中找到自己对应的packet_type结构。

    系统只有创建了一个PF_PACKE类型的socket才会将一个packet_type结构加到ptype_all链表中。

》 对于ETH_P_IP和ETH_P_ARP可以在ptype_base中找到自己的packet_type结构。

    如果协议类型是ETH_P_IP那么func函数就是ip_rcv

    如果协议类型是ETH_P_ARP那么func函数就是arp_rcv


OK,现在说说deliver_skb函数:

static inline int deliver_skb(struct sk_buff *skb,
                               struct packet_type *pt_prev,
                               struct net_device *orig_dev)
{
         atomic_inc(&skb->users);
         return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);    // 调用的还是对应的不同协议的func函数
}


最终还是调用了func函数了,下面注意:主要说将数据包传递给ip层进行处理,所以看看 ip_rcv

ip_rcv是怎么和ETH_P_IP关联起来的,这个我们上面说过这个packet_type结构,这个结构是保存不同协议和自己的处理函数func的,那么这个结构体有自己的处理方法:

static struct packet_type arp_packet_type __read_mostly = {
        .type = cpu_to_be16(ETH_P_ARP),
        .func = arp_rcv,    // 关联上
};

static struct packet_type ip_packet_type __read_mostly = {
        .type = cpu_to_be16(ETH_P_IP),
        .func = ip_rcv,    // 关联上
        .gso_send_check = inet_gso_send_check,
        .gso_segment = inet_gso_segment,
        .gro_receive = inet_gro_receive,
        .gro_complete = inet_gro_complete,
};

下面就来看看ip_rcv函数~~~~

请看另一篇blog~~~~~待续哦~~~~










GitHub 加速计划 / li / linux-dash
10.39 K
1.2 K
下载
A beautiful web dashboard for Linux
最近提交(Master分支:2 个月前 )
186a802e added ecosystem file for PM2 4 年前
5def40a3 Add host customization support for the NodeJS version 4 年前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐