🍔Introduction
既然有了文件系统,那么接下来我们将会编写网络堆栈。在这个实验中,我们将为一张网卡编写一个驱动程序。该卡将基于 Intel 82540EM 芯片,也称为 E1000。
👵QEMU’s virtual network
我们将使用 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),因此我们无法直接连接到 JOS 内部运行的服务器,甚至无法从运行 QEMU 的主机连接。为了解决这个问题,我们将 QEMU 配置为在主机上的某个端口上运行一台服务器,该服务器只需通过 JOS 的某个端口进行连接,并在真实主机和虚拟网络之间来回穿梭数据。
您将在端口 7(echo 服务)和 80(http 服务)上运行 JOS 服务器。为了避免在共享的计算机上发生冲突,makefile 会根据用户 ID 生成转发端口。要了解 QEMU 在开发主机上转发到哪些端口,请运行 make which-ports。为了方便起见,makefile 还提供 make nc-7 和 make nc-80,它们允许您直接与终端中这些端口上运行的服务器进行交互。
👚Packet Inspection
makefile 配置的 QEMU 网络堆栈会记录所有传入和传出的数据包 qemu.pcap 在实验目录中。
要获得捕获数据包的十六进制/ASCII 格式文本,请使用如下的 tcpdump 命令:
tcpdump -XXnr qemu.pcap
或者,可以使用 Wireshark 以图形方式检查 pcap 文件,Wireshark 还可以解码和检查数百个网络协议。
🩲Debugging the E1000
我们很幸运能使用仿真硬件;由于 E1000 是在软件中运行的,因此模拟的 E1000 可以以用户可读的格式报告其内部状态和遇到的问题。
E1000 可以产生大量调试输出,因此必须启用特定的日志通道。您可能会发现一些有用的通道:
| 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 和 txer 日志记录,请使用:
make E1000_DEBUG=tx,txer...
🧓The Network Server
从头开始编写网络堆栈是一项很困难的工作。所以,我们将使用 lwIP,这是一个开源的轻量级 TCP/IP 协议套件,其中包括一个网络堆栈。在这个实验中对我们而言,lwIP 是一个实现 BSD 套接字接口的黑盒,具有数据包输入端口和数据包输出端口。
网络服务器实际上是四种环境的组合:
- 核心网络服务器环境(包括 socket call dispatcher 和 lwIP)
- 输入环境
- 输出环境
- 定时器环境
下图显示了不同的环境及其关系。该图显示了整个系统,包括稍后将介绍的设备驱动程序。在本实验中,我们将实现绿色的部分。
🩱The Core Network Server Environment
核心网络服务器环境由 socket 调用调度器和 lwIP 本身组成。调度器的工作方式与文件服务器完全相同。用户环境使用 stubs(位于 lib/nsipc.c 中)向核心网络环境发送 IPC 消息。在 lib/nsipc.c,将会发现找寻核心网络服务器的方式与找到文件服务器的方式相同:i386init 创建了具有 NS_TYPE_NS 类型的环境,因此我们扫描 envs数组,查找这种特殊的环境类型。对于每个用户环境 IPC,网络服务器中的调度器代表用户调用 lwIP 提供的相应 BSD socket 接口函数。
常规用户环境不直接使用 nsipc* 函数。而是使用 lib/sockets.c 中的函数,其提供了一个基于文件描述符的 sockets API。因此,用户环境通过文件描述符引用套接字,就像它们引用磁盘文件一样。
许多操作(connect、accept 等)都是特定于套接字的,但是读、写和关闭都要通过 lib/fd.c 中的普通文件描述符设备调度代码来完成。与文件服务器为所有打开的文件维护内部唯一 ID 的方式非常相似,lwIP 还为所有打开的套接字生成唯一 ID。在文件服务器和网络服务器中,我们都使用 Fd 结构体存储的信息将每个环境的文件描述符映射到这些唯一的 ID 空间。
尽管看起来文件服务器和网络服务器的 IPC 调度器的行为是相同的,但有一个关键的区别。像 accept 和 recv 这样的 BSD 套接字调用可以无限期地阻塞。如果调度器让 lwIP 执行其中一个阻塞调用,那么调度器也会阻塞,并且整个系统一次只能有一个未完成的网络调用。这是不可接受的,所以网络服务器使用用户级线程来避免阻塞整个服务器环境。对于每个传入的 IPC 消息,调度器都创建一个线程,并在新创建的线程中处理请求。如果线程阻塞,则只有该线程处于休眠状态,而其他线程继续运行。
除了核心网络环境外,还有三个辅助环境。除了接受来自用户应用程序的消息外,核心网络环境的 dispatcher 还接受来自输入和计时器环境的消息。
👙The Output Environment
当服务于用户环境套接字调用时,lwIP 将生成数据包供网卡传输。lwIP 将使用 NSREQ_OUTPUT IPC 消息将要传输的每个数据包发送到 output helper 环境,数据包附加在 IPC 消息的 page 参数中。输出环境负责接受这些消息,并通过即将创建的系统调用接口将数据包转发到设备驱动程序。
👛The Input Environment
网卡接收到的数据包需要注入 lwIP。对于设备驱动程序接收到的每个数据包,输入环境都会将数据包从内核空间中取出,并使用 NSREQ_INPUT IPC 消息将数据包发送到核心服务器环境。
因为 JOS 很难同时接受 IPC 消息和轮询或等待来自设备驱动程序的数据包,所以将数据包输入功能与核心网络环境进行了分离。我们在 JOS 中没有允许环境监视多个输入源,以确定哪些输入已准备好进行处理的 select 系统调用。
查看 net/input.c 和 net/output.c,您将看到这两个都需要实现。这主要是因为实现依赖于系统调用接口。在实现驱动程序和系统调用接口之后,您将为这两个辅助环境编写代码。
👜The Timer Environment
计时器环境定期向核心网络服务器发送类型为 NSREQ_TIMER 的消息,通知其计时器已过期。lwIP 使用来自该线程的计时器消息来实现各种网络超时。
🥘Part A: Initialization and transmitting packets
现在的 JOS 内核没有时间的概念,所以我们需要添加它。目前,硬件每 10 毫秒产生一个时钟中断。在每个时钟中断上,我们可以增加一个变量来指示时间已过去了10毫秒。在 kern/time.c 中已经实现了这个功能,但尚未完全集成到内核中。
Exercise 1。
使用 make INIT_CFLAGS=-DTEST_NO_NS run-testtime,将会看到系统从 5 数到 0。
🧒The Network Interface Card
编写驱动程序需要深入了解硬件和软件接口。在编写驱动程序时,您需要大量使用英特尔的硬件手册。
Exercise 2。
👝PCI Interface
E1000 是 PCI 设备,它将会接入主板上的 PCI 总线。PCI 总线有地址线、数据线和中断线,允许 CPU 与 PCI 设备通信,并允许 PCI 设备读写内存。PCI 设备在使用前需要被发现和初始化。发现是在 PCI 总线上寻找连接设备的过程。初始化是分配 I/O 和内存空间以及协商设备要使用的 IRQ 线的过程。
在 kern/PCI.c 中您提供了 PCI 代码。为了在引导期间执行 PCI 初始化,PCI 代码会在 PCI 总线上寻找设备。当它找到一个设备时,它读取它的供应商 ID 和设备 ID ,并使用这两个值作为键来搜索 pci_attach_vendor 数组。数组由如下 pci_driver 结构体项组成:
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;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;};
pci_func 结构体的最后三个条目是尤其要关注的,因为它们记录了设备的协商内存、I/O 和中断资源,reg_base 和 reg_size 数组包含最多六个基址寄存器或 BARs 的信息:
- reg_base 存储内存映射 I/O 区域(或 I/O 端口资源的基本 I/O 端口)的基本内存地址
- reg_size 包含 reg_base 中相应基本值的大小(以字节为单位)或 I/O 端口数
- irq_line包含分配给中断设备的 irq_line
调用设备的附加功能时,该设备已找到,但尚未启用。这意味着PCI代码还没有确定分配给设备的资源,例如地址空间和 IRQ 行。因此,PCI_func 结构体的最后三个元素还没有填充。attachfn 应该调用 pci_func_enable,它将启用设备,协商这些资源,并填写 pci_func 结构体。
Exercise 3。
🛍Memory-mapped I/O
软件通过内存映射 I/O(MMIO)与 E1000 通信。
pci_func_enable与 E1000 协商 MMIO 区域,并将其基数和大小存储在条目 0 中,即 reg_base[0] 和 reg_size[0]。这是分配给设备的物理内存地址范围,这意味着必须通过虚拟地址来访问它。由于 MMIO 区域被分配了非常高的物理地址(通常高于3GB),但是由于 JOS 的 256MB 限制,您不能使用 KADDR 来访问它。因此,必须创建一个新的内存映射。我们将使用 MMIOBASE 上面的区域(mmio_map_region 在 Lab4 中实现,它将确保我们不会覆盖 LAPIC 使用的映射)。由于 PCI 设备初始化发生在 JOS 创建用户环境之前,因此您可以在 kern_pgdir 中创建映射,它将始终可用。
Exercise 4。
🎒DMA
可以想象通过对 E1000 寄存器的写入和读取来发送和接收数据包,将非常缓慢,并且需要 E1000 在内部缓冲数据包数据。幸好实际情况不是这样的,E1000 使用直接内存访问(Direct Memory Access)直接从内存中读取和写入数据包,而不涉及 CPU。驱动程序负责为发送和接收队列分配内存,设置 DMA 描述符,并用这些队列的位置配置 E1000,并且之后的一切都是异步的:
- 为了传输数据包,驱动程序将其复制到传输队列中的下一个 DMA 描述符中,并通知 E1000 另一个数据包可用;当有时间发送数据包时,E1000 将从描述符中复制数据
- 同样,当 E1000 接收到一个数据包时,它会将其复制到接收队列中的下一个 DMA 描述符中,驱动程序可以在下一次机会读取该描述符
接收和传输队列在更高的抽象级别上非常相似,两者都由一系列描述符组成。虽然这些描述符的确切结构各不相同,但每个描述符都包含一些标志和分组数据的缓冲区的物理地址:
- 要发送的卡的分组数据
- 操作系统为该卡分配的将接收到的分组写入的缓冲区
队列被实现为循环数组,这意味着当卡或驱动程序到达数组的末尾时,它会返回到数组的开头。两者都有一个头指针和一个尾指针,队列的内容是这两个指针之间的描述符。硬件总是从头部消耗描述符并移动头部指针,而驱动程序总是向尾部添加描述符并移动尾部指针。
传输队列中的描述符表示等待发送的数据包(因此,在稳定状态下,传输队列为空)。对于接收队列,队列中的描述符是卡可以接收数据包的自由描述符(因此,在稳定状态下,接收队列由所有可用的接收描述符组成)。
指向这些数组的指针以及描述符中数据包缓冲区的地址都必须是物理地址,因为硬件不经过MMU直接执行DMA进出物理 RAM。
👨🦰Transmitting Packets
E1000 的发送和接收功能基本上是相互独立的,因此我们可以一次处理一个。我们将首先编写发送数据包的程序,因为我们无法在不发送数据的情况下测试接收先包。
- 必须按照第 14.5 节中描述的步骤初始化要传输的卡
- 传输初始化的第一步是设置传输队列;队列的精确结构在第3.4节中描述,描述符的结构在第3.3.3节中描述
- 我们不会使用 E1000 的 TCP 卸载功能;现在应该阅读这些部分并熟悉这些结构
👞C Structures
您会发现使用 C 的结构体来描述 E1000 的结构很方便。就像 Trapframe 结构体中所看到的一样,C 结构允许精确地在内存中布局数据。如果确实遇到字段对齐问题,请查看 GCC 的 packed 属性。
下面是,手册表 3-8 中给出的传统传输描述符:
结构体的第一个字节从右上角开始,所以将其转换为 C 结构体时要从右到左,从上到下读取。最终构建的 C 结构体如下:63 48 47 40 39 32 31 24 23 16 15 0+---------------------------------------------------------------+| Buffer address |+---------------+-------+-------+-------+-------+---------------+| Special | CSS | Status| Cmd | CSO | Length |+---------------+-------+-------+-------+-------+---------------+
驱动程序必须为传输描述符数组和传输描述符所指向的数据包缓冲区保留内存。有几种方法可以做到这一点: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 字节,这限制了这些缓冲区的大小。更复杂的驱动程序可以动态分配数据包缓冲区(例如,在网络使用率较低时减少内存开销),甚至可以直接传递由用户空间提供的缓冲区(一种称为“零拷贝”的技术)。
Exercise 5。
既然 transmit 已经初始化,那么就必须编写代码来传输数据包,并通过系统调用让用户空间可以访问它。要传输数据包,必须将其添加到传输队列的尾部,这意味着将数据包数据复制到下一个数据包缓冲区,然后更新 TDT 寄存器,以通知 e1000 传输队列中有另一个数据包(TDT 是传输描述符数组的索引,而不是字节偏移量)。
为了检测传输队列已满,但是 e1000 还没有发送完成的情况,需要向 e1000 提供一些反馈。需要注意的是,不能只使用 TDH(transmit descriptor head)寄存器;文档明确指出,从软件中读取这个寄存器是不可靠的。但是,如果在传输描述符的命令字段中设置 RS 位,则当 e1000 已在该描述符中传输数据包时,该卡将在描述符的状态字段中设置 DD 位。如果一个描述符的 DD 位被设置,你就知道可以安全地回收这个描述符并用它来传输另一个数据包。
如果用户调用 transmit 系统调用,但下一个描述符的 DD 位没有设置,该怎么办?你得决定在这种情况下该怎么办:
- 你可以把包扔了。网络协议对此具有弹性,但如果丢弃大量数据包,协议可能无法恢复
- 您可以告诉用户环境它必须重试,就像 sys_ipc_try_send 所做的那样
👨🦲Transmitting Packets: Network Server
现在有了一个到设备驱动程序传输端的系统调用接口,可以发送数据包了。output helper 环境的目标是在循环中执行以下操作:接受来自核心网络服务器输出 NSREQ_OUTPUT 类型的 IPC 消息,并使用上面添加的系统调用将这些 IPC 消息附带的数据包发送到网络设备驱动程序。
NSREQ_OUTPUT IPC 消息由 net/lwip/jos/jif/jif.c 中的 low_level_output 函数发送,该函数将 lwIP 堆栈粘附到 JOS 的网络系统。每个 IPC 消息将包含一个包含 union Nsipc 的页,该页的数据包位于其 jif_pkt 结构体中(参见 inc/ns.h)。jif_pkt 如下:
struct jif_pkt {int jp_len;char jp_data[0];};
jp_len 表示数据包的长度。IPC 页上的所有后续字节都专用于数据包内容。使用 char jp_data[0] 是表示没有预先确定长度的数据。由于 C 不进行数组边界检查,只要确保结构后面有足够的未使用内存,就可以使用 jp_data,就像它是任意大小的数组一样。
当设备驱动程序的传输队列中没有更多空间时,就需要注意设备驱动程序、输出环境和核心网络服务器之间的交互了。核心网络服务器使用 IPC 向输出环境发送数据包。如果输出环境由于发送数据包系统调用而挂起,因为驱动程序没有更多用于新数据包的缓冲区空间,则核心网络服务器将阻止等待输出服务器接受 IPC 调用。
Exercise 8。
🥐Part B: Receiving packets and the web server
👱♀️Receiving Packets
就像传输数据包一样,您必须配置 E1000 来接收数据包,并提供接收描述符队列和接收描述符。第 3.2 节描述了数据包接收的工作原理,包括接收队列结构和接收描述符,第 14.4 节详细介绍了初始化过程。
Exercise 9。
接收队列与传输队列非常相似,只是它由等待用传入数据包填充的空数据包缓冲区组成。因此,当网络空闲时,发送队列是空的(因为所有包都已发送),而接收队列是满的(空包缓冲区)。
当 E1000 接收到一个数据包时,它首先检查它是否匹配配置的过滤器(例如,查看数据包是否寻址到此 E1000 的 MAC 地址),如果它不匹配任何过滤器,则忽略该数据包。否则,E1000 将尝试从接收队列的头部检索下一个接收描述符。如果头(RDH)赶上了尾(RDT),那么接收队列就没有空闲描述符了,所以卡会丢弃数据包。如果有空闲接收描述符,它将数据包数据复制到描述符指向的缓冲区中,设置描述符的 DD(描述符完成)和 EOP(数据包结束)状态位,并递增 RDH。
如果 E1000 接收到的数据包大于一个接收描述符中的数据包缓冲区,它将根据需要从接收队列中检索尽可能多的描述符,以存储数据包的全部内容。为了表明发生了这种情况,它将在所有这些描述符上设置 DD 状态位,但只在最后一个描述符上设置 EOP 状态位。您可以在驱动程序中处理这种可能性,或者简单地将卡配置为不接受“长数据包”(也称为巨型帧),并确保您的接收缓冲区足够大,可以存储最大的标准以太网数据包(1518字节)。
Exercise 10。
现在可以实现接收数据包了。要接收数据包,您的驱动程序必须跟踪它希望保存下一个接收到的数据包的描述符(提示:根据您的设计,E1000 中可能已经有一个寄存器来跟踪此数据包)。与 transmit 类似,文档说明 RDH 寄存器不能可靠地从软件中读取,因此为了确定数据包是否已被传递到此描述符的数据包缓冲区,您必须读取描述符中的 DD 状态位。如果设置了 DD 位,您可以将数据包数据从描述符的数据包缓冲区中复制出来,然后通过更新队列的尾部索引 RDT 来告诉卡描述符是空闲的。
如果没有设置 DD 位,则没有接收到数据包。这相当于传输队列已满时的接收端,在这种情况下可以执行以下几项操作:
- 您只需返回“重试”错误并要求调用者重试;虽然这种方法对于完全的传输队列很有效,因为这是一种暂时的情况,但是对于空的接收队列则不太合理,因为接收队列可能会在很长一段时间内保持为空
- 第二种方法是暂停调用环境,直到接收队列中有要处理的数据包;这种策略与 sys_ipc_recv 非常相似;就像 IPC 的情况一样,由于每个 CPU 只有一个内核堆栈,一旦离开内核,堆栈上的状态就会丢失。我们需要设置一个标志,指示环境已被接收队列下溢挂起,并记录系统调用参数;这种方法的缺点是复杂性:必须指示 E1000 生成接收中断,并且驱动程序必须处理这些中断,以便恢复等待数据包时阻塞的环境
👳♀️Receiving Packets: Network Server
在网络服务器输入环境中,您需要使用新的接收系统调用来接收数据包,并使用 NSREQ_input IPC 消息将它们传递到核心网络服务器环境。这些 IPC 输入消息应该有一个带有 union Nsipc 的页面,该页面的 jif_pkt 结构体的 pkt 字段填充了从网络接收到的数据包。
Exercise 12。
Question 2。
🧔The Web Server
web 服务器以其最简单的形式将文件的内容发送给请求的客户机。我们在 user/httpd.c 中为一个非常简单的 web 服务器提供了框架代码。框架代码处理传入的连接并解析报头。
Exercise 13。
🍥Exercise And Question
👦No1
为 kern/trap.c 中的每个时钟中断添加一个对 time_tick 的调用。实现 sys_time_msec 并将其添加到 kern/syscall.c 中的 syscall,以便用户空间可以访问时间。
🎢trap.c
直接向 IRQ_OFFSET + IRQ_TIMER 的 case 中添加对 time_tick 的调用即可;其中需要注意的是在多处理器的环境中,每个 CPU 都会产生中断我们只需要加一次即可。
case IRQ_OFFSET + IRQ_TIMER:lapic_eoi();if (cpunum() == 0) {time_tick();}sched_yield();return;
🎪syscall.c
就是一个简单的函数调用没有什么值得讲的地方,记得在 syscall 函数中注册即可。
// Return the current time.static int sys_time_msec(void) {// LAB 6: Your code here.return time_msec();}
👶No2
8254x_GBe_SDM.pdf
阅读文档,略。
👵No3
执行 attachfn 以初始化 E1000。 在 kern/pci.c 中的 pci_attach_vendor 数组中添加一个条目,以便在找到匹配的 pci 设备时触发函数(请确保将它放在标记表结尾的{0,0,0}条目之前);然后,通过 pci_func_enable 启动 E1000 设备即可。 已经提供了 kern/e1000.c 和 kern/e1000.h 文件,这样您就不需要关心构建系统。它们当前为空,您需要在本练习中填写它们。您可能还需要在内核的其他位置包含 e1000.h 文件。 当您引导内核时,您应该看到它显示 E1000 卡的 PCI 功能已启用;您的代码现在应该通过 make grade 的 pci-attach 测试。
初始化 PCI 的大致流程:
- 在 pci_init 函数中,root_bus 被全部清 0
- 交给 pci_scan_bus 函数来扫描这条总线上的所有设备,说明在 JOS 中 E1000 网卡是连接在 0 号总线上的;pci_scan_bus 函数来顺次查找 0 号总线上的 32 个设备,如果发现其存在,那么顺次扫描它们每个功能对应的配置地址空间,将一些关键的控制参数读入到 pci_func 中进行保存
- 得到 pci_func 后,传入 pci_attach 函数去查找是否为已存在的设备,并用相应的初始化函数来初始化设备
🎭e1000.h
首先在文档 5.2 节中查找 venderID 和 deviceID:
然后在 e1000.h 中添加如下宏定义与函数定义: ```cinclude “kern/pci.h”
define E1000_VENDER_ID_82540EM 0x8086
define E1000_DEV_ID_82540EM 0x100E
int e1000_attachfn(struct pci_func* pcif);
<a name="wDSPO"></a>### 🖼e1000.c在 e1000.c 中添加 e1000_attachfn 的实现,简单的使用 pci_func_enable 函数启动即可:```cint e1000_attachfn(struct pci_func* pcif) {pci_func_enable(pcif);cprintf("reg_base:%x, reg_size:%x, irq_line:%x\n", pcif->reg_base[0],pcif->reg_size[0], pcif->irq_line);return 0;}
🎨pci.c
在 pci_attach_vendor 数组中添加一个 e1000 启动条目:
struct pci_driver pci_attach_vendor[] = {{E1000_VENDER_ID_82540EM, E1000_DEV_ID_82540EM, &e1000_attachfn},{0, 0, 0},};
👩🦰No4
在 attachfn 中,通过调用 mmio_map_region(在 Lab4 中编写了这个函数来支持 LAPIC 的内存映射),为 E1000 的条目 0 创建一个虚拟内存映射。 需要将此映射的位置记录在一个变量中,以便以后可以访问刚刚映射的区域。可以参考 kern/lapic.c 中的 lapic 变量进行实现。如果确实使用指向设备寄存器映射的指针,请确保将其声明为 volatile;否则,允许编译器将会缓存值并重新排序对此内存的访问。 为了验证成功进行了映射,可以访问寄存器空间中从第 8 个字节开始的四个字节,并且获得的值为:0x80080783(表示链路带宽达到 1000 MB)。
🧵e1000.h
首先定义寄存器偏移与在内存中的计算方式。在计算虚拟地址加和时注意,因为 bar_va 是 uint32_t 类型,所以实际上执行的计算会是 bar_va + sizeof(uint32_t) offset,所以需要改成下面的形式(或是直接全部定义为 void):
#define E1000_STATUS 0x00008 /* Device Status - RO */#define E1000REG(offset) (uint32_t*)(bar_va + (offset >> 2))
🧶e1000.c
然后定义 volatile 的全局变量 bar_va,接着在 attachfn 中使用 mmio_map_region 函数进行映射:
volatile uint32_t* bar_va;int e1000_attachfn(struct pci_func* pcif) {pci_func_enable(pcif);cprintf("reg_base:%x, reg_size:%x, irq_line:%x\n", pcif->reg_base[0],pcif->reg_size[0], pcif->irq_line);// Exercise4 create virtual memory mappingbar_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;}
👩🦱No5
完成 14.5 节中描述的初始化步骤:
- 参考第 13 节中寄存器布局
- 参考第 3.3.3 节和第 3.4 中的传输描述符和传输描述符数组的定义
请注意对传输描述符数组的对齐要求以及对该数组长度的限制。由于 TDLEN 必须是 128 字节对齐的,并且每个传输描述符是 16 字节,因此您的传输描述符数组需要是 8 个传输描述符的整数倍。但是,不要超过 64 个描述符,否则编写的测试程序将无法测试传输环溢出。 对于 TCTL.COLD,可以假定为全双工操作;对于 TIPG,请参考 IEEE 802.3 标准 IPG 第 13.4.34 节的表 13-77 中描述的默认值。
🛒初始化过程
初始化的流程如下:
- 为传输描述符列表分配一个内存区域,应确保该内存在 16字节上对齐。用区域地址编程传输描述符基址(TDBAL/TDBAH)寄存器。TDBAL 用于描述 32 位地址,TDBAL 和 TDBAH 共同描述 64 位地址
- 将传输描述符长度(TDLEN)寄存器设置为描述符的大小(字节为单位),这个寄存器必须 128 字节对齐
- 通电或软件启动以太网控制器复位后,传输描述符头和尾(TDH/TDT)寄存器初始化为 0b
- 为所需操作初始化传输控制寄存器(TCTL),以包括以下内容:
- 设置 TCTL.EN 位至 1b,用于正常操作
- 设置 Pad 短包(TCTL.PSP)位到 1b
- 配置冲突阈值(TCTL.CT)到 10h
- 配置碰撞距离(TCTL.COLD)它的期望值;对于全双工操作,此值应设置为 40h,对于千兆位半双工,此值应设置为 200h,对于 10/100 半双工,此值应设置为 40h
- 使用以下十进制值对 TIPG 寄存器进行设置,以获得最小合法数据包间隔:
👓e1000.h
向其中添加如下的结构体定义和宏定义,具体的原因可以查看 Exercise 2 中的文档:
#define TXDESCS 32#define TX_PKT_SIZE 1518#define E1000_VENDER_ID_82540EM 0x8086#define E1000_DEV_ID_82540EM 0x100E#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) (uint32_t*)(bar_va + (offset >> 2))struct e1000_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 e1000_tdlen {uint32_t zero : 7;uint32_t len : 13;uint32_t rsv : 12;};struct e1000_tdh {uint16_t tdh;uint16_t rsv;};struct e1000_tdt {uint16_t tdt;uint16_t rsv;};struct e1000_tctl {uint32_t rsv1 : 1;uint32_t en : 1;uint32_t rsv2 : 1;uint32_t psp : 1;uint32_t ct : 8;uint32_t cold : 10;uint32_t swxoff : 1;uint32_t rsv3 : 1;uint32_t rtlc : 1;uint32_t nrtu : 1;uint32_t rsv4 : 6;};struct e1000_tipg {uint32_t ipgt : 10;uint32_t ipgr1 : 10;uint32_t ipgr2 : 10;uint32_t rsv : 2;};int e1000_attachfn(struct pci_func* pcif);static void e1000_transmit_init();
🕶e1000.c
添加初始化函数,实际上就是对各个寄存器的设置:
volatile uint32_t* 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];int e1000_attachfn(struct pci_func* pcif) {pci_func_enable(pcif);cprintf("reg_base:%x, reg_size:%x, irq_line:%x\n", pcif->reg_base[0],pcif->reg_size[0], pcif->irq_line);// Exercise4 create virtual memory mappingbar_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() {for (int i = 0; i < TXDESCS; i++) {// init buffer addrtx_desc_array[i].addr = PADDR(tx_buffer_array[i]);tx_desc_array[i].cmd = (E1000_TXD_CMD_RS >> 24) | (E1000_TXD_CMD_EOP >> 24);tx_desc_array[i].status |= E1000_TXD_STAT_DD;}// TDBAL registeruint32_t* tdbal = E1000REG(E1000_TDBAL);*tdbal = PADDR(tx_desc_array);// TDBAH regsiteruint32_t* tdbah = (uint32_t*)E1000REG(E1000_TDBAH);*tdbah = 0;// TDLEN registerstruct e1000_tdlen* tdlen = (struct e1000_tdlen*)E1000REG(E1000_TDLEN);tdlen->len = TXDESCS;// TDH register, should be init 0tdh = (struct e1000_tdh*)E1000REG(E1000_TDH);tdh->tdh = 0;// TDT register, should be init 0tdt = (struct e1000_tdt*)E1000REG(E1000_TDT);tdt->tdt = 0;// TCTL registerstruct e1000_tctl* tctl = (struct e1000_tctl*)E1000REG(E1000_TCTL);tctl->en = 1;tctl->psp = 1;tctl->ct = 0x10;tctl->cold = 0x40;// TIPG registerstruct e1000_tipg* tipg = (struct e1000_tipg*)E1000REG(E1000_TIPG);tipg->ipgt = 10;tipg->ipgr1 = 4;tipg->ipgr2 = 6;}
运行 make E1000_DEBUG=TXERR,TX qemu 看到下面的结果:
👨🦱No6
编写一个函数,通过检查下一个描述符是否空闲、将包数据复制到下一个描述符并更新 TDT 来传输包。确保处理的传输队列已满的情况。
编写一个传输函数即可,注意检查对应的标志位,当传输失败时为上层返回传输失败而不是直接丢弃:
int e1000_transmit(void* data, size_t len) {// 得到现在的 tdtuint32_t current = tdt->tdt;// 判断状态是否可用if (!(tx_desc_array[current].status & E1000_TXD_STAT_DD)) {return -E_TRANSMIT_RETRY;}// 设置传输数据包需要的标识以及拷贝数据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);memcpy(tx_buffer_array[current], data, len);// 更新 tdtuint32_t next = (current + 1) % TXDESCS;tdt->tdt = next;return 0;}
我们直接在 e1000_attachfn 函数中添加一个测试即可(最简单了hhhh):
char* data = "transmit test";e1000_transmit(data, 13);
运行 make E1000_DEBUG=TXERR,TX qemu 看到下面的结果:
运行 tcpdump -XXnr qemu.pcap 看到如下结果:
👩🦲No7
添加系统调用,具体的形式自行决定(不要忘记检查从用户空间传递到内核的指针)。
🦺kernel
首先在内核中添加系统调用的 handler:
static int sys_packet_try_send(void* addr, size_t len) {user_mem_assert(curenv, addr, len, PTE_U);return e1000_transmit(addr, len);}
然后在 syscall 函数中添加一个 case:
case SYS_packet_try_send:return sys_packet_try_send((void*)a1, a2);
🥽User
然后在用户空间添加对应的触发 syscall 的函数,首先在 inc/lib.h 中添加函数签名:
int sys_packet_try_send(void* addr, size_t len);
然后在 lib/syscall.c 中添加触发函数:
int sys_packet_try_send(void* data, size_t len) {return syscall(SYS_packet_try_send, 1, (uint32_t)data, len, 0, 0, 0);}
👩🦳No8
实现 net/output.c。
这个函数,我们需要实现下面的功能:
- 从 network server 中读取数据包
- 将数据包发送到网卡驱动 ```c extern union Nsipc nsipcbuf;
void output(envid_t ns_envid) { binaryname = “ns_output”;
int perm; int32_t req; envid_t envid;
while (true) { // 从 network server 中读取数据包 req = ipc_recv(&envid, &nsipcbuf, &perm); if (req != NSREQ_OUTPUT) { cprintf(“not a nsreq output\n”); continue; }
// 将数据包发送到网卡驱动// 在发送成功前一直发送数据包struct jif_pkt* pkt = &(nsipcbuf.pkt);while (sys_packet_try_send(pkt->jp_data, pkt->jp_len) < 0) {sys_yield();}
} }
使用 make E1000_DEBUG=TXERR,TX run-net_testoutput,可以看到下面结果:<br /><br />使用 tcpdump -XXnr qemu.pcap,可以看到下面的结果:<br /><br />使用 make grade 可以通过 part A 所有测试。<a name="QfF8H"></a>## 👨🦳Q1> 您是如何构建传输实现的?具体来说,如果传输队列已满,您该怎么办?当传输队列满时,将返回小于 0 的值;在 output 函数中,我们将判断返回值当小于 0 时,不停的重试发送数据。<a name="KZwu7"></a>## 👱♂️No9> 阅读第 3.2 节。您可以忽略任何有关中断和校验和卸载的内容(如果以后决定使用这些功能,您可以返回到这些部分),而且您不必关心阈值的细节以及卡的内部缓存如何工作。阅读文档,略。<a name="rw0qf"></a>## 👸No10> 按照 14.4 节中的过程设置接收队列并配置 E1000。> 不必支持巨型帧或多播。目前,不要将卡配置为使用中断;如果您决定使用接收中断,可以稍后更改。另外,将 E1000 配置为剥离以太网 CRC,因为 grade 脚本希望剥离它。> 默认情况下,该卡将过滤掉所有数据包。您必须使用卡自己的 MAC 地址配置接收地址寄存器(RAL 和 RAH),以便接受发往该卡的数据包。> 可以简单地硬编码 QEMU 的默认 MAC 地址 52:54:00:12:34:56。注意字节顺序,MAC 地址是从最低位字节写到最高位字节的,所以 52:54:00:12 是MAC地址的低位 32 位,34:56 是高位 16 位。> E1000 仅支持一组特定的接收缓冲区大小。如果将接收数据包缓冲区设置得足够大并禁用长数据包,则不必担心数据包跨越多个接收缓冲区。与传输类似,接收队列和数据包缓冲区在物理内存中必须是连续的。> 您应该至少使用 128 个接收描述符。<a name="Z04AK"></a>### 🥼初始化过程1. 用所需的以太网地址编程接收地址寄存器(RAL/RAH)。RAL[0]/RAH[0] 应始终用于存储以太网控制器的单个以太网 MAC 地址;这可以来自 EEPROM 或任何其他方式1. 为接收描述符列表分配一个内存区域。软件应确保该内存在段落(16字节)边界上对齐。用区域地址编程接收描述符基址(RDBAL/RDBAH)寄存器。RDBAL 用于 32 位地址,RDBAL 和 RDBAH 都用于 64 位地址1. 将接收描述符长度(RDLEN)寄存器设置为描述符环的大小(以字节为单位);此寄存器必须 128 字节对齐1. 在通电或软件启动的以太网控制器复位后,接收描述符头和尾寄存器(通过硬件)初始化为 0b。应该分配适当大小的接收缓冲区,指向这些缓冲区的指针应该存储在接收描述符环中。软件用适当的头和尾地址初始化接收描述符头(RDH)寄存器和接收描述符尾(RDT)。头应该指向描述符环中的第一个有效接收描述符,尾应该指向描述符环中最后一个有效描述符之外的一个描述符1. 将接收控制(RCTL)寄存器编程为所需操作的适当值,以包括以下内容:- 设置接收器启用(RCTL.EN)位至 1b,用于正常操作;但是,最好使以太网控制器接收逻辑处于禁用状态(RCTL.EN=0b)直到接收描述符环初始化并且软件准备好处理接收的数据包- 设置广播接受模式(RCTL.BAM)位到 1b,允许硬件接受广播数据包- 设置带式以太网 CRC(RCTL.SECRC)位,如果希望硬件在将接收数据包 DMA 到主机内存之前剥离CRC<a name="EzEy6"></a>### 🧥e1000.h向头文件中添加宏定义和结构体定义:```c#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
添加初始化函数,其实就是对寄存器各个值的设置,记得在 e1000_attachfn 中调用即可:
struct e1000_rdh* rdh;struct e1000_rdt* rdt;struct e1000_rx_desc rx_desc_array[RXDESCS];char rx_buffer_array[RXDESCS][RX_PKT_SIZE];uint32_t E1000_MAC[] = {0x52, 0x54, 0x00, 0x12, 0x34, 0x56};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 registeruint32_t* rdbal = (uint32_t*)E1000REG(E1000_RDBAL);uint32_t* rdbah = (uint32_t*)E1000REG(E1000_RDBAH);*rdbal = PADDR(rx_desc_array);*rdbah = 0;for (int i = 0; i < RXDESCS; i++) {rx_desc_array[i].addr = PADDR(rx_buffer_array[i]);}// RDLEN registerstruct e1000_rdlen* rdlen = (struct e1000_rdlen*)E1000REG(E1000_RDLEN);rdlen->len = RXDESCS;// RDH registerrdh = (struct e1000_rdh*)E1000REG(E1000_RDH);rdh->rdh = 0;// RDT registerrdt = (struct e1000_rdt*)E1000REG(E1000_RDT);rdt->rdt = RXDESCS - 1;// RCTL registeruint32_t* rctl = (uint32_t*)E1000REG(E1000_RCTL);*rctl = E1000_RCTL_EN | E1000_RCTL_BAM | E1000_RCTL_SECRC;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;}
运行 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput 看到下面的结果:
🤴No11
编写一个函数,从 E1000 接收数据包,并通过添加系统调用将其公开给用户空间。确保接收队列为空。
👖e1000_receive()
int e1000_receive(void* addr, size_t* len) {uint32_t current = rdt->rdt;// 不是 DD 状态重试if (!(rx_desc_array[current].status & E1000_RXD_STAT_DD)) {return -E_RECEIVE_RETRY;}if (rx_desc_array[current].errors) {cprintf("receive errors\n");return -E_RECEIVE_RETRY;}*len = rx_desc_array[current].length;memcpy(addr, rx_buffer_array[current], *len);rdt->rdt = (current + 1) % RXDESCS;return 0;}
🩳添加系统调用
添加内核 handler,记得在 syscall 中注册:
static int sys_packet_recv(void* addr, size_t* len) {return e1000_receive(addr, len);}
添加用户空间 syscall 触发器,记得在 inc/lib.h 中添加签名:
int sys_packet_recv(void* data, size_t* len) {return syscall(SYS_packet_recv, 1, (uint32_t)data, (uint32_t)len, 0, 0, 0);}
👳♂️No12
实现 net/input.c。
需要实现下面的功能:
- 读取来自驱动的数据包
- 将数据包发送给 network 服务
当使用 IPC 将数据也发送给 network 服务时,需要一段的时间读取,不能马上使用这个页。
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);while (sys_time_msec() < end)sys_yield();}void input(envid_t ns_envid) {binaryname = "ns_input";size_t len;char buf[RX_PKT_SIZE];while (1) {if (sys_packet_recv(buf, &len) < 0) {continue;}nsipcbuf.pkt.jp_len = len;memcpy(nsipcbuf.pkt.jp_data, buf, len);ipc_send(ns_envid, NSREQ_INPUT, &nsipcbuf, PTE_P | PTE_U | PTE_W);sleep(50);}}
运行 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput 看到下面的结果:
运行 make grade 看到下面结果:
👲Q2
您是如何构建接收实现的?特别是,如果接收队列为空,并且用户环境请求下一个传入数据包,您该怎么办?
👼No13
web 服务器缺少处理将文件内容发送回客户端的代码。通过实现 send_file 和 send_data 来完成 web 服务器。
send_data()
通过文件描述符读取文件,然后通过 req 对象的 sock 写入到网卡上:
static int send_data(struct http_request* req, int fd) {// LAB 6: Your code here.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;}}
send_file()
打开请求的 url 进行读取:
- 如果文件不存在,请使用 send_error 发送 404 错误
- 如果文件是一个目录,使用 send_error 发送 404 错误
- 将发送文件大小设置为文件的大小 ```c 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); ```
