WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

MIT 6.828 - Lab - Networking

分类:操作系统标签: 6.828创建时间:2024-02-06 00:00:00

课程主页:https://pdos.csail.mit.edu/6.828/2023/index.html

本次的实验是实现网卡的驱动程序,完成数据的发送和接收。大部分程序都已经写好了,只需要补全 transmitsend 两个函数即可。通过本次实验可以了解到网卡是如何将数据从内存发送出去,以及数据是如何从网卡接收到内存中的,能够对 DMA 有大致的了解。

本次实验中使用的是 E1000 网卡,在实验开始之前需要大致了解它是如何工作的,可以看 Intel E1000 Software Developer’s Manual 了解其原理,但一上来就看这个容易把人弄糊涂,如果对硬件不熟悉,很难看明白。我结合源码和文档,大致了解了一些工作原理,但也仅仅够我完成本次实验。

E1000

在内存中有两个环形队列,分别是发送队列和接收队列,队列中均存放的是发送和接收的描述信息,操作系统会通过网卡提供的结构(寄存器)将这两个队列的地址和长度等信息告诉网卡。

接收队列:
+------------+------------+------------+------------+------------+------------+
| Descriptor | Descriptor | Descriptor | Descriptor | Descriptor | Descriptor |
+------------+------------+------------+------------+------------+------------+


发送队列:
+------------+------------+------------+------------+------------+------------+
| Descriptor | Descriptor | Descriptor | Descriptor | Descriptor | Descriptor |
+------------+------------+------------+------------+------------+------------+

发送流程:

在未发送任何数据时,队列是空的:

   +---+---+---+---+---+---+---+---+---+---+
   |   |   |   |   |   |   |   |   |   |   |
   +---+---+---+---+---+---+---+---+---+---+
   ^
  TDH (Transmit Descriptor Head register )
  TDT (Transmit Descriptor Tail register)

网卡在内存中映射了两个寄存器,TDH 和 TDT 指向待发送的范围。要想发送数据,就在队列中加入待发送数据的描述信息:

   +---+---+---+---+---+---+---+---+---+---+
   |///|///|///|///|   |   |   |   |   |   |
   +---+---+---+---+---+---+---+---+---+---+
   ^               ^
  TDH             TDT

之后再更新 TDT,将其指向最后一个待发送的描述符的尾部,如此网卡就知道有数据可以发送了,就会根据描述信息读取数据执行发送。

因此想要发送数据,只需要读取出 TDT 的值,并往队列中这个位置写入待发送信息,比如将待发送数据设置到 tx_desc->addr 上,将数据长度设置到 tx_desc->length 上,以及根据需要设置其他各字段即可。之后然后更新 TDT 的值,此时网卡就知道有数据需要发送了。

如果像下面这种情况,数据写满了怎么办呢?

   +---+---+---+---+---+---+---+---+---+---+
   |///|///|///|///|///|///|///|///|///|///|
   +---+---+---+---+---+---+---+---+---+---+
   ^
  TDH
  TDT

这就要看看发送描述信息的具体构成了:

上图中的结构,写成 C 结构体如下:

struct tx_desc {
  uint64 addr;
  uint16 length;
  uint8 cso;
  uint8 cmd;
  uint8 status;
  uint8 css;
  uint16 special;
};

其中 status 中可以携带多个信息:

其中 DD (Descriptor Done) 表示当前 Descriptor 是否发送完成,因此可以基于这个字段判断 TDT 指向的 Descriptor 是否已经发送出去。

接收流程:

相似地,内存中也有一个接收队列,在没有接收到任何数据时,队列中整个范围都可以存放数据:

   +---+---+---+---+---+---+---+---+---+---+
   |   |   |   |   |   |   |   |   |   |   |
   +---+---+---+---+---+---+---+---+---+---+
   ^                                   ^
  RDH                                 RDT
  

RDH: Receive Descriptor Head register
RDT: Receive Descriptor Tail register

当收到部分数据后,RDH 后向后移动:

   +---+---+---+---+---+---+---+---+---+---+
   |///|///|///|   |   |   |   |   |   |   |
   +---+---+---+---+---+---+---+---+---+---+
               ^                       ^
              RDH                     RDT

因此,最早被接收到的数据放在存放在 queue[(RDT + 1) % RING_SIZE] 处。接收队列中的描述信息格式如下:

也可以通过 status 字段判断这个 descriptor 是空的还是已经有接收到的数据。

实现

本次实验代码中,已经 tx_ringrx_ring 两个队列,用来存放待发送和待接收的信息。因为数据都是通过 mbuf 来存储的,而要发送和接收的数据并是存储在 mbuf 内部的,所以提供了 tx_mbufsrx_mbufs 两个数组来存放 mbuf。mbufs 和 ring 在相同位置上是对应的。

#define TX_RING_SIZE 16
static struct tx_desc tx_ring[TX_RING_SIZE] __attribute__((aligned(16)));
static struct mbuf *tx_mbufs[TX_RING_SIZE];

#define RX_RING_SIZE 16
static struct rx_desc rx_ring[RX_RING_SIZE] __attribute__((aligned(16)));
static struct mbuf *rx_mbufs[RX_RING_SIZE];

为了应对并发读写的场景,需要对发送和接收队列加锁:

struct spinlock e1000_transmit_lock;
struct spinlock e1000_recv_lock;

并且在 e1000_init 中对锁做初始化:

initlock(&e1000_transmit_lock, "e1000_transmit");
initlock(&e1000_recv_lock, "e1000_recv_lock");

下面看发送数据的实现:

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.
  //

  /**
   * +---+---+---+---+---+---+---+---+---+---+
   * |   |///|///|///|   |   |   |   |   |   |
   * +---+---+---+---+---+---+---+---+---+---+
   *     ^           ^    
   *    TDH         TDT
   * 
   * TDT: This is the location where software writes the first new descriptor
   */

  acquire(&e1000_transmit_lock);
  int tx_index = regs[E1000_TDT]; // 下一个待发送数据包存放的位置
  if (tx_ring[tx_index].status & E1000_TXD_STAT_DD) { // 已经被发送了
    if (tx_mbufs[tx_index]) {   // 这里通过下标得到已经发送的 mbuf 并释放,因为初始化的时候 tx_mbufs 中
      mbuffree(tx_mbufs[tx_index]); // 元素被初始化为 0,因此这里需要判断一下
    }

    tx_mbufs[tx_index] = m;   // 存放新的 buf
    tx_ring[tx_index].addr = (uint64)m->head;  // 将地址指向待发送数据
    tx_ring[tx_index].length = m->len;   // 设置待发送数据的长度
    // 下面一行设置成 cmd 字段,可以看看前面给出的 E1000 的文档 3.3.3.1 节
    tx_ring[tx_index].cmd |= E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP;
    tx_ring[tx_index].status = 0;  // 清空 status 字段

    // 更新 TDT
    regs[E1000_TDT] = (tx_index + 1) % TX_RING_SIZE;
  }
  release(&e1000_transmit_lock);
  return 0;
}

这里上层应用是生产者,网卡是消费者,有新的数据包待发送时,就将其放置在 TDT 位置,然后向后移动 TDT,这里是在生产数据。

下面是接收数据的实现:

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()).
  //

  /**
   * +---+---+---+---+---+---+---+---+---+---+
   * |///|   |   |   |   |   |   |   |   |   |
   * +---+---+---+---+---+---+---+---+---+---+
   *     ^                               ^    
   *    RDH                             RDT
   * 
   * RDT: the last location where hardware can store received data.
   */

  acquire(&e1000_recv_lock);
  int next = (regs[E1000_RDT] + 1) % TX_RING_SIZE;  // 第一个已经接收完成的数据包
  while (rx_ring[next].status & E1000_RXD_STAT_DD) { // 判断是否真的已经接收完成
    struct mbuf *mbuf = rx_mbufs[next];  // 拿出接收完成的数据包
    mbuf->len = rx_ring[next].length;
    rx_mbufs[next] = mbufalloc(0);   // 新创建一个接收缓冲区,并放入队列
    rx_ring[next].addr = (uint64)rx_mbufs[next]->head;
    rx_ring[next].length = sizeof(rx_mbufs[next]->buf);
    rx_ring[next].status = 0;

    net_rx(mbuf); // 将接收到的数据交给网络协议栈


    regs[E1000_RDT] = next;  // 更新 RDT, 告知 E1000 可以在此处存放接收到的新数据包
    next = (regs[E1000_RDT] + 1) % TX_RING_SIZE;  // 尝试读取下一个数据包
  }
  release(&e1000_recv_lock);
}

这里上层应用是消费者,网卡是生产者,RDH 和 RDT 指定的范围是可以存放接收到的数据包的地方,每接收一个 RDH 就会往后移动,当 RDH 和 RDT 重合的时候,接收缓冲区就满了。而最早被接收到但尚未被上层读取的数据包就在 RDT 后面。当 e1000_recv 从队列中拿走一个数据包之后,需要更新 RDT,以扩大可存放新数据包的范围。这里增加 RDT 是在消费数据。

另外在 e1000_recv 中需要尝试读取多个数据包,而不是一次仅读取一个,这是因为即使网卡接收到多个数据包,但也只会触发一次中断。在 e1000_init 有如下配置:

regs[E1000_RDTR] = 0; // interrupt after every received packet (no timer)
regs[E1000_RADV] = 0; // interrupt after every packet (no timer)

RDTR (Receive Interrupt Delay Timer / Packet Timer) 见 3.2.7.1.1 节,是说接收到数据包后,需要等待一段时间再触发中断,这可以将多个数据包汇聚起来,避免多次触发中断。RADV(Receive Interrupt Absolute Delay Timer) 见 3.2.7.1.2 节,也是相似的功能。RDTR 和 RADV 都是为了避免短时间内大量数据包到达后频繁触发中断。我看文档中有如下描述:

Setting the Packet Timer to 0b disables both the Packet Timer and the Absolute Timer (described below) and causes the Receive Timer Interrupt to be generated whenever a new packet has been stored in memory.

但我发现还是需要在 e1000_recv 中多次执行读取才行。这里我猜应该是多个 descriptor 构成一个 packet,因此一个 packet 需要读取多次。

在上面两个函数的实现中,我使用了自己定义的自旋锁,而没有使用现成的 e1000_lock。这是因为在 e1000_recvnet_rx 中可能会执行发送操作,这样就会调用 e1000_transmit 会引发死锁。

评论 (评论内容仅博主可见,不会公开显示)