lab / 2023.10.31

6.s081 lab6 Networking

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

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

Preparation

切换到对应分支

1$ git fetch
2$ git checkout net
3$ 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 首部、以太网首部,最后得到一个完整的数据包,就可以发送了。

 1int
 2sockwrite(struct sock *si, uint64 addr, int n)
 3{
 4  struct proc *pr = myproc();
 5  struct mbuf *m;
 6
 7  m = mbufalloc(MBUF_DEFAULT_HEADROOM); // 分配一个 mbuf,并留出首部空间。
 8  if (!m)
 9    return -1;
10
11  if (copyin(pr->pagetable, mbufput(m, n), addr, n) == -1) { // 载入数据
12    mbuffree(m);
13    return -1;
14  }
15  net_tx_udp(m, si->raddr, si->lport, si->rport);
16  return n;
17}
 1void
 2net_tx_udp(struct mbuf *m, uint32 dip,
 3           uint16 sport, uint16 dport)
 4{
 5  struct udp *udphdr;
 6
 7  // put the UDP header...
 8
 9  // now on to the IP layer
10  net_tx_ip(m, IPPROTO_UDP, dip);
11}
12
13static void
14net_tx_ip(struct mbuf *m, uint8 proto, uint32 dip)
15{
16  struct ip *iphdr;
17
18  // push the IP header...
19
20  // now on to the ethernet layer
21  net_tx_eth(m, ETHTYPE_IP);
22}
23
24static void
25net_tx_eth(struct mbuf *m, uint16 ethtype)
26{
27  struct eth *ethhdr;
28
29  // push the ethernet header...
30
31  if (e1000_transmit(m)) { // 发送,该函数需要我们实现
32    mbuffree(m);
33  }
34}

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()。醒了以后发现数据已经有了,那就美滋滋地读取,最后返回给用户态。

 1int
 2sockread(struct sock *si, uint64 addr, int n)
 3{
 4  ...
 5  acquire(&si->lock);
 6  while (mbufq_empty(&si->rxq) && !pr->killed) { // 等待 sockrecvudp 的唤醒
 7    sleep(&si->rxq, &si->lock);
 8  }
 9  ...
10  m = mbufq_pophead(&si->rxq); // 取出 socket 的接收队列队首数据
11  ...
12  if (copyout(pr->pagetable, addr, m->head, len) == -1) {
13    mbuffree(m);
14    return -1;
15  }
16  ...
17}
 1// called by e1000_recv
 2void net_rx(struct mbuf *m)
 3{
 4  ...
 5  if (type == ETHTYPE_IP) // type 为以太网首部的类型字段
 6    net_rx_ip(m);
 7  else if (type == ETHTYPE_ARP)
 8    net_rx_arp(m);
 9  else
10    mbuffree(m);
11}
12
13static void
14net_rx_ip(struct mbuf *m)
15{
16  ...
17  struct ip *iphdr;
18  iphdr = mbufpullhdr(m, *iphdr);
19  net_rx_udp(m, len, iphdr);
20  ...
21}
22
23static void
24net_rx_udp(struct mbuf *m, uint16 len, struct ip *iphdr)
25{
26  struct udp *udphdr;
27  udphdr = mbufpullhdr(m, *udphdr);
28  ...
29  sockrecvudp(m, sip, dport, sport); // 分别从首部中提取出,并经过 ntohs 的大小端转换
30  ...
31}
32
33static void
34net_rx_arp(struct mbuf *m)
35{
36  ...
37  net_tx_arp(ARP_OP_REPLY, smac, sip); // 这个要特别注意,收到一个 ARP 包后要马上发回去
38  ...
39}
 1void
 2sockrecvudp(struct mbuf *m, uint32 raddr, uint16 lport, uint16 rport)
 3{
 4  ...
 5found:
 6  ...
 7  mbufq_pushtail(&si->rxq, m);  // 将数据插到 socket 的接收队列末尾
 8  wakeup(&si->rxq);             // 并唤醒
 9  ...
10}

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 进行互斥处理;
 1int
 2e1000_transmit(struct mbuf *m)
 3{
 4  acquire(&e1000_lock);
 5  uint32 tail = regs[E1000_TDT];
 6  struct tx_desc* txd = &tx_ring[tail];
 7  struct mbuf* last_mbuf = tx_mbufs[tail];
 8
 9  if ((txd->status & E1000_TXD_STAT_DD) == 0) {
10    release(&e1000_lock);
11    return -1;
12  }
13
14  if (last_mbuf) {
15    struct mbuf* t;
16    while (last_mbuf) {
17      t = last_mbuf->next;
18      mbuffree(last_mbuf);
19      last_mbuf = t;
20    }
21  }
22
23  tx_mbufs[tail] = m;
24  txd->addr = (uint64)(m->head);
25  txd->length = (uint16)(m->len);
26  txd->cmd = E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP;
27  txd->status = 0;  // not done
28  regs[E1000_TDT] = (tail+1) % TX_RING_SIZE;
29
30  release(&e1000_lock);
31  return 0;
32}

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,所以需要一个大的循环;
 1static void
 2e1000_recv(void)
 3{
 4  uint32 tail;
 5  struct rx_desc* rxd;
 6  struct mbuf* m;
 7
 8  for (;;) {
 9    regs[E1000_RDT] = (regs[E1000_RDT]+1) % RX_RING_SIZE;
10    tail = regs[E1000_RDT];
11    rxd = &rx_ring[tail];
12    m = rx_mbufs[tail];
13
14    if ((rxd->status & E1000_RXD_STAT_DD) == 0) {
15      regs[E1000_RDT] = (regs[E1000_RDT]-1) % RX_RING_SIZE;
16      break;
17    }
18
19    m->len = rxd->length;
20    net_rx(m);
21
22    if ((rx_mbufs[tail] = mbufalloc(0)) == 0)
23      panic("e1000_recv");
24    rxd->status = 0;
25    rxd->addr = (uint64)rx_mbufs[tail]->head;
26  }
27}

测试结果

 1$ make grade
 2...
 3== Test running nettests ==
 4$ make qemu-gdb
 5(3.1s)
 6== Test   nettest: ping ==
 7  nettest: ping: OK
 8== Test   nettest: single process ==
 9  nettest: single process: OK
10== Test   nettest: multi-process ==
11  nettest: multi-process: OK
12== Test   nettest: DNS ==
13  nettest: DNS: OK
14== Test time ==
15time: OK
16Score: 100/100

最后的工作

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

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