MIT6.828_LAB6_Part A: Initialization and transmitting packets

Lab 6: Network Driver

Introduction

本实验室默认能自主完成的最后一个项目,现在我们已经有了一个文件系统,
本实验中我们将为网卡接口编写驱动程序,该卡基于Intel 82540EM芯片,也称为E1000。

Getting Started

但是,网卡驱动程序还不足以使JOS连接到Internet。 在新的lab6代码中,我们为您提供了网络堆栈和网络服务器。 与以前的实验一样,使用git获取该实验的代码,合并到您自己的代码中,并浏览新目录net中的内容以及kern /中的新文件。
除了编写驱动程序之外,还需要创建一个系统调用接口来提供对驱动程序的访问权限。 我们将实现缺少的网络服务器代码,以在网络堆栈和驱动程序之间传输数据包。 还将通过完成Web服务器将所有内容连接在一起。 使用新的Web服务器,我们将能够从文件系统提供文件。
许多内核设备驱动程序代码我们都得从头开始编写。 与以前的实验相比,本实验提供的指导要少得多:没有框架文件,没有任何固定的系统调用接口,许多设计决策都由自己决定。 因此,建议在开始任何练习之前,先阅读完整的实验内容。
我们将使用QEMU的用户模式网络堆栈,因为它的运行不要求任何管理权限。我们已经更新了makefile,以启用QEMU的用户模式网络堆栈和虚拟E1000网卡。
默认情况下,QEMU提供一个在IP地址 10.0.2.2上运行的虚拟路由器,并将为JOS分配IP地址10.0.2.15。 为简单起见,我们将这些默认值硬编码到net / ns.h中的网络服务器中。
尽管QEMU的虚拟网络允许JOS进行到Internet的任意连接,但JOS的10.0.2.15地址在QEMU内部运行的虚拟网络外没有任何意义(即QEMU充当NAT,NAT应该是指网络地址转换,这里没意义应该是说10.0.2.15是一个专用网络中的本地ip,在外网无意义),因此我们在运行JOS时,无法直接连接到服务器 即使是在运行QEMU的主机中。 为了解决这个问题,我们将QEMU配置为在主机的某个端口上运行服务器,该服务器仅连接到JOS中的某个端口,并在真实主机和虚拟网络之间来回穿梭数据。
你会运行JOS 服务器在ports 7(echo) 和 80 (http)。可以键入make which-ports来查找QEMU转发到你的开发主机上的那些端口。为了方便,makefile也提供make nc-7和make nc-80,它们允许你跟运行在你终端的这些端口的服务器直接交互。

Packet Inspection

makefile同样配置了QEMU的网络堆栈以记录所有的输入/输出包到lab目录下的qemu.pcap.
想要得到一个hex/ASCII dump(转储)的被捕获数据包,可以键入如下指令:

tcpdump -xxnr qemu.pcap

Debugging the E1000

我们能够使用模拟硬件其实很幸运,因为E1000是运行在软件中的,模拟的E1000能够以一种用户可读的格式告诉我们它的内部状态和它遇到的所有问题,正常情况下,这对一个驱动开发者来说很奢侈。
E1000能够产生很多调试输出,所以必须启用特定的logging channels,一些channels可能比较有用:

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命令。

The Network Server

从头开始写一个网络堆栈是很难的,相反我们将使用lwIP,一个开源的轻量级的TCP/IP协议套件,其中包含了一个网络堆栈,你可以在此处查看更多关于lwIP的信息,在这个实验中,lwIP对于我们来说是个黑箱,它实现了一个BSD socket接口并具有封包输入端口和封包输出端口。网络服务器实际上由四个进程组成:

sore network server environment (includes socket call dispatcher and lwIP)
input environment
output environment
timer environment

下图展示了这些不同进程间的关系,和包括设备驱动在内的整个系统,稍后会对此介绍,本实验中,我们将实现下图中标记为绿色的部分:
MIT6.828_LAB6_Part A: Initialization and transmitting packets_第1张图片

The Core Network Server Environment

核心网络服务器进程由the socket call dispatcher和lwIP组成,socket call dispatcher的工作方式类似于文件服务器,用户进程使用stubs来向核心网络进程发送消息,阅读lib/nsipc.c,你会发现我们查找核心网络服务器的方式和我们查找文件服务器的方式是相同的,i386_init中以NS_TYPE_NS创建了NS进程,对每个用户进程IPC,网络服务器上的调度程序会代表用户调用合适的lwIP提供的BSD socket接口函数.
普通用户进程不能直接使用nsipc_ *类函数,相反,它们使用lib/sockets.c中的函数,这些函数提供了一个基于文件描述符的sockets API,因此用户进程通过文件描述符引用sockets,正如使用文件描述符引用硬盘中的文件一样,有些操作是socket特有的,如connect,accept等等,有些是通用的如read,write,close等等,类似于文件服务器为每个打开文件维护一个内部唯一的id一样,lwIP同样会为打开的套接字维护一个唯一的id,在文件服务器和网络服务器中,我们使用存储在struct Fd中的信息将每个进程的文件描述符映射到这些惟一的ID空间。

文件服务器和网络服务器的IPC调度程序的行为相同,但还是有一个关键区别。BSD sockets calls 比如 accept和recv 可以无限期地阻塞。如果dispatcher(调度器)让lwIP执行这些阻塞调用中的一个,调度器也会阻塞,对于整个系统,一次只能有一个未完成的网络调用。对于每个传入的IPC消息,dispatcher创建一个线程,并在新创建的线程中处理请求。如果线程阻塞,则只有该线程处于休眠状态,而其他线程继续运行。
除了核心网络进程之外,还有三个辅助进程。除了接受来自用户应用程序的消息外,核心网络进程的调度器还接受来自input和timer进程的消息。

The Output Environment

当服务于用户进程sockets calls时,lwIP将生成输出包供网卡传输。LwIP将使用NSREQ_OUTPUT IPC消息将每个要传输的包发送到输出辅助进程,这个输出包会装入到IPC消息的页面参数中。输出辅助进程负责接收这些IPC消息,并通过您即将创建的系统调用接口将包转发到设备驱动程序。

The Input Environment

网卡接收到的数据包需要注入到lwIP中。对于设备驱动程序接收到的每个包,输入进程将包从内核空间中取出(使用您将实现的内核系统调用),并使用NSREQ_INPUT IPC消息将包发送到核心网络进程。 将输入包的功能与核心网络进程分离是因为JOS使得同时接受IPC消息和轮询或等待来自设备驱动程序的包变得困难。JOS中没有select系统调用,该调用允许环境监视多个输入源,以确定哪些输入已准备好被处理。
查看net/input.c和net/output.c,你会发现这两者都需要实现。该实现主要依赖你的系统调用接口。在实现驱动程序和系统调用接口之后,你将为这两个辅助进程编写代码。

The Timer Environment

timer进程定期向核心网络服务器发送NSREQ_TIMER IPC消息,通知它一个计时器已经过期。lwIP使用来自这个线程的计时器消息来实现各种网络超时。

Part A: Initialization and transmitting packets

我们首先要为jos内核添加时钟的概念,内核中每10ms都会由硬件产生一个时钟中断,每次时钟中断时我们可以增加一个变量的计数来指出时间已经过去了10ms,这部分的实现在kern/time.c中,但是尚未集成到内核中。
练习1:在kern/trap.c中为每次时钟中断添加一个对time_tick的调用,实现sys_time_msec并且在kern/syscall.c中加入该系统调用,这样用户空间就能访问时间。键入make INIT_CFLAGS=-DTEST_NO_NS run-testtime来测试你的代码. 你应当看到进程在1秒内从5开始倒数,“-DTEST_NO_NS”会禁用启动网络服务器进程,因为它会引起panic。
代码如下,syscall.c:

case(SYS_time_msec):
  return sys_time_msec();

trap.c:

if(tf->tf_trapno == IRQ_OFFSET+IRQ_TIMER)
 { 
  lapic_eoi();
  time_tick();
  sched_yield();
 }

练习1很简单,没啥好讲的。
测试通过:

在这里插入图片描述

The Network Interface Card

编写一个驱动要求对硬件和提供给上层软件的接口有深度了解,该实验文本将提供有关如何与E1000进行交互的高级概述,但是在编写驱动程序时需要充分利用Intel的手册。
练习2.浏览E1000的英特尔软件开发人员手册。本手册涵盖了几个紧密相关的以太网控制器。 QEMU模拟82540EM。
略读第2章,以对该设备有所了解。要编写驱动程序,还需要熟悉第3章和第14章以及4.1(尽管不是4.1的小节)。你还需要参考第13章。其他章节主要介绍你的驱动程序无需与之交互的E1000组件。不用担心细节;只需了解文档的结构,以便以后查找。
阅读手册时,请记住E1000是具有许多高级功能的复杂设备。有效的E1000驱动程序仅需要NIC提供的部分功能和接口。请仔细考虑与网卡接口交互的最简单方法。

PCI Interface

E1000是一个PCI设备,这意味着它能插入到主板上的PCI总线上,PCI总线有地址,数据和中断线,并且允许cpu与PCI设备进行通信,允许PCI设备读写内存。
一个PCI设备在被使用前首先要被发现和初始化,发现指等待PCI总线查找附加的PCI设备的过程,初始化是分配I/O和内存空间,也是为PCI设备分配中断请求线(IRQ LINE)的过程.
PCI是外围设备互连(Peripheral Component Interconnect)的简称,是在目前计算机系统中得到广泛应用的通用总线接口标准:

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

当我们想要查询1个特定PCI设备的配置空间时,需要向I/O地址[0cf8,0cfb]写入1个4字节的查询码指定总线号:设备号:功能号以及其配置地址空间中的查询位置。PCI Host Bridge将监听对于这个I/O端口的写入,并将查询结果写入到[0cfc,0cff],我们可以从这个地址读出1个32位整数表示查询到的相应信息。
课程中已经在kern/pci.c中给出了PCI的相关代码,为了在boot阶段执行PCI初始化,PCI代码会先等待PCI总线查找设备,当找到一个设备时,便会读取它的供应商ID和设备ID,然后使用这两个值作为键值来搜索pci_attach_vendor数组,该数组由pci_drive结构体组成:

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

如果在该数组中找到了供应商id与设备id都匹配的一项,PCI代码会调用表项的attachfn函数来执行设备的初始化(设备也可以通过class来识别,这是在kern/pci.c中的另一个驱动表的作用)
attachfn会被传递一个PCI函数来初始化,一张PCI卡能拥有许多功能,但E1000中只有一个,下面是JOS中的一个PCI功能的结构:

struct pci_func {
struct pci_bus *bus;
int32_t dev;
uint32_t func;
int32_t dev_id;
uint32_t dev_class;
uint32_t reg_base[6];
uint32_t reg_size[6];
uint8_t irq_line;
};

上面的结构反映了开发人员手册4.1节Table 4-1中的几项。struct pci_func的最后三项尤其让我们感兴趣,因为它们记录了设备的内存, I/O, 和中断源等相关信息。reg_base和reg_size数组包含最多六个基址寄存器的信息。reg_base存储内存映射I/O区域的基内存地址或基I/O端口,reg_size包含对应的reg_base基址的字节大小或I/O端口数量,irq_line包含分配给设备用于中断的IRQ线。
当一个设备的attach函数被调用时,该设备已经被找到了但还不能使用,这意味着PCI代码尚未决定把资源(如地址空间,IRQ线)分配给设备,因此,pci_func结构体的最后三项也还未被填充,attach函数应该调用pci_func_enable来分配相应的资源,填充pci_func结构体,以使能该设备。

练习3.
实现一个attach函数来初始化E1000,在kern/pci.c的pci_attach_vendor数组中添加一个表项以使当PCI设备被找到时触发该attach函数,在手册5.2节中你能找到82540EM的供应商ID和设备ID,现在只使用pci_func_enable来初始化E1000设备,其他初始化工作放在实验后面。
课程提供了kern / e1000.c和kern / e1000.h文件,这样就无需弄乱构建系统。 它们目前为空; 此练习中将需要填写它们。 可能还需要在内核的其他位置包含e1000.h文件。
引导内核时,应该看到它显示E1000卡的PCI功能已启用。 代码现在应该通过了make grade的pci attach测试。

E1000的供应商ID和设备ID:

MIT6.828_LAB6_Part A: Initialization and transmitting packets_第2张图片

设备功能的配置空间布局:

MIT6.828_LAB6_Part A: Initialization and transmitting packets_第3张图片

系统初始化PCI设备的流程如下:
首先在i386_init中调用pci_init将总线数据清0,然后调用pci_scan_bus扫描0号总线,并初始化0号总线上的PCI设备:

MIT6.828_LAB6_Part A: Initialization and transmitting packets_第4张图片
pci_scan_bus流程见注释:
MIT6.828_LAB6_Part A: Initialization and transmitting packets_第5张图片
结合上面给出的设备功能配置空间图读一遍pci.c的流程后,思路就很清晰了。

练习3代码如下:
e1000.c:

// LAB 6: Your driver code here
int  PCI_E1000_Driver(struct pci_func *pcif)
{
 
 pci_func_enable(pcif);
 return 0;
}

e1000.h:

#define E1000_DEV_ID 0x100E
#define E1000_VENDOR_ID 0x8086
#include 
int PCI_E1000_Driver(struct pci_func *pcif);

pci.c:

struct pci_driver pci_attach_vendor[] = {
 {E1000_VENDOR_ID,E1000_DEV_ID,&PCI_E1000_Driver},
 { 0, 0, 0 },
};

测试通过:
在这里插入图片描述

Memory-mapped I/O

软件通过内存映射IO(MMIO-统一编址)和E1000网卡进行通信。我们已经在JOS两次见到过它了:对于CGA和LAPIC都是通过直接读写“内存”来控制和访问的。但是这些读写操作并不经过DRAM的,而是直接对设备的IO空间进行操作。
 pci_func_enable为E1000网卡分配了一个MMIO区域,并且将它的基地址和大小存储在了reg_base[0]和reg_size[0]中。这是一段为设备分配的物理地址,意味着需要通过虚拟内存访问它。因为MMIO区域通常都被放在非常高的物理地址上(通常高于3GB),因此我们不能直接使用KADDR去访问它,因为JOS 256MB的内存限制。所以我们需要建立一个新的内存映射。我们将会使用高于MMIOBASE的区域(lab4中的mmio_map_region将会保证我们不会复写LAPIC的映射)。因为PCI设备的初始化发生在JOS创建用户之前,所以我们可以在内核页表中建立映射,从而保证它永远可用。
练习 4:
 在E1000网卡的初始化函数中,通过调用mmio_map_region函数来为E1000网卡的BAR0建立一个虚拟内存映射。你需要使用1个变量记录下该映射地址以便之后可以访问映射的寄存器。查看在kern/lapic.c中的lapic变量,效仿它的做法。假如你使用1个指针指向设备寄存器映射地址,那么你必须声明它为volatile,否则编译器会运行缓存该值和重新排序内存访问序列。
  为了测试你的映射,可以尝试将网卡设备状态寄存器的内容打印出来,该寄存器大小为4个字节,地址偏移量为0x08,打印值应为0x80080783,表示全双工1000MB/S。

E1000相关寄存器的常量宏定义可以参考课程中给出的文件。
MMIO_MAP_REGION中会在MMIO区域对应的虚拟地址的页表项中设置对应的权限位来标志这是一个内存映射I/O区域:

在这里插入图片描述

代码如下:

volatile uint32_t* E1000;
// LAB 6: Your driver code here
int  PCI_E1000_Driver(struct pci_func *pcif)
{
 
 pci_func_enable(pcif);
 //将E1000的I/O空间映射到虚拟地址MMIOBASE之上
 //MMIO_MAP_REGION中在建立映射插入页目录表项和页表项时会设置相关权限位来标志这是一个内存映射I/O
 E1000 = mmio_map_region(pcif->reg_base[0],pcif->reg_size[0]); 
 cprintf("E1000 STATUS: %x\n", E1000[E1000_STATUS/4]);
 return 1;
}

测试通过:
在这里插入图片描述

DMA

你能想象通过对E1000中的寄存器进行读写来发送和接受数据包,但是这种方式很慢并且要求E1000在内部缓冲打包书,相反,E1000使用直接内存访问(DMA)来绕过cpu直接对内存进行读写。驱动负责为发送和接收队列分配内存,设置DMA描述符,并且使用这些队列的位置写入到E1000的配置信息中,但是之后的一切是异步的。 要发送数据包,驱动程序将其复制到传输队列中的下一个DMA描述符并通知E1000另一个数据包可用,当发送数据包时,E1000又会将数据从描述符中拷贝出来,同样地,当E1000接收一个数据包时,该数据包被复制到接收队列的下一个DMA描述符,下一次时机到来时,驱动程序可以从DMA描述符中读取该包。
接收和发送队列从高层次看非常相似,都有一系列的描述符组成,尽管这些描述符的确切结构有所不同,但每个描述符都包含一些标识位和存储数据包的的缓冲区的物理地址(供网卡发送的数据包或者OS分配给网卡的用于写入接收数据包的缓冲区)。
队列由循环数组构成,这表示当网卡或者驱动到达了数组的末尾时,它又会转回数组的头部。每个循环数组都有一个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发送描述符格式。

C Structures

你会发现用C语言的struct很好描述E1000的结构,就像以前见过的trapFrame一样,C语言的struct能让你准确地在内存上布局数据,C会在结构的各个元素间插入空白用于对齐,但是对于E1000里的结构这都不是问题。例如,传统的发送描述符如下图所示:

MIT6.828_LAB6_Part A: Initialization and transmitting packets_第6张图片

该结构的第一个字节在最右上方,将它转换成一个C语言的struct,从右往左读,从上往下读,你会看到该结构体刚好是对齐的,不会有空白填充。

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个byte,这就表明了这些缓冲区至少要多大。更加复杂的驱动可以动态地获取数据包缓冲区(为了降低网络使用率比较低的时候带来的浪费)或者直接提供由用户空间提供的缓冲区,不过一开始简单点总是好的。

练习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节的描述,可以将发包初始化的过程分为以下几步:
1.为发送描述符数组分配一块内存,内存应当按16字节对齐
2.根据分配内存的地址设置TDBAL和TDBAH寄存器,TDBAL用于32位地址,而TDBAL和TDBAH用于64位地址
3.根据发送描述符数组的大小设置TDLEN寄存器,TDLEN寄存器的数值应当是128的倍数
4.发送描述符数组头/尾寄存器(TDH/TDT)应被初始化为0
5.设置发送控制(TCTL)寄存器:
5.1 设置使能位(TCTL.EN)为1
5.2 设置TCTL.PSP位为1
5.3 将TCTL.CT设置成合适的值,以太网标准为10h,该设置仅在半双工下有意义。
5.4 设置TCTL.COLD,全双工时,设置为40h,千兆级半双工时设置为200h,10/100半双工时,设置为40h,此处可以默认为是全双工的
5.5 设置TIPG寄存器来获得最小合法包间的间距(参考13.4.34章节而非14.5章节)
TIPG应该设置的值参考下图:
MIT6.828_LAB6_Part A: Initialization and transmitting packets_第7张图片
MIT6.828_LAB6_Part A: Initialization and transmitting packets_第8张图片

e1000.h:

#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H
#endif  // SOL >= 6

#define E1000_DEV_ID	0x100E
#define E1000_VENDOR_ID 0x8086
#define E1000_STATUS   0x00008  /* Device Status - RO */
#define E1000_TDBAL    0x03800  /* TX Descriptor Base Address Low - RW */
#define E1000_TDBAH    0x03804  /* TX Descriptor Base Address High - RW */
#define E1000_TDLEN    0x03808  /* TX Descriptor Length - RW */
#define E1000_TDH      0x03810  /* TX Descriptor Head - RW */
#define E1000_TDT      0x03818  /* TX Descripotr Tail - RW */
#define E1000_TIDV     0x03820  /* TX Interrupt Delay Value - RW */
#define E1000_TXDCTL   0x03828  /* TX Descriptor Control - RW */
//TCTL 
#define E1000_TCTL     0x00400  /* TX Control - RW */
#define E1000_TIPG     0x00410  /* TX Inter-packet gap -RW */
#define E1000_TCTL_RST    0x00000001    /* software reset */
#define E1000_TCTL_EN     0x00000002    /* enable tx */
#define E1000_TCTL_BCE    0x00000004    /* busy check enable */
#define E1000_TCTL_PSP    0x00000008    /* pad short packets */
#define E1000_TCTL_CT     0x00000ff0    /* collision threshold */
#define E1000_TCTL_COLD   0x003ff000    /* collision distance */
#define E1000_TXD_CMD_RS     0x08000000 /* Report Status */
#define E1000_TXD_CMD_EOP    0x01000000 /* End of Packet */
#define E1000_TXD_STAT_DD    0x00000001 /* Descriptor Done */

#define TX_MAX_LEN 64

#include 
int PCI_E1000_Driver(struct pci_func *pcif);

struct tx_desc //定义发送描述符,大小为16字节
{
	uint64_t addr;
	uint16_t length;
	uint8_t cso;
	uint8_t cmd;
	uint8_t status;
	uint8_t css;
	uint16_t special;
}__attribute__((packed));


struct tBuffer //定义数据包缓冲区
{
	char buffer[2048];
}__attribute__((packed));


struct tx_desc transmitRing[64]; //声明一个发送描述符数组
struct tBuffer tDescBuffer[64]; //声明一个数据包缓冲区数组

void transmitInit();

e1000.c:

#include 
#include 
#include 

volatile uint32_t* E1000;
// LAB 6: Your driver code here
//发送部分的初始化主要分为三部分工作:
//1.为设备分配一个发送描述符数组和用于缓冲发送包的缓冲区数组,并将二者绑定
//2.初始化发送描述符的一些值
//3.告诉设备发送部分的相关信息,并进行配置
void transmitInit()//定义发送初始化函数
{	
	if(!E1000) 
	{
		cprintf("E1000 == NULL, return\n");
		return;
	}
	memset(transmitRing,0,sizeof(struct tx_desc)*TX_MAX_LEN);
	memset(tDescBuffer,0,sizeof(struct tBuffer)*TX_MAX_LEN);
	for(int i=0;i>24)|(E1000_TXD_CMD_EOP>>24);//设置RS位允许网卡设置DD位
		transmitRing[i].status = E1000_TXD_STAT_DD; //设置DD位标识该描述符空闲可用
	}
	E1000[E1000_TDBAL/4] = PADDR(&transmitRing[0]); //设置TDBAL为发送描述符数组的物理首地址
	E1000[E1000_TDBAH/4] = 0; 
	E1000[E1000_TDLEN/4] = TX_MAX_LEN*sizeof(struct tx_desc); //设置TDLEN为描述符数组的大小
	E1000[E1000_TDH/4] = 0; //设置TDH和TDT为0
	E1000[E1000_TDT/4] = 0;
	E1000[E1000_TCTL/4] |= (E1000_TCTL_EN|E1000_TCTL_PSP); //设置TCTL寄存器的EN和PSP位
	E1000[E1000_TCTL/4] &= ~E1000_TCTL_CT; //设置TCTL寄存器的CT位
	E1000[E1000_TCTL/4] |= 0x10<<4;
	E1000[E1000_TCTL/4] &= ~E1000_TCTL_COLD; //设置TCTL寄存器的COLD位
	E1000[E1000_TCTL/4] |= 0x40<<12;
	E1000[E1000_TIPG/4] |= (10)|(4<<10)|(6<<20);
	
}


int  PCI_E1000_Driver(struct pci_func *pcif)
{
	
	pci_func_enable(pcif);
	//将E1000的I/O空间映射到虚拟地址MMIOBASE之上
	//MMIO_MAP_REGION中在建立映射插入页目录表项和页表项时会设置相关权限位来标志这是一个内存映射I/O
	E1000 = mmio_map_region(pcif->reg_base[0],pcif->reg_size[0]); 
	transmitInit(); 
	cprintf("E1000 STATUS: %x\n", E1000[E1000_STATUS/4]);
	return 1;
}

试着运行make E1000_DEBUG=TXERR,TX qemu。如果您正在使用qemu,那么在设置TDT register时,应该会看到一条“e1000: tx disabled”消息(因为这是在设置TCTL.EN之前发生的),并且不再有“e1000”消息。

现在传输已经初始化,接下来需要编写代码来传输数据包,并通过系统调用让用户空间能够访问它。您必须将它添加到传输队列的尾部,这意味着将发送包复制到下一个发送包缓冲区,然后更新TDT(transmit descriptor tail)寄存器,以通知网卡在传输队列中有另一个包。(注意,TDT是传输描述符数组的索引,而不是字节偏移量;) 然而,传输队列只有这么大。如果网卡落后于要发送的包,且传输队列已满,会发生什么情况?为了检测这种情况,您需要从E1000得到一些反馈。不幸的是,您不能只使用TDH(transmit descriptor head)寄存器;文档明确声明从软件中读取寄存器是不可靠的。但是,如果您在发送描述符的cmd字段中设置RS位(Report Status),那么,当网卡发送了该描述符对应的数据包之后,网卡将在描述符的status字段中设置DD位(Descriptor Done)。如果设置了描述符的DD位,您就知道可以安全地回收该描述符并使用它来传输另一个包。

如果用户调用您的发送系统调用,但是下一个描述符的DD位没有被设置,表示传输队列已满,该怎么办?你必须决定在这种情况下该怎么办。你可以直接把数据包扔了,网络协议对此是有弹性的,但是如果丢弃大量的数据包,协议可能无法恢复。也可以告诉用户进程去重试,就像您对sys_ipc_try_send所做的那样。这样做的好处是将问题回推给生成数据的进程。

练习6:写一个函数来实现传送数据包的功能,函数思路大致如下:检查下一个描述符是否空闲,若空闲则将数据包复制到该描述符对应的数据包缓冲区内,然后更新TDT,确保记得处理发送队列为满的情况。可以通过在内核中调用完成的发送函数来发送一些数据来测试你的发包代码是否正确,发送的数据不必遵循任何特定的网络协议,可以键入make E1000_DEBUG=TXERR,TX qemu来测试,应当会看到类似下方的输出:

e1000: index 0: 0x271f00 : 9000002a 0

每行给出发送数组中的索引,该发送描述符的缓冲区地址,cmd / CSO / length字段以及special / CSS / status字段。如果没有显示你希望从发送描述符中获取的值,请检查是否填写了正确的描述符,以及是否正确配置了TDBAL和TDBAH。如果收到“e1000: TDH wraparound @0, TDT x, TDLEN y”消息,则表示E1000一直贯穿传输队列而没有停止(如果QEMU没有检查,它将进入无限循环),这可能意味着您没有正确处理TDT。如果收到很多“ e1000:tx disable”消息,则说明您没有正确设置发送控制寄存器。
QEMU运行后,您可以运行tcpdump -XXnr qemu.pcap来查看传输的数据包数据。如果您看到了来自QEMU的预期“ e1000:index”消息,但是您的数据包捕获为空,请再次检查您是否填写了每个必要的字段并在传输描述符中添加了一些位(E1000可能检查了你的传输描述符,但认为它不需要发送任何东西)。

注意不要忘记设置发送描述符的相关数据和网卡尾指针寄存器,代码如下:

//发包函数,addr指出数据所在起始内存地址,length指出要发的长度
int e1000_transmit_data(void* addr, int length)
{
	length = MIN(length,BF_MAX_SIZE);
	int tail = E1000[E1000_TDT/4];
	struct tx_desc* next_desc = &transmitRing[tail];
	if(next_desc->status&E1000_TXD_STAT_DD)//DD位设备为0,说明该描述符可用
	{
		memmove(KADDR(next_desc->addr),addr,length);
		next_desc->status &= ~E1000_TXD_STAT_DD; //将DD位置0,标志该描述符正在使用
		next_desc->length = (uint16_t)length; //设置标识符的length,表示该次操作要传送多少字节
		E1000[E1000_TDT/4] = (tail+1)%TX_MAX_LEN; //尾指针向后移动一位,表明有包等待传输
		return length;
	}
	else
		return -1;
	
}

在监视器代码中添加一行代码调用发包函数:

MIT6.828_LAB6_Part A: Initialization and transmitting packets_第9张图片

make E1000_DEBUG=TXERR,TX qemu的测试结果,可以看到length = 11,status=0都是正确的:

在这里插入图片描述

tcpdump -XXnr qemu.pcap,数据包也正常捕获了

在这里插入图片描述

练习7:
添加一个系统调用使你能从用户空间发包过来,具体的接口由你觉得,别忘了检查所有从用户态传递到内核态的指针。

代码如下:

lib/syscall.c
int sys_e1000_transmit(void* buf, size_t len)
{
	return syscall(SYS_e1000_transmit, 0,(uint32_t)buf, len, 0,0,0);
}
kern/syscall.c
static int 
sys_e1000_transmit(void *buf, size_t len)
{
	user_mem_assert(curenv,buf,len,PTE_U|PTE_P);
	//注意:用户进程通过系统调用进入e1000_transmit_data函数时,内核页表中关于E1000的映射关系已经被复制到用户进程页表中
	return e1000_transmit_data(buf,len);
}
case SYS_e1000_transmit: 	
		return sys_e1000_transmit((char*)(a1),a2);

记得声明函数和syscall number:

inc/lib.h
int sys_e1000_transmit(void* buf, size_t len);
inc/syscall.h
SYS_e1000_transmit,

Transmitting Packets: Network Server

现在我们有了能够访问设备驱动发送端的系统调用了,是时候试试发包了,输出辅助进程的目标是循环做以下事情:从核心网络服务器接收NSREQ_OUTPUT IPC消息并且使用我们刚刚添加的系统调用将数据包和这些IPC消息发给网络设备驱动,NSREQ_OUTPUT IPC由net/lwip/jos/jif/jif.c中的low_level_output 函数发出,它将lwIP堆栈绑定到JOS的network system。每个IPC将包含一个页面,该页面由一个union Nsipc和它的struct jif_pkt pkt字段中的包组成(参见inc/ns.h)。

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

jp_len代表数据包的长度,ipc上的所有后续字节都用于数据包内容,使用长度为0的jp_data数组是一个常用的c技巧,这表示一个不预先决定长度的缓冲区,因此c不会对该数组做边界检查,只要你确保在这个结构体后有足够空闲的内存,那么你就可以把jp_data当作一个任意长度的数组使用。

当设备驱动程序的传输队列中没有更多空间时,要注意设备驱动, 输出辅助进程和核心网络服务器之间的交互。核心网络服务器使用IPC向输出进程发送数据包。如果由于send packet系统调用而导致输出进程挂起(suspended),因为驱动程序没有更多的缓冲空间来容纳新包,那么核心网络服务器将阻塞,等待输出进程接受IPC调用。

练习8.实现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

使用更大的包计数进行测试,请尝试E1000_DEBUG=TXERR,TX NET_CFLAGS=-DTESTOUTPUT_COUNT=100 run-net_testoutput。如果这个溢出了您的传输循环数组,请再次检查您是否正确地处理了DD状态位,并且您已经告诉硬件设置了DD状态位(使用RS命令位)。
您的代码应该通过make grade的testoutput测试。
output.c代码:

void
output(envid_t ns_envid)
{
	binaryname = "ns_output";
	
	// LAB 6: Your code here:
	// 	- read a packet from the network server
	//	- send the packet to the device driver
	while(1)
	{
		if(NSREQ_OUTPUT == ipc_recv(NULL,&nsipcbuf,NULL)) //接收到了网络服务器的请求和相关数据包
		{	
			//发包调用返回-1时,说明发送描述符队列已满,此次发送失败,则采用循环尝试发包的方法处理该情况
			while(-1 == sys_e1000_transmit(nsipcbuf.pkt.jp_data,nsipcbuf.pkt.jp_len))
				sys_yield();//调用sys_yield先让出处理器
		}
	}
}

测试通过:

MIT6.828_LAB6_Part A: Initialization and transmitting packets_第10张图片

提问:你如何组织你的发包功能的实现的?当发送描述符队列满时,你是如何处理的?
答:当发送描述符队列满时,驱动程序提供的发包函数会返回-1,根据返回值可以判断发包是否成功,若成功则输出辅助进程准备接收下一个数据包,若失败,则输出辅助进程会循环尝试发送该包,期间接收工作会被阻塞,直到该包发送完成,才会接收下一个包。

你可能感兴趣的:(操作系统)