前言

  • 本篇是关于 MIT 6.S081-2020-Lab11(networking) 的实现;
  • 如果发现内容上的纰漏,请不要吝啬您的键盘。

准备工作

怎么说呢,这最后一个实验……非常得……有趣……(汗颜)

这个实验的测试分为两个部分:一是主机和 qemu 运行的 xv6 之间进行 APR 通信;二是 qemu 运行的 xv6 会向谷歌的 DNS server 发送一个查询,收到回复之后会打印输出。我们的实验任务就是在设备驱动侧完成以太网数据帧的处理。

xv6 里的网络协议层很简单,自顶向下依次有应用层、传输层、网络层和数据链路层。在这次实验中,传输层(Socket Layer)只有一个 UDP;网络层有 IP 和 ARP;数据链路层只关注将以太网帧载荷上传给网络层的部分。层与层之间都会有个 Packet Buffer,用来缓存层间上传和下传的 Packet,让层内的协议实体进行 PDU 的封装解封装。

各层内的协议实体的实现都是些独立的可并发调用的组件:中断处理程序,IP Processing Thread,网络应用程序。网卡将电信号或光信号转换成数字信号变成一个 Packet 放到一个网卡内置的内存队列里,然后会向 CPU 发送中断;中断处理程序会将网卡内部的 Packet 拷贝到 RAM 中的 Packet Buffer 中;IP Processing Thread 是个一直在运行的内核线程,可以处理 RAM 中 Packet 上传给 Socket Layer 的 Packet Buffer 中;最后 Socket Layer 再处理 Packet,最终递交到应用程序的 Packet Buffer 中。如果不算网卡内置的队列,在 RAM 中,仅仅是接受过程就有三个 Packet Buffer 进行交互。

队列的设计我们已经在先前的磁盘的中断处理程序见过了,当时是为了解耦中断处理程序和磁盘提出来队列的设计,当然这种设计很常见,并不是磁盘中断处理程序所独有的设计。到了网卡这边也是类似的作用,但还有两个特殊的语义:

  1. 若接收端存在短暂的大流量,硬件网卡的处理速度要比软件 IP Processing Thread 的处理速度快得多,因此为了抵消这种速度差异,用了一个队列作为缓冲;
  2. 若网卡一直被占用,导致发送端队列中存在大量的 packet,就可以在网卡完事后在空闲时将这些 packet 发走,从而提高网卡利用率。

很久以前没有应用 DMA 的时候,网卡接收到一个 packet 后会放到网卡的内置队列中,之后向 CPU 发送一个中断,之后 CPU 执行中断处理程序来将网卡内置队列中的数据逐字节拷贝到 RAM 的指定位置中,进而让 IP Processor Thread 读取。但实际上,CPU 访问外设中的数据是个开销比较大的动作,毕竟外设不像内存里 CPU 那么“近”。

但这次的实验要用的 E1000 型号的网卡要先进一点,网卡会将接收进来的 packet 通过 DMA 技术直接拷贝到 RAM 的指定位置中(通过网卡内部的 DMA Engine),然后才向 CPU 发送一个中断。响应中断后,中断处理程序不会再去访问网卡,而是会直接访问内存中的那个存放 packet 的那个位置。所以这个位置是主机在初始化网卡的时候相互约定好的一个内存地址。具体地,我们是用一个长为 16 的、大小为 16*1500 字节的 packet buffer 来在内存存储这些 packet 的 payload 的。而在访问这些 packet 时,又是通过另外的长为 16、大小为 16*64 的数组来存储指向这些 packet 的指针的,这个数组被称为 DMA ring,是个循环数组。发送和接收都各有一个 DMA ring,对于发送来说叫 TX ring,对于接受来说叫 RX ring

这是一张路由器的性能图,我们关注的是实心离散点的分布。路由器的工作就从输入网卡接收 Packet,再从另输出网卡把这个 Packet 打出去(同一个一张网卡既能输入也能输出 Packet,这里两个网卡调个个儿也是一样的)。这里并没有运用 DMA 技术,所以 CPU 要拿到收到的 packet 时,是要访问网卡而不是访问 RAM。可以看到随着输入 pps(packets per second) 的增加,曲线是先上升、在下降。那么问题是,为什么先上升再下降、而不是一直上升、也不是先上升再不变?

CPU 在这里的工作只有两个:一是执行中断处理程序处理接收端网卡的中断,将 packet 拷贝到 RAM 中;二是执行某个内核线程将 packet 拷贝到发送端的网卡的缓冲中去。

之所以曲线上升到一段不再上升了,不是因为发送网卡的带宽太小,而是 CPU 利用率达到了 100%,以目前的 CPU 的性能已经无法再将处理更多的 Packet 了。当然,也不是说就一定不是发送网卡带宽造成的瓶颈,只是大多情况网卡带宽相对于 CPU 性能来说都是够用的。

因为每进来一个 packet 就会触发一次中断,处理这儿的中断可是一笔不低的 CPU 开销。如果网卡不断接收 packet,CPU 会一直在处理中断,而没时间在把这些 packet 转移到另一个网卡,导致另一个网卡发送 packet 的速率反而下降了(这种现象被称为是 Live Lock,与 Dead Lock 相对应,Dead Lock 放着不管它是不可能消失的,而 Live Lock 则可能可以)。此时对 CPU 而言将接收进来的 packet 转交给另一个网卡才是更重要的工作,处理中断反而是次要的。所以索性 CPU 就关中断了,从中断模式转为轮询模式来处理接收网卡的接受的 packet(主动解除 Live Lock,而不是被动地等待 Live Lock 消失)。如果能用 DMA 技术,就能够有效减缓这里的 Live Lock。

实验部分

实验的任务就是完成网卡驱动的顶半部的功能,一个读一个写,跟我们在中断那一篇文章中介绍的 UART 是类似的。I/O 接口的初始化和底半部中断处理程序的代码已经提前帮我们写好了,所以整个驱动剩下都就是实现 int e1000_transmit(struct mbuf *m)void e1000_recv(void)。而实验指导书上的 hints 基本把读和写的流程详细的描述了一遍,基本上按照那个实现就行了。所以虽然是 hard,但其实要写的东西不算多,如果不看 hints 只看操作手册去实现,那才是真的难。

发送:

/* kernel/e1000.c */

int
e1000_transmit(struct mbuf *m)
{
  //
  // Your code here.
  //
  // the mbuf contains an ethernet frame; program it into
  // the TX descriptor ring so that the e1000 sends it. Stash
  // a pointer so that it can be freed after sending.
  //


// First ask the E1000 for the TX ring index
// at which it's expecting the next packet,
// by reading the E1000_TDT control register.

  acquire(&e1000_lock);
  uint64 tx_ring_index = regs[E1000_TDT];

// Then check if the the ring is overflowing.
// If E1000_TXD_STAT_DD is not set in the descriptor
// indexed by E1000_TDT, the E1000 hasn't finished
// the corresponding previous transmission request,
// so return an error.

  if ((tx_ring[tx_ring_index].status & E1000_TXD_STAT_DD) == 0) {
    release(&e1000_lock);
    return -1;
  }

// Otherwise, use mbuffree() to free the last mbuf
// that was transmitted from that descriptor (if there was one).
  if (tx_mbufs[tx_ring_index])
    mbuffree(tx_mbufs[tx_ring_index]);

// Then fill in the descriptor.
// m->head points to the packet's content in memory,
// and m->len is the packet length.
// Set the necessary cmd flags (look at Section 3.3 in the E1000 manual)
// and stash away a pointer to the mbuf for later freeing.
  tx_ring[tx_ring_index].addr = (uint64)m->head;
  tx_ring[tx_ring_index].length = m->len;
  tx_ring[tx_ring_index].cmd = E1000_TXD_CMD_EOP | E1000_TXD_CMD_RS;
  tx_mbufs[tx_ring_index] = m;


// Finally, update the ring position by adding one to E1000_TDT modulo TX_RING_SIZE.
  regs[E1000_TDT] = (tx_ring_index + 1) % TX_RING_SIZE;
  release(&e1000_lock);

  return 0;
}

接收:

/* kernel/e1000.c */

static void
e1000_recv(void)
{
  //
  // Your code here.
  //
  // Check for packets that have arrived from the e1000
  // Create and deliver an mbuf for each packet (using net_rx()).
  //

  // First ask the E1000 for the ring index
  // at which the next waiting received packet (if any) is located,
  // by fetching the E1000_RDT control register and adding one modulo RX_RING_SIZE.
  while (1) {
    uint64 rx_ring_index = regs[E1000_RDT];
    rx_ring_index = (rx_ring_index + 1) % RX_RING_SIZE;

    // Then check if a new packet is available
    // by checking for the E1000_RXD_STAT_DD bit
    // in the status portion of the descriptor. If not, stop.
    if ((rx_ring[rx_ring_index].status & E1000_RXD_STAT_DD) == 0)
      break;

    // Otherwise, update the mbuf's m->len to the length reported in the descriptor.
    // Deliver the mbuf to the network stack using net_rx().
    rx_mbufs[rx_ring_index]->len = rx_ring[rx_ring_index].length;
    net_rx(rx_mbufs[rx_ring_index]);

    // Then allocate a new mbuf using mbufalloc() to replace the one just given to net_rx().
    // Program its data pointer (m->head) into the descriptor.
    // Clear the descriptor's status bits to zero.
    if ((rx_mbufs[rx_ring_index] = mbufalloc(0)) == 0)
      break;
    rx_ring[rx_ring_index].addr = (uint64)rx_mbufs[rx_ring_index]->head;
    rx_ring[rx_ring_index].status = 0;

    // Finally, update the E1000_RDT register to be the
    // index of the last ring descriptor processed.
    regs[E1000_RDT] = rx_ring_index;

    // At some point the total number of packets
    // that have ever arrived will exceed the ring size (16);
    // make sure your code can handle that.
  }
}

课程总结

这 11 个实验做下来感觉难度最大的还是页表和锁的实验,毕竟最终测试都还没通过……以后有时间感觉 xv6 的东西有点忘了的时候再把这两个实验做一遍就行了,算是自己给自己挖的坑吧。此外,另外两个比较重要的实验是进程调度、文件系统的实验,理解工作原理很重要。最后还有时间的话再把 mmap 实验的 fork 升级成 cow fork。总之现在已经把 6.S081 2020 的所有实验从头到尾都做了一遍,可以暂时先告一段落了。

做这种国外公开的课程实验,其过程比较难受的,因为它没有老师、没有同学,意味着遇到问题你只能在自研或是在互联网上请教陌生人(但 MIT 的这门课的教授上课讲的内容非常清晰,果然还是名校的老师);没有成绩、没有学分、没有 DDL,意味着学下去纯靠主观能动性,不会有第三方的压力倒逼你自己。所以做这种课程实验最好是选测试充分的(每个实验都要执行对应的 uint test 后执行 usertests 做回归测试)、有 auto grade score 的(make grade),要不然你自己都不知道自己做的对不对。

最开始的时候完全不熟悉 xv6,甚至连操作系统的一些最基础理论知识都不理解,以致于第一和第二个实验是大量参考了别人现成的代码才勉强通过的。在第三个页表实验的时候开始独立地做了下去。这也是为什么前两个实验都没有对应的博文,页表和锁的实验的测试挂了还厚着脸皮写了博文。哪怕是问别人,也不要看别人写好的代码,否则没有意义。

不知道务实的文章应该长什么样子,写博客文章这种东西的技术下限可以说是零。自己写这种博文的目的大都不是为了给别人看才写的。只是觉得学了东西,得找个地方把它记录下来,写的时候顺便不断去对比去校正认知,那就觉得这是务实的,因为输出的过程才是重要的。记录下来了之后,想着什么时候再去温故一下都是次要的,因为很多东西写出来之后我自己都不打算以后再去看一遍。

私是个每天都会被自己菜醒的生物,以上。

参考链接

03-05 23:41