6-828Lab6

Network Driver (default final project)

Lab 6 Network Driver

网卡基于E1000芯片

除了编写驱动之外,还需要创建一个 system call 来访问我们的驱动程序。我们将实现缺失的网络服务器代码,以在网络堆栈和驱动程序之间传输数据包。我们还可以通过完成web服务器将所有内容绑定在一起。使用新的web服务器,我们能够从文件系统中提供文件。

没有框架文件,没有固定的系统调用接口,许多设计决策都留给您。

QEMU’s virtual network

QEMU是一个模拟处理器软件,默认提供一个运行在IP 10.0.2.2 上的虚拟路由,并为JOS分配IP 10.0.2.15 ,为了解决内网连接问题,host无法运行在QEMU上的web server,我们配置QEMU在主机某个端口上运行服务。该端口只需连接到JOS中的某个端口,并在真实主机和虚拟网络之间来回传送数据。

image-20220710105135537

echo server是回显服务,用户输入什么,服务器将输入信息返回给客户端。

webserver 是指驻留于因特网上某种类型计算机的程序。当Web浏览器(客户端)连到服务器上并请求文件时,服务器将处理该请求并将文件反馈到该浏览器上,附带的信息会告诉浏览器如何查看该文件(即文件类型)。服务器使用HTTP(超文本传输协议)与客户机浏览器进行信息交流,这就是人们常把它们称为HTTP服务器的原因。目前最主流的三个Web服务器是Apache Nginx IIS。

Packet Inspection

Makefile 配置了QEMU的网络栈,将进出的网络包记录到 qemu.pcap中。我们可以使用图形化的 wireshark,或是使用命令行获得网络包的hex/ASCII信息。

1
tcpdump -XXnr qemu.pcap

Debugging the E1000

The E1000 can produce a lot of debug output, so you have to enable specific logging channels. Some channels you might find useful are:

Flag Meaning
tx 日志发送操作
txerr 日志发送错误
rx Log changes to RCTL
rxfilter Log 日志入方向过滤
rxerr Log 接受错误
unknown Log 对未知寄存器进行读写操作
eeprom Log reads from the EEPROM(带电可擦可编程只读存储器)
interrupt Log 记录中断和中断寄存器的更改。

To enable “tx” and “txerr” logging, for example, use make E1000_DEBUG=tx,txerr ….

The Network Server

从零开始写一个网络栈是十分困难的,因此我们使用lwIP, 其是一个开源的轻量级TCP/IP协议套件,其中包含网络栈。

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

  • 核心网络服务器环境(包括套接字调用调度程序和lwIP)

  • 输入环境

  • 输出环境

  • 计时器的环境

我们将实现图中标记为绿色的部分。

MIT6.828 Lab6_Network Driver

The Core Network Server Environment

Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。

img

img

img

For each user environment IPC, the dispatcher in the network server calls the appropriate BSD socket interface function provided by lwIP on behalf of the user.

Regular user environments do not use the nsipc_* calls directly. Instead, they use the functions in lib/sockets.c, which provides a file descriptor-based sockets API.

文件服务器和网络服务器之间有一个关键的区别。像accept和recv这样的BSD套接字调用可以无限阻塞。由于这是不可接受的,网络服务器使用用户级线程来避免阻塞整个服务器环境。

除了核心网络环境之外,还有三个辅助环境。除了接收来自用户应用程序的消息外,核心网络环境的调度程序还接收来自输入环境和计时器环境的消息。

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

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

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

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

输出环境

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

输入环境

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

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

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

定时器环境

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

Part A: Initialization and transmitting packets 实现和转发包(报文)

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

我们需要为我们的内核添加时间概念。使用时钟中断,并用一个变量进行计数。

Add a call to time_tick for every clock interrupt in kern/trap.c(in kern/time.c). Implement sys_time_msec and add it to syscall in kern/syscall.c so that user space has access to the time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1
// LAB 4: Your code here.
case (IRQ_OFFSET + IRQ_TIMER):
// 回应8259A 接收中断。
lapic_eoi();
time_tick();
sched_yield();
break;
// 2
static int
sys_time_msec(void)
{
// LAB 6: Your code here.
return time_msec();
// panic("sys_time_msec not implemented");
}
// 3
case SYS_time_msec:
return sys_time_msec();

The Network Interface Card

Browse Intel’s Software Developer’s Manual for the E1000. 本手册涵盖了几个密切相关的以太网控制器。QEMU模拟82540EM。

接收和发送描述:

数据包接收

​ 识别到网络上存在一个数据包,

​ 执行地址过滤,

​ 将数据包存储在接收数据FIFO中,

​ 将数据传输到主机内存中的接收缓冲区,

​ buffer size RCTL.BSIZE & RCTL.BSEX

​ updating the state of a receive descriptor.

​ Status

​ Errors

​ Specia

PCI Interface

PCI是外围设备互连(Peripheral Component Interconnect)的简称,是在目前计算机系统中得到广泛应用的通用总线接口标准:

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

为了在引导期间执行PCI初始化,PCI代码遍历PCI总线以寻找设备。 当它找到设备时,它会读取其供应商ID和设备ID,并使用这两个值作为搜索pci_attach_vendor数组的键。 该数组由struct pci_driver条目组成,如下所示:

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

如果发现的设备的供应商ID和设备ID与数组中的条目匹配,则PCI代码会调用该条目的attachfn来执行设备初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
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_func的最后三个成员,因为它们记录了设备的negotiated memory,I/O 和中断资源。 reg_basereg_size数组包含最多六个基址寄存器或BAR的信息。reg_base存储内存映射 I/O 区域的基地址(或 I/O 端口资源的基本 I/O 端口),reg_size包含reg_base中相应基值的大小(以字节为单位)或 I/O 端口数, 和irq_line包含分配给设备的 IRQ line 以进行中断。

pci_func_enable将会使能设备,协调资源,并将其填入struct pci_func

Exercise 3

Implement an attach function to initialize the E1000.

查看手册以及根据内核启动时的打印信息,我们可以知道 E1000 的 Vender ID = 0x8086, Device ID = 0x100E。

Add an entry to the pci_attach_vendor array in kern/pci.c to trigger your function if a matching PCI device is found,将E1000加入数组中

1
2
3
4
struct pci_driver pci_attach_vendor[] = {
{ PCI_E1000_VENDOR_ID, PCI_E1000_DEVICE_ID, &pci_e1000_attach},
{ 0, 0, 0 },
};

编写 e1000.c and e1000.h文件。.c文件写 attach函数, 在.h 文件中定义其 PCI ID等信息。

just enable the E1000 device via pci_func_enable.

Memory-mapped I/O

软件通过 Memory-mapped I/O 与 E1000 进行通信。之前我们已经两次接触过 MMIO这个概念了,分别在 CGA控制台和 lapic。 我们通过内存地址对设备进行读写。这些以内存地址为基础的读写目标并不是 DRAM,而是设备。

pci_func_enable 为 E1000分配了一个 MMIO 区域并且在 BAR0 中存储了它的 base and size。这是分配给设备的一系列物理内存地址,这意味着我们必须通过虚拟地址访问它。 由于MMIO区域被分配了非常高的物理地址(通常高于3GB),因为JOS的256MB限制,我们无法使用KADDR(内核地址)访问它。所以我们将创建一个新的映射。我们还是使用mmio_map_region分配MMIOBASE以上的区域,其保证了我们不会修改到之前创建的 LAPIC 映射。由于PCI设备初始化发生在JOS创建用户环境之前,因此您可以在kern_pgdir中创建映射,并且它总是可用的。

Exercise 4

In your attach function, create a virtual memory mapping for the E1000’s BAR 0 by calling mmio_map_region (which you wrote in lab 4 to support memory-mapping the LAPIC).

将E1000的物理地址映射到虚拟地址,同时记录映射的虚拟地址,方便之后对 E1000 设备的访问。

1
2
// 映射,并保存其虚拟地址,方便访问。
e1000 = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
1
2
3
4
5
6
7
8
cprintf("device status:[%08x]\n", *(uint32_t *)((uint8_t *)e1000 + E1000_DEVICE_STATUS));
// 或者写为 e1000[E1000_DEVICE_STATUS >> 2];
#define E1000_LOCATE(offset) (offset >> 2)
cprintf("device status:[%08x]\n", e1000[E1000_LOCATE(E1000_DEVICE_STATUS)]);

// 结果
PCI function 00:03.0 (8086:100e) enabled
device status:[80080783]

DMA

通过读写 E1000 寄存器来传输接收数据包是十分低效的做法,并且还需要E1000 缓存数据包。因此考虑到各个方面的因素,我们采用 DMA 的方式直接访问内存。驱动程序负责为发送和接收队列分配内存,设置DMA描述符,并使E1000定位到这些队列,但之后的所有内容都会变成异步的。

从抽象层来看,接收和发送队列非常相似。 两者都由一系列描述符组成。 虽然这些描述符的确切结构各不相同,但每个描述符包含一些标志和数据包数据缓冲区的物理地址(要发送的网卡的数据包数据,或者操作系统为网卡写入接收数据包而分配的缓冲区)。

Transmitting Packets

初始化要发送的网卡。传输初始化的第一步是设置传输队列。

MIT6.828 Lab6_Network Driver

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Transmit Descriptor */
struct E1000TxDesc {
uint64_t buffer_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));

在驱动程序初始化期间为每个描述符保留数据包缓冲区的空间,并简单地将数据包数据复制到这些预分配的缓冲区中。

Exercise 5

执行第14.5节(但不是它的子节)中描述的初始化步骤。使用第13节作为初始化过程中引用的寄存器的参考,使用第3.3.3节和第3.4节作为传输描述符和传输描述符数组的参考。

在对各寄存器进行初始化时,要不断得在第13章找到其偏移地址和详细定义。最后按照要求写入地址。

我们必须将要传输的数据包添加到传输队列的尾部,这意味着需要将数据包数据复制到下一个数据包缓冲区,然后更新TDT(传输描述符尾部)寄存器以通知网卡在传输队列中有另一个数据包。[Note] TDT 是传输描述符数组的索引而不是偏移地址。

如果在发送描述符的命令字段中设置RS(Report Status)位,则当网卡在该描述符中发送了数据包时,网卡将在描述符的状态字段中设置DD(Descriptor Done)位。 如果设置了描述符的DD位,我们就能回收该描述符并使用它来传输另一个数据包。

如果用户调用传输系统调用,但下一个描述符的DD位未置位,表明传输队列已满,该怎么办? 你必须决定在这种情况下该做什么。你可以简单地丢弃数据包。网络协议对此具有弹性,但如果丢弃大量数据包,协议可能无法恢复。我们可以告诉用户环境它必须重试,就像我们对sys_ipc_try_send所做的那样。This has the advantage of pushing back on the environment generating the data.。

Exercise 6

编写一个函数,通过检查下一个描述符是否空闲来传输一个包,将包数据复制到下一个描述符,并更新TDT。确保您处理的传输队列已满。

Exercise 7

添加一个系统调用,允许您从用户空间传输数据包。具体的界面取决于您。不要忘记检查从用户空间传递给内核的指针

Transmitting Packets: Network Server传输报文:网络服务器

既然我们已经有了 packet send 的系统调用,也是时候发送数据包了。output helper environment 运行在一个循环中:从 core network server 接收 NSREQ_OUTPUT IPC 信息,使用 system call 将伴随 IPC 发送过来的 Packet 发送到设备驱动中。 NSREQ_OUTPUT由在net/lwip/jos/jif/jif.c文件中的low_level_output 函数发送 , which glues the lwIP stack to JOS’s network system. 每个IPC 都包含一个由 union Nsipc构成的页,packet 存在于 struct kif_pkt字段。struct jif_pkt looks like:

1
2
3
4
struct jif_pkt {
int jp_len;
char jp_data[0];
};

在结构的末尾使用像jp_data这样的零长度数组是用于表示没有预定长度的缓冲区的常见C技巧(有些人会说是恶心)。 由于C不进行数组边界检查,只要确保结构后面有足够的未使用内存,就可以使用jp_data,就好像它是一个任意大小的数组一样。

Exercise 8

Implement net/output.c.

/net/serv.c中创建了一个相当于守护进程output,在output 中我们需要接收 ipc, 解析信息然后调用 system call 发送数据包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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) {
req = ipc_recv((envid_t *)&whom, &nsipcbuf, &perm);
if (req != NSREQ_OUTPUT) {
continue;
}
while (sys_pkt_try_send(nsipcbuf.pkt.jp_data, nsipcbuf.pkt.jp_len) < 0) {
sys_yield();
}
}
}

Linux Daemon(守护进程)是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。它不需要用户输入就能运行而且提供某种服务,不是对整个系统就是对某个用户程序提供服务。Linux系统的大多数服务器就是通过守护进程实现的。常见的守护进程包括系统日志进程syslogd、 web服务器httpd、邮件服务器sendmail和数据库服务器mysqld等。