课程相关的资源、视频和教材见Lab util: Unix utilities开头。

该来的还是来了,这次的Lab需要我们手搓网卡驱动,希望一切顺利。

准备

对应课程

本次的作业是Lab Net,我们将为xv6操作系统实现一个网卡驱动,本次就这单独一个任务。

网课和Lab的顺序有所错开,按照课程进度,我们需要看掉Lecture 9,也就是B站课程的P8,同时阅读PDF中的第五章Interrupts and device drivers。

另外还请看Lecture 21,即B站网课P20,这里简单的介绍了计网的一些概念和网络栈的相关知识。

有可能的话,可以事后看一下Lecture 24中关于Lab Net的Q&A部分。

系统环境

使用Arch Linux虚拟机作为实验环境…

环境依赖配置,编译使用make qemu等,如遇到问题,见第一次的Lab util: Unix utilities

使用准备

参考本次的实验说明书: Lab net: Network driver

从仓库clone下来课程文件:

1
git clone git://g.csail.mit.edu/xv6-labs-2021

切换到本次作业的net分支即可:

1
2
cd xv6-labs-2021
git checkout net

在作业完成后可以使用make grade对所有结果进行评分。

题目

完成网卡驱动

背景简介

我们将使用一个名称为E1000的网络设备来处理网络通信,对于xv6和我们写的驱动来说,E1000看起来就是一个真的硬件,并且连接到了真的以太局域网(LAN)。事实上,驱动程序要通信的E1000是由QEMU提供的模拟,连接到一个也是由QEMU模拟的局域网,合着全是模拟出来的呗,没一点真的。在这个模拟出来的局域网中,xv6作为"guest",IP地址为10.0.2.15。同时,QEMU还安排当前运行QEMU的计算机以10.0.2.2的IP地址出现在局域网中。当xv6使用E1000向10.0.2.2发送数据包时,QEMU将数据包发送给运行QEMU的(真实的)计算机(也就是"host"主机)上的相应应用程序。

我们将使用QEMU的“用户模式网络栈”,相关文档可以查看User Networking (SLIRP)。作业中相关的Makefile已经更新,QEMU下的用户网络栈和E1000网卡均已启用。

注意到的是,Makefile中配置QEMU实现了记录功能,所有传入和传出的数据包都会被保存在主目录下的packets.pcap文件下。相关的数据对我们的调试可能很有帮助,我们可以通过相关记录来确认xv6有没有按照预期发送和接受数据包。如果需要显示数据包相关记录,执行命令:

1
tcpdump -XXnr packets.pcap

这次的xv6实验加入了一些其他文件,例如文件kernel/e1000.c下包含了E1000设备的初始化代码,以及带填充的传输和接受数据包的函数(也就是我们的作业内容)。在kernel/e1000_dev.h中包含了E1000寄存器和标识位的相关定义,这部分在Intel E1000软件开发者手册Software Developer’s Manual中也有所描述。kernel/net.ckernel/net.h中包含了一个实现IP、UDP和ARP协议的简单网络栈。这些文件还包含了数据结构的代码,用来保存数据包,称为mbuf:

1
2
3
4
5
6
struct mbuf {
struct mbuf *next; // the next mbuf in the chain
char *head; // the current start position of the buffer
unsigned int len; // the length of the buffer
char buf[MBUF_SIZE]; // the backing store
};

最后,kernel/pci.c中包含在xv6启动时在PCI总线上搜索E1000网卡的代码。

要求和提示

我们的目标是在kernel/e1000.c下完成函数e1000_transmit()e1000_recv()的补全,使得驱动程序可以收发数据包。

补全代码时可以参考E1000软件开发者手册Software Developer’s Manual,这里列出有帮助的部分章节:

  • 第2节概述了整个设备的情况。
  • 第3.2节给出了数据包接收的概述。
  • 第3.3节以及第3.4节给出了数据包传输的概述。
  • 第13节给出了E1000使用的寄存器的概述。
  • 第14节对帮助理解提供的代码有所帮助。

我们需要大致浏览E1000软件开发者手册Software Developer’s Manual,这个手册包含了多个相关的以太网控制器,其中QEMU模拟了82540EM。大致浏览第2章可以帮助我们快速了解设备情况。为了方便编写驱动程序,我们还需要了解第3章和第14章,以及4.1章(4.1的小节就免了),此外我们还需要使用第13章作为参考。其他章节大度涉及一些我们的驱动不必交互的E1000组件。细节相关的问题暂时不要太过担心,先了解一下文档的结构方便后续找文件。事实上,E1000有许多高级功能,其中大部分都可以忽略,只需要一小部分基本功能就可以完成这个实验。

kernel/e1000.c中提供的e1000_init()函数将E1000配置为,从内存中读出要传输的数据包,将收到的数据包写入内存中。我们将这种技术称为DMA,也就是直接内存访问。

由于突发的数据包到达的速度可能比驱动程序处理的速度快,e1000_init()为E1000提供了多个缓冲区,E1000可以将数据包写入其中。E1000要求这些缓冲区由内存中的 "描述符 "数组来描述;每个描述符包含内存中的一个地址,E1000可以将收到的数据包写入其中。我们使用结构体rx_desc用于表示描述符的相关格式信息:

1
2
3
4
5
6
7
8
9
10
// [E1000 3.2.3]
struct rx_desc
{
uint64 addr; /* Address of the descriptor's data buffer */
uint16 length; /* Length of data DMAed into data buffer */
uint16 csum; /* Packet checksum */
uint8 status; /* Descriptor status */
uint8 errors; /* Descriptor Errors */
uint16 special;
};

描述符构成的数组我们称之为接收环/接收队列,这是一个循环数组,长度设置为RX_RING_SIZE,每当我们的网卡或者驱动程序到达了数组的最后,他接下来就会转到数组头部:

1
2
#define RX_RING_SIZE 16
static struct rx_desc rx_ring[RX_RING_SIZE] __attribute__((aligned(16)));

e1000_init()使用mbufalloc()为E1000分配用于DMA的mbuf数据包缓冲区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define MBUF_SIZE              2048
#define MBUF_DEFAULT_HEADROOM 128

struct mbuf {
struct mbuf *next; // the next mbuf in the chain
char *head; // the current start position of the buffer
unsigned int len; // the length of the buffer
char buf[MBUF_SIZE]; // the backing store
};

// <- push <- trim
// -> pull -> put
// [-headroom-][------buffer------][-tailroom-]
// |----------------MBUF_SIZE-----------------|

同理,这里还有发送环,长度设置为TX_RING_SIZE,驱动将数据包放入其中,希望E1000进行发送:

1
2
#define TX_RING_SIZE 16
static struct tx_desc tx_ring[TX_RING_SIZE] __attribute__((aligned(16)));

每当net.c中的网络栈需要发送一个数据包的时候,他将调用e1000_transmit(),并使用参数为需要发送的mbuf数据包。我们的发送功能代码必须在发送环的描述符位置放置一个指针,结构体tx_desc描述了描述符的格式:

1
2
3
4
5
6
7
8
9
10
11
// [E1000 3.3.3]
struct tx_desc
{
uint64 addr;
uint16 length;
uint8 cso;
uint8 cmd;
uint8 status;
uint8 css;
uint16 special;
};

我们需要确保每一个mbuf最终被释放,但是只有在E1000完成了数据包传输之后才能进行释放,在完成数据包传输后E1000会将描述符中的E1000_TXD_STAT_DD置位来进行表示。

每当E1000从以太网收到一个数据包时,它首先将数据包DMA到接收环中的下一个位置描述符中,然后生成一个中断。我们的e1000_recv()函数需要扫描接收环,并通过调用net_rx()将每个新数据包的mbuf传输到网络栈(见kernel/net.c)。然后我们需要为新的mbuf开辟空间并将其放置到描述符中,这样当E1000再次到达接收环中的那个点时,它就能找到一个新的缓冲区来DMA一个新的数据包。

除了读写内存中的描述符环,我们的驱动程序还需要通过E1000在内存中映射的控制寄存器与其进行交互,来检测接收的数据包在何时可用,或者通知E1000驱动程序已经在发送描述符中填入发送的数据包。全局寄存器regs指向E1000的第一个控制寄存器,我们的驱动程序可以通过指定regs为数组的方式获得其他寄存器的值。我们尤其需要使用E1000_RDTE1000_TDT的值。

为了测试我们的驱动程序功能是否正常,我们需要打开两个终端,一边运行make server,另一边运行make qemu,并在xv6中运行nettests。这个nettests测试会尝试向make server创造出来的host主机发送一个UDP数据包。

当完成实验后,E1000驱动会发送数据包,QEMU会将它发送到host主机,make server会看见并发送一个响应数据包,然后E1000驱动程序和nettests就会看到响应数据包。在host主机发送响应前,还需向xv6发送一个ARP请求包来确定他的MAC地址,并等待xv6以ARP响应包,这很计算机网络。这部分的逻辑不用我们担心,kernel/net.c中都已经处理好了,只需要我们完成驱动程序即可。如果一切顺利,nettests就会打印testing ping: OK,而在make server这端会打印a message from xv6!

如果我们细看中间的数据包记录,主目录下使用tcpdump -XXnr packets.pcap输出示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
reading from file packets.pcap, link-type EN10MB (Ethernet)
15:27:40.861988 IP 10.0.2.15.2000 > 10.0.2.2.25603: UDP, length 19
0x0000: ffff ffff ffff 5254 0012 3456 0800 4500 ......RT..4V..E.
0x0010: 002f 0000 0000 6411 3eae 0a00 020f 0a00 ./....d.>.......
0x0020: 0202 07d0 6403 001b 0000 6120 6d65 7373 ....d.....a.mess
0x0030: 6167 6520 6672 6f6d 2078 7636 21 age.from.xv6!
15:27:40.862370 ARP, Request who-has 10.0.2.15 tell 10.0.2.2, length 28
0x0000: ffff ffff ffff 5255 0a00 0202 0806 0001 ......RU........
0x0010: 0800 0604 0001 5255 0a00 0202 0a00 0202 ......RU........
0x0020: 0000 0000 0000 0a00 020f ..........
15:27:40.862844 ARP, Reply 10.0.2.15 is-at 52:54:00:12:34:56, length 28
0x0000: ffff ffff ffff 5254 0012 3456 0806 0001 ......RT..4V....
0x0010: 0800 0604 0002 5254 0012 3456 0a00 020f ......RT..4V....
0x0020: 5255 0a00 0202 0a00 0202 RU........
15:27:40.863036 IP 10.0.2.2.25603 > 10.0.2.15.2000: UDP, length 17
0x0000: 5254 0012 3456 5255 0a00 0202 0800 4500 RT..4VRU......E.
0x0010: 002d 0000 0000 4011 62b0 0a00 0202 0a00 .-....@.b.......
0x0020: 020f 6403 07d0 0019 3406 7468 6973 2069 ..d.....4.this.i
0x0030: 7320 7468 6520 686f 7374 21 s.the.host!

输出可能有所差异,但是需要包含字符串ARP, Request, ARP, Reply, UDP, a.message.from.xv6this.is.the.host

此外nettests还会执行其他测试,例如DNS记录查询。最终通过所有测试的示例如下:

1
2
3
4
5
6
7
8
9
$ nettests
nettests running on port 25603
testing ping: OK
testing single-process pings: OK
testing multi-process pings: OK
testing DNS
DNS arecord for pdos.csail.mit.edu. is 128.52.129.126
DNS OK
all tests passed.

在做Lab前,首先可以试试向e1000_transmit()e1000_recv()中加入打印语句,然后运行make servernettests(在xv6中)。应该可以从打印语句观察到nettests()调用了e1000_transmit

在实现e1000_transmit时有几点提示:

  • 首先通过读取E1000_TDT控制寄存器来确定E1000的发送环中下一个数据包的索引位置。
  • 然后检查发送环是否溢出,如果E1000_TXD_STAT_DDE1000_TDT指向的索引位置描述符中设置了,那就说明E1000还没有完成对应的上次传输请求,因此返回一个错误。
  • 否则,使用mbuffree()释放从该描述符传输的最后一个mbuf(如果有)。
  • 接下来,填充描述符。m->head应该指向内存中的数据包内容,而m->len表示数据包长度。记得设置必要的cmd标志(参见 E1000 手册中的第 3.3 节),并保存指向mbuf的指针,以便稍后释放。
  • 最后,对E1000_TDT加一来更新环形位置,不要忘记对TX_RING_SIZE取模。
  • 如果e1000_transmit()成功向发送环中加入了mbuf,返回0。如果失败(例如,暂时没有可用的描述符来传输mbuf),返回-1好让调用者知道其释放mbuf

在实现e1000_recv的时候有几点提示:

  • 首先通过读取E1000_RDT控制寄存器并加一取模,来确定E1000接收环中,下一个等待接收的数据包的索引位置(如果有的话)。
  • 接下来,通过检查描述符中的status中的E1000_RXD_STAT_DD位来判断是否新的数据包已经准备好了。如果没有,直接停下。
  • 否则,根据描述符中的长度更新mbufm->len。使用net_rx()mbuf传给网络栈。
  • 接下来使用mbufalloc()开辟内存给新的mbuf来替代刚传给net_rx()mbuf。将其数据头m->head设置为描述符的地址,并将描述符的状态清空。
  • 最后,我们更新E1000_RDT寄存器,指向接收环中最后一个处理掉的描述符的位置。
  • e1000_init()实现了对接收环中mbuf的初始化,可以适度借鉴其中代码。
  • 有可能还没到达的数据包的总数已经超过了接收环的大小,即16,我们需要处理这种情形。

我们需要使用锁来防止可能的情形,例如xv6在多个进程中使用了E1000,或者进程在内核线程使用E1000过程中遭遇中断。

接收实现

这次实验的前置知识看起来非常多,多到吓人…

首先DMA的实现机制就是在内存中开辟额外的区域,网卡自身有自己的缓冲队列buffer queue。在接收时,DMA将队列中的数据拷贝到内存中,然后使用中断通知CPU,CPU从内存中读;而在发送时内核负责向内存中写,网卡负责从内存中读。

首先我们来看接收的实现,在这次的Lab中,我们在这里设置环形缓冲区,每块缓冲区由接收描述符struct rx_desc指定,而struct rx_mbufs作为具体存放数据的地方。接收环的示意图如下,这张图和相关说明见开发手册3.2.6:

我们维护了一个队列用于保存表示待填充数据的描述符,其位置从Head开始,到Tail结束,且包含Head和Tail指向的位置。这之间是待填充数据,这之外是已填充待读取数据

每当网卡硬件尝试向内存填充数据时,就会向Head位置填充,并将Head指针后移(Head可以后移到Tail位置但是不能超过,此时表示队列空),即出队;每当内核软件尝试从内存读取数据,就会从Tail+1位置读取(前提是Tail+1位置没有达到Head,即队列未满),并将Tail后移,即入队。

在处理接收数据包时,有一些寄存器需要用到:

  • 接收环Tail指针寄存器(Receive Descriptor Tail register, RDT)

    在E1000中表示为E1000_RDT寄存器,寄存器中保存了Tail指针从Base位置的偏移量。

  • 接收环Head指针寄存器(Receive Descriptor Head register, RDH)

    在E1000中表示为E1000_RDH寄存器,寄存器中保存了Head指针从Base位置的偏移量。

我们在e1000_init()中看到,初始化时,E1000_RDH0E1000_RDTRX_RING_SIZE - 1

1
2
3
regs[E1000_RDH] = 0;
regs[E1000_RDT] = RX_RING_SIZE - 1;
regs[E1000_RDLEN] = sizeof(rx_ring);

这就意味着队列目前是满的状态,队列内部全部都是待填充的位置。

另外在提示中,建议我们使用E1000_RXD_STAT_DD标识位来确定当前描述符下的数据包是否准备好了,我们用来检测Tail指针后面的描述符是否是已填充待读取的状态。

另外我们在e1000_init()初始化代码中看到,程序将描述符接收环rx_ring和真实的内存缓冲区rx_mbufs按照下标绑定:

1
2
3
4
5
6
7
memset(rx_ring, 0, sizeof(rx_ring));
for (i = 0; i < RX_RING_SIZE; i++) {
rx_mbufs[i] = mbufalloc(0);
if (!rx_mbufs[i])
panic("e1000");
rx_ring[i].addr = (uint64) rx_mbufs[i]->head;
}

这就意味着我们可以通过描述符的下标直接对应到缓冲区的mbuf*对象,硬件每次写入缓冲区的时候也会根据描述符中的addr直接找到下标对应的mbuf*head位置直接写入。

回到问题上来,在我们尝试根据读出的描述符组织mbuf后,根据提示mbuf中的长度len还未更新,需要我们和描述符被硬件更新的length保持一致。至此我们就正确处理好了mbuf对象,使用net_rx()mbuf传给网络栈即可。

由于当前的mbuf交给网络栈处理了,随后我们需要在相同下标位置绑定新的mbuf给对应的描述符,开辟新mbuf的空间可使用mbufalloc(),并将描述符的status状态清空,等待后续硬件在新分配的mbuf下写入。

在一次中断触发中我们需要尽可能多读一些数据包,所以我们要不断后移Tail直到不能读取为止。

注意到由E1000中断触发的读取程序并不需要加锁,得益于PLIC的存在,处理器在接收网卡中断之后,直到处理结束前都不会接收中断,因此没有竞争问题。

因此程序大致可以写作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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()).
//
while (1) {
// 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.
int index = (regs[E1000_RDT] + 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 (!(rx_ring[index].status & E1000_RXD_STAT_DD)) {
// If not, stop.
return;
}
// Otherwise, update the mbuf's m->len to the length reported in the descriptor.
rx_mbufs[index]->len = rx_ring[index].length;
// Deliver the mbuf to the network stack using net_rx().
net_rx(rx_mbufs[index]);
// Then allocate a new mbuf using mbufalloc() to replace the one just given to net_rx().
rx_mbufs[index] = mbufalloc(0);
// Program its data pointer (m->head) into the descriptor. Clear the descriptor's status bits to zero.
rx_ring[index].addr = (uint64)rx_mbufs[index]->head;
rx_ring[index].status = 0;
// Finally, update the E1000_RDT register to be the index of the last ring descriptor processed.
regs[E1000_RDT] = index;
}
}

发送实现

如果我们要实现发送,那么类似的,内核负责向内存中写,网卡负责从内存中读。

在这里我们同样维护了一个缓冲区,只不过这次是发送队列,每块缓冲区由接收描述符struct tx_desc指定,而struct tx_mbufs作为具体存放数据的地方。接收环的示意图如下,这张图和相关说明见开发手册3.4,看起来还是非常相似的:

我们维护了一个队列用于保存表示待发送数据的描述符,其位置从Head开始,到Tail结束,且包含Head,不包含Tail指向的位置。这之间是待发送数据,这之外是待填充数据

每当内核软件尝试向内存写入要发送的数据,就会从Tail位置读取(前提是Tail位置没有达到Head,即队列未满),并将Tail后移,即入队。每当网卡硬件尝试从内存读取要发送的数据时,就会从Head位置读取(前提是Head位置目前没有与Tail重合,即队列不空),并将Head后移,即出队。

至于如何辨别是满还是空,我们需要鉴别Tail所指位置的描述符中statusE1000_TXD_STAT_DD标志位是否有效,如果有效说明是空,Tail位置可以入队,否则说明是满。

在处理接收数据包时,有一些寄存器需要用到:

  • 发送环Tail指针寄存器(Transmit Descriptor Tail register, TDT)

    在E1000中表示为E1000_TDT寄存器,寄存器中保存了Tail指针从Base位置的偏移量。

  • 发送环Head指针寄存器(Transmit Descriptor Head register, TDH)

    在E1000中表示为E1000_TDH寄存器,寄存器中保存了Head指针从Base位置的偏移量。

我们在e1000_init()中看到,初始化时,E1000_TDH0E1000_TDT0

1
2
3
4
5
6
7
8
9
10
11
// [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;

说明当前队列为空,没有待发送数据的描述符。

mbuf数组的下标和描述符的下标是对应的,这里就不再展开了。当我们使用内核将mbuf传入时,需要将其与对应的描述符绑定,并更新描述符的长度lengthmbuf中的len,将描述符中的addr指向mbuf中的head表示数据包的地址。如果之前有mbuf对象绑定在描述符上,需要我们使用mbuffree()释放一下。

此外,我们还需要设置描述符中的status开启一些需要的标志位,例如E1000_TXD_CMD_EOPE1000_TXD_CMD_RS,用于告知数据包结束和要求通过statusDD位反馈状态。

另外记得全程加锁,以避免不同内核线程之间的竞争,以及中断切入导致的死锁。

所以,最后的代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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.
//

// To avoid race or deadlock.
acquire(&e1000_lock);
// First ask the E1000 for the TX ring index at which it's expecting the next packet,
// by reading the E1000_TDT control register.
int index = regs[E1000_TDT];
// Then check if the the ring is overflowing.
if (!(tx_ring[index].status & E1000_TXD_STAT_DD)) {
// 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.
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[index]) {
mbuffree(tx_mbufs[index]);
}
// Then fill in the descriptor
tx_ring[index].length = (uint16)m->len;
tx_ring[index].addr = (uint64)m->head;
// Set the necessary cmd flags
tx_ring[index].cmd = E1000_TXD_CMD_EOP | E1000_TXD_CMD_RS;
tx_mbufs[index] = m;
// Finally, update the ring position by adding one to E1000_TDT modulo TX_RING_SIZE.
regs[E1000_TDT] = (index + 1) % TX_RING_SIZE;
release(&e1000_lock);
return 0;
}

测试

分别开启两个终端,在主目录下分别运行make servermake qemu,并在xv6窗口中运行nettests

结果看起来是正确的,一切正常

总结

接下来,我们尝试运行make grade尝试进行评分,这里注意在主目录下添加文件time.txt,表示完整耗时。

结果如下:

这次的Lab信息量有点大,理清思路花了不少时间… 加油吧