这里要我们在软件层面实现数据包的收发操作。
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.cint 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.cvoid 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.cint 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.cvoid 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 其实给的很详细了,这里只说一些我认为比较坑的点:
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 进行互斥处理;
kernel/e1000.cint 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 来即可。也说下坑点:
- 不用加锁!不用加锁!如果加了锁,调用
net_rx
发现是个 ARP 包会马上调用e1000_transmit()
,里面也有加锁,那就会导致连上两次锁又得不到释放,结果不言而喻; - 因为一开始会先令 E1000_RDT 增加,所以如果检查 E1000_RDT 发现对应
rx_desc
的 E1000_RXD_STAT_DD 位为 0,需要将 E1000_RDT 回退一格,这样下一次调用时就会跳到正确的位置; - 每次读取要把所有满足 E1000_RXD_STAT_DD=1 的读完,而不能一次调用只读一个
mbuf
,所以需要一个大的循环;
kernel/e1000.cstatic 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
最后的工作
git commit -am ""
将所有修改提交到本地;- 执行
make handin
。由于 lab0 保存了 APIKey,故直接成功提交;
可选的挑战再说吧,没有什么想做的欲望。