背景
在编写代码之前,您可能会发现复习《xv6 书籍》中的“第 6 章:中断和设备驱动程序”很有帮助。
你将使用一个名为 E1000 的网络设备来处理网络通信。对于 xv6(以及你编写的驱动程序)来说,E1000 看起来像一块真实的硬件,连接到一个真实的以太网局域网(LAN)。实际上,你的驱动程序将要与之通信的 E1000 是由 qemu 提供的模拟设备,连接到一个同样由 qemu 模拟的局域网。在这个模拟的局域网中,xv6("客户机")拥有 IP 地址 10.0.2.15。qemu 安排运行 qemu 的计算机("主机")出现在局域网中,IP 地址为 10.0.2.2。当 xv6 使用 E1000 向 10.0.2.2 发送数据包时,qemu 将数据包传递给主机上的相应应用程序。
你将使用 QEMU 的"用户模式网络栈"。QEMU 的文档中有更多关于用户模式栈的信息。我们已经更新了 Makefile 以启用 QEMU 的用户模式网络栈和 E1000 网络卡模拟。
Makefile 配置 QEMU 将所有传入和传出的数据包记录到你的实验目录中的文件 packets.pcap 中。查看这些记录可能有助于确认 xv6 是否正在发送和接收你期望的数据包。要显示记录的数据包:
tcpdump -XXnr packets.pcap我们为这个实验向 xv6 仓库添加了一些文件。文件 kernel/e1000.c 包含 E1000 的初始化代码,以及用于传输和接收数据包的空函数,你需要填充这些函数。 kernel/e1000_dev.h 包含 E1000 定义的寄存器和标志位的定义,这些在 Intel E1000 软件开发者手册中有描述。 kernel/net.c 和 kernel/net.h 包含一个简单的网络栈,实现了 IP、UDP 和 ARP 协议; net.c 包含用户进程发送 UDP 数据包的完整代码,但缺少大部分接收数据包并传递到用户空间的代码。最后, kernel/pci.c 包含在 xv6 启动时搜索 PCI 总线上 E1000 卡的代码。
第一部分:网卡
你的任务是完成 e1000_transmit() 和 e1000_recv() ,都在 kernel/e1000.c 中,以便驱动程序能够传输和接收数据包。当你完成 make grade 并表明你的解决方案通过了"txone"和"rxone"测试时,这部分工作就完成了。
在编写代码时,你会参考 E1000 软件开发者手册,特别是:
- 第 3.2 节描述数据包接收(但跳过 3.2.8 和 3.2.9)。
- 第 3.3.1、3.3.2、3.3.3 和 3.4 节描述传输。
- 第 13 节描述 E1000 的寄存器(按需参考;不必通读全文)。
软件开发者手册描述了几个密切相关的以太网控制器。QEMU 模拟 82540EM。你需要熟悉上述第 3 章的各节。其他章节主要涵盖你的驱动程序无需交互的 E1000 方面。初期不必担心细节;只需了解文档结构以便日后查找。E1000 具有许多高级功能,其中大部分可以忽略。完成此实验仅需一小部分基本功能。
我们提供的 e1000_init() 功能在 e1000.c 配置中使 E1000 从 RAM 读取要发送的数据包,并将接收到的数据包写入 RAM。这种技术称为直接内存访问(DMA),指的是 E1000 硬件直接向/从 RAM 读写数据包。
由于数据包突发可能会比驱动程序处理得更快, e1000_init() 为 E1000 提供了多个缓冲区,E1000 可以将接收到的数据包写入这些缓冲区。E1000 需要通过 RAM 中的"描述符"数组来描述这些缓冲区;每个描述符包含一个 E1000 可以写入接收数据包的 RAM 地址。 struct rx_desc 描述了描述符的格式。描述符数组称为接收环或接收队列。它是一个环形结构,因为当卡片或驱动程序到达数组末尾时,它会回绕到开始处。 e1000_init() 为 E1000 分配数据包缓冲区以进行 DMA。还有一个发送环,驱动程序应将希望 E1000 发送的数据包放入其中。 e1000_init() 配置这两个环的大小为 RX_RING_SIZE 和 TX_RING_SIZE 。
当 net.c 中的网络栈需要发送数据包时,它会调用 e1000_transmit() ,并将包含待发送数据包的缓冲区的指针传递给 e1000_transmit() ; net.c 使用 kalloc() 分配这个缓冲区。你的发送代码必须在 TX(发送)环的描述符中放置一个指向数据包数据的指针。 struct tx_desc 描述了描述符的格式。你需要确保每个缓冲区最终都会被传递给 kfree() ,但只有当 E1000 完成数据包的发送后(E1000 在描述符中将 E1000_TXD_STAT_DD 位设置为 1 来指示这一点)。
当 E1000 从以太网接收每个数据包时,它会将数据包 DMA 到下一个 RX(接收)环描述符中 addr 指向的内存。如果 E1000 中断尚未挂起,E1000 会请求 PLIC 在启用中断后立即发送一个中断。你的 e1000_recv() 代码必须扫描 RX 环,并通过调用 net_rx() 将每个新数据包传递给网络栈(在 net.c )。然后你需要分配一个新的缓冲区并将其放入描述符中,这样当 E1000 再次到达 RX 环的该点时,它会找到一个新鲜缓冲区来 DMA 一个新数据包。
除了在 RAM 中读写描述符环之外,您的驱动程序还需要通过内存映射的控制寄存器与 E1000 交互,以检测接收到的数据包何时可用,并通知 E1000 驱动程序已经用要发送的数据包填充了一些 TX 描述符。全局变量 regs 保存了 E1000 第一个控制寄存器的指针;您的驱动程序可以通过将 regs 作为数组索引来访问其他寄存器。您需要特别使用索引 E1000_RDT 和 E1000_TDT 。
要测试 e1000_transmit()发送单个数据包,在一个窗口中运行 python3 nettest.py txone ,在另一个窗口中运行 make qemu ,然后在 xv6 中运行 nettest txone (xv6 发送单个数据包)。如果一切顺利(即 qemu 的 e1000 模拟器在 DMA 环中看到了数据包并将其转发到 qemu 外部), nettest.py 将打印 txone: OK 。
如果传输成功, tcpdump -XXnr packets.pcap 应该产生如下输出:
reading from file packets.pcap, link-type EN10MB (Ethernet) 21:27:31.688123 IP 10.0.2.15.2000 > 10.0.2.2.25603: UDP, length 5
> 0x0000: 5255 0a00 0202 5254 0012 3456 0800 4500 RU....RT..4V..E.
> 0x0010: 0021 0000 0000 6411 3ebc 0a00 020f 0a00 .!....d.>.......
> 0x0020: 0202 07d0 6403 000d 0000 7478 6f6e 65 ....d.....txone
要测试 e1000_recv()接收两个数据包(一个 ARP 查询,然后一个 IP/UDP 数据包),在一个窗口中运行 make qemu ,在另一个窗口中运行 python3 nettest.py rxone 。 nettest.py rxone 通过 qemu 向 xv6 发送一个 UDP 数据包;qemu 实际上首先向 xv6 发送一个 ARP 请求,然后(在 xv6 返回 ARP 回复后)将 UDP 数据包转发给 xv6。如果 e1000_recv()工作正常并将这些数据包传递给 net_rx() , net.c 应该打印 ```text arp_rx: received an ARP packet ip_rx: received an IP packetnet.c 已经包含了检测 qemu 的 ARP 请求并调用 e1000_transmit() 发送回复的代码。这个测试要求 e1000_transmit()和 e1000_recv()都能正常工作。此外,如果一切顺利, tcpdump -XXnr packets.pcap 应该产生如下输出:
reading from file packets.pcap, link-type EN10MB (Ethernet) 21:29:16.893600 ARP, Request who-has 10.0.2.15 tell 10.0.2.2, length 28
> 0x0000: ffff ffff ffff 5255 0a00 0202 0806 0001 ......RU........
> 0x0010: 0800 0604 0001 5255 0a00 0202 0a00 0202 ......RU........
> 0x0020: 0000 0000 0000 0a00 020f ..........
21:29:16.894543 ARP, Reply 10.0.2.15 is-at 52:54:00:12:34:56, length 28 > 0x0000: 5255 0a00 0202 5254 0012 3456 0806 0001 RU....RT..4V.... > 0x0010: 0800 0604 0002 5254 0012 3456 0a00 020f ......RT..4V.... > 0x0020: 5255 0a00 0202 0a00 0202 RU........ 21:29:16.902656 IP 10.0.2.2.61350 > 10.0.2.15.2000: UDP, length 3 > 0x0000: 5254 0012 3456 5255 0a00 0202 0800 4500 RT..4VRU......E. > 0x0010: 001f 0000 0000 4011 62be 0a00 0202 0a00 ......@.b....... > 0x0020: 020f efa6 07d0 000b fdd6 7879 7a ..........xyz
你的输出会略有不同,但它应该包含字符串"ARP, Request"、"ARP, Reply"、"UDP"和"....xyz"。 如果上述两个测试都通过,那么 make grade 应该显示前两个测试通过。 **e1000 提示** 对于 e1000_transmit : - 首先在 e1000_transmit() 和 e1000_recv() 中添加打印语句,并运行(在 xv6 中) nettest txone 。你应该从打印语句中看到 nettest txone 生成一个对 e1000_transmit 的调用。 - e1000_dev.h 中的描述符定义使用了“传统”的传输描述符格式(第 3.3.3 节)。 - 首先通过读取 E1000_TDT 的控制寄存器来询问 E1000 它期望的下一个数据包的 TX 环索引。 - 然后检查环是否溢出。如果索引为 E1000_TDT 的描述符中未设置 E1000_TXD_STAT_DD ,则 E1000 尚未完成相应的先前传输请求,因此返回错误。 - 否则,使用 kfree() 释放从该描述符传输的最后一个缓冲区(如果存在)。 - 然后填充描述符。设置必要的命令标志(参考 E1000 手册的 3.3 节)。 - 最后,通过将 E1000_TDT 加一并取模 TX_RING_SIZE 来更新环的位置。 对于 e1000_recv : - 首先通过获取 E1000_RDT 控制寄存器并加一取模 RX_RING_SIZE ,向 E1000 询问下一个等待接收的包(如果有的话)所在的环索引。 - 然后通过检查描述符的 status 部分中的 E1000_RXD_STAT_DD 位来判断是否有新包可用。如果没有,停止。 - 通过调用 net_rx() 将数据包缓冲区传递给网络栈。 - 然后用 kalloc() 分配一个新的缓冲区来替换刚刚分配给 net_rx() 的那个。将描述符的状态位清零。 - 最后,更新 E1000_RDT 寄存器为最后处理的环描述符的索引。 - e1000_init() 用缓冲区初始化接收环,你可能需要看看它是如何做的,或许可以借鉴一些代码。 - 某个时刻,到达过的数据包总数将超过环的大小(16);确保你的代码能够处理这种情况。 - e1000 可以每个中断传输多个数据包;你的 e1000_recv 应该处理这种情况。 你需要锁来应对 xv6 可能从多个进程使用 E1000,或者在中断到达时可能正在内核线程中使用 E1000 的可能性。
这一个任务的提示基本上就是完整的实现步骤了。有几点需要注意:
- 在发送的部分,为了知道当前缓冲区需不需要
kfree(),需要维护一个数组来记录。 - 在发送和接收的方法开始前后需要持有和释放
e1000_lock来避免竞态问题。 - 发送时需要设置
EOP(代表结束)以及RS(代表期望硬件markDD)。 - 在调整
TDT和RDT前调用__sync_synchronize()来确保内存同步。 - 在
e1000_recv()中确保在调用net_rx()前释放e1000_lock,避免重入。
第二部分:UDP 接收
UDP,即用户数据报协议,允许不同互联网主机上的用户进程交换单个数据包(数据报)。UDP 位于 IP 协议之上。用户进程通过指定 32 位的 IP 地址来指示它想要将数据包发送到哪个主机。每个 UDP 数据包都包含一个源端口号和一个目的端口号;进程可以请求接收发往特定端口号的数据包,并且在发送时可以指定目的端口号。因此,如果两个不同主机上的进程知道彼此的 IP 地址以及各自监听的端口号,它们就可以通过 UDP 进行通信。例如,Google 在 IP 地址为 8.8.8.8 的主机上运行 DNS 名称服务器,监听 UDP 端口 53。
在这个任务中,你需要在 kernel/net.c 中添加代码以接收 UDP 数据包、将它们排队,并允许用户进程读取它们。 net.c 已经包含了用户进程发送 UDP 数据包所需的代码(e1000_transmit()除外,这部分由你提供)。
你的任务是填充 ip_rx() 、 sys_recv() 和 sys_bind() 在 kernel/net.c 中的实现。当你完成 make grade 并告知你的解决方案通过所有测试时,你就算完成了。
你可以通过在一个窗口中启动 python3 nettest.py grade ,然后在另一个窗口中在 xv6 中运行 nettest grade 来运行与 make grade 相同的测试。如果一切顺利,你应该在第一个(xv6 之外的)窗口中看到这个内容:
$ python3 nettest.py grade txone: OK rxone: sending one UDP packet并在 xv6 窗口中看到这个内容:
$ nettest grade txone: sending one packet arp_rx: received an ARP packet ip_rx: received an IP packet ping0: starting ping0: OK ping1: starting ping1: OK ping2: starting ping2: OK ping3: starting ping3: OK dns: starting DNS arecord for pdos.csail.mit.edu. is 128.52.129.126 dns: OK free: OK这个实验的 UDP 系统调用 API 规范看起来是这样的:
- send(short sport, int dst, short dport, char *buf, int len) : 该系统调用向具有 IP 地址 dst 的主机上的监听端口 dport 的进程发送 UDP 数据包。数据包的源端口号将是 sport (接收进程会报告此端口号,以便它可以回复发送者)。UDP 数据包的内容(有效载荷)将是地址 buf 处的 len 字节。成功时返回值为 0,失败时返回值为-1。
- recv(short dport, int *src, short *sport, char *buf, int maxlen) : 该系统调用返回目标端口 dport 的 UDP 数据包的有效载荷。如果在调用 recv() 之前已有一个或多个数据包到达,它应立即返回最早等待的数据包。如果没有数据包在等待, recv() 应等待直到到达 dport 的数据包。 recv() 应按到达顺序看到给定端口的数据包。 recv() 将数据包的 32 位源 IP 地址复制到 *src ,将数据包的 16 位 UDP 源端口号复制到 *sport ,将最多 maxlen 字节数据包的 UDP 有效载荷复制到 buf ,并从队列中移除数据包。系统调用返回复制的 UDP 有效载荷字节数,如果出错则返回-1。
- bind(short port) : 一个进程应该在调用 recv(port, ...) 之前调用 bind(port) 。如果一个 UDP 数据包到达,其目标端口尚未传递给 bind() , net.c 应该丢弃该数据包。这个系统调用的原因是初始化 net.c 需要的任何结构,以便在后续的 recv() 调用中存储到达的数据包。
- unbind(short port) : 你不需要实现这个系统调用,因为测试代码没有使用它。但如果你愿意,可以为了与 bind() 提供对称性而实现它。 所有传递给这些系统调用作为参数的地址和端口号,以及由它们返回的,都必须是主机字节序(见下文)。
你需要提供系统调用的内核实现,除了 send() 之外。程序 user/nettest.c 使用这个 API。
要让 recv() 工作,你需要在 ip_rx() 中添加代码, net_rx() 会为每个接收到的 IP 数据包调用这些代码。 ip_rx() 应该判断到达的数据包是否为 UDP,以及其目标端口是否已传递给 bind() ;如果这两个条件都满足,它应该将数据包保存到 recv() 可以找到的地方。然而,对于任何给定的端口,最多只能保存 16 个数据包;如果已有 16 个数据包正在等待 recv() ,则该端口的新入数据包应被丢弃。这条规则的作用是防止快速或滥用发送者迫使 xv6 耗尽内存。此外,如果一个端口因为已有 16 个数据包正在等待而被丢弃数据包,这不应影响其他端口到达的数据包。
ip_rx() 查看的数据包缓冲区包含一个 14 字节的以太网头部,接着是一个 20 字节的 IP 头部,然后是一个 8 字节的 UDP 头部,最后是 UDP 数据载荷。你可以在 kernel/net.h 中找到每个部分的 C 结构体定义。维基百科在这里对 IP 头部进行了描述,这里对 UDP 进行了描述。
生产 IP/UDP 实现很复杂,需要处理协议选项并验证不变量。你只需要做足够的工作来通过 make grade 。你的代码需要查看 IP 头中的 ip_p 和 ip_src,以及 UDP 头中的 dport、sport 和 ulen。
您需要关注字节顺序。以太网、IP 和 UDP 头字段中包含多字节整数时,将最高有效字节放在数据包的第一位。RISC-V 处理器在内存中排列多字节整数时,将最低有效字节放在第一位。这意味着,当代码从数据包中提取多字节整数时,必须重新排列字节。这适用于短(2 字节)和 int(4 字节)字段。您可以使用 ntohs() 和 ntohl() 函数分别用于 2 字节和 4 字节字段。查看 net_rx() 了解在查看 2 字节以太网类型字段时的示例。
如果你的 E1000 代码中存在错误或遗漏,它们可能只在 ping 测试期间开始引发问题。例如,ping 测试会发送和接收足够多的数据包,导致描述符环索引会回绕。
一些建议:
- 创建一个结构体来跟踪已绑定端口及其队列中的数据包。
- 参考 sleep(void *chan, struct spinlock *lk) 和 wakeup(void *chan) 中的函数实现 recv() 的等待逻辑。
- sys_recv() 复制数据包到目标地址时使用的是虚拟地址;你需要将数据包从内核复制到当前用户进程。
- 确保释放已复制或已丢弃的数据包。
基本上就是实现UDP的绑定、解析、用户空间拷贝
实现步骤如下:
- 新增
struct udp_bind,包含int port, int num_pkt, char* pkts[16],用于实现绑定。 - 新增
int num_udp_binds, struct udp_bind udp_binds[64],用于保存绑定。 - 实现
sys_bind():- 检查是否已有绑定。
- 插入新绑定。
- 实现
sys_recv():- 判断port是否存在。
- 获取port对应的pkt。
- 如果当前pkt的queue中没有等待包,则
sleep(bind, &netlock) - 弹出queue。此时已经可以释放
netlock。 - 解析
eth, ip, udp, payload。 - 逐一
copyout()。 kfree()当前pkt。
- 实现
ip_rx():- 解析并检查当前是否已有bind,如果没有或已经满了则报错。
- 插入queue。
wakeup(bind)。- 解锁。
最终结果
== Test running nettest ==
$ make qemu-gdb
(34.9s)
== Test nettest: txone ==
nettest: txone: OK
== Test nettest: arp_rx ==
nettest: arp_rx: OK
== Test nettest: ip_rx ==
nettest: ip_rx: OK
== Test nettest: ping0 ==
nettest: ping0: OK
== Test nettest: ping1 ==
nettest: ping1: OK
== Test nettest: ping2 ==
nettest: ping2: OK
== Test nettest: ping3 ==
nettest: ping3: OK
== Test nettest: dns ==
nettest: dns: OK
== Test nettest: free ==
nettest: free: OK
== Test time ==
time: OK
Score: 171/171