6.s081 Lab6 Networking


这里要我们在软件层面实现数据包的收发操作。

Preparation

切换到对应分支

$ git fetch
$ git checkout net
$ make clean

模拟网络

我们将使用名为 E1000 的网络设备来处理网络通信,实际上是 qemu 模拟了一个网卡和 PCI 总线,以及若干寄存器。不妨从 connect() 开始,逐步分析。

从 connect 开始

connect() 实际上会触发系统调用 sys_connect(),它主要调用 sockalloc() 根据传入的参数(目的 ip 地址,源端口和目的端口)新建一个套接字,然后将其注册到一个 SOCK 类型的文件中,然后为该文件分配一个 fd 并返回。

通过对该 fd 进行读写,就是进行数据的收发操作。

write(fd)

先看发数据。在用户态执行 write(fd) 会跳转到系统调用 sys_write(fd),进一步执行 filewrite(fd),发现传入的 fd 指向一个 SOCK 文件,下一步就会执行 sockwrite()。该函数就是将数据拷贝到 struct mbuf 类型的变量中,然后利用协议栈,不断封装 TCP/UDP 首部、IP 首部、以太网首部,最后得到一个完整的数据包,就可以发送了。

kernel/sysnet.c
int sockwrite(struct sock *si, uint64 addr, int n) { struct proc *pr = myproc(); struct mbuf *m; m = mbufalloc(MBUF_DEFAULT_HEADROOM); // 分配一个 mbuf,并留出首部空间。 if (!m) return -1; if (copyin(pr->pagetable, mbufput(m, n), addr, n) == -1) { // 载入数据 mbuffree(m); return -1; } net_tx_udp(m, si->raddr, si->lport, si->rport); return n; }
kernel/net.c
void net_tx_udp(struct mbuf *m, uint32 dip, uint16 sport, uint16 dport) { struct udp *udphdr; // put the UDP header... // now on to the IP layer net_tx_ip(m, IPPROTO_UDP, dip); } static void net_tx_ip(struct mbuf *m, uint8 proto, uint32 dip) { struct ip *iphdr; // push the IP header... // now on to the ethernet layer net_tx_eth(m, ETHTYPE_IP); } static void net_tx_eth(struct mbuf *m, uint16 ethtype) { struct eth *ethhdr; // push the ethernet header... if (e1000_transmit(m)) { // 发送,该函数需要我们实现 mbuffree(m); } }

read(fd)

收数据的流程基本与上面类似。在用户态执行 read(fd) 会跳转到系统调用 sys_read(fd),进一步执行 fileread(fd),发现传入的 fd 指向一个 SOCK 文件,下一步就会执行 sockread()。到这里就开始不一样了。sockread() 如果发现接收队列 mbufq 为空,就 sleep 直到被唤醒。

而唤醒操作实际上由硬件决定。每当 E1000 收到一个数据包,就会触发一次中断 e1000_intr(),里面会调用 e1000_recv()。这是我们需要实现的,事实上,根据任务手册我们也能大概推断出,该函数需要调用若干次 net_rx,不断拆解头部最后得到数据,发给 sockrecvudp(),它会将数据(实际上是 mbuf)push 进接收队列,并唤醒沉睡中的 sockread()。醒了以后发现数据已经有了,那就美滋滋地读取,最后返回给用户态。

kernel/sysnet.c
int sockread(struct sock *si, uint64 addr, int n) { ... acquire(&si->lock); while (mbufq_empty(&si->rxq) && !pr->killed) { // 等待 sockrecvudp 的唤醒 sleep(&si->rxq, &si->lock); } ... m = mbufq_pophead(&si->rxq); // 取出 socket 的接收队列队首数据 ... if (copyout(pr->pagetable, addr, m->head, len) == -1) { mbuffree(m); return -1; } ... }
kernel/net.c
// called by e1000_recv void net_rx(struct mbuf *m) { ... if (type == ETHTYPE_IP) // type 为以太网首部的类型字段 net_rx_ip(m); else if (type == ETHTYPE_ARP) net_rx_arp(m); else mbuffree(m); } static void net_rx_ip(struct mbuf *m) { ... struct ip *iphdr; iphdr = mbufpullhdr(m, *iphdr); net_rx_udp(m, len, iphdr); ... } static void net_rx_udp(struct mbuf *m, uint16 len, struct ip *iphdr) { struct udp *udphdr; udphdr = mbufpullhdr(m, *udphdr); ... sockrecvudp(m, sip, dport, sport); // 分别从首部中提取出,并经过 ntohs 的大小端转换 ... } static void net_rx_arp(struct mbuf *m) { ... net_tx_arp(ARP_OP_REPLY, smac, sip); // 这个要特别注意,收到一个 ARP 包后要马上发回去 ... }
kernel/sysnet.c
void sockrecvudp(struct mbuf *m, uint32 raddr, uint16 lport, uint16 rport) { ... found: ... mbufq_pushtail(&si->rxq, m); // 将数据插到 socket 的接收队列末尾 wakeup(&si->rxq); // 并唤醒 ... }

Task1: E1000 Transmit

OK,接下来我们首先进行一个发送函数的实现。关于 tx_ring[]tx_mbufs[] 以及其他寄存器就不提了,我们只要知道是一个环形结构即可,且 E1000_TDT 寄存器表明了我们应该从哪个索引进行写入。lab 手册的 hint 其实给的很详细了,这里只说一些我认为比较坑的点:

  1. Otherwise, use mbuffree() to free the last mbuf that was transmitted from that descriptor (if there was one). 这里需要遍历整个 mbuf 链表释放,防止内存泄漏;
  2. tx_desccmd 字段设置的是比特位,因为以太网最大数据包大小为 1518,而 mbuf 的 buffer 大小为 2048,所以一个 mbuf 必定能容纳一个以太网包,需要为其置位 E1000_TXD_CMD_EOP,表示一个包结束了;
  3. tx_desccsocssspecial 字段都可以不用设置;
  4. 别忘了用来对并发 transmit 进行互斥处理;
kernel/e1000.c
int e1000_transmit(struct mbuf *m) { acquire(&e1000_lock); uint32 tail = regs[E1000_TDT]; struct tx_desc* txd = &tx_ring[tail]; struct mbuf* last_mbuf = tx_mbufs[tail]; if ((txd->status & E1000_TXD_STAT_DD) == 0) { release(&e1000_lock); return -1; } if (last_mbuf) { struct mbuf* t; while (last_mbuf) { t = last_mbuf->next; mbuffree(last_mbuf); last_mbuf = t; } } tx_mbufs[tail] = m; txd->addr = (uint64)(m->head); txd->length = (uint16)(m->len); txd->cmd = E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP; txd->status = 0; // not done regs[E1000_TDT] = (tail+1) % TX_RING_SIZE; release(&e1000_lock); return 0; }

Task2: E1000 Recv

接收函数也是按照 hint 来即可。也说下坑点:

  1. 不用加锁!不用加锁!如果加了锁,调用 net_rx 发现是个 ARP 包会马上调用 e1000_transmit(),里面也有加锁,那就会导致连上两次锁又得不到释放,结果不言而喻;
  2. 因为一开始会先令 E1000_RDT 增加,所以如果检查 E1000_RDT 发现对应 rx_desc 的 E1000_RXD_STAT_DD 位为 0,需要将 E1000_RDT 回退一格,这样下一次调用时就会跳到正确的位置;
  3. 每次读取要把所有满足 E1000_RXD_STAT_DD=1 的读完,而不能一次调用只读一个 mbuf,所以需要一个大的循环;
kernel/e1000.c
static void e1000_recv(void) { uint32 tail; struct rx_desc* rxd; struct mbuf* m; for (;;) { regs[E1000_RDT] = (regs[E1000_RDT]+1) % RX_RING_SIZE; tail = regs[E1000_RDT]; rxd = &rx_ring[tail]; m = rx_mbufs[tail]; if ((rxd->status & E1000_RXD_STAT_DD) == 0) { regs[E1000_RDT] = (regs[E1000_RDT]-1) % RX_RING_SIZE; break; } m->len = rxd->length; net_rx(m); if ((rx_mbufs[tail] = mbufalloc(0)) == 0) panic("e1000_recv"); rxd->status = 0; rxd->addr = (uint64)rx_mbufs[tail]->head; } }

测试结果

$ make grade
...
== Test running nettests ==
$ make qemu-gdb
(3.1s)
== Test   nettest: ping ==
  nettest: ping: OK
== Test   nettest: single process ==
  nettest: single process: OK
== Test   nettest: multi-process ==
  nettest: multi-process: OK
== Test   nettest: DNS ==
  nettest: DNS: OK
== Test time ==
time: OK
Score: 100/100

最后的工作

  1. git commit -am "" 将所有修改提交到本地;
  2. 执行 make handin。由于 lab0 保存了 APIKey,故直接成功提交;

可选的挑战再说吧,没有什么想做的欲望。


  目录