MIT 6.S081 Lab Net实验
本文是MIT课程6.S081操作系统学习笔记的一部分:
- Lab util: Unix utilities
- Lab syscall: System calls
- Lab pgtbl: Page tables
- Lab traps: Traps
- Lab cow: Copy-on-write fork
- Lab thread: Multithreading
- Lab net: Network driver
- Lab lock: Parallelism/locking
- Lab fs: File system
- Lab mmap: Mmap
相关的代码都放在了GitHub下了:RayZhang13/MIT-6.S081-2021
课程相关的资源、视频和教材见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 | cd xv6-labs-2021 |
在作业完成后可以使用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.c
和kernel/net.h
中包含了一个实现IP、UDP和ARP协议的简单网络栈。这些文件还包含了数据结构的代码,用来保存数据包,称为mbuf:
1 | struct mbuf { |
最后,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 | // [E1000 3.2.3] |
描述符构成的数组我们称之为接收环/接收队列,这是一个循环数组,长度设置为RX_RING_SIZE
,每当我们的网卡或者驱动程序到达了数组的最后,他接下来就会转到数组头部:
1 |
|
e1000_init()
使用mbufalloc()
为E1000分配用于DMA的mbuf
数据包缓冲区:
1 |
|
同理,这里还有发送环,长度设置为TX_RING_SIZE
,驱动将数据包放入其中,希望E1000进行发送:
1 |
|
每当net.c
中的网络栈需要发送一个数据包的时候,他将调用e1000_transmit()
,并使用参数为需要发送的mbuf
数据包。我们的发送功能代码必须在发送环的描述符位置放置一个指针,结构体tx_desc
描述了描述符的格式:
1 | // [E1000 3.3.3] |
我们需要确保每一个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_RDT
和E1000_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 | reading from file packets.pcap, link-type EN10MB (Ethernet) |
输出可能有所差异,但是需要包含字符串ARP, Request
, ARP, Reply
, UDP
, a.message.from.xv6
和this.is.the.host
。
此外nettests
还会执行其他测试,例如DNS记录查询。最终通过所有测试的示例如下:
1 | $ nettests |
在做Lab前,首先可以试试向e1000_transmit()
和e1000_recv()
中加入打印语句,然后运行make server
和nettests
(在xv6中)。应该可以从打印语句观察到nettests()
调用了e1000_transmit
。
在实现e1000_transmit
时有几点提示:
- 首先通过读取
E1000_TDT
控制寄存器来确定E1000的发送环中下一个数据包的索引位置。 - 然后检查发送环是否溢出,如果
E1000_TXD_STAT_DD
在E1000_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
位来判断是否新的数据包已经准备好了。如果没有,直接停下。 - 否则,根据描述符中的长度更新
mbuf
的m->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_RDH
为0
,E1000_RDT
为RX_RING_SIZE - 1
:
1 | regs[E1000_RDH] = 0; |
这就意味着队列目前是满的状态,队列内部全部都是待填充的位置。
另外在提示中,建议我们使用E1000_RXD_STAT_DD
标识位来确定当前描述符下的数据包是否准备好了,我们用来检测Tail指针后面的描述符是否是已填充待读取的状态。
另外我们在e1000_init()
初始化代码中看到,程序将描述符接收环rx_ring
和真实的内存缓冲区rx_mbufs
按照下标绑定:
1 | memset(rx_ring, 0, sizeof(rx_ring)); |
这就意味着我们可以通过描述符的下标直接对应到缓冲区的mbuf*
对象,硬件每次写入缓冲区的时候也会根据描述符中的addr
直接找到下标对应的mbuf*
的head
位置直接写入。
回到问题上来,在我们尝试根据读出的描述符组织mbuf
后,根据提示mbuf
中的长度len
还未更新,需要我们和描述符被硬件更新的length
保持一致。至此我们就正确处理好了mbuf
对象,使用net_rx()
将mbuf
传给网络栈即可。
由于当前的mbuf
交给网络栈处理了,随后我们需要在相同下标位置绑定新的mbuf
给对应的描述符,开辟新mbuf
的空间可使用mbufalloc()
,并将描述符的status
状态清空,等待后续硬件在新分配的mbuf
下写入。
在一次中断触发中我们需要尽可能多读一些数据包,所以我们要不断后移Tail直到不能读取为止。
注意到由E1000中断触发的读取程序并不需要加锁,得益于PLIC的存在,处理器在接收网卡中断之后,直到处理结束前都不会接收中断,因此没有竞争问题。
因此程序大致可以写作:
1 | static void |
发送实现
如果我们要实现发送,那么类似的,内核负责向内存中写,网卡负责从内存中读。
在这里我们同样维护了一个缓冲区,只不过这次是发送队列,每块缓冲区由接收描述符struct tx_desc
指定,而struct tx_mbufs
作为具体存放数据的地方。接收环的示意图如下,这张图和相关说明见开发手册3.4,看起来还是非常相似的:
我们维护了一个队列用于保存表示待发送数据的描述符,其位置从Head开始,到Tail结束,且包含Head,不包含Tail指向的位置。这之间是待发送数据,这之外是待填充数据。
每当内核软件尝试向内存写入要发送的数据,就会从Tail位置读取(前提是Tail位置没有达到Head,即队列未满),并将Tail后移,即入队。每当网卡硬件尝试从内存读取要发送的数据时,就会从Head位置读取(前提是Head位置目前没有与Tail重合,即队列不空),并将Head后移,即出队。
至于如何辨别是满还是空,我们需要鉴别Tail所指位置的描述符中status
的E1000_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_TDH
为0
,E1000_TDT
为0
:
1 | // [E1000 14.5] Transmit initialization |
说明当前队列为空,没有待发送数据的描述符。
mbuf
数组的下标和描述符的下标是对应的,这里就不再展开了。当我们使用内核将mbuf
传入时,需要将其与对应的描述符绑定,并更新描述符的长度length
为mbuf
中的len
,将描述符中的addr
指向mbuf
中的head
表示数据包的地址。如果之前有mbuf
对象绑定在描述符上,需要我们使用mbuffree()
释放一下。
此外,我们还需要设置描述符中的status
开启一些需要的标志位,例如E1000_TXD_CMD_EOP
和E1000_TXD_CMD_RS
,用于告知数据包结束和要求通过status
的DD
位反馈状态。
另外记得全程加锁,以避免不同内核线程之间的竞争,以及中断切入导致的死锁。
所以,最后的代码大致如下:
1 | int |
测试
分别开启两个终端,在主目录下分别运行make server
和make qemu
,并在xv6窗口中运行nettests
:
结果看起来是正确的,一切正常
总结
接下来,我们尝试运行make grade
尝试进行评分,这里注意在主目录下添加文件time.txt
,表示完整耗时。
结果如下:
这次的Lab信息量有点大,理清思路花了不少时间… 加油吧