MIT6.828 LAB6: Network Driver

来源:互联网 发布:引用另外一个表格数据 编辑:程序博客网 时间:2024/06/05 07:16

  抽了点空把LAB6重新整理一下,作为结束符~~。
Introduction
  我们已经实现了1个文件系统,当然OS还需要1个网络栈,在本次实验中我们将实现1个网卡驱动,这个网卡基于Intel 82540EM芯片,也就是熟知的E1000网卡。
  网卡驱动不足以使你的OS能连接上Internet。在LAB6新增加的代码中,我们提供了1个网络栈(network stack)和网络服务器(network server)在net/目录和kern/目录下。
  本次新增加的文件如下:
  net/lwip目录:开源轻量级TCP/IP协议组件包括1个网络栈
  net/timer.c:定时器功能测试程序
  net/ns.h:网卡驱动相关的参数宏定义和函数声明
  net/testinput.c:收包功能测试程序
  net/input.c:收包功能的用户态函数
  net/testoutput.c:发包功能测试程序
  net/output.c:发包功能的用户态函数
  net/serv.c:网络服务器的实现
  kern/e1000.c:网卡驱动的内核实现
  kern/e1000.h:网卡驱动实现相关的参数宏定义和函数声明

  除了实现网卡驱动,我们还要实现1个系统调用接口来访问驱动。我们需要实现网络服务器代码来传输网络数据包在网络栈和驱动之间。同时网络服务器也能使用文件系统中的文件。
  大部分内核驱动代码必须从零开始编写,这次实验比前面的实验提供更少的指导:没有骨架文件、没有系统调用接口等。总之一句话,要实现这次实验需要阅读很多提供的指导说明手册,才能完成实验。

QEMU’s virtual network
  我们将会使用QEMU用户态网络栈,因为它运行不需要管理员权限。关于QEMU用户态网络栈的说明在这里(QEMU用户态网络栈)。我们已经更新了makefile,从而能够使用QEMU用户态网络栈和虚拟E1000网卡。
  在默认情况下,QEMU会提供一个运行在IP为10.0.2.2的虚拟路由器并且分配给JOS一个10.0.2.15的IP地址。为了简单起见,我们把这些默认设置硬编码在了net/ns.h中。
  尽管QEMU的虚拟网络允许JOS和互联网做任意的连接,但是JOS的10.0.2.15 IP地址在QEMU运行的虚拟网络之外没有任何意义(QEMU就像一个NAT),所以我们不能直接和JOS中运行的se服务器连接,即使是运行QEMU的宿主机上也不行。为了解决这个问题,我们通过配置QEMU,让JOS的一些端口和宿主机的某些端口相连,让服务器运行在这些端口上,从而让数据在宿主机和虚拟网络之间进行交换。
  我们将在端口7(echo)和80(http)运行端口。为了避免端口冲突,makefile里实现了端口转发。可以通过运行make which-ports来找出QEMU转发的端口,也可以通过make nc-7和make nc-80来和运行在这些端口上的服务器交互。

Packet Inspection
  makefile也配置了QEMU的网络栈来记录各种进入和出去的数据包到qemu.pcap文件中。为了获得hex/ASCII的转换,我们可以使用tcpdump命令(Linux下非常有用的网络抓包分析工具,具体的参数说明可以用man tcpdump):
  tcpdump -XXnr qemu.pcap
  或者,也可以使用著名的Wireshark图形化工具来解析pcap文件。

Debugging the E1000  
  很幸运我们使用的是模拟硬件,E1000网卡运行为软件,模拟的E1000网卡能以用户可读的形式,向我们汇报有用的信息,比如内部状态和问题。
  模拟E1000网卡能产生一系列debug输出,通过打开特殊的日志通道,来捕获输出信息:
  标志

注意: E1000_DEBUG标志只在mit6.828课程提供QEMU中有用。
  
The Network Server
  从零开始写1个网络栈是很难的。这里,我们使用lwIP开源TCP/IP协议组件来实现网络栈(具体可以查看lwIP)。在这个实验中,我们只需知道lwIP是一个黑盒,它实现了BSD的socket接口并且有一个数据包input port和数据包output port。
  网络服务器其实是由以下四个environments组成的
  (1) 核心网络服务 environment(包括socket调用分发和lwIP
  (2) 输入environment
  (3) 输出environment
  (4) 计时environment

  下图显示了各个environments以及它们之间的关系。图中展示了整个系统包括设备驱动。在本次实验中,我们将实现被标记为绿色的那些部分。
  架构图
  其实整个网络服务器实现与文件系统的实现类似,也是通过IPC机制来在各个environment之间进行数据交互。

The Core Network Server Environment  
  核心网络服务environment由socket调用分发器和lwIP组成。The socket调用分发和文件服务器的工作方式类似。用户 environment通过stubs(定义在lib/nsipc.c)向核心网络environment发送IPC消息。查看lib/nsipc.c可以发现,核心网络服务器的工作方式和文件服务器是类似的:i386_init创建了NS environment,类型为NS_TYPE_NS,因此我们遍历envs,找到这个特殊的environment type。对于每一个用户environment的IPC,网络服务器中的IPC分发器会调用由lwIP提供的BSD socket接口来实现。
  普通的用户environment不直接使用nsipc_*调用。通常它们都使用lib/sockets.c中提供的基于文件描述符的sockets API。因此,用户environment通过文件描述符来引用socket,就像引用普通的磁盘文件一样。虽然socket有许多特殊的操作(比如connect、accept等等),但是像read,write,close这样的操作也是通过lib/fd.c中正常的文件描述符device-dispatcher代码。就像文件服务器会为所有打开的文件维护一个内部独有的ID,lwIP也会为每个打开的socket维护一个独有的ID。在文件服务器或者网络服务器中,我们使用存储在struct Fd中的信息来映射每个environment的文件描述符到相应的ID空间中。
  虽然看起来文件服务器和网络服务器的IPC分发器工作方式相同,但是事实上有一个非常重要的区别。有些BSD socket的操作,例如accept和recv可能会永远阻塞。如果分发器让lwIP运行其中一个堵塞调用,那么很可能分发器会阻塞,因此整个系统在某一时刻只能有一个网络调用,显然,这是不能让人接收的。因此网络服务器使用用户级线程去避免整个服务器environment的阻塞。对于每一个到来的IPC,分发器都会创建一个线程,然后由它对请求进行处理。即使这个线程阻塞了,那么也仅仅只是它进入休眠状态,而其他的线程照样能继续运行。
  除了核心网络environment之外,还有其他三个辅助的environment。除了从用户程序中获取消息以外,核心网络 environment的分发器还从input environment和timer environment处获取信息。

The Output Environment 
  当处理用户environment的socket调用时,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 发送给核心服务器environment。
  packet input的功能从核心网络environment中剥离出来了,因为接收IPC并且同时接收或等待来自设备驱动的packet对于JOS是非常困难的。因为JOS中没有select这样能够允许environment监听多个输入源并且判断出哪个源已经准备好了。
  net/input.c和net/output.c中就是我们要实现的2个用户态函数,当我们实现完网卡驱动和系统调用接口后。

The Timer Environment       
  timer environment会定期地向核心网络服务器发送NSREQ_TIMER IPC,通知它又过去了一个时间间隔,而lwIP会利用这些时间信息去实现各种的网络超时。

Part A: Initialization and transmitting packets
  我们的内核中还没有时间的概念,所以我们需要加上它。现在每隔10ms都有一个由硬件产生的时钟中断。每次出现一个时钟中断的时候,我们都对一个变量进行加操作,表示过去了10ms。这实现在kern/time.c中,但是并未归并到内核中。
  Exercise 1:
  在kern/trap.c中增加1个time_tick调用来处理每次时钟中断,实现sys_time_msec系统调用,使用户空间能读取时间。
  回答:
  首先在kern/trap.c的trap_dispatch函数中,对于IRQ_OFFSET + IRQ_TIMER中断添加time_tick调用:

//kern/trap.cif (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {        lapic_eoi();        time_tick();        sched_yield();        return;}//kern/time.cvoidtime_tick(void){       ticks++;    if (ticks * 10 < ticks)        panic("time_tick: time overflowed");}

  接下去就是添加获取时间的系统调用,具体流程和之前的一样,主要是在kern/syscall.c的中实现sys_time_msec函数,在该函数中调用time_msec函数来获得系统时间。

//kern/syscall.c// Return the current time.static intsys_time_msec(void){    return time_msec();}   //kern/time.cunsigned inttime_msec(void){       return ticks * 10;}

  通过运行make INIT_CFLAGS=-DTEST_NO_NS run-testtime来测试计时器共,将会看到从5到1的倒计时。其中”-DTEST_NO_NS”禁止启动网络服务器environment,因为我们暂时还没实现。

The Network Interface Card
  要写1个驱动必须要深入硬件和软件接口,在本次实验中我们将给1个高层次综述关于如何与E1000网卡交互,但是你需要去使用Intel的帮助手册来实现驱动。
  Exercise 2:
  浏览Intel关于E1000网卡的软件开发手册,该手册包括了多个相关的网卡控制器,而QEMU模拟的是82540EM。
  回答:
  主要是浏览第2张关于设备架构,为了编写驱动需要阅读第3章收发包描述符、第14章通用初始化和重置操作、第13章寄存器描述。

PCI Interface
  E1000网卡是一个PCI设备,这说明它是插入主板的PCI总线。PCI总线有地址总线、数据总线和中断总线,从而允许CPU能访问PCI设备,PCI设备也能读写内存。一个PCI设备在使用之前需要被发现并且初始化。发现的过程是指遍历PCI总线找到已经连接的设备。初始化是指为设备分配IO和内存空间并且指定IRQ线的过程。
  PCI是外围设备互连(Peripheral Component Interconnect)的简称,是在目前计算机系统中得到广泛应用的通用总线接口标准:  

  • 在一个PCI系统中,最多可以有256根PCI总线,一般主机上只会用到其中很少的几条。
  • 在一根PCI总线上可以连接多个物理设备,可以是一个网卡、显卡或者声卡等,最多不超过32个。
  • 一个PCI物理设备可以有多个功能,比如同时提供视频解析和声音解析,最多可提供8个功能。
  • 每个功能对应1个256字节的PCI配置空间。

      我们在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函数进行初始化工作。(设备也能被class识别,我们在kern/pci.c中也提供了其它驱动表)
  每一个PCI设备都有它映射的内存地址空间和I/O区域,除此之外,PCI设备还有配置空间,一共有256字节,其中前64字节是标准化的,提供了厂商号、设备号、版本号等信息,唯一标示1个PCI设备,同时提供最多6个的IO地址区域。
  配置空间
  PCI配置空间1
  PCI配置空间2
  当我们向查询1个特定PCI设备的配置空间时,需要向I/O地址[0cf8,0cfb]写入1个4字节的查询码指定总线号:设备号:功能号以及其配置地址空间中的查询位置。PCI Host Bridge将监听对于这个I/O端口的写入,并将查询结果写入到[0cfc,0cff],我们可以从这个地址读出1个32位整数表示查询到的相应信息。
  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,使设备运行起来。
  Exercise 3:
  实现1个attach函数来初始化E1000网卡,在pci_attach_vendor数组中增加1个表项来触发,可以在参考手册的5.2章节来找到82450EM的vendor ID和device ID。目前暂时使用pci_func_enable来使能E1000网卡设备,初始化工作放到后面。
  回答:
  在JOS中是如何对PCI设备进行初始化的,这部分模块主要定义在pci.c中,JOS会在系统初始化时调用pci_init函数来进行设备初始化(在kern/init.c的i386_init函数中)。
  首先来看一些最基本的变量和函数:

// pci_attach_class matches the class and subclass of a PCI devicestruct pci_driver pci_attach_class[] = {    { PCI_CLASS_BRIDGE, PCI_SUBCLASS_BRIDGE_PCI, &pci_bridge_attach },    { 0, 0, 0 },};// pci_attach_vendor matches the vendor ID and device ID of a PCI device. key1// and key2 should be the vendor ID and device ID respectivelystruct pci_driver pci_attach_vendor[] = {    { PCI_E1000_VENDOR, PCI_E1000_DEVICE, &pci_e1000_attach },    { 0, 0, 0 },};static voidpci_conf1_set_addr(uint32_t bus,           uint32_t dev,           uint32_t func,           uint32_t offset){    assert(bus < 256);    assert(dev < 32);    assert(func < 8);    assert(offset < 256);    assert((offset & 0x3) == 0);    uint32_t v = (1 << 31) |        // config-space        (bus << 16) | (dev << 11) | (func << 8) | (offset);    outl(pci_conf1_addr_ioport, v);}static uint32_tpci_conf_read(struct pci_func *f, uint32_t off){    pci_conf1_set_addr(f->bus->busno, f->dev, f->func, off);    return inl(pci_conf1_data_ioport);}static voidpci_conf_write(struct pci_func *f, uint32_t off, uint32_t v){    pci_conf1_set_addr(f->bus->busno, f->dev, f->func, off);    outl(pci_conf1_data_ioport, v);}

   pci_attach_class和pci_attach_vendor2个数组就是设备数组,3个函数是堆PCI设备最基本的读状态和写状态的函数:   

  • pci_conf_read函数是读取PCI配置空间中特定位置的配置值
  • pci_conf_write函数是设置PCI配置空间中特定位置的配置值
  • pci_conf1_set_addr函数是负责设置需要读写的具体设备

       这里涉及的2个I/O端口正是我们上面提到的操作PCI设备的IO端口。
       接下来我们看看如何初始化PCI设备,进入pic_init函数

intpci_init(void){    static struct pci_bus root_bus;    memset(&root_bus, 0, sizeof(root_bus));    return pci_scan_bus(&root_bus);}static intpci_scan_bus(struct pci_bus *bus){    int totaldev = 0;    struct pci_func df;    memset(&df, 0, sizeof(df));    df.bus = bus;    for (df.dev = 0; df.dev < 32; df.dev++) {        uint32_t bhlc = pci_conf_read(&df, PCI_BHLC_REG);        if (PCI_HDRTYPE_TYPE(bhlc) > 1)     // Unsupported or no device            continue;        totaldev++;        struct pci_func f = df;        for (f.func = 0; f.func < (PCI_HDRTYPE_MULTIFN(bhlc) ? 8 : 1);             f.func++) {            struct pci_func af = f;            af.dev_id = pci_conf_read(&f, PCI_ID_REG);            if (PCI_VENDOR(af.dev_id) == 0xffff)                continue;            uint32_t intr = pci_conf_read(&af, PCI_INTERRUPT_REG);            af.irq_line = PCI_INTERRUPT_LINE(intr);            af.dev_class = pci_conf_read(&af, PCI_CLASS_REG);            if (pci_show_devs)                pci_print_func(&af);            pci_attach(&af);        }    }    return totaldev;}

  在pci_init函数中,root_bus被全部清0,然后交给pci_scan_bus函数来扫描这条总线上的所有设备,说明在JOS中E1000网卡是连接在0号总线上的。pci_scan_bus函数来顺次查找0号总线上的32个设备,如果发现其存在,那么顺次扫描它们每个功能对应的配置地址空间,将一些关键的控制参数读入到pci_func中进行保存。
  得到pci_func函数后,被传入pci_attach函数去查找是否为已存在的设备,并用相应的初始化函数来初始化设备。
  通过查阅手册,我们知道E1000网卡的Vendor ID为0x8086,Device ID为0x100E,所以我们先实现1个e1000网卡初始化函数:

intpci_e1000_attach(struct pci_func *pcif){    pci_func_enable(pcif);    return 1;}

  这里调用了pci_func _enable函数来设置PCI设备配置即pci_func结构体,具体填充可以查看上面的流程介绍。
  最后修改kern/pci.c中的pci_attach_vendor数组,把E1000网卡的初始化程序添加进入:

// pci_attach_class matches the class and subclass of a PCI devicestruct pci_driver pci_attach_class[] = {    { PCI_CLASS_BRIDGE, PCI_SUBCLASS_BRIDGE_PCI, &pci_bridge_attach },    { 0, 0, 0 },};

  那么在JOS启动时,你就能看到E1000网卡被激活的信息。

Memory-mapped I/O
  软件通过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创建映射,从而保证它永远可用。
  Exercise 4:
  在E1000网卡的初始化函数中,通过调用mmio_map_region函数来为E1000网卡的BAR0建立一个虚拟内存映射。你需要使用1个变量记录下该映射地址以便之后可以访问映射的寄存器。查看在kern/lapic.c中的lapic变量,效仿它的做法。假如你使用1个指针指向设备寄存器映射地址,那么你必须声明它为volatile,否则编译器会运行缓存该值和重新排序内存访问序列。
  为了测试你的映射,可以尝试答应处设备状态寄出去,该寄存器为4个字节,值为0x80080783,表示全双工1000MB/S。
  回答:
  根据练习的提示,仿照lapic中的做法,在kern/e1000.c中声明1个全局变量e1000,该变量是1个指针,指向映射地址。然后调用mmio_map_region函数来申请内存建立映射,输出状态寄存器的值。关于寄存器位置和相关掩码,我们需要查看开发手册,设置宏定义,这一步可以借鉴QEMU的e1000_hw.h文件,拷贝相关定义到kern/e1000.h中。代码如下,具体的宏定义可以参考github。

intpci_e1000_attach(struct pci_func *pcif){    pci_func_enable(pcif);    e1000 = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);    cprintf("e1000: bar0  %x size0 %x\n", pcif->reg_base[0], pcif->reg_size[0]);    cprintf("e1000: status %x\n", e1000[STATUS/4]);

DMA
  我们可以想象通过读写E1000网卡的寄存器来发送和接收packet,但这实在是太慢了,而且需要E1000暂存packets。因此E1000使用Direct Access Memory(DMA)来直接从内存中读写packets而不通过CPU。驱动的作用就是负责为发送和接收队列分配内存,建立DMA描述符,以及配置E1000网卡,让它知道这些队列的位置,不过之后的所有事情都是异步。在发送packet的时候,驱动会将它拷贝到transmit队列的下一个DMA描述符中,然后通知E1000网卡另外一个包到了。E1000网卡会在能够发送下一个packet的时候,将packet从描述符中拷贝出来。同样,当E1000网卡接收到一个packet的时候,就会将它拷贝到接收队列的下一个DMA描述符中,并且在合适的时机,驱动会将它从中读取出来。
  从高层次来看,接收和发送队列是非常相似的,都是由一系列的描述符组成。但是这些descriptor具体的结构是不同的,每个描述符都包含了一些flag以及存储packet数据的物理地址。
  队列由循环数组构成,这表示当网卡或者驱动到达了数组的末尾时,它又会转回数组的头部。每个循环数组都有一个head指针和tail指针,这两个指针之间的部分就是队列的内容。网卡总是从head消耗描述符并且移动head指针,同时,驱动总是向尾部添加描述符并且移动tail指针。发送队列的描述符代表等待被发送的packet。对于接收队列,队列中的描述符是一些闲置的描述符,网卡可以将收到的packet放进去。
  这些指向数组的指针和描述符中packet buffer的地址都必须是物理地址,因为硬件直接和物理RAM发生DMA,并不经过MMU。

Transmitting Packets
  E1000网卡的发送和接收函数是独立的,因此我们能一次处理其中一个。我们将首先实现发送packet的操作,因为没有发送就不能接收。
  首先,我们要做的是初始化网卡的发包。根据14.5章节描述的步骤,发送操作初始化的第一步就是建立发送队列,具体队列结构的描述在3.4章节,描述符的结构在3.3.3章节。我们不会使用E1000网卡的TCP offload特性,所以我们专注于”legacy transmit descriptor format”。

C Structures
  我们会发现用C的结构描述E1000网卡的结构是相当容易的。就像我们之前遇到过的struct Trapframe,C结构能让你精确地控制数据在内存中的布局。C会在结构的各个元素间插入空白用于对齐,但是对于E1000里的结构这都不是问题。例如,传统的发送描述符如下图所示:
  发送描述符
  按照从上往下,从右往左的顺序读取,我们可以发现,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;};

  我们的驱动需要为发送描述符数组和发送描述符指向的packet buffers预留内存。对于这一点,我们有很多实现方法,包括可以通过动态地分配页面并将它们存放在全局变量中。我们用哪种方法,需要记住的是E1000总是直接访问物理内存的,这意味着任何它访问的buffer都必须在物理空间上是连续的。
  同样,我们有很多方法处理packet buffer。最简单的就是像最开始我们说的那样,在驱动初始化的时候为每个描述符的packet buffer预留空间,之后就在这些预留的buffer中对packet进行进出拷贝。Ethernet packet最大有1518个byte,这就表明了这些buffer至少要多大。更加复杂的驱动可以动态地获取packet buffer(为了降低网络使用率比较低的时候带来的浪费)或者直接提供由用户空间提供的buffers,不过一开始简单点总是好的。
  Exercise 5:
  根据14.5章节的描述,实现发包初始化,同时借鉴13章节(寄存器初始化)、3.3.3章节((发送描述符)和3.4章节(发送描述符数组)。
  记住发送描述数组的对弈要求和数组长度的限制。TDLEN必须是128字节对齐的,每个发送描述符是16字节的,你的发送描述符数组大小需要是8的倍数。在JOS中不要超过64个描述符,以防不好测试发送环形队列溢出情况。
  对于TCTL.COLD,你可以认为是全双工的。对于TIPG,要参考13.4.34章节表13-77关于IEEE802.3标准IPG的默认值描述(不要使用14.5章节的默认值)
  回答:
  这里需要查看开发手册14.5章节关于发送初始化的描述,主要步骤如下:

  • 为发送描述符队列分配一块连续空间,设置TDBAL和TDBAH寄存器的值指向起始地址,其中TDBAL为32位地址,TDBAL和TDBAH表示64位地址。
  • 设置TDLEN寄存器的值为描述符队列的大小,以字节计算。
  • 设置发送队列的Head指针(TDH)和Tail指针(TDT)寄存器的值为0。
  • 初始化发送控制TCTL寄存器的值,包括设置Enable位为1(TCTL.EN)、TCTL.PSP位为1、TCTL.CT位为10h、TCTL.COLD位为40h。
  • 设置TIPG寄存器为期望值
      
      首先是发送队列的设置,这里采用最简单的方法,声明发送描述符结构体和packet buffer结构体,并定义1个64大小的全局发送描述符数组和1个64大小的packet buffer数组,即都使用静态分配的方法。由于packet最大为1518字节,根据后面接收描述符的配置,将packet buffer设置为2048字节。
//kern/e1000.hstruct tx_desc{    uint64_t addr;    uint16_t length;    uint8_t cso;    uint8_t cmd;    uint8_t status;    uint8_t css;    uint16_t special;} __attribute__((packed));struct packet{           char body[2048];};//kern/e1000.cstruct tx_desc tx_d[TXRING_LEN] __attribute__((aligned (PGSIZE)))        = {{0, 0, 0, 0, 0, 0, 0}};struct packet pbuf[TXRING_LEN] __attribute__((aligned (PGSIZE)))        = {{{0}}};

   在pci_enable_attach函数中初始化相关寄存器的设置和发送描述符初始化。

static voidinit_desc(){    int i;    for(i = 0; i < TXRING_LEN; i++){        memset(&tx_d[i], 0, sizeof(tx_d[i]));        tx_d[i].addr = PADDR(&pbuf[i]);        tx_d[i].status = TXD_STAT_DD;        tx_d[i].cmd = TXD_CMD_RS | TXD_CMD_EOP;    }}intpci_e1000_attach(struct pci_func *pcif){    pci_func_enable(pcif);    init_desc();    e1000 = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);    cprintf("e1000: bar0  %x size0 %x\n", pcif->reg_base[0], pcif->reg_size[0]);    e1000[TDBAL/4] = PADDR(tx_d);    e1000[TDBAH/4] = 0;    e1000[TDLEN/4] = TXRING_LEN * sizeof(struct tx_desc);    e1000[TDH/4] = 0;    e1000[TDT/4] = 0;    e1000[TCTL/4] = TCTL_EN | TCTL_PSP | (TCTL_CT & (0x10 << 4)) | (TCTL_COLD & (0x40 << 12));    e1000[TIPG/4] = 10 | (8 << 10) | (12 << 20);    cprintf("e1000: status %x\n", e1000[STATUS/4]);    return 1;}

  在完成了exercise 5之后,发送已经初始化完成。我们需要实现包的发送工作,然后让用户空间能够通过系统调用获取这些包。为了发送一个包,我们需要将它加入到发送队列的尾部,这意味着我们要将packet拷贝到下一个packet buffer,并且更新TDT寄存器,从而告诉网卡,已经有另一个packet进入发送队列了。(需要注意的是,TDT是一个指向transmit descriptor array的index,而不是一个byte offset)
   但是,发送队列只有这么大。如果网卡迟迟没有发送packet,发送队列满了怎么办?为了检测这种情况,我们需要反馈给E1000网卡一些信息。不幸的是,我们并不能直接使用TDH寄存器,文档中明确声明,读取该寄存器的值是不可靠的。然而,如果我们在发送描述符的command filed设置了RS位,那么当网卡发送了这个描述符中的包之后,就会设置该描述符的status域的DD位。如果一个描述符的DD位被设置了,那么我们就可以知道循环利用这个描述符是安全的,可以利用它去发送下一个packet。
   如果当用户调用了发包的系统调用,但是下一个描述符的DD位没有设置怎么办?这是否代表发送队列满了么?遇到这种情况我们应该如何处理?我们可以选择简单地直接丢弃这个packet。许多网络协议都对这种情况有弹性的设置,但是如果我们丢弃了很多packet的话,协议可能就无法恢复了。我们也许可以告诉user environment我们需要重新发送,就像sys_ipc_try_send中做的一样。我们可以让驱动一直处于自旋状态,直到有一个发送描述符被释放,但是这可能会造成比较大的性能问题,因为JOS内核不是设计成能阻塞的。最后,我们可以让transmitting environment睡眠并且要求网卡在有transmit descriptor被释放的时候发送一个中断。
   Exercise 6:
   写一个函数通过检查下一个描述符是否可用来发送一个包,拷贝数据包内容到下一个描述符中,更新TDT,确保你能正确解决发送队列满了的情况。
   回答:
   在初始化工作中我们已经设置发送描述符的状态位为DD,即表示可用,只要在发送函数里获取Tail指针寄存器的值,判断该指针指向的发送描述符是否可用,如果可用将数据包内容拷贝到描述符中,并更新描述符的状态位和TDT寄存器。

inte1000_transmit(void *addr, size_t len){    uint32_t tail = e1000[TDT/4];    struct tx_desc *nxt = &tx_d[tail];    if((nxt->status & TXD_STAT_DD) != TXD_STAT_DD)        return -1;    if(len > TBUFFSIZE)        len = TBUFFSIZE;    memmove(&pbuf[tail], addr, len);    nxt->length = (uint16_t)len;    nxt->status &= !TXD_STAT_DD;    e1000[TDT/4] = (tail + 1) % TXRING_LEN;    return 0;}

  当你完成发包代码后,可以在内核中调用该函数来测试代码正确性(比如可以在monitor.c中添加调用)。运行make E1000_DEBUG=TXERR,TX qemu测试,你会看到如下输出:
  e1000: index 0: 0x271f00 : 9000002a 0
  其中每一行表示1个发送的数据包,index给出了在发送描述符数组中的索引,之后的为该描述符中packet buffer的地址,然后是cmd/CSO/length标志位,最后是special/CSS/status标志位。
  Exercise 7:
  添加1个系统调用来让用户空间可以发送数据包。具体的接口实现取决于自己。
  回答:
  仿照sys_ipc_try_send调用,在系统调用涉及的文件中添加调用号和接口函数。

//kern/syscall.c// Send network packetstatic intsys_netpacket_try_send(void *addr, size_t len){    user_mem_assert(curenv, addr, len, PTE_U);    return e1000_transmit(addr, len);}

Transmitting Packets: Network Server
  现在我们已经有了访问设备驱动发送端的系统调用接口,那么该发送一些packets了。output helper environment的作用就是不断做如下的循环:从核心网络服务器中接收NSREQ_OUTPUT类型的IPC消息,然后用我们自己写的系统调用将含有这些IPC消息的packet发送到网卡驱动。NSREQ_OUTPUT 的IPC消息是由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是任意大小的数组。
  我们需要搞清楚当设备驱动的发送队列中没有空间的时候,设备驱动,output environment和核心网络服务器三者之间的关系。核心网络服务器通过IPC将packet发送给output environment。如果output environment因为驱动中没有足够的缓存空间用于存放新的packet而阻塞,核心网络服务器会一直阻塞直到output environment接受了IPC为止。
  Exercise 8:
  实现net/output.c。
  回答:
  这里主要是实现output environment的工作。net/testoutput.c是测试发包的代码。

static envid_t output_envid;static struct jif_pkt *pkt = (struct jif_pkt*)REQVA;voidumain(int argc, char **argv){    envid_t ns_envid = sys_getenvid();    int i, r;    binaryname = "testoutput";    output_envid = fork();    if (output_envid < 0)        panic("error forking");    else if (output_envid == 0) {        output(ns_envid);        return;    }           for (i = 0; i < TESTOUTPUT_COUNT; i++) {        if ((r = sys_page_alloc(0, pkt, PTE_P|PTE_U|PTE_W)) < 0)            panic("sys_page_alloc: %e", r);        pkt->jp_len = snprintf(pkt->jp_data,                       PGSIZE - sizeof(pkt->jp_len),                       "Packet %02d", i);        cprintf("Transmitting packet %d\n", i);        ipc_send(output_envid, NSREQ_OUTPUT, pkt, PTE_P|PTE_W|PTE_U);        sys_page_unmap(0, pkt);    }    // Spin for a while, just in case IPC's or packets need to be flushed    for (i = 0; i < TESTOUTPUT_COUNT*2; i++)        sys_yield();

  在testoutput.c中,先fork1个environment,即output environment,然后运行需要实现的output函数,在原先environment中通过ipc_send发送数据包的内容。所以在output environment中,就需要实现通过ipc_recv接受到IPC信息时,如果为NSREQ_OUTPUT,那么调用发包系统调用来发送数据包到网卡驱动。

voidoutput(envid_t ns_envid){    binaryname = "ns_output";    int perm;    envid_t eid;    while(1) {        if (ipc_recv(&eid, &nsipcbuf, &perm) != NSREQ_OUTPUT)            continue;                                                while(sys_netpacket_try_send(nsipcbuf.pkt.jp_data, nsipcbuf.pkt.jp_len) < 0)            sys_yield();    }}

  至此整个发包流程就完成了,运行 make E1000_DEBUG=TXERR,TX run-net_testoutput来测试发包功能。
  Q1:
  你是如何组织你的发包流程,当发送队列满时你会怎么做?
  回答:
  整个流程可以查看上面的代码说明,当发送队列满时,那么output environment就会重发,但是sleep一会儿,其实最好使用中断机制。

Part B: Receiving packets and the web server
Receiving Packets
  与发包类似,我们必须配置E1000网卡来接受包并提供接收描述符队列和接收描述符。3.2章节描述了收包是如何工作的,包括接收队列结构和接收描述符,14.4章节描述了初始化过程。
  Exercise 9:
  阅读32.章节,忽略关于中断和校验负载。
  回答:
  主要是了解收包工作的流程。
  
  接收队列和发送队列非常相似,不同的是它由空的packet buffer组成,等待被即将到来的packet填充。因此,当网络暂停的时候,发送队列是空的,但是接收队列是满的。当E1000接收到一个packet时,它会首先检查这个packet是否满足该网卡的configured filters(比如,这个包的目的地址是不是该E1000的MAC地址)并且忽略那些不符合这些filter的packet。否则,E1000尝试获取从接收队列获取下一个空闲的描述符。如果Head指针(RDH)已经追赶上了Tail指针(RDT),那么说明接收队列已经用完了空闲的descriptor,因此网卡就会丢弃这个packet。如果还有空闲的接收描述符,它会将packet data拷贝到描述符包含的buffer中,并且设置描述符的DD(descriptor done)和EOP(End of Packet)状态位,然后增加RDH。
  如果E1000网卡收到一个packet,它的数据大于一个接收描述符的packet buffer,它会继续从接收队列中获取尽可能多的描述符,用来存放packet的所有内容。为了表明这样的情况,它会在每个descriptor中都设置DD状态位,但只在最后一个descriptor中设置EOP状态位。我们可以让驱动对这种情况进行处理,或者只是简单地对对网卡进行配置,让它不接收这样的“long packet”,但是我们要确保我们的receive buffer能够接收最大的标志Ethernet packet(1518字节)。
  Exercise 10:
  根据14.4章节建立接收队列和配置E1000网卡,无须支持”long packets”和multicast。暂时不要配置使用中断,同时忽略CRC。
  默认情况下,网卡会过滤所有的packet,我们必须配置接收地址寄存器(RAL和RAH)为网卡的MAC地址以使得能接受发送给该网卡的包。目前可以简单地硬编码QEMU的默认MAC地址52:54:00:12:34:56。注意字节顺序MAC地址从左到右是从低地址到高地址的,所以52:54:00:12为低32位,34:56为高16位
  E1000网卡只支持一系列特殊的receive buffer大小(可查看13.4.22章节关于RCTL.BSIZE的描述)。假如我们配置receive packet buffers足够大并关闭long packets,那么我们就无需担心跨越多个receive buffer的包。同时记住接收队列和packet buffer也必须是连续的物理内存。我们必需使用至少128个接收描述符。
  回答:
  整个流程跟发包初始化配置类似,查看开发手册14.4章节关于接收初始化的描述。主要相关工作如下:

  • 设置接受地址寄存器(RAL/RAH)为网卡的MAC地址。
  • 初始化multicast表数组为0。
  • 设置中断相关寄存器的值,这里我们关闭中断
  • 为接收描述符队列分配一块连续空间,设置RDBAL和RDBAH寄存器的值指向起始地址,其中RDBAL为32位地址,RDBAL和RDBAH表示64位地址。
  • 设置RDLEN寄存器的值为描述符队列的大小,以字节计算。
  • 设置接收队列的Head指针(RDH)和Tail指针(RDT)寄存器的值为0。Head指针指向第1个可用的描述符,Tail指向最后1个可用描述符的下一个描述符。(这里存在问题,如果将Head指针和Tail指针初始化为0,那么将接收不到数据包,应该将Tail指针初始化为最后1个可用描述符即RDLEN-1,因为像上面描述的当RDH等于RDT的时候,网卡认为队列满了,会丢弃数据包)。
  • 设置接收控制寄存器RCTL的值,主要包括设置RCTL.EN标志位为1(激活)、RCTL.LBM标志位为00(关闭回环)、RCTL.BSIZE标志位为00和RCTL.BSEX位为0(buffer大小为2048字节)、RCTL.SECRC标志位为1(忽略校验)。

      上面最重要的就是红色部分的描述,第一次做的时候卡在这里,测试通不过,仔细查看了多次手册。具体初始化代码如下:

static voidinit_desc(){    ......    for(i = 0; i < RXRING_LEN; i++){        memset(&rx_d[i], 0, sizeof(rx_d[i]));        rx_d[i].addr = PADDR(&prbuf[i]);        rx_d[i].status = 0;    }}intpci_e1000_attach(struct pci_func *pcif){    ......   e1000[RA/4] = mac[0];    e1000[RA/4+1] = mac[1];    e1000[RA/4+1] |= RAV;    cprintf("e1000: mac address %x:%x\n", mac[1], mac[0]);    memset((void*)&e1000[MTA/4], 0, 128 * 4);    e1000[ICS/4] = 0;    e1000[IMS/4] = 0;    //e1000[IMC/4] = 0xFFFF;    e1000[RDBAL/4] = PADDR(rx_d);    e1000[RDBAH/4] = 0;    e1000[RDLEN/4] = RXRING_LEN * sizeof(struct rx_desc);    e1000[RDH/4] = 0;    e1000[RDT/4] = RXRING_LEN - 1;    e1000[RCTL/4] = RCTL_EN | RCTL_LBM_NO | RCTL_SECRC | RCTL_BSIZE;    return 1;}

  完成后,运行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput,testinput会发送ARP广播,QEMU会自动回应。虽然现在我们的驱动无法接收该回应,但是使用debug标志可以看到:
  “e1000: unicast match[0]: 52:54:00:12:34:56”
  现在我们要实现接收包。为了接收packet,我们的驱动需要跟踪到底从哪个描述符中获取下一个received packet。和发送时相似,文档中说明从软件中读取RDH寄存器也是不可靠的。所以,为了确定一个packet是否被发送到描述符的packet buffer中,我们需要读取该描述符的DD状态位。如果DD已经被置位,那么我们可以将packet data从描述符的packet buffer中拷贝出来,然后通过更新队列的RDT告诉网卡该描述符已经被释放了。
  如果DD没有被置位,那么说明没有接收到任何packet。这和发送端队列已满的情况是一样的,在这种情况下,我们可以做很多事情。我们可以简单地返回一个“try again”的error并且要求调用者继续尝试。这种方法对于发送队列已满的情况是有效的,因为那种情况是短暂的,但是对于空的接收队列就不合适了,因为接收队列可能很长时间处于空的状态。第二种方法就是将calling environment挂起,直到接收队列中有packet可以处理。这种方法和sys_ipc_recv和相似。就像在IPC中所做的,每个CPU只有一个kernel stack,一旦我们离开kernel,那么栈上的state就会消失。我们需要设置一个flag来表明这个environment是因为接收队列被挂起的并且记录下系统调用参数。这种方法的缺点有点复杂:E1000网卡必须被配置成能产生接收中断并且驱动还需要能够对中断进行处理,为了让等待packet的environment能恢复过来。
  Exercise 11:
  写1个函数来从E1000网卡接收1个包,并添加1个系统调用暴露给用户空间。确保你能处理接收队列为空的情况。
  当然也可以使用中断来处理接收流程。
  回答:
  与发包类似,读取RDT寄存器的值,判断最后1个可用描述符的下一个描述符的标志位是否为DD,如果是则拷贝该描述符中的buffer,清除DD位,并增加RDT。

inte1000_receive(void *addr, size_t buflen){    uint32_t tail = (e1000[RDT/4] + 1) % RXRING_LEN;    struct rx_desc *nxt = &rx_d[tail];    if((nxt->status & RXD_STAT_DD) != RXD_STAT_DD) {        return -1;    }    if(nxt->length < buflen)        buflen = nxt->length;    memmove(addr, &prbuf[tail], buflen);    nxt->status &= !RXD_STAT_DD;    e1000[RDT/4] = tail;    return buflen;}

  关于系统调用的添加这里就不再描述了。

Receiving Packets: Network Server
  在网络服务器input environment中,我们将需要使用新添加的收包系统调用来接收数据包并通过NSREQ_INPUT IPC消息传递给核心网络服务器environment。
  Exercise 12:
  实现net/input.c
  回答:
  这里主要是实现input environment的工作。net/testinput.c是测试收包的代码。

voidumain(int argc, char **argv){    envid_t ns_envid = sys_getenvid();    int i, r, first = 1;    binaryname = "testinput";    output_envid = fork();    if (output_envid < 0)        panic("error forking");    else if (output_envid == 0) {        output(ns_envid);        return;    }    input_envid = fork();    if (input_envid < 0)        panic("error forking");    else if (input_envid == 0) {        input(ns_envid);        return;    }    cprintf("Sending ARP announcement...\n");    announce();    while (1) {        envid_t whom;        int perm;        int32_t req = ipc_recv((int32_t *)&whom, pkt, &perm);        if (req < 0)            panic("ipc_recv: %e", req);        if (whom != input_envid)            panic("IPC from unexpected environment %08x", whom);        if (req != NSREQ_INPUT)            panic("Unexpected IPC %d", req);        hexdump("input: ", pkt->jp_data, pkt->jp_len);        cprintf("\n");        // Only indicate that we're waiting for packets once        // we've received the ARP reply        if (first)            cprintf("Waiting for packets...\n");        first = 0;    }}

  fork了2个新的environment,其中1个执行output,发送ARP广播,另外1个执行input,接收QEMU的回应。通过ipc_recv来获得input environment收到的数据包。
  在net/input.c的input函数中通过调用收包系统调用从网卡驱动处获得数据包,这里的注意点是根据注释有可能收包太快,发送给网络服务器,但是网络服务器可能读取过慢,导致相应的内容被冲刷,所以我们采用10页的缓冲来存放从网卡驱动获得的数据包。

input(envid_t ns_envid){       binaryname = "ns_input";    int i, r;    int32_t length;    struct jif_pkt *cpkt = pkt;    for(i = 0; i < 10; i++)        if ((r = sys_page_alloc(0, (void*)((uintptr_t)pkt + i * PGSIZE), PTE_P | PTE_U | PTE_W)) < 0)            panic("sys_page_alloc: %e", r);    i = 0;     while(1) {        while((length = sys_netpacket_recv((void*)((uintptr_t)cpkt + sizeof(cpkt->jp_len)), PGSIZE - sizeof(cpkt->jp_len))) < 0) {            // cprintf("len: %d\n", length);            sys_yield();        }        cpkt->jp_len = length;        ipc_send(ns_envid, NSREQ_INPUT, cpkt, PTE_P | PTE_U);        i = (i + 1) % 10;        cpkt = (struct jif_pkt*)((uintptr_t)pkt + i * PGSIZE);        sys_yield();    }}

  完成之后,运行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput,将看到如下的信息:

Sending ARP announcement...Waiting for packets...e1000: index 0: 0x26dea0 : 900002a 0e1000: unicast match[0]: 52:54:00:12:34:56input: 0000   5254 0012 3456 5255  0a00 0202 0806 0001input: 0010   0800 0604 0002 5255  0a00 0202 0a00 0202input: 0020   5254 0012 3456 0a00  020f 0000 0000 0000input: 0030   0000 0000 0000 0000  0000 0000 0000 0000

  以input开头的行是QEMU的ARP回应的内容。
  为了更完善地测试你的收包代码,JOS准备了1个echosrv守护进程,你可以运行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-echosrv来启动该服务器,并在另外1个中断运行make nc-7来连接服务器。当连接上后,你在客户端的输入会有回显。
   到目前为止,我们已经实现了完整的发包和收包流程。

The Web Server
   1个简答的web服务器将发送1个文件内容给请求客户端。JOS已经在user/httpd.c文件中提供可骨架代码,处理socket连接和Http头转义。
   Exercise 13:
   实现user/httpd.c文件中的send_file函数和send_data函数。
   回答:
  关于http协议这里不描述了,里面的代码主要是处理socket连接和Http头的转义。主要工作是判断文件存不存在,不存在发送404错误;是否是1个目录,如果是则发送404错误。设置文件大小,读取文件内容并发送。

static intsend_file(struct http_request *req){    int r;    off_t file_size = -1;    int fd;    struct Stat st;    if ((fd = open(req->url, O_RDONLY)) < 0)        return send_error(req, 404);    if ((r = fstat(fd, &st)) < 0)        return send_error(req, 404);    if (st.st_isdir)        return send_error(req, 404);    file_size = st.st_size;    if ((r = send_header(req, 200)) < 0)        goto end;    if ((r = send_size(req, file_size)) < 0)        goto end;    if ((r = send_content_type(req)) < 0)        goto end;    if ((r = send_header_fin(req)) < 0)        goto end;    r = send_data(req, fd);end:    close(fd);    return r;}static intsend_data(struct http_request *req, int fd){    char buf[128];    int r;    while(1){        r = read(fd, buf, 128);        if(r <= 0)            return r;        if(write(req->sock, buf, r) != r)            return -1;    }}

  关于httpd的工作流程暂时不分析。
  到此就完成了整个LAB6的内容,的确比之前的LAB要花费更多地精力。