bookstack

实验手册

介绍

这个实验是实现网络驱动,网卡幸好是Intel 82540EM,也被称为 E1000

开始

跟之前一样,没啥好说的,就是切换分支和合并分支

除了写一个驱动之外,你还需要创建一个系统调用接口来访问驱动。你将实现缺少的网络服务代码来转换网络栈和驱动之间的包。通过完成网络服务器,你会把所有的东西联系在一起。

大部分内核驱动代码都需要自己完成。这个实验比之前的代码提示要少一点:没有骨架文件,没有系统调用接口也没有很多设计结论给到你。所以,我们建议你在开始任何练习之前完整阅读要求。

QEMU虚拟网络

我们将使用QEMU的用户模式网络栈,因为他不需要管理员权限运行。QEMU的文档有更多关于用户网络,我们已经更新了makefile,来启用QEMU的用户模式网络栈和虚拟E1000网卡

QEMU提供一个默认地址为10.0.2.2的虚拟路由,分配JOS的IP地址为10.0.2.15。为了让事情简单,我们在网络服务器net/ns.h中写死了这些默认值

然而QEMU的虚拟网络允许JOS随意连接到互联网,JOS的10.0.2.15地址对于QEMU虚拟网络之外是没有意义的(也就是说QEMU的表现形式是NAT)。所以我们不能直接连接运行在JOS内部的服务器。为了能够访问,我们配置QEMU,在宿主机的端口上运行一个服务器,这个服务器只需连接JOS的某个端口,并在真实的主机和虚拟网络之间传输数据

你将运行JOS服务器在端口7(echo)和80(http)上。为了找到QEMU的哪个端口指向开发机,运行make which-ports。为了方便,makefile提供了make nc-7make nc-80,允许你在terminal上直接和运行在这个端口上的服务器通信(这个目标仅仅是连接到运行QEMU实例,你必须单独运行QEMU)

包检测

makefile配置了QEMU网络栈来记录所有进出包,在实验目录的qemu.pcap

使用tcpdump可以拿到hex/ASCII

tcpdump -XXnr qemu.pcap

也可以使用Wireshark来图形化分析抓包文件。Wireshark包含上百种网络协议的解包

调试E1000

因为E1000运行在软件中,仿真的E1000以可阅读的形式报告给我们,它内部状态和发生的任何问题。通常这是对于驱动开发来说是非常奢侈的

E1000能产生调试输出,所以你必须启用指定的日志通道。可能用到的通道如下:

FlagMeaning
txLog packet transmit operations
txerrLog transmit ring errors
rxLog changes to RCTL
rxfilterLog filtering of incoming packets
rxerrLog receive ring errors
unknownLog reads and writes of unknown registers
eepromLog reads from the EEPROM
interruptLog interrupts and changes to interrupt registers.

例如,使用make E1000_DEBUG=tx,txerr ...来启用txtxerr日志

你可以进一步使用软件模拟硬件进行调试。如果你不理解为什么E1000没有按照期望的运行,你可以查看QEMU的E1000实现,在hw/net/e1000.c文件中

网络服务器

写一个网络栈是非常困难的。然而,我们使用lwIP,一个开源轻量TCP/IP协议。你可以在l找到更多的信息。这个作业,正如你关心的一样,lw是一个黑盒,它实现了BSD套接字接口和一个包输入/输出端口

网络服务器实际上是四个环境组合:

  • 核心网络服务器环境(包括套接字所有分发和lwIP)
  • 输入环境
  • 输出环境
  • 定时器环境

下面插图显示了不同环境之间的关系

核心网络服务器环境

核心网络服务器环境是由所有套接字调用分发者和lwIP组成。套接字调度器和文件服务器工作类似。用户环境使用根(lib/nsipc.c)发送IPC消息给核心网络环境。如果你查看lib/nsipc.c,你可以看到核心网络服务器和文件服务器一样:i386_init创建带有NS_TYPE_NS的环境,所以我们扫描envs,找到特殊的环境类型。对于每个用户环境IPC,网络服务器的调度器调用合适BSD套接字接口函数

常用的用户环境不使用nsipc_*直接调用。相反,他们使用lib/sockets.c中的函数,它提供了一个基于文件描述符的套接字API。因此,用户环境通过文件描述符指向套接字,就像指向磁盘上的文件一样。大量的操作(connect, accept等)是指定套接字的,但是read, writeclose是通过普通的文件描述符lib/fd.c完成的。和文件服务器为所有打开的文件保留唯一ID一样,lwIP也生成唯一ID为所有打开的套接字。在文件服务器和网络服务器中,我们使用保存在struct Fd中的信息来映射每个环境文件描述符到这些唯一ID空间

尽管文件服务器和网络服务器的IPC调度器行为一样,但是还有一个关键的区别。BSD套接字调用像acceptrecv能无限阻塞。如果调度器允许lwIP执行阻塞调用中的一个,调度器也会阻塞,整个系统,同一个时间只有一个网络调用。因为这是不能接受的,所以网络服务器使用用户级别的线程来避免阻塞整个服务环境。对于每个到达的IPC消息,调度器创建一个线程,在线程中处理请求。如果线程阻塞,只有那个线程进入休眠,其他线程会继续执行

除了核心网络环境之外,也有三个辅助环境,除了从用户程序接收消息,核心网络环境调度器也从输入和时间环境接收消息

输出环境

当正在服务用户环境的套接字调用时,lwIP会为网卡生成包数据.lwIP发送每个包给输出辅助环境,使用NSREQ_OUTPUT的IPC消息,包数据会在IPC的页参数带上。输出环境负责接收这些消息,通过你创建的系统调用接口分发给设备驱动

输入环境

通过网卡接收到的包,需要注入到lwIP中,对于每一个设备驱动接收到的包,输入环境从内核空间拉出包(使用你需要实现的内核系统调用),然后发送包数据到核心服务环境,使用NSREQ_INPUT的IPC消息

包输入功能与核心网络环境分离,因为JOS很难同时接收IPC消息,同时轮询或等待设备驱动数据包也很困难。在JOS中,我们没有select系统调用,这个系统调用可以监听多个输入源来区分哪个输入准备进行了

如果你阅读过net/input.cnet/output.c,你会发现这两个都需要你实现。这是主要的,因为实现决定了系统调用接口。在你实现了驱动和系统调用接口之后,你需要为两个辅助环境编写代码

定时器环境

定时器环境周期性发送NSREQ_TIMER类型消息给核心网络服务器,通知一个定时器已经过期了。来自线程的定时器消息被lwIP用来实现各种各样的网络超时

Part A: 实现和转发包

你的内核代码没有时间概念,所以你需要添加。现在有时钟中断,这是由硬件每10ms生成的。每个时钟中断,我们可以递增变量来表明时间已经提前了10ms。这个实现在kern/time.c,但是没有植入到你的内核中

进行练习1

网卡

写一个驱动需要深度了解硬件和给软件提供的接口。这个实现文本提供E1000接口概览,但是当你写自己的驱动时,你需要阅读Intel手册。

进行练习2

PCI接口

E1000是一个PCI设备,意味着可以插拔到主板的PCI总线。PCI总线有地址,数据和中断线,并且允许CPU和PCI设备交流,PCI设备读取内存。一个PCI设备在使用之前需要被发现和初始化。发现是一个扫描PCI总线查找附属设备的进程。初始化是分配I/O和内存空间的进程,同时分配IRQ线给设备使用

我们已经在kern/pci.c提供了PCI代码。为了在启动的时候初始化PCI,PCI代码遍历PCI总线,查找设备。当它找到了设备,它读取供应商ID和设备ID,使用这两个值作为key来搜索pci_attach_vendor数组。数组是由struct pci_driver组成的入口

struct pci_driver {
    uint32_t key1, key2;
    int (*attachfn) (struct pci_func *pcif);
};

如果在数组中发现了设备的供应商ID和设备ID,PCI代码调用入口的attachfn来进行设备初始化。(设备也能通过类来标识,这就是kern/pci.c驱动表的作用)

附属函数传递了一个PCI函数来初始化。PCI卡能够表现多个函数,尽管E1000只表现一个。下面是JOS的PCI函数

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的入口。struct pci_func最后三个入口是我们最感兴趣的,因为他们记录了内存,I/O和中断资源。reg_basereg_size数组包含六个基本地址寄存器(Base Address Registers)。reg_base为内存映射I/O区域保存了基本内存地址(或I/O端口资源基本I/O端口), reg_size包含了字节大小或I/O端口数量, irq_line包含了分配给设备中断的IRQ线。E1000的BARs特殊定义在表4-2中给出了

当设备的附属函数被调用时,设备已经被发现但是没有启用。这意味着PCI代码没有决定分配给设备的资源,比如地址空间和IRQ线,因此,struct pci_func最后三个元素还没有被填充。附加函数应该调用pci_func_enable,这个可以启用设备,分配资源填充struct pci_func

进行练习3

内存映射I/O

软件通过内存映射I/O方式和E1000进行交流。在之前的JOS中,你已经看到了两点:CGA控制台和LAPIC都是从内存读写的设备。但是这些读写操作没有到DRAM;他们直接操作这些设备

pci_func_enable约定一个MMIO区域给E1000,并在BAR 0的位置保存基本值和大小(也就是reg_base[0]reg_size[0])。这是分配给设备的物理内存地址,也就是说你必须通过虚拟地址来访问它。因为MMIO区域被分配在高物理地址(通常大于3GB),你不能使用KADDR来访问,因为JOS内存为256MB。因此你必须创建一个新内存映射。我们使用这个区域在MMIOBASE以上(确保lab4中mmio_map_region没有覆盖LAPIC的映射)。因为PCI设备初始化是在JOS创建用户环境之前,所以你可以在kern_pgdir创建映射,并一直都是可用的

进行练习4

提示:你需要使用大量常量,比如寄存器位置和位掩码值。尝试复制出开发手册是很容易出错的,可能会造成严重的崩溃。我们建议使用QEMU的e1000_hw.h作为引导手册。我们不建议全部复制,因为它定义了很多你不需要的变量,但是是一个很好的参考

DMA

你可能觉得通过E1000寄存器读写来发送和接收包,但是这可能会很慢并且需要E1000缓存包数据。相反,E1000使用DMA(Direct Memory Access)来直接从内存读写包,不需要CPU参与。驱动负责为发送和接收队列分配内存,设置DMA描述符和配置E1000队列位置,但是在这之后,所有的事情就是异步的。为了发送包,驱动复制它到发送队列队列中下一个DMA描述符,并标记E1000的下一个包是可用的,需要发送包的时候,1000会从描述符拷贝数据出来。相反,当E1000接收包时,会拷贝到下一个接收队列中下一个DMA描述符,驱动下一次从这个地方读取

接收和发送队列在高层次上是相似的。都是由描述符序列组成的,然而这些描述符的结构体并不相同,每个描述符包含一些标志和物理地址(含有包数据的缓存),要么是网卡发送的数据包,要么是由OS分配的将要接收包数据的缓存

队列是由循环数组实现的,意味着当网卡或者驱动到达数组尾部的时候,它会从头继续开始。含有头指针和尾指针,队列的内容是两个指针之间的描述符。硬件总是从头部消费描述符然后移动头指针,然而驱动总是在尾部添加描述符然后移动指针。描述符在发送队列中存在等待发送的包(因此,在稳定状态下,发送队列是空的)。对于接收队列,队列中的描述符是空闲描述符,网卡能接收包数据到描述符中(因此,在稳定状态下,接收队列是由所有可用的接收描述符组成的)。在不让E1000迷惑的情况下,正确地更新尾部寄存器是棘手的,注意

这些在描述符中的数组的指针和包缓存地址必须是物理地址,因为硬件直接从物理的RAM操作DMA,不需要经过MMU

发送包

E1000发送和接收函数是基本独立的,所以我们可以同时操作。我们将首先攻击传输数据包,因为在没有发送"I’m here!" 数据包之前,不能测试接收

首先,你必须初始化网卡来发送,跟着14.5中的步骤(你不必担心子章节)。发送初始化第一步是设置发送队列。队列的结构在3.4节描述了,并且描述符的结构体在3.3.3章节描述了。我们不使用E1000的TCP卸载功能,所以我们不能关注"传统发送描述符格式".你应该阅读这些章节,熟悉这些结构体

C结构体

你会发现使用C结构体描述E1000的结构是非常方便的。正如你看到像struct Trapframe一样,C结构体让你精确了解内存中的结构数据。C能够在字段之间插入padding,但是E1000的布局让这些不成问题。如果你发现了字段对其问题,看看GCC的"packed"属性

例如,考虑一下手册中表3-8给出的传统发送描述符:

 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;
    uint8_t special;
};

你的驱动必须为发送描述符数组和由发送描述符指向的包缓存保留内存。有一些方法来做到这些,动态分配页面或申明一个全局变量。无论你选择哪种方法,永远记住E1000直接访问物理内存,意味着访问的任何缓存在物理内存中必须是连续的

也有多种方法处理包缓存。最简单的,也是我们开始推荐的,是在驱动初始化过程中,为每个描述符的包缓存保留空间,然后仅仅拷贝包数据进或出预先分配的缓存。Ethernet包最大的大小是1518字节,这限制了需要多大的缓存。一些复杂的驱动可能动态分配包缓存(例如,为了在用网络时,降低内存)或者甚至直接由用户空间提供缓存(零拷贝技术),但是最好是从简单的开始

进行练习5

既然发送已经初始化了,我们必须写代码发送数据,然后通过系统调用来访问。为了发送数据包,我们必须把它夹到发送队列的尾部,这意味着复制包数据到下一个包缓存,然后更新TDT(transmit descriptor tail)寄存器来通知网卡,发送队列有数据了(注意TDT是发送描述符数组的索引,而不是字节偏移;文档中没有说明这一点)

然而,发送队列也是很大的。如果网卡在发送包数据之后坏了并且发送队列满了,会发生什么呢?为了检测这个条件,你需要一些来自E1000的反馈。不幸的是,你不能仅仅使用TDH(transmit descriptor head)寄存器;文档明确的说明了从软件读取这个寄存器是不可信的。然而如果你在发送描述符的命令位置,设置RS位,然后当网卡在这个描述符发送包的时候,网卡会设置这个描述符的DD位。如果描述符的DD位被设置了,你知道循环描述符并使用它发送另一个包是安全的。

如果用户调用你的发送系统调用,但是DD位没有被设置,是不是表明发送队列满了呢?你必须决定这种情况下做什么。你可能简单丢掉这个包。网络协议是有弹性的,但是如果你丢了一个大的包,协议可能不会恢复。你可能告诉用户环境必须重试,就像你在sys_ipc_try_send中一样。这对于环境生成的数据发送包是有好处的

进行练习6

进行练习7

发送包:网络服务器

既然已经有发送的系统调用接口了,是时候发送包。输出辅助环境的目标就是做以下循环:从网络服务器接收NSREQ_OUTPUT的IPC信息,然后使用系统调用发送包到网络设备驱动。NSREQ_OUTPUT的IPC通过在net/lwip/jos/jif/jif.clow_level_output函数发送,这个包含了JOS的网络系统lwIP协议栈。每个IPC会包含一个页面union Nsipcstruct jif_pkt pktstruct jif_pkt长这样

struct jif_pkt {
    int jp_len;
    char jp_data[0];
}

jp_len表示包的长度,IPC页面上的所有子序列字节表明包的内容。在结构体末尾使用一个0长度的数组比如jp_data是C语言的一个小把戏。因为C没有做数组界限检查,只要你确保有足够没有使用的内存,你就可以使用jp_data用作任何大小的数组。

当设备驱动的发送队列没有空间的时候,注意设备驱动,输出环境和核心网络服务器之前的交互核心网络服务器用IPC发送包给输出环境。如果输出环境由于发送包系统调用暂停,因为驱动没有更多空闲的空间,核心网络服务器会阻塞等待输出服务器接收IPC调用

进行练习8

Part B:接收包和网络服务器

接收包

和发送包一样,你必须配置E1000来接收数据包,并提供一个接收描述符队列和接收描述符。3.2节描述了包接收是怎么工作的,包括接收队列的结构体和接收描述符,以及初始化进程的细节在14.4节

进行练习9

接收队列和发送队列相似,除了它是一个空队列,在等待进来的包填充。因此当网络空闲的时候,发送队列是空的(因为所有的包都已经被发送了),但是接收包是满的

当E1000接收到一个包时,它首先校验网卡配置的过滤器(例如,是不是把包发送给E1000的MAC地址)并且忽略包,如果没有过滤成功。否则E1000尝试从头部接收包。如果头部(RDH)到了尾部(RDT),接收队列在空闲描述符之外了,所以网卡就丢弃这个包。如果有一个空闲接收描述符,它拷贝包数据到缓存,通过描述符指定,设置描述符完成和包尾部位,然后增加RDH

如果E1000接收包大于包缓存,它会尝试更多的描述符来保存整个包内容。为了表明这个已经实现了,他会设置DD位在所有的描述符上,但是只有EOP状态在最后一个描述符上设置。

进行练习10

进行练习11

进行练习12

网络服务器

提供一个发送文件的服务器,主要是user/httpd.c的函数

进行练习13