这个实验是你可以自己完成的默认期末项目。
现在你有了一个文件系统,任何操作系统都不应该没有网络栈。在这个实验中,你将编写一个网络接口卡的驱动程序。该卡将基于英特尔82540EM芯片,也被称为E1000。
git add .
git commit -am 'my solution to lab5'
git checkout -b lab6 origin/lab6
git merge lab5
然而,网卡驱动程序将不足以使您的操作系统连接到互联网。在新的lab6代码中,我们为您提供了一个网络栈和一个网络服务器。与之前的实验室一样,使用git获取此实验室的代码,合并到您自己的代码中,并探索新的net/目录中的内容,以及kern/中的新文件。
除了编写驱动程序,您还需要创建一个系统调用接口来访问您的驱动程序。您将实现缺失的网络服务器代码,以在网络堆栈和驱动程序之间传输数据包。您还将通过完成web服务器将所有内容捆绑在一起。使用新的web服务器,您将能够从您的文件系统提供文件。
大部分内核设备驱动程序代码都必须从头编写。这个实验室提供的指导比以前的实验室少得多:没有骨架文件,没有写在石头上的系统调用接口,许多设计决策都留给您。出于这个原因,我们建议你在开始任何单独的练习之前阅读整个作业。很多学生觉得这个实验比以前的实验更难,所以请相应地安排时间。
和之前一样,你需要完成实验室中描述的所有常规练习和至少一个挑战性问题。在answers-lab6.txt中写下实验中提出的问题的简要答案,以及你的挑战练习的描述。
我们将使用QEMU的用户模式网络堆栈,因为它不需要管理权限即可运行。QEMU的文档在这里有关于user-net的更多信息(here)。我们已经更新了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),因此我们不能直接连接到JOS内部运行的服务器,甚至从运行QEMU的主机上也不能。为了解决这个问题,我们配置QEMU,使其在主机上的某个端口上运行服务器,该端口只需连接到JOS中的某个端口,并在真实主机和虚拟网络之间来回传输数据。
您将在端口7 (echo)和端口80 (http)上运行JOS服务器。为了避免在共享的Athena机器上发生冲突,makefile会根据你的用户ID为它们生成转发端口。要查明QEMU转发到开发主机上的哪些端口,请运行make which-ports。为了方便起见,makefile还提供了make nc-7和make nc-80,它们允许您直接与在终端中这些端口上运行的服务器交互。(这些目标只连接到正在运行的QEMU实例;QEMU必须单独启动。)
通俗点来讲,就是 这个JOS服务器用的是 7 和80端口,但是你的虚拟机上面可能已经用了,所以帮你转发到另一个端口了。
生成文件还配置QEMU的网络堆栈,以将所有传入和传出数据包记录到您的实验室目录中的qemu.pcap。
要获取捕获的数据包的hex/ASCII,请使用tcpdump,如下所示:
tcpdump -XXnr qemu.pcap或者,您可以使用Wireshark以图形方式检查pcap文件。Wireshark还知道如何解码和检查数百种网络协议。如果您使用的是Athena,则必须使用Wireshark的前身ethereal,它位于sipbnet locker。
捕获数据包的两种方式推荐
我们很幸运能够使用仿真硬件。由于E1000在软件中运行,因此仿真的E1000可以以用户可读的格式向我们报告其内部状态以及遇到的任何问题。通常,使用裸机编写驱动程序的开发人员将无法获得这种奢侈。
由于是仿真模式模拟硬件,所以E1000在软件中运行,用户直接明显地看到其中内部状态信息,在裸机上编写却没有这么方便
E1000可以产生很多调试输出,因此您必须启用特定的日志记录通道。您可能会发现有用的一些渠道是:
调试E1000输出的方式 make E1000_DEBUG=tx,txerr ...。
例如,要启用tx和txerr日志记录,请使用make E1000_DEBUG=tx,txerr ...。
注意: E1000_DEBUG标志仅在6.828版本的QEMU中起作用。
您可以进一步使用软件仿真的硬件进行调试。如果您陷入困境并且不了解E1000为什么没有按预期方式做出响应,则可以在hw/net/e1000.c中查看QEMU的E1000实现。
下面的内容介绍为了实现JOS能够和外界进行网络信息传输的功能,从低至上所需要完成的模块,以及我们所需要具体实现的内容
从头开始编写网络堆栈是一项艰巨的工作。相反,我们将使用lwIP,这是一个开源的轻量级TCP/IP协议套件,其中包括一个网络堆栈。您可以在此处(here)找到有关lwIP的更多信息 。就此而言,就我们而言,lwIP是一个黑箱,它实现了BSD套接字接口(socket之类API),并具有一个数据包输入端口和一个数据包输出端口。
本设计中主要使用了lwip(Light weight IP)协议栈提供的套接字接口
网络服务器实际上是四个环境的组合:
核心网络服务器环境(包括套接字调用分派器和lwIP)
输入环境
输出环境
计时器环境
下图显示了不同的环境及其关系。该图显示了包括设备驱动程序在内的整个系统,稍后将进行介绍。在本实验中,您将实现以绿色突出显示的部分。
这个地方已经告诉你我们要实现什么了:
1.实现 E1000驱动里面的 TX 用于传输数据, RX用于发送数据。
2.实现 发送环境和 输出环境, 时钟环境已经帮我们实现好了,我们后面会去看看
3. http 服务器,这些事具体应用服务器了。
The core network server environment 由 socket call dispatcher 和 lwIP本身组成。 socket call dispatcher 的工作原理与文件服务器完全相同。用户环境使用stubs(在lib/nsipc.c中)向核心网络环境发送IPC消息。如果您查看lib/nsipc.c,您将看到我们找到核心网络服务器的方式与找到文件服务器的方式相同:i386_init使用NS_TYPE_NS创建NS(network server)环境,因此我们扫描envs,寻找这种特殊类型的环境。对于每个用户环境IPC,网络服务器中的dispatcher代表用户调用lwIP提供的适当 BSD socket interface function。
普通用户环境不直接使用nsipc_* calls。相反,它们使用lib/sockets.c中的函数,它们提供了一个file descriptor-based的sockets API。因此,用户环境通过文件描述符引用sockets,就像它们引用磁盘上的文件一样。许多操作(connect, accept等)是特定于sockets的,但是read、writes和close都要经过lib/fd.c中的普通文件描述符device-dispatch code。就像文件服务器为所有打开的文件保留惟一ID,lwIP也为所有打开的套接字生成惟一ID。在文件服务器和网络服务器中,我们使用存储在struct Fd中的信息将每个环境的文件描述符映射到这些惟一的ID空间。
尽管看起来文件服务器和网络服务器的IPC调度程序的行为相同,但还是有一个关键区别。BSD sockets calls 比如 accept和recv 可以无限期地阻塞。如果dispatcher(调度器)让lwIP执行这些阻塞调用中的一个,调度器也会阻塞,对于整个系统,一次只能有一个未完成的网络调用。对于每个传入的IPC消息,dispatcher创建一个线程,并在新创建的线程中处理请求。如果线程阻塞,则只有该线程处于休眠状态,而其他线程继续运行。
使用线程防止阻塞影响效率
除了core network environment 之外,还有三个helper环境。除了接受来自用户应用程序的消息外,核心网络环境的dispatcher还接受来自input和timer环境的消息。
为用户环境套接字调用提供服务时,lwIP将生成数据包供网卡传输。LwIP将使用NSREQ_OUTPUTIPC消息将每个要发送的数据包发送到输出帮助程序环境,并将该数据包附加在IPC消息的page参数中。输出环境负责接受这些消息,并通过即将创建的系统调用接口将数据包转发到设备驱动程序。
网卡收到的数据包需要注入lwIP。对于设备驱动程序收到的每个数据包,输入环境(使用您将实现的内核系统调用)将数据包拉出内核空间,然后使用NSREQ_INPUTIPC消息将数据包发送到核心服务器环境。
数据包输入功能与核心网络环境分开,因为JOS使其难以同时接受IPC消息以及轮询或等待来自设备驱动程序的数据包。我们select 在JOS中没有系统调用,该调用允许环境监视多个输入源以标识准备好处理哪些输入。
将外界传输过来的数据包接收,并将其从驱动程序(内核空间)拉出到network server environment(用户空间),然后通过lwip解封装传输给http进行解析。
如果你看看net/input.c和net/output.c你会看到,都需要执行。这主要是因为实现取决于您的系统调用接口。在实现驱动程序和系统调用接口之后,将为两个帮助程序环境编写代码。
计时器环境会定期向NSREQ_TIMER核心网络服务器发送消息类型,通知其计时器已过期。lwIP使用此线程的计时器消息来实现各种网络超时。
通过这些我们大致知道这个网络的流程了,实际上核心服务器和文件服务器是一模一样的,让我们再做一次实际上也就是把上次的代码在看一遍。至于输出环境,输入环境和时钟环境,就是让我们实现的东西。
做这个实验之前可以看看最后的流程图总结
您的内核没有时间概念,因此我们需要添加它。当前,硬件每10毫秒产生一次时钟中断。在每个时钟中断处,我们都可以增加一个变量以指示时间提前了10ms。这是在kern/ time.c中实现的,但尚未完全集成到您的内核中。
不着急做实验,我们先去看看kern/ time.c
static unsigned int ticks;
//初始化
void
time_init(void)
{
ticks = 0;
}
// This should be called once per timer interrupt. A timer interrupt
// fires every 10 ms.
//每10ms中断一次就调用这个函数,然后其中ticks自增
void
time_tick(void)
{
ticks++;
if (ticks * 10 < ticks) //数据溢出
panic("time_tick: time overflowed");
}
//表示已经中断过的时间 ticks * 10(单位ms)
unsigned int
time_msec(void)
{
return ticks * 10;
}
在kern/trap.c中,为每个时钟中断添加对time_tick的调用。实现sys_time_msec,并将其添加到kern/syscall.c中的系统调用,使用户空间能够访问该时间。
kern/trap.c
// Handle clock interrupts. Don't forget to acknowledge the
// interrupt using lapic_eoi() before calling the scheduler!
// LAB 4: Your code here.
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
lapic_eoi();
sched_yield();
//防止其他cpu跟着触发
if (cpunum() == 0) { //lab6
time_tick();
}
sched_yield();
return;
}
kern/syscall.c
//添加系统调用
case SYS_time_msec:
ret=sys_time_msec();
break;
static int
sys_time_msec(void)
{
// LAB 6: Your code here.
return time_msec();
}
输入make INIT_CFLAGS=-DTEST_NO_NS run-testtime进行测试
bitmap is good
starting count down: 5 4 3 2 1 0
Welcome to the JOS kernel monitor!
练习1完成,实现了时钟中断
编写驱动程序需要深入了解硬件和提供给软件的接口。该实验文本将提供有关如何与E1000进行交互的高级概述,但是您在编写驱动程序时需要充分利用Intel的手册。
Browse Intel's Software Developer's Manual for the E1000. This manual covers several closely related Ethernet controllers. QEMU emulates the 82540EM.
你现在应该浏览第2章,对这个设备有个大致的了解。要编写你的驱动程序,你需要熟悉第3章、第14章以及4.1章(虽然不包括4.1章的小节)。你还需要参考第13章。其他章节主要介绍了E1000的组件,你的驱动程序不需要与之交互。现在不要担心细节;只需要对文档的结构有一个感觉,以便稍后找到内容。
主要是浏览第2章关于设备架构,为了编写驱动需要阅读第3章收发包描述符、第14章通用初始化和重置操作、第13章 寄存器描述。
在阅读手册时,请记住E1000是一个具有许多高级功能的复杂设备。一个工作的E1000驱动程序只需要网卡提供的功能和接口的一小部分。仔细考虑与卡片交互的最简单方法。我们强烈建议您在使用高级功能之前先安装一个基本的驱动程序。
深入PCI与PCIe之一:硬件篇 可以看看了解一下,加深理解
E1000是一个PCI设备,这意味着它可以插入主板上的PCI bus(总线)。PCI总线具有地址、数据和中断线路,允许CPU与PCI设备通信,并允许PCI设备读写内存。PCI设备在使用之前需要被发现和初始化。discovery是在PCI总线上寻找附加设备的过程。initialization是分配I/O和内存空间,以及协商(negotiating)给设备使用的IRQ线的过程。
这里最重要的就是告诉我们使用E1000设备初始需要做什么:discovery和Initialization
discovery:遍历总线找到连接的设备
initialization:分配I/O和内存空间,协商中断要求(IRQ)
我们已经在kern/PCI.c中为您提供了PCI代码。为了在引导期间执行PCI初始化,PCI代码将遍历PCI总线寻找设备。当它找到一个设备时,它读取其vendor ID和device ID,并使用这两个值作为键来搜索pci_attach_vendor数组。数组由struct pci_driver条目组成,如下所示:
// 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 respectively
//存储厂商号和设备号
struct pci_driver pci_attach_vendor[] = {
{ 0, 0, 0 },
};
// PCI driver table
//key1和key2:厂商号和设备号
struct pci_driver {
uint32_t key1, key2;
int (*attachfn) (struct pci_func *pcif);
};
如果匹配成功,PCI代码将调用该条目的attachfn来执行设备初始化。(设备也可以通过class来标识,这是kern/pci.c中的另一个驱动表的作用。)
遍历PCI总线通过厂商号和设备号寻找是否与存储的设备(E1000)的信息匹配,如果匹配则用attachfn函数进行设备初始化
The attach function is passed a PCI function to initialize. A PCI card can expose multiple functions, though the E1000 exposes only one。下面是我们JOS中的一个PCI function:
一般PCI card可以暴露给多个函数,但是E1000只暴露给一个
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;
};
struct pci_bus {
struct pci_func *parent_bridge;
uint32_t busno;
};
上面的结构反映了开发人员手册4.1节Table 4-1中的一些条目。struct pci_func的最后三个条目尤其让我们感兴趣,因为它们记录了设备的negotiated memory, I/O, and interrupt resources。reg_base和reg_size数组包含最多六个Base Address Registers or BARs的信息。reg_base存储内存映射I/O区域的base memory addresses(or base I/O ports for I/O port resources),reg_size包含对应的reg_base基值的字节大小或I/O端口数量,irq_line包含分配给设备用于中断的IRQ lines。表4-2的后半部分给出了E1000条的具体含义。
说明了pci_func中最后三个条目的意义,下面的实验就是需要初始化这三个条目
每一个PCI设备都有它映射的内存地址空间和I/O区域,除此之外,PCI设备还有配置空间,一共有256字节,其中前64字节是标准化的,提供了厂商号、设备号、版本号等信息,唯一标示1个PCI设备,同时提供最多6个的IO地址区域。
00h-3Ch这64个字节是标准化的,提供了厂商号、设备号、版本号等信息,唯一标示1个PCI设备,同时提供最多6个的IO地址区域。
调用设备的attach函数时,已找到该设备,但尚未启用该设备。这意味着PCI代码还没有确定分配给设备的资源,例如地址空间和IRQ lines,因此,struct pci_func结构的最后三个元素还没有填充。attach函数应该调用pci_func_enable(在attach函数里调用,而不是直接作为attach函数),它将启用设备,协商这些资源,并填充struct pci_func。
attach函数应该调用pci_func_enable(在attach函数里调用,而不是直接作为attach函数),它将启用设备,协商这些资源,并填充struct pci_func。为设备分配资源
其中reg_base数组保存了内存映射I/O的基地址, reg_size保存了以字节为单位的大小。 irq_line包含了IRQ线。
当attachfn函数指针指向的函数执行后,该设备就算被找到了,但还没有启用,attachfn函数指针指向的函数应该调用pci_func_enable(),该函数启动设备,协商资源,并且填充传入的struct pci_func结构。
小结
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配置空间
实现一个attach函数来初始化E1000。在kern/ PCI.c中的pci_attach_vendor数组中添加一个条目,以便在找到匹配的PCI设备时触发函数(请确保将它放在表示 末尾的{0,0,0}条目之前)。您可以在第5.2节中找到QEMU模拟的82540EM的 vendor ID and device ID。您还应该在启动时JOS扫描PCI总线时看到列出的这些。
现在,只需通过pci_func_enable启用E1000设备。我们将在整个实验室中添加更多的初始化。
我们已经为您提供了kern/e1000.c和kern/e1000.h文件,这样您就不需要破坏构建系统。它们目前是空白的;这次exercise你需要把它们填进去。您可能还需要在内核的其他位置包含e1000.h头文件。
在手册5.2中找到E1000(即82540EM)的配置信息,将其配置到kern/e1000.h中和pci_attach_vendor数组中
kern/e1000.h
#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H
//lab6
#include "kern/pci.h"
#define E1000_VENDER_ID_82540EM 0x8086
#define E1000_DEV_ID_82540EM 0x100E
int e1000_attachfn(struct pci_func *pcif);
#endif // SOL >= 6
kern/e1000.c
#include
#include
// LAB 6: Your driver code here
//使用pci_func_enable初始化PCI
int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
cprintf("reg_base:%x, reg_size:%x\n", pcif->reg_base[0], pcif->reg_size[0]);
return 0;
}
kern/pci.h
// 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 respectively
//存储厂商号和设备号
struct pci_driver pci_attach_vendor[] = {
{E1000_VENDER_ID_82540EM, E1000_DEV_ID_82540EM, &e1000_attachfn},
{ 0, 0, 0 },
};
当您启动内核时,您应该看到它打印启用了E1000卡的PCI功能。您的代码现在应该通过了make grade的pci attach测试
PCI初始化的主要函数执行流程如下图:
其中有一个非常重要的数据结构,就是pci_attach_class,通过其中的函数执政才能够执行e1000_attachfn,最终才能实现PCI的初始化
(160条消息) MIT6.828 LAB6: Network Driver_bysui的博客-CSDN博客 里面有各个函数作用介绍
软件通过memory-mapped I/O (MMIO)与E1000通信。您以前在JOS已经见过两次了:CGA控制台和LAPIC都是通过写入和读取“内存”来控制和查询(query)的设备。但是这些读和写不会进入DRAM;它们直接进入这些设备。
pci_func_enable与E1000协商一个MMIO区域,并将其base和size存储在BAR 0中(即reg_base[0]和reg_size[0])。这是分配给设备的物理内存地址的范围,这意味着您必须做一些事情来通过虚拟地址访问它。由于MMIO区域被分配了非常高的物理地址(通常在3GB以上),由于JOS的256MB限制,您不能使用KADDR访问它。因此,您必须创建一个新内存映射。
我们将使用MMIOBASE上面的区域(Lab 4中的mmio_map_region将确保我们不会覆盖LAPIC使用的映射)。由于PCI设备初始化发生在JOS创建用户环境之前,所以您可以在kern_pgdir中创建映射,并且它总是可用的。
pci_func_enable初始化时分配了物理空间给E1000,但是这个地址很高,我们必须将其进行映射然后通过虚拟地址访问,映射时注意不要覆盖之前实验映射的区域
在attach函数中,通过调用mmio_map_region为E1000的BAR 0创建一个虚拟内存映射(你在lab 4中写的用来支持内存映射LAPIC)。
您将希望在a variable中记录这个映射的位置,以便稍后访问刚才映射的寄存器。以kern/lapic.c中的lapic变量为例说明一种方法。如果确实使用指向设备寄存器映射的指针,请确保声明它为volatile;否则,编译器将被允许缓存值并重新排序对该内存的访问。
C语言丨深入理解volatile关键字 - 知乎 (zhihu.com)
要测试映射,请尝试打印设备状态寄存器(第13.4.2节)。这是一个从寄存器空间的字节8开始的4字节寄存器。您应该得到0x80080783,这表示一个完整的双工链路(full duplex link)的大小为1000MB/s。
查看状态寄存器的偏移值为:8h 注意这里的偏移的基址时e1000被分配的地址空间的基址,不是之前表格中的PCI总线的基址。
e1000.h
#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H
//lab6
#include "kern/pci.h"
#define E1000_VENDER_ID_82540EM 0x8086
#define E1000_DEV_ID_82540EM 0x100E
#define E1000_STATUS 0x00008 /* Device Status - RO */
#define E1000REG(offset) (void *)(bar_va + offset)
int e1000_attachfn(struct pci_func *pcif);
#endif // SOL >= 6
e1000.c
#include
#include
// LAB 6: Your driver code here
//使用pci_func_enable初始化PCI
volatile void *bar_va;
int
e1000_attachfn(struct pci_func *pcif)
{
// pci_func_enable(pcif);
// cprintf("reg_base:%x, reg_size:%x\n", pcif->reg_base[0], pcif->reg_size[0]);
// return 0;
pci_func_enable(pcif);
cprintf("reg_base:%x, reg_size:%x\n", pcif->reg_base[0], pcif->reg_size[0]);
//Exercise4 create virtual memory mapping
//该函数从线性地址MMIOBASE开始映射物理地址pa开始的size大小的内存,并返回pa对应的线性地址。
//可以通过线性地址bar_va访问e1000设备
bar_va = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
uint32_t *status_reg = E1000REG(E1000_STATUS);
assert(*status_reg == 0x80080783);
return 0;
}
提示:您需要很多常量(constants),比如寄存器的位置和bit masks的值。试图从开发人员手册中复制这些内容很容易出错,错误可能导致痛苦的调试过程。我们建议使用QEMU的e1000_hw.h头作为指导原则。我们不建议逐字复制它,因为它定义的内容比您实际需要的多得多,而且可能不会以您需要的方式定义内容,但这是一个很好的起点。
lab3和lab4的结果是,我们可以通过直接访问bar_va开始的内存区域来设置E1000的特性和工作方式。铜鼓内存映射可以访问e1000的寄存器,然后传输和接收数据都可以使用寄存器,但是效率很低,下面实现DMA用于数据传输
您可以想象通过从E1000的寄存器写和读来发送和接收数据包,但是这会很慢,并且需要E1000在内部缓冲数据包数据。相反,E1000使用Direct Memory Access(DMA)直接从内存读写数据包数据,而不涉及CPU。驱动程序负责为发送和接收队列分配内存,设置DMA描述符,并使用这些队列的位置配置E1000,但之后的一切都是异步的。
要传输数据包,驱动程序将其复制到传输队列中的下一个DMA描述符中,并通知E1000另一个数据包可用;当有时间发送数据包时,E1000将从描述符中复制数据。同样,当E1000接收到一个数据包时,它将它复制到接收队列中的下一个DMA描述符中,驱动程序可以在下次机会时从该描述符中读取。
直接通过寄存器传输数据报很慢,所以使用DMA(Direct Memory Access),
驱动(drives)工作:为发送和接收队列分配内存,设置DMA描述符,使用这些队列
传输和接收的过程:(DMA不涉及CPU)
在高层次上,接收队列和传输队列非常相似。两者都由一系列描述符组成。虽然这些描述符的确切结构各不相同,但是每个描述符都包含一些标志和包含包数据的缓冲区的物理地址(要么是要发送的数据包数据,要么是操作系统分配给card的缓冲区,以便将接收到的数据包写入其中)。
在顶层接收和发送队列是相似的,都由描述符组成,每个描述符主要的成员有:一些flag,buffer(用于网卡发送数据或者接收数据)
队列被实现为循环数组,这意味着当card或驱动程序到达数组的末尾时,它会绕回开始处。它们都有一个头指针和一个尾指针,队列的内容是这两个指针之间的描述符。硬件总是从头部拿出描述符并移动头部指针,而驱动程序总是向尾部添加描述符并移动尾部指针。(所以头指针指向有内容的,尾指针指向空的)。传输队列中的描述符表示等待发送的包(因此,在稳定状态(steady state)下,传输队列是空的)。正确地更新tail register而不使E1000混淆是很棘手的;小心!
指向这些数组的指针以及描述符中包缓冲区的地址都必须是物理地址,因为硬件直接在物理RAM之间执行DMA,而不需要经过MMU。
队列被设置为循环队列,队列的内容时头指针和尾指针之间的描述符(指针的类型应该就是队列描述符)。 硬件从头指针消费元素,驱动在尾指针后生产元素。一般情况,发送队列为空,接收队列的描述符可以使用(可以接收数据包)
在这个操作在过程中必须时物理地址,不需要经过MMU
E1000的transmit和receive功能基本上是相互独立的,所以我们一次可以处理一个。我们将首先测试transmitting packets,因为如果不首先发送一个“I’m here!”包,我们就无法测试receive。
首先,您必须按照第14.5节中描述的步骤初始化card(您不必关心子章节)。传输初始化的第一步是设置传输队列。队列的确切structure见第3.4节,描述符的structure见第3.3.3节。我们不会使用E1000对大数据包分片的特性,所以您可以专注于“legacy(传统) transmit descriptor format”。您现在应该阅读这些部分,熟悉这些结构。
E1000发送和接收功能独立
首先我们需要初始化E1000来支持发送包。第一步是建立发送队列,队列的具体结构在3.4节,描述符的结构在3.3.3节。不使用E1000对大数据包分片的特性,专注于传统的发送描述符的格式。
阴影部分表示已经被硬件发送但是还没有被软件回收的,回收涉及释放与描述符相关的缓冲区。
发送描述符队列通过下面这些寄存器描述:
TDBAL/TDBAH:寄存器中包含描述符队列缓冲区的开始基址,TDBAL包含低32位,TDBAH包含高32位
TDLEN:循环缓冲区的大小
TDH:相对于基址的偏移值,表示头指针的描述符
TDT:尾指针的描述符
您会发现使用C结构体来描述E1000的structure非常方便。正如您在struct Trapframe等结构中看到的,C struct允许您在内存中精确布局数据。C可以在字段之间插入填充( padding ),但是E1000的结构是这样安排的,所以这不应该是一个问题。如果您确实遇到字段对齐问题,请查看GCC的“packed”属性。
从gcc的__attribute__((packed))聊到结构体大小的问题 - 哈哈不是嘎嘎 - 博客园 (cnblogs.com)
例如,考虑手册table 3-8中给出的并重现在下面的传统(legacy) transmit descriptor:
结构的第一个字节从右上角开始,因此要将其转换为C结构体,请从右向左、从上到下读取。如果你斜视它,你会发现所有的字段都很符合标准大小的类型:
struct e1000_tx_desc
{
uint64_t addr; /* Address of the descriptor's data buffer */
uint16_t length; /* Data buffer length */
uint8_t cso; /* Checksum offset */
uint8_t cmd; /* Descriptor control */
uint8_t status; /* Descriptor status */
uint8_t css; /* Checksum start */
uint16_t special;
}__attribute__((packed));
驱动程序必须为transmit descriptor数组和由传输描述符指向的packet buffers预留内存。有几种方法可以做到这一点,从动态分配页面到简单地在全局变量中声明页面。无论您选择什么,请记住E1000直接访问物理内存,这意味着它访问的任何缓冲区必须在物理内存中是连续的。
分配内存的方式主要有两种:动态分配和全局变量静态分配。但是分配的内存都必须是连续的,因为E1000直接访问物理内存。
还有多种方法可以处理packet buffers。最简单的方法(我们建议从这里开始)是在驱动程序初始化期间为每个描述符预留一个包缓冲区,并简单地将包数据复制到这些预先分配的缓冲区中并从中取出。以太网数据包的最大大小为1518字节,这限制了这些缓冲区的大小。更复杂的驱动程序可以动态分配包缓冲区(例如,当网络使用率较低时,可以减少内存开销),甚至可以传递用户空间直接提供的缓冲区(一种称为“zero copy”的技术),但是最好从简单的开始。
零拷贝技术详解
我们驱动程序必须为发送描述符数组和发送描述符指向的数据包缓冲区保留内存。 有几种方法可以做到这一点,从动态分配页面到简单地在全局变量中声明它们。 一定要记住 E1000 直接访问物理内存,这意味着它访问的任何缓冲区必须在物理内存中是连续的。
还有多种方法来处理数据包缓冲区。 我们建议从最简单的开始, 是在驱动程序初始化期间为每个描述符保留数据包缓冲区的空间,并简单地将数据包数据复制到这些预分配的缓冲区中。
成熟的驱动可以动态的分配甚至直接使用用户太的数据空间(又叫0拷贝)
执行14.5节中描述的初始化步骤(但不是它的子节)。使用第13节作为初始化过程涉及寄存器的参考,使用第3.3.3和3.4节作为传输描述符和传输描述符数组的参考。
注意传输描述符数组的对齐要求和对该数组长度的限制。由于TDLEN( Transmit Descriptor Length)必须是128字节对齐的,并且每个传输描述符都是16字节,所以您的传输描述符数组需要8个传输描述符的若干倍。但是,不要使用超过64个描述符,否则我们的测试将无法测试传输环路溢出。
TCTL.COLD(Transmit Control Register——TCTL),可假设采用全双工操作。对于TIPG,请参考IEEE 802.3标准IPG第13.4.34节表13-77中描述的默认值(不要使用14.5节中的表中的值)。
14.5节的初始化步骤;初始化寄存器参考13节;初始化描述符和描述符数组参考3.3.3和3.4节
按照14.5节的描述初始化。步骤如下:
分配一块内存用作发送描述符队列,起始地址要16字节对齐。用传送描述符基地址填充(TDBAL/TDBAH——Transmit Descriptor Base Address ) 寄存器,(TDBAL/TDBAH)指向这段区域的物理地址
设置(TDLEN——Transmit Descriptor Length )寄存器,该寄存器保存发送描述符队列长度,必须128字节对齐。
设置(TDH/TDT—— Transmit Descriptor Head and Tail )寄存器,这两个寄存器都是发送描述符队列的下标。分别指向头部和尾部。应该初始化为0。([Note] TDT 是传输描述符数组的索引而不是偏移地址。)
初始化TCTL(Transmit Descriptor Head and Tail )寄存器。设置TCTL.EN位为1b,设置TCTL.PSP位为1b。设置TCTL.CT为10h。设置TCTL.COLD为40h。
设置TIPG(传输报文间间隔Transmit Inter-packet Gap)寄存器。
kern/e1000.h
#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H
//lab6
#include "kern/pci.h"
#define E1000_VENDER_ID_82540EM 0x8086
#define E1000_DEV_ID_82540EM 0x100E
#define TXDESCS 32//传送数组大小
#define TX_PKT_SIZE 1518//以太网数据报的最大值,即传输数组的最大值
//see section 14.5 in https://pdos.csail.mit.edu/6.828/2018/labs/lab6/e1000_hw.h
#define E1000_STATUS 0x00008 /* Device Status - RO */
#define E1000_TCTL 0x00400 /* TX Control - RW */
#define E1000_TIPG 0x00410 /* TX Inter-packet gap -RW */
#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_TXD_STAT_DD 0x00000001 /* Descriptor Done */
#define E1000_TXD_CMD_EOP 0x00000001 /* End of Packet */
#define E1000_TXD_CMD_RS 0x00000008 /* Report Status */
#define E1000REG(offset) (void *)(bar_va + offset)
//传送描述符布局 table3-8
struct e1000_tx_desc
{
uint64_t addr; /* Address of the transmit descriptor in the host memory */
uint16_t length; /* Data buffer length */
uint8_t cso; /* Checksum offset */
uint8_t cmd; /* command field 命令字段*/
uint8_t status; /* Descriptor status 状态字段*/
uint8_t css; /* Checksum start */
uint16_t special;
}__attribute__((packed));
//传送符控制寄存器
struct e1000_tctl {
uint32_t rsv1: 1;
uint32_t en: 1;//Transmit Enable 使能位:1有效
uint32_t rsv2: 1;
uint32_t psp: 1;//Pad Short Packets 填充短的数据报
uint32_t ct: 8;//Collision Threshold 碰撞阈值
uint32_t cold: 10;//Collision Distance 碰撞距离(计网CSMA/CD)
uint32_t swxoff: 1;
uint32_t rsv3: 1;
uint32_t rtlc: 1;
uint32_t nrtu: 1;
uint32_t rsv4: 6;
};
//传输报文间间隔Transmit Inter-packet Gap
struct e1000_tipg {
uint32_t ipgt: 10;//IPG Transmit Time
uint32_t ipgr1: 10;
uint32_t ipgr2: 10;
uint32_t rsv: 2;
};
//Transmit Descriptor Tail
struct e1000_tdt {
uint16_t tdt;/*Transmit Descriptor Tail*/
uint16_t rsv;
};
//Transmit Descriptor Length sizeof(e1000_tdlen)=4
struct e1000_tdlen {
uint32_t zero: 7;
/*Descriptor Length.Number of bytes allocated to the transmit descriptor circular buffer*/
uint32_t len: 13;
uint32_t rsv: 12;
};
//Transmit Descriptor Head
struct e1000_tdh {
uint16_t tdh;/*Transmit Descriptor Head*/
uint16_t rsv;
};
int e1000_attachfn(struct pci_func *pcif);
static void e1000_transmit_init();
#endif // SOL >= 6
kern/e1000.c
#include
#include
// LAB 6: Your driver code here
//定义为volatile表示直接从变量内存地址中读取数据,从而可以提供对特殊地址的稳定访问。
volatile void *bar_va;
struct e1000_tdh *tdh;//头指针
struct e1000_tdt *tdt;//尾指针
//可以根据发送队列描述可以找到发送队列缓冲区
struct e1000_tx_desc tx_desc_array[TXDESCS];//发送队列描述符
char tx_buffer_array[TXDESCS][TX_PKT_SIZE];//发送队列缓冲区
//1.使用pci_func_enable初始化E1000网卡,并为其分配资源
//2.初始化发送队列
int
e1000_attachfn(struct pci_func *pcif)
{
// pci_func_enable(pcif);
// cprintf("reg_base:%x, reg_size:%x\n", pcif->reg_base[0], pcif->reg_size[0]);
// return 0;
pci_func_enable(pcif);
cprintf("reg_base:%x, reg_size:%x\n", pcif->reg_base[0], pcif->reg_size[0]);
//Exercise4 create virtual memory mapping
//该函数从线性地址MMIOBASE开始映射物理地址pa开始的size大小的内存,并返回pa对应的线性地址。
//可以通过线性地址bar_va访问e1000设备
bar_va = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
uint32_t *status_reg = E1000REG(E1000_STATUS);//状态寄存器
assert(*status_reg == 0x80080783);//判断是否符合
e1000_transmit_init();//初始化传送队列
return 0;
}
//传送队列初始化
static void
e1000_transmit_init()
{
//1.初始化发送描述符队列,根据基址bar_va和宏定义中的偏移,为TDLEN、TDBAL、TDBAH指针分配空间
int i;
for (i = 0; i < TXDESCS; i++) {
tx_desc_array[i].addr = PADDR(tx_buffer_array[i]);//分配地址空间
tx_desc_array[i].cmd = 0;//命令字段初始化
tx_desc_array[i].status |= E1000_TXD_STAT_DD;//表示描述符已经完成
}
//TDBAL register 低地址
uint32_t *tdbal = (uint32_t *)E1000REG(E1000_TDBAL);
*tdbal = PADDR(tx_desc_array);
//TDBAH regsiter 高地址
uint32_t *tdbah = (uint32_t *)E1000REG(E1000_TDBAH);
*tdbah = 0;
//2.TDLEN register
struct e1000_tdlen *tdlen = (struct e1000_tdlen *)E1000REG(E1000_TDLEN);
tdlen->len = TXDESCS;
//3.TDH register, should be init 0
tdh = (struct e1000_tdh *)E1000REG(E1000_TDH);
tdh->tdh = 0;
//TDT register, should be init 0
tdt = (struct e1000_tdt *)E1000REG(E1000_TDT);
tdt->tdt = 0;
//4.TCTL register
struct e1000_tctl *tctl = (struct e1000_tctl *)E1000REG(E1000_TCTL);
tctl->en = 1;//Transmit Enable 使能位:1有效
tctl->psp = 1;//Pad Short Packets 填充短的数据报
tctl->ct = 0x10;//Collision Threshold 碰撞阈值
tctl->cold = 0x40;//全双工设置
//5.TIPG register
struct e1000_tipg *tipg = (struct e1000_tipg *)E1000REG(E1000_TIPG);
tipg->ipgt = 10;
tipg->ipgr1 = 4;
tipg->ipgr2 = 6;
}
小结:
在这个练习中主要使用了上个练习中为e1000设备分配内存所得的指针,使用这个指针以及手册上提供的偏移量可以很容易地设置各个寄存器的值,并未发送队列初始化。
所以这个练习主要的完成的就是发送队列的初始化(从源码可以看出发送队列的定义是驱动程序中的),还有就是初始化维护发送队列的各个寄存器(寄存器是在e1000设备中的,这里的这些寄存器直接和硬件相关,但是还没有涉及DMA)
试着运行make E1000_DEBUG=TXERR,TX qemu。如果您正在使用qemu,那么在设置TDT register时,应该会看到一条“e1000: tx disabled”消息(因为这是在设置TCTL.EN之前发生的),并且不再有“e1000”消息。
现在传输已经初始化,您必须编写代码来传输数据包,并通过系统调用让用户空间能够访问它。您必须将它添加到传输队列的尾部,这意味着将包数据复制到下一个包缓冲区,然后更新TDT(transmit descriptor tail)寄存器,以通知card在传输队列中有另一个包。(注意,TDT是传输描述符数组的索引,而不是字节偏移量;文档对此不是很清楚。)
发送数据的时候首先要将数据报复制到下一个包缓冲区,然后更新TDT寄存器(这个是缓冲区索引而不是地址偏移)
然而,传输队列只有这么大。如果card落后于传输包,且传输队列已满,会发生什么情况?为了检测这种情况,您需要从E1000得到一些反馈。不幸的是,您不能只使用TDH(transmit descriptor head)寄存器;文档明确声明从软件中读取寄存器是不可靠的。但是,如果您在传输描述符的cmd字段中设置RS位(Report Status),那么,当card在该描述符中传输了数据包之后,card将在描述符的status字段中设置DD位(Descriptor Done)。如果设置了描述符的DD位,您就知道可以安全地回收该描述符并使用它来传输另一个包。
这里告诉了我们在如何判断当前的数据报能够被放入发送队列中,如果在发送描述符的设置cmd(命令字段)并且同时设置了status位DD,那么表示当前缓冲区中还有空余,这个描述符可以使用。但是当DD没有设置的时候表示传输去以及满了又该什么办?可以直接丢弃也可以告诉用户环境需要重新发送
如果用户调用您的传输系统调用,但是下一个描述符的DD位没有被设置,表示传输队列已满,该怎么办?你必须决定在这种情况下该怎么办。你可以直接把packet扔了。Network protocols对此是有弹性的,但是如果您丢弃大量的数据包,协议可能无法恢复。您也可以告诉用户环境去重试,就像您对sys_ipc_try_send所做的那样。这样做的好处是将问题回推给生成数据的环境。我们可以让驱动一直处于自旋状态,直到有一个发送描述符被释放,但是这可能会造成比较大的性能问题,因为JOS内核不是设计成能阻塞的。
可以根据发送队列描述可以找到发送队列缓冲区
编写一个函数,通过检查下一个描述符是否空闲、将包数据复制到下一个描述符并更新TDT来传输包。确保您处理了传输队列已满这种情况。
e1000.h
enum {
E_TRANSMIT_RETRY = 1,//表示当前队列中没有空闲位置 retry——重试
};
int e1000_transmit(void *data, size_t len);
e1000.c
e1000_attachfn函数中
//test transmit
char *data="transmit test";
e1000_transmit(data,13);
return 0;
//发送数据报——将数据报复制到队列中,根据status字段判断是否可以copy到队列中
int
e1000_transmit(void *data,size_t len){
uint32_t current=tdt->tdt;//当前的尾指针
//通过status是否设置DD判断队列中是否有空闲的位置
if(tx_desc_array[current].status!=E1000_TXD_STAT_DD){
return E_TRANSMIT_RETRY;//重试
}
//有空闲位置,将数据拷贝进去,同时更新描述符中length、cmd、status
tx_desc_array[current].length=len;
tx_desc_array[current].status &= ~E1000_TXD_STAT_DD;
tx_desc_array[current].cmd |= (E1000_TXD_CMD_EOP|E1000_TXD_CMD_RS);
//copy数据报到数据报队列中,而不是描述符队列中
memcpy(tx_buffer_array[current],data,len);
//更新TDT
uint32_t next = (current + 1) % TXDESCS;//循环队列取余
tdt->tdt = next;
return 0;
}
就像你传输的包。每一行给出传输数组中的索引、该传输描述符的缓冲区地址、cmd/CSO/length字段和special/CSS/status字段。
如果QEMU没有打印您希望从传输描述符中得到的值,请检查是否填充了正确的描述符,以及是否正确配置了TDBAL和TDBAH(Transmit Descriptor Base Address)。
如果您得到“e1000: TDH wraparound @0, TDT x, TDLEN y”消息,这意味着e1000在传输队列中一直运行,没有停止(如果QEMU不检查这个,它将进入一个无限循环),这可能意味着您没有正确地操作TDT。
如果您收到许多“e1000: tx disabled”消息,那么您没有正确设置传输控制寄存器。
一旦QEMU运行,就可以运行 tcpdump -XXnr qemu.pcap查看您传输的数据包数据。如果您看到了来自QEMU的预期“e1000: index”消息,但您的包捕获是空的,请再次检查是否填入了传输描述符中的每个必要字段和位(E1000可能通过了您的传输描述符,但不认为它必须发送任何东西)。
添加一个系统调用,让您可以从用户空间传输数据包。确切的接口由您决定。不要忘记检查从用户空间传递到内核的任何指针。
inc/lib.h
int sys_pkt_send(void *addr, size_t len);
inc/syscall.h
SYS_pkt_send,
lib/syscall.c
int sys_pkt_send(void* data,size_t len)
{
return syscall(SYS_pkt_send,1,data,len,0,0,0);
}
kern/syscall.c
int
sys_pkt_send(void *data, size_t len)
{
return e1000_transmit(data, len);
}
case SYS_pkt_send:
ret = sys_pkt_send((void *)a1, (size_t)a2);
break;
小结:
从这个练习可以总结出系统调用的步骤:
现在您的设备驱动程序的传输端有了一个系统调用接口,是时候发送数据包了。output helper environment的目标是在循环中执行以下操作:接受来自core 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)。
实现发送网络数据包的功能(即实现output helper environment):接收NSREQ_OUTPUT IPC信号并且使用系统调用将数据包和IPC信号发给网卡驱动程序NSREQ_OUTPUT通过lwip中提供的接口low_level_output(net/lwip/jos/jif/jif.c)发送,此时移植的协议栈lwip就绑定到网络系统中。每个IPC将包含一个页面,该页面由一个union Nsipc和它的struct jif_pkt pkt字段中的包组成(参见inc/ns.h)。
struct jif_pkt {
int jp_len;
char jp_data[0]; //对哦,这里数组长度为0 便于扩展
};
jp_len表示数据包的长度。IPC页面上的所有后续字节都专用于包内容(就是除了jp_len外的都是data)。结构的末尾使用像jp_data这样的零长度数组是一种常见的C技巧(有些人会说讨厌),用于表示缓冲区没有预先确定(pre-determined)长度。由于C不做数组边界检查,只要确保结构后面有足够的未使用内存,就可以像使用任何大小的数组一样使用jp_data(哇哦,amazing!)。
C语言结构体中零长度数组在网络通信包中的运用
注意这个结构体的定义,其中关于零长度数组在结构体中的运用,详细可以看上面的链接。在结构体中定义零长度数组,编译器不会为其分配任何空间,使用的时候动态分配相应大小的空间,不会造成任何空间的浪费。
当设备驱动程序的传输队列中没有更多空间时,要注意the device driver, the output environment and the core network server之间的交互。core network server使用IPC向output environment发送数据包。如果由于send packet系统调用而导致output environment挂起(suspended),因为驱动程序没有更多的缓冲空间来容纳新包,那么core network server将阻塞,等待output environmnet接受IPC调用。
注意发送驱动程序队列没有多余空间时,设备驱动程序、输出环境、核心网络服务器之间的交互。核心网络服务器将会挂起知道输出环境将数据报传输到发送队列中,输出环境将会挂起直到队列中有空闲空间。
Implement net/output.c.
#include "ns.h"
#include "inc/lib.h"
extern union Nsipc nsipcbuf;
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
uint32_t whom;
int perm;
int32_t req;
while (1) {
//查询是否存在发送数据包请求 NSREQ_OUTPUT
//若存在,内核会将数据报的信息保存在nsipcbuf(传出参数)
//read a packet from the network server
req = ipc_recv((envid_t *)&whom, &nsipcbuf, &perm);
if (req != NSREQ_OUTPUT) {
cprintf("not a nsreq output\n");
continue;
}
struct jif_pkt *pkt = &(nsipcbuf.pkt);
//调用之前定义好的系统调用发送数据包
//send the packet to the device driver
while (sys_pkt_send(pkt->jp_data, pkt->jp_len) < 0) {
//若当前环境无用,取消当前环境的调度,并选择一个不同的环境来运行。
sys_yield();
}
}
}
当传输队列满时,你时如何处理的?
在output中检测数据包是否发送成功,若未成功,则sched_yeild让出控制器sleep 一会儿。
您可以使用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测试。
How did you structure your transmit implementation? In particular, what do you do if the transmit ring is full?
答:主要是fit_txd_for_E1000_transmit函数,他把传过来的数据存到传输描述符对应的buffer里,如果传输描述符ring满了,该函数就会返回-1,那么output函数就会接收到这个返回值,不停调用sys_yield(),一直到fit_txd_for_E1000_transmit函数成功返回0。
小结:
首先可以看看测试output功能的函数代码:
net/output.c
#include "ns.h"
#ifndef TESTOUTPUT_COUNT
#define TESTOUTPUT_COUNT 10
#endif
static envid_t output_envid;
static struct jif_pkt *pkt = (struct jif_pkt*)REQVA;
void
umain(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();
}
简单分析一下,使用fork函数创建一个子进程,子进程运行实现的output函数;父进程使用ipc_send通信发送数据包,子进程中使用ipc_recv接收接收数据包的内容,接收之后使用之前创建的系统调用sys_pkt_send发送数据包。
所以在实验A部分中实验是循序渐进的,前面的都是在为下一步或者最后做铺垫、准备
在使用e1000_transmit函数发送后,这个函数仅仅只是将数据包加入到队列中,改变了寄存器的值,那之后这么网卡这个硬件是怎么发送数据包的呢?这是目前的疑问,一直都是软件层面编写,如何突然联系到网卡硬件的?
思索的半天不知道怎么回事,联想到代码中和硬件相关的只有寄存器,transmit代码中有一个很重要的寄存器,那就是TDT寄存器,这个寄存器保存了数据队列中队尾的索引值,而且设置了描述符中相关字段的值,这些都是和网卡硬件相关的数据。
然后通过浏览资料发现了一篇文章网卡发送数据流程 - 知乎 (zhihu.com)其中有一句画龙点睛的话,一下子惊醒了我:
此时网卡会感知立即感知到寄存器发生变化,从而进行发送数据。
通过这句话可以简单地直到就是设置了TDT之后,DMA感知到了之后就发送了数据,网卡的数据从哪里来找呢?——初始化的时候将队列的首地址保存在了e1000_tx_desc(e1000描述符)中,然后发送数据包即可。所以前面每一步都是大有深意啊!
DMA 感知到 TDT 的改变后,找到 tx descriptor ring 中下一个将要使用的descriptor。
DMA 通过 PCI 总线将 descriptor 的数据缓存区复制到 Tx FIFO,复制完后,通过 MAC 芯片将数据包发送出去。
发送完后,网卡更新TDH,启动硬中断通知 CPU 释放数据缓存区中的数据包。
这里应该需要在结合组成原理底层部分的知识,比如网卡是如何检测寄存器的,然后又是如何发送的,这些就需要查阅更多的资料了。
小结:
1、网卡驱动创建 tx descriptor ring(一致性 DMA 内存),将 tx descriptor ring 的总线地址写入网卡寄存器 TDBA
2、协议栈通过 dev_queue_xmit() 将 sk_buff 下送网卡驱动
3、网卡驱动将 sk_buff 放入 tx descriptor ring,更新 TDT
4、DMA 感知到 TDT 的改变后,找到 tx descriptor ring 中下一个将要使用的 descriptor
5、DMA 通过 PCI 总线将 descriptor 的数据缓存区复制到 Tx FIFO
6、复制完后,通过 MAC 芯片将数据包发送出去
7、发送完后,网卡更新 TDH,启动硬中断通知 CPU 解除skb->data 的DMA 映射同时释放已成功发送的 skb
就像发送数据包一样,你需要配置E1000来接收数据包,并提供一个接收描述符队列和接收描述符。3.2节描述了分组接收的工作方式,包括接收队列结构和接收描述符,而初始化过程将在14.4节详细描述。
阅读3.2节。你可以忽略任何与中断和校验和卸载有关的内容(如果你决定以后使用这些功能,可以回到这些部分),不必关心阈值的细节以及卡的内部缓存是如何工作的。
接收队列与发送队列非常相似,不同之处在于它由空的分组缓冲区组成,等待接收的分组填充。因此,在网络空闲时,发送队列是空的(因为所有分组都已经发送),而接收队列是满的(由空分组缓冲区组成)。
当E1000接收到一个数据包时,它首先检查它是否与网卡配置的过滤器匹配(例如,查看数据包是否指向这个E1000的MAC地址),如果数据包与任何过滤器都不匹配,则忽略它。否则,E1000尝试从接收队列的头部检索下一个接收描述符。如果头部(RDH)赶上了尾部(RDT),则接收队列超出了空闲描述符,因此卡片丢弃分组。如果有空闲的接收描述符,则将数据包数据复制到描述符指向的缓冲区中,设置描述符的DD (descriptor Done)和EOP (End of packet)状态位,并增加RDH。
数据包过滤、是否有空间接收、是未知描述符字段
如果E1000在一个接收描述符中接收到一个比数据包缓冲区大的数据包,它将从接收队列中检索所需数量的描述符来存储数据包的全部内容。为了表示这种情况已经发生,它会在所有描述符上设置DD状态位,但只在最后一个描述符上设置EOP状态位。您可以在您的驱动程序中处理这种可能性,或者简单地配置网卡不接受“长数据包”(也称为巨型帧),并确保您的接收缓冲区足够大,以存储最大的可能的标准以太网数据包(1518字节)。
接收大的数据包时,将其存储在多个描述符队列所指向的缓冲区中,但是只在最后一个描述符中设置EOP(表示是最后一个),其他全部设置DD。
按照14.4节中的过程建立接收队列并配置E1000。您不必支持“长分组”或多播。现在,不要配置卡来使用中断。如果您决定使用receive中断,则稍后可以更改此设置。此外,配置E1000以剥离以太网CRC,因为grade脚本希望它被剥离。
默认情况下,该卡将过滤掉所有数据包。您必须将接收地址寄存器(RAL和RAH)配置为网卡自己的MAC地址,以便接收到该网卡的数据包。你可以简单地硬编码QEMU的默认MAC地址52:54:00:12:34:56(我们已经在lwIP中硬编码了,所以在这里也这样做不会让事情变得更糟)。注意字节顺序;MAC地址从最低位字节写到最高位字节,因此52:54:00:12是MAC地址的低阶32位,34:56是高阶16位。
E1000只支持一组特定的接收缓冲区大小(在RCTL的描述中给出。13.4.22中的BSIZE)。如果您将接收数据包的缓冲区设置得足够大,并禁用长数据包,则不必担心数据包跨越多个接收缓冲区。另外,请记住,就像传输队列一样,接收队列和分组缓冲区在物理内存中必须是连续的。
你应该至少使用128个接收描述符
初始化E1000(14.4):
设置接收地址寄存器(RAL和RAH)为MAC地址 52:54:00:12(RAL):34:56(RAH)——从低到高
RAL=0x12005452 RAH=
数据包跨越多个缓冲区的问题
e1000.h
#define RXDESCS 128
#define RX_PKT_SIZE 1518
#define E1000_RCTL 0x00100
#define E1000_RCTL_EN 0x00000002 /* enable */
#define E1000_RCTL_BAM 0x00008000 /* broadcast enable */
#define E1000_RCTL_SECRC 0x04000000 /* Strip Ethernet CRC */
#define E1000_RDBAL 0x02800 /* RX Descriptor Base Address Low - RW */
#define E1000_RDBAH 0x02804 /* RX Descriptor Base Address High - RW */
#define E1000_RDLEN 0x02808 /* RX Descriptor Length - RW */
#define E1000_RDH 0x02810 /* RX Descriptor Head - RW */
#define E1000_RDT 0x02818 /* RX Descriptor Tail - RW */
#define E1000_RA 0x05400 /* Receive Address - RW Array */
#define E1000_RAH_AV 0x80000000 /* Receive descriptor valid */
#define E1000_RXD_STAT_DD 0x01 /* Descriptor Done */
#define E1000_RXD_STAT_EOP 0x02 /* End of Packet */
struct e1000_rx_desc {
uint64_t addr;
uint16_t length;
uint16_t chksum;
uint8_t status;
uint8_t errors;
uint16_t special;
}__attribute__((packed));
struct e1000_rdlen {
unsigned zero: 7;
unsigned len: 13;
unsigned rsv: 12;
};
struct e1000_rdh {
uint16_t rdh;
uint16_t rsv;
};
struct e1000_rdt {
uint16_t rdt;
uint16_t rsv;
};
static void e1000_receive_init();
e1000.c
struct e1000_rdh *rdh;
struct e1000_rdt *rdt;
struct e1000_rx_desc rx_desc_array[RXDESCS];//接收描述符队列
char rx_buffer_array[RXDESCS][RX_PKT_SIZE];//接收缓冲区
//MAC地址
uint32_t E1000_MAC[6] = {0x52, 0x54, 0x00, 0x12, 0x34, 0x56};
//配置MAC地址
static void
get_ra_address(uint32_t mac[], uint32_t *ral, uint32_t *rah)
{
uint32_t low = 0, high = 0;
int i;
for (i = 0; i < 4; i++) {
low |= mac[i] << (8 * i);
}
for (i = 4; i < 6; i++) {
high |= mac[i] << (8 * i);
}
*ral = low;
*rah = high | E1000_RAH_AV;
}
static void
e1000_receive_init()
{
//RDBAL and RDBAH register确定寄存器基址
uint32_t *rdbal = (uint32_t *)E1000REG(E1000_RDBAL);
uint32_t *rdbah = (uint32_t *)E1000REG(E1000_RDBAH);
//物理地址初始化
*rdbal = PADDR(rx_desc_array);
*rdbah = 0;
//为接收描述符队列的addr字段指定接收缓冲区的物理地址
//一个描述符对应一个缓冲区
int i;
for (i = 0; i < RXDESCS; i++) {
rx_desc_array[i].addr = PADDR(rx_buffer_array[i]);
}
//RDLEN register
struct e1000_rdlen *rdlen = (struct e1000_rdlen *)E1000REG(E1000_RDLEN);
rdlen->len = RXDESCS;//缓冲区大小
//RDH and RDT register 存储描述符队列头、尾的指针索引
rdh = (struct e1000_rdh *)E1000REG(E1000_RDH);
rdt = (struct e1000_rdt *)E1000REG(E1000_RDT);
rdh->rdh = 0;
//注意RDT是数组的最后一个元素的位置,因为当RDT=RDH时表示队列已经满了
rdt->rdt = RXDESCS-1;
//接收控制寄存器
uint32_t *rctl = (uint32_t *)E1000REG(E1000_RCTL);
//EN(Receiver Enable):使receiver开始工作
//BAM(Broadcast Accept Mode):设置广播模式——设置:接收所有广播数据包,不设置:只有匹配才接收
//SECRC(Strip Ethernet CRC from incoming packe):JOS测试需要,剥离接受包中的CRC(循环校验)
*rctl = E1000_RCTL_EN | E1000_RCTL_BAM | E1000_RCTL_SECRC;
//为RAL和RAH设置MAC地址
uint32_t *ra = (uint32_t *)E1000REG(E1000_RA);
uint32_t ral, rah;
get_ra_address(E1000_MAC, &ral, &rah);
ra[0] = ral;
ra[1] = rah;
}
下面给出初始化值的解释:
RDBAL and RDBAH:RDBAL初始化为接收缓冲区的基地址的物理地址,因为硬件直接在物理地址上工作,RDBAH初始化为0即可
rx_desc_array[i].addr:描述符队列对应地址的初始化为对应缓冲区的地址
rdlen->len:缓冲区数目大小
rdh->rdh = 0:
rdt->rdt = RXDESCS-1;//注意RDT是数组的最后一个元素的位置,因为当RDT=RDH时表示队列已经满了
//EN(Receiver Enable):使receiver开始工作
//BAM(Broadcast Accept Mode):设置广播模式——设置:接收所有广播数据包,不设置:只有匹配才接收
//SECRC(Strip Ethernet CRC from incoming packe):JOS测试需要,剥离接受包中的CRC(循环校验)
*rctl = E1000_RCTL_EN | E1000_RCTL_BAM | E1000_RCTL_SECRC;
为RAL和RAH设置MAC地址:使用get_ra_address(E1000_MAC, &ral, &rah);配置MAC地址
您现在可以对接收功能进行基本测试,甚至不需要编写接收数据包的代码。运行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER Run -net_testinput。testinput将传输一个ARP(地址解析协议)通知包(使用您的包传输系统调用),QEMU将自动回复它。即使你的驱动程序还没有收到这个回复,你应该看到一个“e1000: unicast match[0]: 52:54:00:12:34:56”的消息,表明一个包被e1000接收并匹配配置的接收过滤器。如果你看到一个“e1000: unicast mismatch: 52:54:00:12:34:56”的消息,e1000会过滤掉这个数据包,这意味着你可能没有正确配置RAL和RAH。确保你得到了正确的字节顺序,并且没有忘记设置RAH中的“地址有效”位。如果您没有收到任何“e1000”消息,则可能没有正确启用receive。
测试得到正确结果:
现在可以实现接收数据包了。为了接收一个数据包,你的驱动程序必须跟踪它希望保存下一个接收到的数据包的描述符(提示:根据你的设计,E1000中可能已经有一个寄存器记录这个信息)。与transmit类似,文档指出,RDH寄存器不能从软件中可靠地读取,因此为了确定分组是否已经发送到描述符的分组缓冲区,你必须读取描述符中的DD状态位。如果设置了DD位,就可以从该描述符的数据包缓冲区中复制数据包数据,然后通过更新队列的尾部索引RDT来告诉网卡该描述符是空闲的。
注意: (RDH寄存器不能从软件中可靠地读取)在接收描述符队列中,只有设置了DD才表示数据在缓冲区中(因为初始化的时候没有设置)
如果没有设置DD位,则表示没有收到报文。在接收端,这相当于发送队列已满,在这种情况下可以做几件事。你可以简单地返回一个“try again”错误,并要求调用者重试。虽然这种方法适用于完整的传输队列,因为这是一种瞬态条件,但对于空的接收队列来说,这种方法就不太合理了,因为接收队列可能会在很长一段时间内保持空状态。第二种方法是暂停调用环境,直到接收队列中有分组需要处理为止。该策略与sys_ipc_recv非常类似。就像IPC的情况一样,由于每个CPU只有一个内核栈,因此一旦离开内核,栈上的状态就会丢失。我们需要设置一个标志,表明环境已经通过接收队列下溢挂起,并记录系统调用参数。这种方法的缺点是复杂:必须指示E1000生成接收中断,而驱动程序必须处理这些中断,以便恢复阻塞的环境,等待一个分组。
编写一个函数从E1000接收一个数据包,并通过添加一个系统调用将其暴露给用户空间。确保接收队列为空。
注意:
编写这个程序的时候需要清晰明白接收寄存器中RDT和RDH的含义。
RDH和RDT是头尾指针,存放相对基址的偏移量,RDH的值由硬件增加,表示指向下一次DMA将用的描述符
RDT由软件增加 表示下一次要处理并送交协议栈的有关描述
在接收数据流程的时候,其作用为:
RDT为尾指针,指向最后一个可用描述符,RDH和RDT之间的描述符为网卡可用描述符,RDT由驱动来移动,驱动从第一个描述符开始,轮询DD位是否为1,为1就认为此描述符对应的mbuf有报文,此时会申请新的mbuf,将新mbuf物理地址写到此描述符的pkt_addr,并将DD位置0,这样的话此描述符就又可用被网卡使用了,同时将老的有报文的mbuf返回给用户。描述符再次可用后,驱动就可以更新RDT指向此描述符,为了性能考虑不会每次都会更新RDT,而是等可用描述符超过一定阈值(rx_free_thresh)才更新一次(我们没考虑这个,所以轮询检查)。
dpdk网卡收发包原理 - 蚂蚁没问题 (sisyphus1212.github.io)
小结一下:接收数据包时,网卡接收来自线路上的数据包,将其写入RDH描述符指向的缓冲区,并设置DD位表示数据已接收(所以第一个数据包是在数组下标为0的位置);网卡驱动根据RDT指向在描述符队列中找到其数据包,判断描述符DD字段,若存在数据将其拷贝到recieve函数的传出参数中,并增加RDT的值,修改DD字段
e1000.c
//接收数据包 data:传出参数
int
e1000_receive(void *data, size_t *len)
{
//使用static变量(初始化一次)
static int32_t next = 0;
//如果第一个描述符队列都没有设置DD,那么说明极有可能没有数据,重试
if(!(rx_desc_array[next].status & E1000_RXD_STAT_DD)) { //simply tell client to retry
return -E_RECEIVE_RETRY;
}
//接收了错误的数据包:重试
if(rx_desc_array[next].errors) {
cprintf("receive errors\n");
return -E_RECEIVE_RETRY;
}
*len = rx_desc_array[next].length;
//将数据传出到data中
memcpy(data, rx_buffer_array[next], *len);
rdt->rdt = (rdt->rdt + 1) % RXDESCS;
next = (next + 1) % RXDESCS;
return 0;
}
在网络服务器输入环境中,你需要使用新的receive系统调用来接收数据包,并使用NSREQ_INPUT IPC消息将它们传递给核心网络服务器环境。这些IPC输入消息应该有一个附带有union Nsipc的页面,其中包含struct jif_pkt pkt字段,其中填写了从网络接收到的分组。
Implement net/input.c.
看注释就能懂
#include "ns.h"
#include "kern/e1000.h"
//一个完整的页面
extern union Nsipc nsipcbuf;
//挂起msec毫秒
void
sleep(int msec)
{
//利用实验刚开始设置的时钟中断获得当前时间
unsigned now = sys_time_msec();
unsigned end = now + msec;
//不合法
if ((int)now < 0 && (int)now > -MAXERROR)
panic("sys_time_msec: %e", (int)now);
//挂起msec秒
while (sys_time_msec() < end)
sys_yield();//停一会儿
}
void
input(envid_t ns_envid)
{
binaryname = "ns_input";
// LAB 6: Your code here:
// - read a packet from the device driver
// - send it to the network server
// Hint: When you IPC a page to the network server, it will be
// reading from it for a while, so don't immediately receive
// another packet in to the same physical page.
size_t len;
//保存从缓冲区中得到的数据包
char buf[RX_PKT_SIZE];
//循环取出接收缓冲区中的数据包
while (1) {
//如果没有数据包就继续判断
if (sys_pkt_recv(buf, &len) < 0) {
continue;
}
memcpy(nsipcbuf.pkt.jp_data, buf, len);//保存数据包到jp_data
nsipcbuf.pkt.jp_len = len;//数据包大小
//发送数据包给core network server
ipc_send(ns_envid, NSREQ_INPUT, &nsipcbuf, PTE_P|PTE_U|PTE_W);
//等待50毫秒,保证数据包被core network server完整接收
sleep(50);
}
}
再次运行testinput命令,make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER Run -net_testinput。你应该看到
Sending ARP announcement...
Waiting for packets...
e1000: index 0: 0x26dea0 : 900002a 0
e1000: unicast match[0]: 52:54:00:12:34:56
input: 0000 5254 0012 3456 5255 0a00 0202 0806 0001
input: 0010 0800 0604 0002 5255 0a00 0202 0a00 0202
input: 0020 5254 0012 3456 0a00 020f 0000 0000 0000
input: 0030 0000 0000 0000 0000 0000 0000 0000 0000
以“input:”开头的行是QEMU的ARP回复的hexdump。
你的代码应该能通过make grade的testinput测试。请注意,如果不发送至少一个ARP包来通知QEMU JOS的IP地址,就无法测试包接收,因此传输代码中的bug可能会导致测试失败。
为了更全面地测试网络代码,我们提供了一个名为echosrv的守护进程,它在端口7上设置了一个echo服务器,可以回显通过TCP连接发送的任何内容。使用make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-echosrv在一个终端中启动echo服务器,并在另一个终端中连接nc-7。你输入的每一行都应该被服务器回显。模拟的E1000每次接收到数据包时,QEMU都应该在控制台中打印如下内容:
e1000: unicast match[0]: 52:54:00:12:34:56
e1000: index 2: 0x26ea7c : 9000036 0
e1000: index 3: 0x26f06a : 9000039 0
e1000: unicast match[0]: 52:54:00:12:34:56
web服务器以最简单的形式将文件的内容发送给请求的客户机。我们已经在user/ httpdc中提供了一个非常简单的web服务器的框架代码。框架代码处理传入的连接并解析首部headers。
web服务器缺少将文件内容发送回客户端的代码。通过实现send_file和send_data来完成web服务器。
//发送文件资源
static int
send_file(struct http_request *req)
{
int r;
off_t file_size = -1;
int fd;
// open the requested url for reading
// if the file does not exist, send a 404 error using send_error
// if the file is a directory, send a 404 error using send_error
// set file_size to the size of the file
// LAB 6: Your code here.
//读取对应的URL文件
if ((fd = open(req->url, O_RDONLY)) < 0) {
send_error(req, 404);//资源不存在
goto end;
}
struct Stat stat;
//获取文件状态信息 fd必须是已打开的文件描述符
fstat(fd, &stat);
//判断是否为目录
if (stat.st_isdir) {
send_error(req, 404);
goto end;
}
//写入响应行
if ((r = send_header(req, 200)) < 0)
goto end;
//写入文件的大小 Content-Length:
if ((r = send_size(req, file_size)) < 0)
goto end;
//写入文件类型Content-Type:
if ((r = send_content_type(req)) < 0)
goto end;
//结束响应行:\r\n
if ((r = send_header_fin(req)) < 0)
goto end;
//发送实际的文件资源
r = send_data(req, fd);
end:
close(fd);
return r;
}
//将文件数据写入通信套接字 响应体
static int
send_data(struct http_request *req, int fd)
{
// LAB 6: Your code here.
struct Stat stat;
fstat(fd, &stat);//获取文件状态
void *buf = malloc(stat.st_size);
//read from file 从描述符fd中读取size个字节,存入buf指针的位置
if (readn(fd, buf, stat.st_size) != stat.st_size) {
panic("Failed to read requested file");
}
//write to socket
if (write(req->sock, buf, stat.st_size) != stat.st_size) {
panic("Failed to send bytes to client");
}
free(buf);
return 0;
}
一旦你完成了web服务器,启动网络服务器(让run-httpd-nox)和你最喜欢的浏览器指向http://host:port/index.html,,主机是电脑运行QEMU的名字(如果你运行QEMU雅典娜使用hostname.mit.edu(主机名是雅典娜hostname命令的输出,或本地主机如果你运行web浏览器和QEMU在同一台计算机上)和端口的端口号报道让哪些端口的web服务器。您应该看到由运行在JOS中的HTTP服务器提供的web页面。
在这一点上,你的成功分数应该是105/105。
This completes the lab. As usual, don't forget to run make grade and to write up your answers and a description of your challenge exercise solution. Before handing in, use git status and git diff to examine your changes and don't forget to git add answers-lab6.txt. When you're ready, commit your changes with git commit -am 'my solutions to lab 6', then make handin and follow the directions.
结束了!!!!