源代码参见我的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。在本次实验中,我们将实现被标记为绿色的那些部分。
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能恢复过来。