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