MIT jos 6.828 Fall 2014 训练记录(lab 6)

源代码参见我的github: https://github.com/YaoZengzeng/jos

在这个实验中将实现一个基于Intel 82540M(又称E1000)的网卡驱动。不过,一个网卡驱动还不足以让我们的操作系统连上互联网。在lab6新增加的代码中,已经包含了一个network stack和network server的代码,存放在net/和kern/目录下。

除了需要写一个driver,我们还需要写一个系统调用接口去访问它。我们需要补全network server的代码,从而能在network stack和driver之间传递packet。之后,web server就能操作文件系统中的文件了。

QEMU's virtual network

我们将会使用QEMU用户模式的network stack,因为它运行不需要管理员权限。我们已经更新了makefile,从而能够使用QEMU用户模式的network stack和虚拟的E1000网卡。在默认情况下,QEMU会提供一个运行在IP为10.0.2.2的虚拟路由器并且分配给JOS一个10.0.2.15的IP地址。为了简单起见,我们直接把这些默认设置硬编码在了net/ns.h的network server中。

尽管QEMU的virtual network允许JOS和互联网做任意的连接,但是JOS的10.0.2.15的IP地址在QEMU运行的virtual network之外没有任何的意义(因此,QEMU就像一个NAT),所以我们不能直接和JOS中运行的server连接,即使是运行QEMU的宿主机也不行。因此,通过配置QEMU,我们让JOS的一些端口和宿主机的某些端口相连,让server运行在这些端口上,从而让数据在宿主机和virtual network之间进行交换。我们将在端口7(echo)和80(http)运行端口。

The Network Server

从零开始写一个network stack是非常困难的。因此,我们将使用lwIP,一个开源的轻量级的TCP/IP协议组件,其中包含了很多东西,包括一个network stack。在这个实验中,我们只需知道,lwIP是一个黑盒,它实现了BSD的socket interface并且有一个packet input port和packet output port。

network server其实是由以下四个environments组成的:

(1)、core network server environment (包括socket call dispatcher和lwIP)

(2)、input environment

(3)、output environment

(4)、timer environment

下图显示了各个environments以及它们之间的关系。图中展示了整个系统包括device driver。在本次实验中,我们将实现被标记为绿色的那些部分。

MIT jos 6.828 Fall 2014 训练记录(lab 6)_第1张图片

The Core Network Server Environment

The Core Network Server由socket call dispatcher和lwIP组成。The socket call dispatcher和file server的工作方式类似。User environment通过stubs(定义在lib/nsipc.c)向core network environment发送IPC。通过lib/nsipc.c可以发现,我们找到core network server的方式和我们找file server是类似的:i386_init创建了NS environment,类型为NS_TYPE_NS,因此,我们遍历envs,找到这个特殊的environment type。对于每一个user environment的IPC,network server中的IPC dispatcher会调用由lwIP提供的BSD socket interface来实现。

普通的user environment不直接使用nsipc_*。通常它们都使用lib/sockets.c中提供的基于file descriptor的sockets API。因此,user environment通过file descriptor来调用socket,就像它们引用普通的磁盘文件一样。虽然socket有许多特殊的操作(例如,connect, accept等等),但是像read,write,close这样的操作也是走lib/fd.c中正常的file descriptor device-dispatcher代码的。就像file server会为所有打开的文件维护一个独有的ID,lwIP也会为每个打开的socket维护一个独有的ID。在file server或者network server中,我们都通过struct Fd中的信息将每个environment的file descriptor映射到相应的ID空间中。

虽然,看起来file server和network server的IPC dispatcher的工作方式相同,但是事实上有一个非常重要的区别。有些BSD socket的操作,例如accept和recv可能会永远阻塞。如果dispatcher让lwIP运行其中一个blocking calls,那么很可能dispatcher会阻塞,因此整个系统在某一时刻只能有一个network call。显然,这是不能让人接收的,因此,network server使用user-level threading去避免整个server environment的阻塞。对于每一个到来的IPC,dispatcher都会创建一个线程,然后由它对请求进行处理。即使这个线程阻塞了,那么也仅仅只是它进入休眠状态,而其他的线程照样能够运行。

除了core network environment之外,还有其他三个辅助的environment。除了从user application中获取消息以外,core network environment的dispatcher还从input environment和timer environment处获取信息。

The Output Environment

当处理user environment的socket call时,lwIP会产生packet用于网卡的传递。lwIP会将需要发送的packet通过NSREQ_OUTPUT IPC发送给output helper environment,packet的内容存放在IPC的共享页中。output environment负责接收这些信息并且通过系统调用将这些packet分发到相应的设备驱动。

The Input Environment

网卡得到的packet需要注入到lwIP中。对于设备驱动获得的每一个packet,input environment需要通过相应的系统调用将它们从内核中抽取出来,然后通过NSREQ_INPUT IPC 发送给core server environment。

packet input的功能从core network environment中剥离出来了,因为接收IPC并且同时接收或等待来自设备驱动的packet对于JOS是非常困难的。因为JOS中没有select这样能够允许environment监听多个输入源并且判断出哪个源已经准备好了。

The Timer Environment

timer environment会定期地向core network server发送NSREQ_TIMER的IPC,告诉它又过去了一个时间间隔,而lwIP会利用这些时间信息去实现各种的network timeout。

PartA:Initialization and transmitting packets

我们的kernel中还没有时间的概念,所以我们需要加上它。现在每隔10ms都有一个由硬件产生的时钟中断。每次出现一个时钟中断的时候,我们都对一个变量进行加操作,表示过去了10ms。

The Network Interface Card

PCI Interface

E1000是一个PCI设备,这说明它是插入主板的PCI总线的。PCI总线有地址总线、数据总线和中断总线,从而允许CPU能访问PCI设备,PCI设备也能读写内存。一个PCI设备在使用之前需要被发现并且初始化。发现的过程是指遍历PCI总线找到已经连接的设备。初始化是指为设备分配IO和内存空间并且指定IRQ线的过程。

在kern/pci.c中已经提供了PCI相关的代码。为了在启动过程中实现PCI的初始化,相关的PCI代码遍历了PCI总线进行设备的查找。当它发现一个设备时,它会读取它的vendor ID和device ID,把这两个值作为key去查询pci_attach_vendor数组。该数组的元素是struct pci_driver类型的,如下所示:

struct pci_driver {

  uint32_t key1, key2;

  int (*attachfn) (struct pci_func *pcif);

};

如果被发现的设备的vendor ID和device ID和数组中的某个表项是匹配的,那么接下来就会调用该表项的attachfn进行初始化的工作。attachfn的参数是一个PCI function。通常一个PCI card可以有多个function,虽然E1000只有一个。下面就是我们在JOS中呈现PCI function的方式:

struct pci_func {

  struct pci_bus   *bus;

  uint32_t     dev;

  uint32_t      func;

  uint32_t     dev_id;

  uint32_t     dev_clasee;

  uint32_t     reg_base[6];

  uint32_t     reg_size[6];

  uint8_t       irq_line;

}

上述结构的最后三个表项是最吸引我们的地方,其中记录了该设备的内存、IO和中断资源的信息。reg_base和reg_size数组包含了最多6个Base Address Register(BAR)的信息。reg_base记录了memory-mapped IO region的基内存地址或者基IO端口,reg_size则记录了reg_base对应的内存区域的大小或者IO端口的数目,irq_line则表示分配给设备中断用的IRQ线。

当设备的attachfn被调用时,设备已经被找到了,但是还不能用。这说明相关代码还没有确定分配给设备的资源,比如地址空间和IRQ线,其实就是struct pci_fun中的后三项还没被填充。attachfn函数需要哦调用pci_func_enable来分配相应的资源,填充struct pci_func,使设备运行起来。

Memory-mapped IO

软件通过memory-mapped IO(MMIO)和E1000进行通信。我们已经在JOS两次见到过它了:对于CGA和LAPIC都是通过直接读写“内存”来控制和访问的。但是这些读写操作都是不经过DRAM的,而是直接进入设备。

pci_func_enable为E1000分配了一个MMIO区域,并且将它的基地址和大小存储在了BAR0中,也就是reg_base[0]和reg_size[0]中。这是一段为设备分配的物理地址,这意味着你需要通过虚拟内存访问它。因为MMIO区域通常都被放在非常高的物理地址上(通常高于3GB),因此我们不能直接使用KADDR去访问它,因为JOS 256MB的内存限制。因为我们需要建立一个新的内存映射。我们将会使用高于MMIOBASE的区域(lab4中的mmio_map_region将会保证我们不会复写LAPIC的映射)。因为PCI设备的初始化发生在JOS创建user environment之前,所以我们可以在kern_pgdir创建映射,从而保证它永远可获得。

DMA

我们可以想象通过读写E1000的寄存器来发送和接收packet,但这实在是太慢了,而且需要E1000暂存packets。因此E1000使用Direct Access Memory(DMA)来直接从内存中读写packets而不通过CPU。驱动的作用就是负责为发送和接收队列分配内存,建立DMA descriptor,以及配置E1000,让它知道这些队列的位置,不过之后的所有事情都是异步的了。在发送packet的时候,驱动会将它拷贝到transmit队列的下一个DMA descriptor中,然后通知E1000另外一个包到了。E1000会在能够发送下一个packet的时候,会将packet从descriptor中拷贝出来。同样,当E1000接收到一个packet的时候,它就会将它拷贝到接收队列下一个DMA descriptor中,并且在合适的时机,驱动会将它从中读取出来。

从比较高的层次来看,接收和发送队列是非常类似的。都是由一系列的descriptor组成。但是这些descriptor具体的结构是不同的,每个descriptor都包含了一些flag以及存储packet数据的物理地址。

队列由循环数组构成,这表示当网卡或者驱动到达了数组的末尾时,它又会转回数组的头部。每个循环数组都有一个head pointer和tail pointer,这两个pointer之间的部分就是队列的内容。网卡总是从head消耗descriptor并且移动head pointer,同时,driver总是向尾部添加descriptor并且移动tail pointer。在发送队列的descriptor代表等待被发送的packet。对于接收队列,队列中的descriptor是一些闲置的descriptor,网卡可以将收到的packet放进去。

这些指向数组的pointer和descriptor中packet buffer的地址都必须是物理地址,因为硬件直接和物理RAM发生DMA,并不经过MMU。

Transmitting Packets

E1000的发送和接收函数是独立的,因此我们能一次处理其中一个。我们将首先实现发送packet的操作,因为没有发送就不能接收。

首先,我们要做的是初始化网卡的发包。发送操作初始化的第一步就是建立发送队列,我们不会使用E1000的TCP offload特性,所以我们能专注“legacy transmit descriptor format”。

C structures

我们会发现用C的结构描述E1000的结构是相当容易的。就像我们之前遇到过的struct Trapframe,C结构能让你精确地控制数据在内存中的布局。C会在结构的各个元素间插入空白用于对齐,但是对于E1000里的结构这都不是问题。例如,传统的发送descriptor如下图所示:

 63            48 47   40 39   32 31   24 23   16 15             0
  +---------------------------------------------------------------+
  |                         Buffer address                        |
  +---------------+-------+-------+-------+-------+---------------+
  |    Special    |  CSS  | Status|  Cmd  |  CSO  |    Length     |
  +---------------+-------+-------+-------+-------+---------------+

按照从上往下,从右往左的顺序读取,我们可以发现,struct tx_desc刚好是对齐的,因此不会有空白填充。

struct tx_desc
{
	uint64_t addr;
	uint16_t length;
	uint8_t cso;
	uint8_t cmd;
	uint8_t status;
	uint8_t css;
	uint16_t special;
};

我们的驱动需要为发送descriptor数组和发送descriptor指向的packet buffers预留内存。对于这一点,我们有很多实现方法,可以通过动态地分配页面并将它们存放在全局变量中。我们用哪种方法,需要记住的是E1000总是直接访问物理内存的,这意味着任何它访问的buffer都必须在物理空间上是连续的。

同样,我们有很多方法处理packet buffer。最简单的就是像最开始我们说的那样,就是在驱动初始化的时候为每个descriptor的packet buffer预留空间,之后就在这些预留的buffer中对packet进行进出拷贝。Ethernet packet最大有1518个byte,这就表明了这些buffer至少要多大。更加复杂的驱动可以动态地获取packet buffer(为了降低网络使用率比较低的时候带来的浪费)或者直接提供由用户空间提供的buffers,不过一开始简单点总是好的。

在完成了exercise 5之后,发送已经初始化完成了。我们需要实现包的发送工作,然后让用户空间能够通过系统调用获取这些包。为了发送一个包,我们需要将它加入发送队列的尾部,这意味着我们要我们要将packet拷贝到下一个packet buffer,并且更新TDT寄存器,从而告诉网卡,已经有另一个packet进入发送队列了。(需要注意的是,TDT是一个指向transmit descriptor array的index,而不是一个byte offset)

但是,发送队列只有这么大。如果网卡迟迟没有发送packet,发送队列满了怎么办?为了检测这种情况,我们需要反馈给E1000一些东西。不幸的是,我们并不能直接使用TDH寄存器,文档中明确声明,读取该寄存器的值是不可靠的。然而,如果我们在transmit descriptor的command filed设置了RS位的话,那么当网卡发送了这个descriptor中的包之后,就会设置该descriptor的status域的DD位。如果一个descriptor的DD位被设置了,那么我们就可以知道循环利用这个descriptor是安全的,可以利用它去发送下一个packet。

如果当用户调用了发送的系统调用,但是下一个descriptor的DD位没有设置怎么办?这是代表发送队列满了么?遇到这种情况我们应该如何处理?我们可以选择简单地直接丢弃这个packet。许多网络协议都对这种情况有弹性的设置,但是如果我们丢弃了很多packet的话,协议可能就无法恢复了。我们也许可以告诉user environment我们需要重新发送,就像sys_ipc_try_send中做的一样。我们可以让驱动一直处于自旋状态,直到有一个transmit descriptor被释放,但是这可能会造成比较大的性能问题,因为JOS内核不是设计成能阻塞的。最后,我们可以让transmitting environment睡眠并且要求网卡在有transmit descriptor被释放的时候发送一个中断。

Transmitting Packets: Network Server

既然现在我们已经有了访问设备驱动发送端的系统调用接口,那么现在就该发送一些packets了。output helper environment的作用就是不断做如下的循环:从core network server中接收NSREQ_OUTPUT类型的IPC message,然后用我们自己写的系统调用将伴有这些IPC message的packet发送到network driver。NSREQ_OUTPUT 的IPC message是由net/lwip/jos/jif/jif.c中的low_level_output发送的,它将lwIP stack和JOS的网络系统连在了一起。每一个IPC都会包含一个由union Nsipc组成的页,其中packet存放在struct jif_pkt字段中(见inc/ns.h)。struct jif_pkt如下所示:

struct jif_pkt {

  int   jp_len;

  char  jp_data[0];

}

其中jp_len代表了packet的长度。之后IPC page之后的所有字节都代表了packet的内容。使用一个长度为0的数组,例如jp_data,在struct 的结尾,是C中一种比较通用的方式,用于代表一个未提前指定长度的buffer。因为C中并没有做任何边界检测,只要你确定struct之后有足够的未被使用的内存,我们就可以认为jp_data是任意大小的数组。

我们需要搞清楚当设备驱动的transmit queue中没有空间的时候,设备驱动,output environment core network server三者之间的关系。core network server通过IPC将packet发送给output environment。如果output environment因为驱动中没有足够的缓存空间用于存放新的packet而阻塞的话,core network server会一直阻塞指导output environment接受了IPC为止。

Part B: Receiving packets and the web server

Receiving Packets

接收队列和发送队列非常相似,不同的是它由空的packet buffer组成,等待被即将到来的packet填充。所以,当网络暂停的时候,发送队列是空的,但是接收队列是满的。当E1000接收到一个packet时,它会首先检查这个packet是否满足该网卡的configured filters(比如,这个包的目的地址是不是该E1000的MAC地址)并且忽略那些不符合这些filter的packet。否则,E1000尝试获取从接收队列获取下一个空闲的descriptor。如果头指针(RDH)已经追赶上了尾指针(RDT),那么说明接收队列已经用完了空闲的descriptor,因此网卡就会丢弃这个packet。如果还有空闲的receive descriptor,它会将packet data拷贝到descriptor包含的buffer中,并且设置descriptor的DD(descriptor done)和EOP(End of Packet)状态位,然后增加RDH。

如果E1000收到一个packet,它的数据大于一个receive descriptor的packet buffer,它会继续从接收队列中获取尽可能多的descriptor,用来存放packet的所有内容。为了表明这样的情况,它会在每个descriptor中都设置DD状态位,但只在最后一个descriptor中设置EOP状态位。我们可以让驱动对这种情况进行处理,或者只是简单地对对网卡进行配置,让它不接收这样的“long packet”,但是我们要确保我们的receive buffer能够接收最大的标志Ethernet packet(1518Bytes)

为了接收packet,我们的驱动需要跟踪到底从哪个descriptor中获取下一个received packet。和发送时相似,文档中说明从软件中读取RDH寄存器是不可靠的。所以,为了确定一个packet是否被发送到descriptor的packet buffer中,我们需要读取该descriptor的DD状态位。如果DD已经被置位,那么我们可以将packet data从descriptor的packet buffer中拷贝出来,然后通过更新队列的RDT告诉网卡该descriptor已经被释放了。

如果DD没有被置位,那么说明没有接收到任何packet。这和发送端队列已满的情况是一样的,在这种情况下,我们可以做很多事情。我们可以简单地返回一个“try again”的error并且要求调用者继续尝试。这种方法对于发送队列已满的情况是有效的,因为那种情况是短暂的,但是对于空的接收队列就不合适了,因为接收队列可能很长时间处于空的状态。第二种方法就是将calling environment挂起,直到接收队列中有packet可以处理。这种方法和sys_ipc_recv和相似。就像在IPC中所做的,每个CPU只有一个kernel stack,一旦我们离开kernel,那么栈上的state就会消失。我们需要设置一个flag来表明这个environment是因为接收队列被挂起的并且记录下系统调用参数。这种方法的缺点有点复杂:E1000必须被配置成能产生接收中断并且驱动还需要能够对中断进行处理,为了让等待packet的environment能恢复过来。

 

转载于:https://www.cnblogs.com/YaoDD/p/5998381.html

你可能感兴趣的:(MIT jos 6.828 Fall 2014 训练记录(lab 6))