MIT 6.S081 Lab 7: Network driver

We think caged birds sing, when indeed they cry.

6.S081课程的第七个Lab,为 xv6 编写网卡设备驱动程序。

Background

我们会使用一个名为E1000的网络设备来处理网络通信。对于xv6(以及编写的驱动程序)来说,E1000看起来就像一个连接到真正的以太网局域网(LAN)的真正硬件。实际上,您的驱动程序将要与之通信的E1000是qemu提供的一个仿真实体,它连接到一个同样由qemu仿真的局域网。在这个模拟LAN上,xv6(guest)的IP地址是 10.0.2.15。Qemu还安排运行Qemu的计算机以IP地址10.0.2.2出现在LAN上。当xv6使用E1000将包发送到10.0.2.2时,qemu将包发送到运行qemu的计算机(实际)上的适当应用程序(“host”)。

我们将使用QEMU的“用户模式网络堆栈”。QEMU的文档在这里有更多关于用户模式堆栈的信息。Lab的Makefile已经更新,以启用QEMU的用户模式网络堆栈和E1000网卡。

Job

我们的工作就是阅读E1000的软件开发手册然后实现驱动发送和接收网络包。驱动从 RAM 读取包以发送,将接收包写入 RAM 。这项技术被称为 DMA (direct memory access)。也是 E1000 硬件直接读写 RAM 的关键。关于DMA的知识可看文章I/O 操作的那些事儿:轮询 ,中断 , DMA ,通道

由于数据包突发到达的速度可能快于驱动程序处理它们的速度,e1000_init()为E1000提供了多个缓冲区,E1000可以将数据包写入其中。E1000要求这些缓冲区由RAM中的“描述符”数组描述;每个描述符在内存中包含一个地址,E1000可以像其中写入接收到的数据包。struct rx_desc描述了描述符格式。描述符数组称为接收环或接收队列。这是一个环形,当网卡或驱动程序到达数组的末尾时,它会返回到开头。e1000_init()使用mbufalloc()为E1000的DMA分配mbuf数据包缓冲区。还有一个传输环,驱动程序在其中放置它想要E1000发送的数据包。e1000_init()配置两个RING的大小为RX_RING_SIZETX_RING_SIZE

net.c中的网络堆栈需要发送一个包时,它调用e1000_transmit(),并使用mbuf保存要发送的包。传输代码必须在TX(传输)环的描述符中放置一个指向数据包数据的指针。struct tx_desc描述了描述符格式。软件需要确保每个mbuf最终被释放,但只有在E1000完成传输数据包之后(E1000在描述符中设置E1000_TXD_STAT_DD位来指示这一点)。

当E1000从以太网接收每个数据包时,它首先将数据包直写内存到下一个RX(接收)环描述符所指向的mbuf,然后生成中断。中断处理会调用 e1000_recv(),扫描RX环,并通过调用net_rx()将每个新数据包的mbuf传递到网络堆栈(在net.c中),传入网络栈的mbuf由网络栈负责回收。然后,软件需要分配一个新的mbuf并将其放入描述符中,以便当E1000再次到达RX环中的那个点时,它会找到一个新的缓冲区,并将一个新数据包DMA写到其中。

除了在RAM中读写描述符环之外,驱动程序还需要通过其内存映射的控制寄存器与E1000进行交互,以检测何时接收到可用的数据包,并通知E1000驱动程序已经填充了一些TX描述符以发送数据包。全局变量regs保存一个指向E1000的第一个控制寄存器的指针;驱动程序可以通过将regs索引为数组来获取其他寄存器。尤其需要使用索引E1000_RDTE1000 TDT来获取环中第一个属于驱动(软件)操作的描述符索引。

transmit

实现代码如下,

// in kernel/e1000.c file
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.
  //
  
  int ind;
  
  acquire(&e1000_lock);
  ind = regs[E1000_TDT];

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

  if (tx_mbufs[ind]) 
    mbuffree(tx_mbufs[ind]);
  
  tx_mbufs[ind] = m;
  tx_ring[ind].addr = (uint64) m->head;
  tx_ring[ind].length = m->len;
  tx_ring[ind].cmd = (E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP);

  regs[E1000_TDT] = (ind + 1) % TX_RING_SIZE;

  release(&e1000_lock);
  return 0;
}

实现的要点如下,

  • 首先,通过读取E1000_TDT控制寄存器,向E1000询问其期待的下一个数据包的TX环索引。
  • 然后检查环是否溢出。如果E1000_TDT索引的描述符中没有设置E1000_TXD_STAT_DD,则E1000还没有完成相应的前一个传输请求,因此返回一个错误。
  • 否则,使用mbuffree()释放从该描述符传输的最后一个mbuf(如果有的话)。
  • 然后填充描述符。m->head指向数据包在内存中的内容,m->len表示数据包的长度。设置必要的cmd标志(请参阅E1000手册中的3.3节),这里用RS位指示记录,用EOP位指示包结尾(假设只发送一个包),并保存一个指向mbuf的指针,以便稍后释放。
  • 最后,更新 E1000_TDT
  • 如果e1000_transmit()成功将mbuf添加到环,则返回0。在失败时(例如,没有可用的描述符来传输mbuf),返回-1,以便调用者知道释放mbuf
  • 因为可能有多个进程使用 E1000 这里需要加锁。

receive

实现代码如下,

// in kernel/e1000.c file
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()).
  //
  int ind;
  
  ind = (regs[E1000_RDT] + 1) % RX_RING_SIZE;

  while (rx_ring[ind].status & E1000_RXD_STAT_DD) {
    rx_mbufs[ind]->len = rx_ring[ind].length;
    net_rx(rx_mbufs[ind]);

    rx_mbufs[ind] = mbufalloc(0);
    if(!rx_mbufs[ind])
      panic("e1000");
    
    memset(&rx_ring[ind], 0, sizeof(rx_ring[ind]));
    rx_ring[ind].addr = (uint64) rx_mbufs[ind]->head;
  
    ind = (ind + 1) % RX_RING_SIZE;
  }

  regs[E1000_RDT] = (ind - 1 + RX_RING_SIZE) % RX_RING_SIZE;
}

实现的要点如下,

  • 首先,通过获取E1000_RDT控制寄存器并加一取模RX_RING_SIZE,向E1000询问下一个等待接收的数据包(如果有)所在的环索引。
  • 然后通过检查描述符的状态部分中的E1000_RXD_STAT_DD位来检查是否新的数据包可用(已经被硬件处理完)。如果不是,那就停下来。
  • 否则,将mbufm->len更新为描述符中记录的长度。使用net_rx()mbuf交付到网络堆栈。
  • 然后使用mbufalloc()分配一个新的mbuf来替换刚刚给net_rx()mbuf。将其数据指针m->head记录到描述符中。将描述符的状态位清除为零。
  • 最后,将E1000_RDT寄存器更新。
  • e1000_init()使用mbufs初始化RX环,了解它是如何实现的,也许可以借鉴代码。
  • 在某个时刻,已经到达的数据包总数可能将超过环的大小(16),可以用循环不断向网络栈传mbufs处理。
  • 接收时该函数只会被中断处理函数 e1000_intr() 调用, 因此不会出现并发的情况,无需加锁。

difference in ring

细心阅读可以发现,同样是操作 FIFO 环,发送与接收对环的索引的操作不同,这是由于两个环的 headtail 的记录意义不一样的缘故。平常的环形队列,一般用 [head, tail) 左闭右开的形式指示存储的内容,用 head == tail 指示空队列,用 head == (tail + 1) % LEN 指示满队列,这往往会浪费存储空间的一个 slot (tail所在的 slot 在满队列时不会填充)。而在手册中,发送环与接收环的形式有不同。

负责发送的 TX_RING 也是左闭右开的形式,在软件开发手册的 3.4 节有,

The tail pointer points one entry beyond the last hardware owned descriptor (but at a point still within the descriptor ring).

软件要用到的(software ownership)下一个描述符在 tail 所指的位置。而为了不浪费存储空间,tail 可以与 head 相等,但用 E1000_TXD_STAT_DD 指示,是否被硬件完成操作。所以我们可以看到,在 e1000_init() 函数中 TX_RING 的初始化,

  // [E1000 14.5] Transmit initialization
  memset(tx_ring, 0, sizeof(tx_ring));
  for (i = 0; i < TX_RING_SIZE; i++) {
    tx_ring[i].status = E1000_TXD_STAT_DD;
    tx_mbufs[i] = 0;
  }
  regs[E1000_TDBAL] = (uint64) tx_ring;
  if(sizeof(tx_ring) % 128 != 0)
    panic("e1000");
  regs[E1000_TDLEN] = sizeof(tx_ring);
  regs[E1000_TDH] = regs[E1000_TDT] = 0;

E1000_TDHE1000_TDT 置为 0 且描述符的 status 都置 E1000_TXD_STAT_DD 位。这表示,初始化后的 TX_RING 所有 entry 都属于软件( software owns ) ,软件可以操作。

而负责接收的 RX_RING 是左闭右闭的形式,

HARDWARE OWNS ALL DESCRIPTORS BETWEEN [HEAD AND TAIL]

软件为了写,需要用到 tail 之后的下一个 entry 。这也是为什么e1000_recv() 要用到 E1000_RDT 加一的索引。这一点在其他学习者,如Fan Xiao’s blog中也有记录。

Conclusion

阅读手册写驱动的心得,

  • 了解哪些操作是硬件(E1000设备)完成;
  • 软件(OS驱动)需要做什么工作;
  • 软件与硬件可以同时看到的数据本质上只有硬件控制寄存器的内容(需要映射到内存),只不过硬件通过控制寄存器记录的描述符,可以找到RAM的地址通过DMA读写;
  • 阅读手册就是了解硬件与软件需要如何协同工作。

不同硬件其实可以提供不同的特性,如手册中记录的 TCP/IP checksum 计算与 TCP 分段都可以交由硬件完成。优秀的软件应该最大限度利用硬件的特性。

Reference

  1. 6.S081
  2. 用户模式堆栈
  3. E1000的软件开发手册
  4. I/O 操作的那些事儿:轮询 ,中断 , DMA ,通道
  5. Fan Xiao’s blog