Lab6: Network Driver 网络驱动
Introduction 引言
目前JOS虽然已经有了文件系统,却不没有网络协议栈,任何现代操作系统都不会不支持网络通信.在本实验中,我们将为网卡编写一个驱动程序.网卡将基于Intel 82540EM芯片,也称为E1000.
Getting Started准备工作
和前几个实验类似,首先执行如下命令以初始化lab6的代码.
athena% cd ~/6.828/lab
athena% add git
athena% git commit -am 'my solution to lab5'
nothing to commit (working directory clean)
athena% git pull
Already up-to-date.
athena% git checkout -b lab6 origin/lab6
Branch lab6 set up to track remote branch refs/remotes/origin/lab6.
Switched to a new branch "lab6"
athena% git merge lab5
Merge made by recursive.
fs/fs.c | 42 +++++++++++++++++++
1 files changed, 42 insertions(+), 0 deletions(-)
athena%
当然仅有网卡驱动还不足以将JOS连接到互联网.在lab6代码中,已经为我们提供了网络协议栈和网络服务器的代码.与前面的实验一样,使用git获取lab6的代码,合并之前代码,接下来浏览/net
目录中的文件和/kern
中的新文件.
除了完成网卡驱动,我们还需要创建一个新的系统调用接口来访问网卡驱动.我们将实现缺失的网络服务进程代码,以便在网络协议栈和网卡驱动程序间传输数据包.我们将通过完成一组网络服务进程来把所有的代码联系起来.通过网络服务进程,我们将能够从JOS文件系统中对外提供服务.
大多数内核设备驱动程序代码都必须从头开始编写.本实验提供的指导比以前的实验少得多:没有框架文件,没有已经写好的系统调用接口,许多设计需要我们自己决策.因此,我们最好在开始单个练习前,先完整阅读整个实验文档.
QEMU's virtual network QEMU虚拟网络
我们将使用QEMU的用户模式网络协议栈,因为它在普通用户权限就可以运行.更多用户网络的内容请参考QEMU文档.我们已经更新了makefile以启用QEMU的用户模式网络协议栈和虚拟E1000网卡.
guest (10.0.2.15) <------> Firewall/DHCP server <-----> Internet
| (10.0.2.2)
|
----> DNS server (10.0.2.3)
|
----> SMB server (10.0.2.4)
默认情况下,QEMU提供一个运行在IP10.0.2.2
上的虚拟路由器,并将为JOS分配IP地址10.0.2.15
.为了简单起见,我们在net/ns.h
将这些默认值硬编码到网络服务器中.
虽然QEMU虚拟网络允许JOS任意访问互联网,但是JOS的IP地址10.0.2.15
在QEMU虚拟网络之外没有任何意义(也就是说,QEMU充当了一个NAT),所以我们从外部不能直接连接到JOS内部运行的进程,即使是运行QEMU的主机也无法访问JOS内部的进程.为了解决这个问题,我们将QEMU配置为在主机上的某个端口上运行一个服务进程,该服务进程负责连接到JOS中的某个端口,并在真实主机和虚拟网络之间传输数据,即开启端口转发功能.
Packet Inspection 查看通信报文
makefile还额外配置了QEMU,从而将所有通信数据包记录到lab目录中的qemu.pcap.
可以使用如下命令,查看数据包的内容:
tcpdump -XXnr qemu.pcap
另外,我们也可以使用Wireshark图形界面来查看pcap文件.wireshark支持数百种网络协议,我个人非常喜欢使用.
Debugging the E1000 调试E1000
我们很幸运能够使用仿真硬件.由于E1000运行在软件中,模拟网卡E1000可以以用户可读的格式向我们报告其内部状态和遇到的问题.通常,对于用裸机编写的驱动程序开发人员来说是不可能获取这些信息的.
E1000可以产生很多调试输出,所以我们必须启用特定的日志channel.一些有用的channel如下:
| Flag | Meaning |
|-----------|----------------------------------------------------|
| tx | Log packet transmit operations |
| txerr | Log transmit ring errors |
| rx | Log changes to RCTL |
| rxfilter | Log filtering of incoming packets |
| rxerr | Log receive ring errors |
| unknown | Log reads and writes of unknown registers |
| eeprom | Log reads from the EEPROM |
| interrupt | Log interrupts and changes to interrupt registers. |
例如,要启用"tx"和"txerr"日志记录,请使用make E1000_DEBUG=tx,txerr ...
注意,E1000_DEBUG
仅针对6.828版本的QEMU有效.
我们可以进一步使用软件模拟硬件进行调试.如果开发过程中陷入困境,不明白E1000为什么没有按照预期的方式响应,可以看看QEMU在hw/e1000.c
中的E1000实现.
The Network Server 网络服务进程
从零开始写网络堆栈是一项艰苦的工作.因此,我们将使用lwIP,这是一个开源的轻量级TCP/IP协议套件,其中包括一个网络协议栈.你可以在这里找到更多关于lwIP的信息.在这个任务中,就我们而言,lwIP是一个黑盒,它实现了一个BSD套接字接口,并且有一个数据包输入端口和数据包输出端口.
网络服务进程实际上是四个进程的组合:
- 核心网络服务进程(包括套接字调用调度程序和lwIP)
- 输入辅助进程
- 输出辅助进程
- 定时器进程
下图显示了不同的进程及其关系.该图显示了包括设备驱动程序在内的整个系统,这将在后面介绍.在本实验中,我们将实现绿色显示的部分.
The Core Network Server Environment 核心网络服务进程
核心网络服务进程由套接字调用调度程序和lwIP两部分组成.套接字调用调度程序的工作方式与文件服务进程完全一样.用户进程使用库函数(在lib/nsipc.c
中)向核心网络进程发送ipc消息.如果查看lib/nsipc.c
,我们会发现找到核心网络服务进程的方式与找到文件服务进程的方式相同: i386_init
用NS_TYPE_NS
创建了NS进程,所以我们扫描envs
,寻找这种特殊的进程类型.对于每个用户进程IPC,网络服务进程中的调度程序代表用户调用lwIP提供的BSD套接字接口函数.
普通用户进程不会直接使用nsipc_*
调用.相反,它们使用lib/sockets.c
中的函数,后者提供了一个基于文件描述符的套接字应用编程接口.因此,用户进程通过文件描述符引用套接字,就像他们如何引用磁盘上的文件一样.有些操作(connect,accept等)是套接字特有的,但是read,write和close都要经过lib/fd.c
中的普通文件描述符设备调度代码.就像文件服务进程如何维护所有打开文件的内部唯一标识一样,lwIP也为所有打开的套接字生成唯一标识.在文件服务进程和网络服务进程中,我们使用存储在struct Fd
中的信息将每个进程的文件描述符映射到这些唯一的标识空间.
尽管文件服务进程和网络服务进程的IPC调度程序看起来是一样的,但还是有一个关键的区别.像accept和recv这样的BSD套接字调用可以无限期地阻塞.如果调度程序让lwIP执行其中一个阻塞调用,调度程序也会阻塞,整个系统一次只能有一个未完成的网络调用.当然这是不可接受的,网络服务进程使用用户级线程来避免阻塞整个服务进程.对于每个传入的IPC消息,调度程序创建一个线程,并在新创建的线程中处理请求.如果线程阻塞,那么只有该线程进入睡眠状态,而其他线程继续运行.
除了核心网络服务进程之外,还有三个辅助进程.除了接受来自用户应用程序的消息,核心网络服务进程的调度程序也接受来自输入和定时器进程的消息.
The Output Environment 输出辅助进程
当被用户进程套接字调用时,lwIP将生成数据包,再使用网卡发送出去.LwIP将使用NSREQ_OUTPUT
IPC发送数据包到输出辅助进程中,数据包附加在IPC消息的页面参数中.输出进程负责接受这些消息,并通过我们即将创建的系统调用接口将数据包转发到设备驱动程序.
The Input Environment 输入辅助进程
网卡收到的数据包需要传给lwIP中.对于设备驱动程序接收到的每个数据包,输入进程将数据包从内核空间中取出(使用我们即将实现的内核系统调用),并使用NSREQ_INPUT
IPC消息将数据包发送到核心服务进程.
数据包输入功能与核心网络进程是分开的,因为JOS很难同时接受IPC消息和轮询或等待来自设备驱动程序的数据包.JOS中没有select
系统调用,它允许进程监控多个输入源,以识别哪个输入准备好被处理.
如果浏览net/input.c
和net/output.c
,会发现两者都有需要实现的部分.这主要是因为实现取决于我们的系统调用接口.实现驱动程序和系统调用接口后,我们将实现这两个辅助进程.
The Timer Environment 定时器进程
计时器进程定期向核心网络服务进程发送NSREQ_TIMER
类型的消息,通知它计时器已过期.lwIP使用来自该线程的计时器消息来实现各种网络超时功能.
Part A: Initialization and transmitting packets A: 初始化和传输数据包
JOS内核没有时间的概念,所以我们需要添加它.目前,硬件每10ms生成一个时钟中断.在每个时钟中断,我们可以自增一个变量来表示时间度过了10ms.逻辑在kern/time.c
中已经实现,但尚未完全集成到JOS内核中.
Exercise1
为kern/trap.c
中的每个时钟中断添加一个time_tick
调用.实现sys_time_msec
,并将其添加到kern/syscall.c
中的syscall
,以便用户空间可以获取时间.
使用make INIT_CFLAGS=-DTEST_NO_NS run-testtime
,来测试这部分实现.我们应该会看到进程每隔1秒钟从5开始倒数."-DTEST_NO_NS"禁止启动网络服务进程,因为此时进程尚未完成,会导致死机.
The Network Interface Card 网卡
编写驱动程序需要深入了解硬件及硬件呈现给软件的接口.lab文档将提供如何与E1000交互的简单描述,但在编写驱动程序时,我们还是需要仔细阅读英特尔手册.
Exercise2
浏览Intel Software Developer's Manual.手册涵盖几个密切相关的以太网控制器.其中我们的QEMU模拟了82540EM
.
我们现在应该浏览第二章,了解一下这个设备.要编写驱动程序,我们需要熟悉第3章和第14章,以及4.1(不包括4.1.*).我们还需要参考第13章.其他章节主要涉及E1000的组件,我们的驱动程序不必与之交互.现在不要担心细节;浏览一下文档的结构,便于以后查找相关内容.
阅读手册时,请记住E1000是一款具有许多高级功能的复杂设备.正常工作的E1000驱动程序只需要网卡提供的一小部分功能和接口.仔细考虑与网卡交互的最简单方法.强烈建议在利用高级功能之前,首先让基本驱动程序正常工作.
PCI Interface PCI接口
E1000是一个PCI设备,这意味着它需要插入主板上的PCI总线.PCI总线有地址,数据和中断三种总线,允许CPU与PCI设备通信,并允许PCI设备读写内存.PCI设备在使用前需要被发现和初始化.发现是在PCI总线上寻找连接设备的过程.初始化是分配I/O和内存空间的过程,也是为设备协商IRQ线的过程.
JOS已经为我们在kern/pci.c
中提供了PCI代码.为了在启动引导过程中执行PCI初始化,PCI代码在PCI总线上寻找设备.找到设备后,它会读取其供应商ID和设备ID,并将这两个值用作搜索pci_attach_vendor
数组的关键字.该数组由如下struct pci_driver
条目组成:
struct pci_driver {
uint32_t key1, key2;
int (*attachfn) (struct pci_func *pcif);
};
如果发现的设备供应商ID和设备ID与数组中的条目匹配,PCI代码将调用该条目的attchfn
来执行设备初始化.(设备也可以通过类来识别,这是kern/pci.c
中的另一个驱动程序表的作用.)
attachfn
函数将传入的struct pci_func
初始化.PCI接口可以支持多个初始化函数,但E1000只支持一个.以下是JOS中的struct pci_func
:
struct pci_func {
struct pci_bus *bus;
uint32_t dev;
uint32_t func;
uint32_t dev_id;
uint32_t dev_class;
uint32_t reg_base[6];
uint32_t reg_size[6];
uint8_t irq_line;
};
上述结构反映了开发手册第4.1节表4-1中的一些条目.struct pci_func
的最后三个条目对我们特别有意义,因为它们记录了设备协商后的内存,IO和中断资源.reg_base
和reg_size
数组包含多达六个基址寄存器(Base Address Registers,BARs)的信息.reg_base
存储内存映射IO区域的基本内存地址(或IO端口资源的基本IO端口),reg_size
包含reg_base
中相应基值的字节大小或IO端口数量,irq_line
包含分配给设备的irq中断线.E1000 BARs的具体含义见表4-2的后半部分.
当设备的attachfn
函数被调用时,该设备已找到,但尚未启用.这意味着PCI代码尚未确定分配给设备的资源,例如地址空间和IRQ线,因此,struct pci_func
的最后三个元素尚未填充.attachfn
函数应该调用pci_func_enable
,这将启用设备,协商这些资源,并填充struct pci_func
.
Exercise3
实现attachfn()
函数来初始化E1000.在kern/pci.c
中的pci_attach_vendor
数组中添加一项,以便在找到匹配的pci设备时触发我们的函数(请务必将它放在标记表格结尾的{0,0,0}项之前).我们可以在第5.2节中找到QEMU模拟的82540EM的供应商ID和设备ID.JOS在引导时,会扫描PCI总线,我们还应该看到这些信息被打印出来.
现在,只需通过pci_func_enable
启用E1000设备.我们将在整个实验中添加更多初始化操作.
JOS已经为我们提供了kern/e1000.c
和kern/e1000.h
文件,因此我们修改编译系统.它们目前是空白的;我们需要在这个练习中填写它们.我们可能还需要在内核的其他地方包含e1000.h
文件。
当启动内核时,我们应该会看到它打印E1000网卡的PCI功能已启用.现在执行make grade
来测试代码,应该可以通过pci attach
测试.
Memory-mapped I/O 内存映射IO
软件通过内存映射输入输出(memory-mapped IO,MMIO)与E1000通信.在JOS中我们已经见过两次了: CGA console和LAPIC都是通过直接操作内存来控制和查询设备.但是这些读写不是操作物理内存;而是直接操作这些设备.
pci_func_enable
与E1000协商MMIO区域,并将其基数和大小存储在BAR0(即,reg_base[0]
和reg_size[0]
)中.这是分配给设备的一个物理内存地址区间,这意味着我们必须通过虚拟地址来访问它.由于MMIO地址被分配在非常高的物理地址(通常超过3GB),由于JOS的256兆内存限制,我们不能使用KADDR来访问它.因此必须创建一个新的内存映射.我们将使用MMIOBASE上方的区域(在lab4中的mmio_map_region
将确保我们不会覆盖LAPIC使用的映射).因为PCI设备初始化发生在JOS创建用户进程之前,所以我们可以在kern_pgdir
中创建映射,并且它总是可用的.
Exercise4
在我们的attachfn()
函数中,通过调用mmio_map_region
(在lab4中编写了该函数来支持LAPIC的内存映射),为E1000的BAR0创建一个虚拟内存映射.
我们需要在变量中记录该映射的位置,以便以后可以访问刚刚映射的寄存器.看看kern/lapic.c
中的lapic变量,以了解实现这一点的一种方法.如果我们使用了指向设备寄存器映射的指针,请确保声明它为volatile
;否则,编译器可能缓存值并重排序对此内存的访问.
要测试映射是否正确,请尝试打印出设备状态寄存器(第13.4.2节).这是一个4字节寄存器,从寄存器空间的第8个字节开始.我们应该会得到0x80080783
,这表示全双工链路速率为1000MB/s.
提示: 我们需要很多常量,比如寄存器的位置和位掩码的值.试图从开发手册中复制这些值容易出错,而错误会导致痛苦的调试过程.我们建议改用QEMU的e1000_hw.h作为指导.不建议逐字逐句地复制它,因为它定义的东西远远超过了我们的实际需要,可能超出了我们的需要,但这是一个很好的参考.
DMA 直接内存访问
我们可以想象通过读写E1000的寄存器来发送和接收数据包,但是这将会很慢,并且需要E1000在内部缓冲数据包数据.相反,E1000使用直接存储器访问(Direct Memory Access,DMA)来直接从存储器读取和写入数据包,而不需要CPU参与.驱动程序负责为发送和接收队列分配内存,设置DMA描述符,并用这些队列的位置配置E1000,但此后的一切都是异步的.要发送数据包,驱动程序将其复制到发送队列中的下一个DMA描述符中,并通知E1000有数据包可用;当有时间发送数据包时,E1000将从描述符中复制数据.同样,当E1000接收到数据包时,它会将其复制到接收队列中的下一个DMA描述符中,驱动程序可以在下一次读取该描述符.
在上层看来,接收和发送队列非常相似.两者都由一系列描述符组成.虽然这些描述符的确切结构各不相同,但每个描述符都有一些标志位和包含数据包的缓冲区的物理地址(或者是要发送的数据包,或者是操作系统分配的用于写入接收数据包的缓冲区).
队列被实现为循环数组,这意味着当网卡或驱动程序到达数组的末尾时,它会回到队头.两者都有一个头指针和一个尾指针,队列的内容是这两个指针之间的描述符.硬件总是从头部消耗描述符并移动头部指针,而驱动程序总是向尾部添加描述符并移动尾部指针.发送队列中的描述符代表等待发送的数据包(因此,在稳定状态下,传输队列为空).对于接收队列,队列中的描述符是可以接收数据包的空闲描述符(因此,在稳定状态下,接收队列由所有可用的接收描述符组成).正确更新尾部寄存器而不混淆是很棘手的;小心点.
指向这些数组的指针以及描述符中数据包缓冲区的地址都必须是物理地址,因为硬件与物理内存交互时不会经过内存管理单元(MMU).
Transmitting Packets 传输数据包
E1000的发送和接收函数基本上是独立的,因此我们先集中完成一个函数.我们将首先完成发送函数,因为在没有发送函数前,我们测试不了接收函数.
首先,我们必须按照第14.5节中描述的步骤初始化要传输的网卡(不必考虑其中的子小节).发送初始化的第一步是建立发送队列.队列的精确结构在第3.4节中描述,描述符的结构在第3.3.3节中.我们不会使用E1000的TCP卸载功能,所以关注"legacy transmit descriptor format"即可.现在应该阅读这些章节,熟悉这些结构.
C Structures C语言结构
用C语言结构体来描述E1000的数据结构很方便.正如在struct Trapframe
中看到的,C结构体让我们可以在内存中精确地布局数据.C可以在字段之间插入填充,但是E1000的数据结构布局应该不需要.如果确实遇到字段对齐问题,请查阅GCC的"packed"属性.
例如,手册表3-8中给出的传统传输描述符(legacy transmit descriptor format):
63 48 47 40 39 32 31 24 23 16 15 0
+---------------------------------------------------------------+
| Buffer address |
+---------------|-------|-------|-------|-------|---------------+
| Special | CSS | Status| Cmd | CSO | Length |
+---------------|-------|-------|-------|-------|---------------+
该结构的第一个字节从右上角开始,因此要将它转换成一个C结构,从右向左,从上到下读取.如果仔细观察,我们会发现所有的字段都非常适合标准大小的类型:
struct tx_desc
{
uint64_t addr;
uint16_t length;
uint8_t cso;
uint8_t cmd;
uint8_t status;
uint8_t css;
uint16_t special;
};
我们的驱动程序必须为传输描述符数组和传输描述符指向的数据包缓冲区申请内存.有几种方法可以做到这一点,从动态分配页面到简单地在全局变量中声明它们.无论选择什么,请记住E1000是直接访问物理内存的,这意味着它访问的任何缓冲区在物理内存中都必须是连续的.
还有多种方式来处理数据包缓冲区.我们建议从最简单的开始,就是在驱动程序初始化期间为每个描述符申请一个数据包缓冲区内存,并简单地将数据包数据复制到这些预分配的缓冲区中或从中拷贝出来.以太网数据包的最大大小是1518字节,这限制了这些缓冲区的大小.更复杂的驱动程序可以动态分配数据包缓冲区(例如,在网络使用率较低时减少内存开销),甚至可以通过用户空间直接提供的缓冲区(一种称为zero copy的技术),但最好从简单的实现开始.
Exercise5
实现第14.5节(但不包括其小节)中描述的初始化步骤.使用第13节作为初始化过程中寄存器的参考,第3.3.3节和第3.4节作为传输描述符和传输描述符数组的参考.
请注意传输描述符数组的对齐要求和该数组长度的限制.因为TDLEN必须是128字节对齐的,并且每个传输描述符是16字节,所以我们的传输描述符数组长度将是8的倍数.但是,不要使用超过64个描述符,否则我们的测试将无法测试发送环溢出.
对于TCTL.COLD
,我们可以假设全双工操作.对于TIPG
,参考IEEE 802.3
标准IPG
第13.4.34节表13-77中描述的默认值(不要使用第14.5节表中的值).
通过运行make E1000_DEBUG=TXERR,TX qemu
来测试这部分实现.如果正在使用6.828的qemu,在设置TDT寄存器时,我们应该会看到"e1000: tx disabled"消息(因为这发生在设置TCTL之前).并且不再有其他"e1000"消息.
现在传输部分已经初始化完成,我们必须编写代码来传输数据包,并通过系统调用让用户空间可以使用它.要传输数据包,我们必须将其添加到传输队列的尾部,这意味着将数据包数据复制到下一个数据包缓冲区,然后更新TDT(传输描述符尾部)寄存器,以通知网卡在传输队列中有了新的数据包等待传输.(请注意,TDT是传输描述符数组的索引,而不是字节偏移量;文档对此描述不太清楚.)
然而,传输队列只有这么大.如果网卡来不及传输数据包,并且传输队列已满,会发生什么情况?为了检测这种情况,我们需要E1000的一些反馈.不幸的是,我们不能只使用TDH(transmit descriptor head,传输描述符头)寄存器;文档明确指出,从软件中读取该寄存器是不可靠的.但是,如果我们在传输描述符的cmd字段中设置了RS位,那么,当网卡传输了该描述符中的数据包后,网卡将在描述符的status字段中设置DD位.如果一个描述符的DD位被设置,我们就知道可以回收那个描述符并使用它来传输另一个数据包.
如果用户调用我们的发送系统调用,但下一个描述符的DD位没有被置位,则表示传输队列已满,该怎么办?在这种情况下,我们需要决定如何处理这种情况.我们当然可以直接把包扔掉.网络协议对此具有弹性和容忍度,但是如果我们丢弃了大量数据包,协议可能无法恢复.除此之外,我们可以告诉用户进程它必须重试,就像sys_ipc_try_send
那样.这样做的好处是可以将生成数据的进程向后延迟.
Exercise6
编写一个传输数据包的函数,首先检查下一个描述符是否空闲,然后将包数据复制到下一个描述符中,最后更新TDT.请确保正确处理了传输队列已满的情况.
现在是测试数据包传输函数的好时机.通过从内核直接调用传输函数,尝试只传输几个数据包.当前我们不需要创建任何特定网络协议的数据包.运行make E1000_DEBUG=TXERR,TX qemu
来运行测试.当传输数据包时,我们应该看到这样的打印:
e1000: index 0: 0x271f00 : 9000002a 0
...
每行给出发送数组中的索引,发送描述符的缓冲区地址,cmd/CSO/length
字段和special/CSS/status
字段.如果QEMU没有打印期望的传输描述符值,请检查我们是否填写了正确的描述符,以及是否正确配置了TDBAL
和TDBAH
.如果收到"e1000: TDH wraparound @0, TDT x, TDLEN y"消息,这意味着e1000一直在传输队列中运行而没有停止(如果QEMU不检查这个,它将进入无限循环),这可能意味着我们没有正确处理TDT.如果收到许多"e1000: tx disabled”消息,那么可能没有正确设置传输控制寄存器.
一旦QEMU运行,我们就可以运行tcpdump -XXnr qemu.pcap
来查看传输的数据包.如果我们从QEMU中看到了预期的"e1000: index"消息,但抓取的数据包是空的,请仔细检查我们是否在传输描述符中填写了每个必要的字段和位(e1000可能检查了我们的传输描述符,但不认为它有需要发送的内容).
Exercise7
添加允许从用户空间传输数据包的系统调用.具体的接口由我们自己决定.不要忘记检查从用户空间传递到内核的任何指针.
Transmitting Packets: Network Server 传输数据包: 网络服务进程
现在我们已经有了一个发送数据包到设备驱动的系统调用接口,是时候发送数据包了.传输辅助进程的目标是在循环中执行以下操作: 接受来自核心网络服务进程的NSREQ_OUTPUT
IPC消息,并使用我们在上面添加的系统调用将这些IPC消息的数据包发送到网络设备驱动程序.NSREQ_OUTPUT
IPC由net/lwip/jos/jif/jif.c
中的low_level_output
函数发送,该函数将lwip协议栈连接到jos的网络系统.每个IPC包括一个由Nsipc组成的页面,数据包位于struct jif_pkt
pkt字段中(见inc/ns.h).struct jif_pkt
看起来像:
struct jif_pkt {
int jp_len;
char jp_data[0];
};
jp_len
表示数据包的长度.IPC页面上的所有后续字节都表示数据包内容.在一个结构体的末尾使用像jp_data这样的零长度数组是一种常见的表示没有预先确定长度的缓冲区的技巧(有些人会说是厌恶的).由于C不进行数组边界检查,只要确保结构体后面有足够的未使用内存,就可以像使用任意大小的数组一样使用jp_data.
当设备驱动程序的传输队列中没有更多空间时,请注意设备驱动程序,辅助输出进程和核心网络服务进程之间的交互.核心网络服务进程使用IPC向辅助输出进程发送数据包.当驱动程序没有更多的缓冲空间用于新数据包,辅助输出进程将由于发送数据包系统调用而暂停,同时核心网络服务器将阻塞等待辅助输出进程接受IPC调用.
Exercise8
实现net/output.c
.
我们可以使用net/testoutput.c
来测试我们的输出代码,而无需涉及整个网络服务器.尝试运行make E1000_DEBUG=TXERR,TX run-net_testoutput
.我们应该看到这样的东西:
Transmitting packet 0
e1000: index 0: 0x271f00 : 9000009 0
Transmitting packet 1
e1000: index 1: 0x2724ee : 9000009 0
...
同时,tcpdump -XXnr qemu.pcap
应输出:
reading from file qemu.pcap, link-type EN10MB (Ethernet)
-5:00:00.600186 [|ether]
0x0000: 5061 636b 6574 2030 30 Packet.00
-5:00:00.610080 [|ether]
0x0000: 5061 636b 6574 2030 31 Packet.01
...
如果要使用更大的数据包计数进行测试,尝试执行make E1000_DEBUG=TXERR,TX NET_CFLAGS=-DTESTOUTPUT_COUNT=100 run-net_testoutput
.如果这导致我们的环形输出队列溢出,请再次检查我们是否正确处理了DD status位,以及是否已正确设置硬件启用DD标志位(使用RS command位).
我们的代码应该通过make grade
的testoutput
测试.
Q&&A
- 我们是如何实现数据包传输的?特别是,如果环形传输队列已满,我们要怎么处理?
Part B: Receiving packets and the web server
Receiving Packets 接收数据包
就像传输数据包一样,我们必须配置E1000来接收数据包,并提供接收描述符队列和接收描述符.第3.2节描述了数据包接收的工作原理,包括接收队列结构和接收描述符,初始化过程详见第14.4节.
Exercise9
阅读第3.2节.我们可以忽略任何关于中断和"校验和"卸载的内容(如果稍后决定使用这些功能,可以再返回阅读这个部分),而且我们不必关心阈值的细节和网卡内部缓存如何工作.
接收队列与发送队列非常相似,除了它由等待被输入数据包填充的空数据包缓冲区组成.因此,当网络空闲时,发送队列为空(因为所有数据包都已发送),但接收队列为满(均为空数据包缓冲区).
当E1000收到数据包时,它首先检查它是否匹配网卡配置的过滤器(例如,查看数据包是否匹配本E1000的MAC地址),如果数据包与任何过滤规则都不匹配,则忽略该数据包.否则,E1000试图从接收队列的头部搜索下一个接收描述符.如果头部(Receive Descriptor Head,RDH)追上尾部(Receive Descriptor Tail,RDT),那么接收队列就没有可用的描述符,所以网卡会丢弃该数据包.如果有一个空闲的接收描述符,它会将数据包复制到描述符指向的缓冲区中,设置描述符的DD(Descriptor Done)和EOP(End of Packet)状态位,并将RDH递增.
如果E1000接收到大于描述符缓冲区的数据包,它将从接收队列中搜索尽可能足够的描述符来存储数据包的全部内容.为了表示这种情况,它将在所有相关描述符上设置DD状态位,但是只在最后一个描述符上设置EOP状态位.我们可以在驱动程序中处理这种可能性,或者简单地将网卡配置为不接受"长数据包"(也称为巨型帧, jumbo frames),并确保我们的接收缓冲区足够大,可以存储最大长度的标准以太网数据包(1518字节).
Exercise10
按照第14.4节中的过程设置接收队列并配置E1000.我们不必支持"长数据包"或多播.目前,不要将网卡配置为使用中断;如果我们决定使用接收中断功能,可以稍后更改.此外,将E1000配置为去掉以太网CRC,因为测试脚本预计它被去掉.
默认情况下,网卡会过滤掉所有数据包.我们必须用网卡自己的MAC地址配置接收地址寄存器(Receive Address Register,RAL和RAH),以便接受发往该网卡的数据包.我们可以简单地对QEMU的默认MAC地址52:54:00:12:34:56
进行硬编码(JOS已经在lwIP中对其进行了硬编码,所以在这里进行也不会使事情变得更糟).要小心字节顺序;MAC地址从最低位字节到最高位字节,因此52:54:00:12
是MAC地址的低位32位,34:56
是高位16位.
E1000仅支持一组特定的接收缓冲区大小(在第13.4.22小节中的RCTL.BSIZE
中给出了描述).如果我们使接收包缓冲区足够大并禁用长数据包,就不必担心数据包横跨多个接收缓冲区的情况.此外,请记住就像发送一样,接收队列和数据包缓冲区在物理内存中必须是连续的.
我们应该使用至少128个接收描述符.
现在,我们可以对接收功能进行基本测试了,甚至无需编写接收数据包的代码.运行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput
.testinput
将发送一个地址解析协议(ARP)广播包(使用我们的数据包传输系统调用),QEMU将自动回复该报文.即使我们的驱动程序尚未收到此回复,我们也应该会看到打印"e1000: unicast match[0]: 52:54:00:12:34:56",表明E1000已接收到数据包,并且与配置的接收过滤器相匹配.如果我们看到打印"e1000: unicast mismatch: 52:54:00:12:34:56",则E1000会过滤掉数据包,这意味着我们可能没有正确配置RAL和RAH.确保字节顺序正确,并且不要忘记在RAH中设置"Address Valid"位.如果我们没有看到任何"e1000"打印,那可能没有正确启用网卡的接收功能.
现在,我们已经准备好实现接收数据包了.要接收数据包,我们的驱动程序必须跟踪它预期保存下一个接收到的数据包的描述符(提示:根据之前设计,E1000中可能已经有一个寄存器来保持这个信息).与传输类似,文档指出软件读取RDH寄存器是不可靠地,因此为了确定一个数据包是否已被保持到描述符的数据包缓冲区,我们必须读取描述符中的DD状态位.如果设置了DD位,那我们可以从描述符的包缓冲区中复制包数据,然后通过更新队列的尾部索引RDT告诉网卡该描述符已经被释放.
如果DD位未被置位,则没有收到数据包.这和发送队列已满时的情况类似,在这种情况下可以做几件事.我们可以简单地返回一个"try again"错误,并要求调用者重试.虽然这种方法对于发送队列很有效,因为这是一种暂时的情况,但是对于空的接收队列则不太合理,因为接收队列可能会在很长一段时间内保持为空.第二种方法是暂停调用进程,直到接收队列中有数据包要处理.这种策略与sys_ipc_recv
非常相似.就像在IPC中一样,因为我们每个CPU只有一个内核栈,一旦我们离开内核,栈上的数据就会丢失.我们需要设置一个标志,表示一个进程已经被接收队列下溢挂起,并记录系统调用参数.这种方法的缺点是复杂:E1000必须被设置为生成接收中断,并且驱动程序必须处理这个中断,以便恢复被阻塞等待数据包的进程.
Exercise11
编写一个函数来接收来自E1000的数据包,并通过添加系统调用将其暴露给用户空间.请确保正确处理接收队列为空的情况.
Receiving Packets: Network Server 接收数据包: 网络服务进程
在输入辅助进程中,我们需要使用新的接收系统调用来接收数据包,并使用NSREQ_INPUT
IPC消息将它们传递给核心网络服务进程.这些IPC输入消息应该有一个带有联合体Nsipc的页面,其struct jif_pkt
的pkt字段用于填充从网络接收的数据包.
Exercise12
实现net/input.c
通过运行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput
来测试代码,我们应该看到如下打印:
Sending ARP announcement...
Waiting for packets...
e1000: index 0: 0x26dea0 : 900002a 0
e1000: unicast match[0]: 52:54:00:12:34:56
input: 0000 5254 0012 3456 5255 0a00 0202 0806 0001
input: 0010 0800 0604 0002 5255 0a00 0202 0a00 0202
input: 0020 5254 0012 3456 0a00 020f 0000 0000 0000
input: 0030 0000 0000 0000 0000 0000 0000 0000 0000
以"input: "开头的行是QEMU的ARP回复的hexdump.
我们的代码应该通过make grade
中的testinput
.请注意,如果不发送至少一个ARP包来通知QEMU JOS的IP地址,就无法测试数据包接收,因此传输代码中有错误也会导致测试失败.
为了更彻底地测试我们的网络代码,JOS还提供了一个echosrv
的守护进程,它在端口7上运行一个echo
服务器,该服务器将对通过TCP连接发送的任何内容进行echo
.使用make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-echosrv
在一个终端中启动echo
服务器,并在另一个终端中运行make nc-7
与之连接.我们键入的每一行都应该被服务器回显.每当模拟网卡E1000接收到数据包时,QEMU应该在控制台上打印如下内容:
e1000: unicast match[0]: 52:54:00:12:34:56
e1000: index 2: 0x26ea7c : 9000036 0
e1000: index 3: 0x26f06a : 9000039 0
e1000: unicast match[0]: 52:54:00:12:34:56
此时,我们还应该能够通过echosrv
测试.
Question
- 如何构建我们的接收实现?特别是,如果接收队列为空,并且用户进程请求下一个传入的数据包,会怎么处理?
The Web Server 网络服务器
最简单的网络服务器会将文件内容发送给请求客户端.jOS已经提供了一个非常简单的网络服务器框架代码.
Exercise13
网络服务器缺少将文件内容发送回客户端的代码.通过实现send_file
和send_data
来完成网络服务器.
完成网络服务器后,启动网络服务器make run-httpd
,并用我们最喜欢的浏览器访问http://host:port/index.html,其中host是运行QEMU的计算机的名称(如果在同一台计算机上运行网络浏览器和QEMU,请使用localhost),port是通过`make which-ports`获得的网络服务器端口号.我们应该会看到一个运行在JOS内部的网页.
此时,运行make grade
应该是105/105.
Question
- JOS的网络服务器提供的网页说了什么?
- 完成整个实验大约花了多长时间?