网卡驱动实验
Lab: networking
这是课程最后一个实验,难度不大,但是需要深刻理解整个网络包传输的运转过程。首先,需要了解一下E1000网卡(DMA设备)以及内存。
下面是几句手册里的重要段落,描述了实验中的重要数据结构:
tx_desc(传输) 、 rx_desc(接收)。
Memory buffers pointed to by descriptors store packet data.
Receive Descriptor Queue Structure:
Hardware maintains a circular ring of descriptors
and writes back used descriptors just prior to advancing the head pointer
这里第一句话提到,文件描述符来指向存储数据的mbuf,因此需要利用文件描述符来获取到包。
第二句话提到对于接收包的文件描述符队列,由硬件维护了一个ring buffer,并在头指针前进之前写回使用过的描述符。可以认为是一个缓冲区。
To maximize memory efficiency,
receive descriptors are “packed” together
and written as a cache line whenever possible.
也就是说,为了最大化内存效率,文件描述符会被打包到一起,然后当缓存有空闲会被一起写入。(解决活锁)
接下来回到实验,最重要的就是上面提到的ring buffer,如下图,来自于讲座视频(Networking)
这里有两个,RX_ring 就是接收区的缓存区,TX_ring就是传输区的缓存区。在e1000.c中定义为
#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];
然后需要完成两个函数,e1000_transmit() 和 e1000_recv(),前者用来发送网络堆栈区的数据包,后者用于接收数据包。二者主要功能就是要将数据包的文件描述符写入各自的ring buffer。大概流程就是下面这张图,接收时,网卡(NIC)把数据包放到一个缓存区,然后用一个IP线程来处理并向上传输,发送时过程对称相反。
接下来按着提示做即可。
Some hints for implementing e1000_transmit:
-
First ask the E1000 for the TX ring index at which it’s expecting the next packet, by reading the E1000_TDT control register.
-
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.
-
Otherwise, use mbuffree() to free the last mbuf that was transmitted from that descriptor (if there was one).
-
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.
-
Finally, update the ring position by adding one to E1000_TDT modulo TX_RING_SIZE.
-
If e1000_transmit() added the mbuf successfully to the ring, return 0. On failure (e.g., there is no descriptor available to transmit the mbuf), return -1 so that the caller knows to free the mbuf.
大概翻译一下就是:
-
首先需要从寄存去regs的E1000_TDT位读取到当前 ring buffer(tx_ring) 的索引。
-
然后检查ring buffer是否已经满载了,因为在初始化的时候,ring buffer 每个位置的状态都被设置为E1000_TXD_STAT_DD (可以看e1000_init 函数),如果当前不是这个状态,说明不可用,没法完成接下来的传输请求。
-
如果可用的话,就是用mbuffree()来清空原来残留的mbuf(可能是上次没发完的,不管如何,要将其丢弃掉)。
-
然后把文件描述符填进去,将来自网络堆栈区的 m->head 填入ring buffer(tx_ring) 的addr位,然后将m->len 填入 ring buffer(tx_ring) 的len,并且要设置ring buffer(tx_ring) 的cmd位,这部分需要查询手册,在 PCI/PCI-X Family of Gigabit Ethernet
Controllers Software Developer’s Manual ,第三章 Table 3-10,就是 E1000_TXD_CMD_RS 和 E1000_TXD_CMD_EOP,前者用于表明包已经被放到了DMA的 FIFO口,后者表明当前已经结束放包了。 -
最后更新一下放下一次包的ring buffer 起始位置,将regs[E1000_TDT] + 1 然后模 TX_RING_SIZE。
接下来就是代码,其实就是上面每一步的实现。
int
e1000_transmit(struct mbuf *m)
{
//
acquire(&e1000_lock); //上锁
int idx = regs[E1000_TDT]; //获得索引
if(!(tx_ring[idx].status & E1000_TXD_STAT_DD)){ //如果当前的ring buffer不可用
release(&e1000_lock);
return -1;
}
if(tx_mbufs[idx]) //清空缓存区的旧东西
mbuffree(tx_mbufs[idx]);
tx_mbufs[idx] = m; //将堆栈区的包放到mbuf
tx_ring[idx].addr = (uint64)m->head; //修改文件描述符
tx_ring[idx].length = m->len; //修改包长度
tx_ring[idx].cmd = E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP; //设置状态位
regs[E1000_TDT] = (regs[E1000_TDT] + 1) % TX_RING_SIZE; //将索引向后移
release(&e1000_lock); //解锁
return 0;
}
接下来是写接收函数 e1000_recv ,基本也是按照参数,但是接收包需要将缓存区放满再停止,而不是一个一个接收,这个后面会解释。
Some hints for implementing e1000_recv:
-
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.
-
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.
-
Otherwise, update the mbuf’s m->len to the length reported in the descriptor. Deliver the mbuf to the network stack using net_rx().
-
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.
-
Finally, update the E1000_RDT register to be the index of the last ring descriptor processed.
e1000_init() initializes the RX ring with mbufs, and you’ll want to look at how it does that and perhaps borrow code. -
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.
大概步骤就是:
- 首先计算 regs[E1000_RDT] + 1 模 RX_RING_SIZE来获取ring buffer(rx_ring) 索引, 注意是接收区的,而且也说明上次处理完最后指针指向的是被处理的位置,而非被处理的下一个位置,这也是与结尾更新寄存器对应位置索引的一个伏笔。
- 然后也是检查这个 ring buffer 的状态为是否可用
- 如果可用,那就将mubf的len位更新为缓存区的len,因为是要接收数据包,所以需要先知道长度。
- 然后将这个mbuf利用net_rx 函数运输到网络堆栈区,此时其实已经完成使命了
- 接下来把这个缓存区清空,重新分配一块mbuf给这个索引位置,并且把这个索引位置的ring buffer 状态为设置为0,并且把ring buffer 地址指向 mbuf的头部,其实就是重新初始化这个索引的 mbuf与 ring buffer。
- 要保证接收的包不能超过缓存区最大大小16,最后把寄存器对应位置更新为当前索引。
static void
e1000_recv(void)
{
//一直接收
while(1){
int idx = (regs[E1000_RDT] + 1) % RX_RING_SIZE; //获得索引
if(!(rx_ring[idx].status & E1000_RXD_STAT_DD)) //当前ring buffer不可用
return;
rx_mbufs[idx]->len = rx_ring[idx].length;
net_rx(rx_mbufs[idx]); //传输到网络堆栈区
//初始化整个idx索引的mbuf与ring buffer
rx_mbufs[idx] = mbufalloc(0);
if(!rx_mbufs[idx])
panic("e1000");
rx_ring[idx].status = 0;
rx_ring[idx].addr = (uint64)rx_mbufs[idx]->head;
//更新索引
regs[E1000_RDT] = idx;
}
}
最后跑一下测试。
jimmy@ubuntu:~/xv6-test/xv6-labs-2020$ ./grade-lab-net
make: 'kernel/kernel' is up to date.
== Test running nettests == (3.8s)
== Test nettest: ping ==
nettest: ping: OK
== Test nettest: single process ==
nettest: single process: OK
== Test nettest: multi-process ==
nettest: multi-process: OK
== Test nettest: DNS ==
nettest: DNS: OK
== Test time ==
time: OK
Score: 100/100
活锁现象与总结
可以知道,传输时是要调用中断,然后向上来运输的,如果有大量的包传入,每个包都调用一次,会造成 live lock现象,但似乎本实验并没有解决这个问题,只是为了体验一下整个过程。
也就是当输入速率大于CPU可以处理的速率后,会产生输出速率下降的情况,按照理论来看,当到达瓶颈后,应该会变成一个固定速率(满载)来传输,但是就是由于要在中断采用大量时间来处理。
为了解决活锁,有两个途径:
- 起一个单独的中断来处理整个请求,直到完成所有的传输或者接收,这种用在客户向服务器请求资源的场景应该比较好用(可以看看开头E1000的处理方式)
- 将中断禁止,然后CPU来轮询检查外部设备何时需要传输或者接收数据,CPU将能处理的处理完了再放包进缓存区
这两种方法都是为了尽量减少中断次数,进而避免活锁。
这个实验整体难度不大,也是重在理解。至此完成了整个MIT6.S081实验,总体来看收获很大,以前学习操作系统主要停留在概念阶段,MIT6.S081将其中很多关键部分做成了实验,让自己写软件来实现很多有趣的机制,对xv6不断进行优化,还是很锻炼能力的。
[1]. https://pdos.csail.mit.edu/6.S081/2020/labs/net.html