这里要我们在软件层面实现数据包的收发操作。
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 其实给的很详细了,这里只说一些我认为比较坑的点:
Otherwise, use mbuffree() to free the last mbuf that was transmitted from that descriptor (if there was one).这里需要遍历整个 mbuf 链表释放,防止内存泄漏;tx_desc的cmd字段设置的是比特位,因为以太网最大数据包大小为 1518,而mbuf的 buffer 大小为 2048,所以一个mbuf必定能容纳一个以太网包,需要为其置位E1000_TXD_CMD_EOP,表示一个包结束了;tx_desc的cso、css、special字段都可以不用设置;- 别忘了用锁来对并发 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 来即可。也说下坑点:
- 不用加锁!不用加锁!如果加了锁,调用
net_rx发现是个 ARP 包会马上调用e1000_transmit(),里面也有加锁,那就会导致连上两次锁又得不到释放,结果不言而喻; - 因为一开始会先令 E1000_RDT 增加,所以如果检查 E1000_RDT 发现对应
rx_desc的 E1000_RXD_STAT_DD 位为 0,需要将 E1000_RDT 回退一格,这样下一次调用时就会跳到正确的位置; - 每次读取要把所有满足 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
最后的工作
git commit -am ""将所有修改提交到本地;- 执行
make handin。由于 lab0 保存了 APIKey,故直接成功提交;
可选的挑战再说吧,没有什么想做的欲望。