Lab7: Networking
最终的任务其实很简单,就是实现 E1000 网卡驱动中的 transmit() 和 recv() 函数。
E1000 的交互方法
接收
如果网卡收到了数据,会产生一个中断,然后调用对应的中断处理程序去处理这个新到达的数据。
描述符
接收描述符的格式如下:
// [E1000 3.2.3]
struct rx_desc
{
uint64 addr; /* Address of the descriptor's data buffer */
uint16 length; /* Length of data DMAed into data buffer */
uint16 csum; /* Packet checksum */
uint8 status; /* Descriptor status */
uint8 errors; /* Descriptor Errors */
uint16 special;
};
我们会在内存中放一个数组的描述符,然后这个数组会被解读成一个环形队列。
如果网卡接收到了一个新的数据包,会检查环形队列 head 位置的描述符。然后把数据写入 head 描述符的缓冲区,也就是 addr 记录的地址。
这里比较重要的还有 status 和 length 属性。网卡在写入的时候就会设置这些属性。
其中,length 表示写入 addr 的数据包长度。status 则可以代表下列状态:
其中,我们需要用到的主要是 DD (Descriptor Done) 这个标志位。其表示网卡已经接收好了这个包。
环形队列
上面我们提到了,如果网卡收到了新的数据,会往环形队列 head 位置描述符的缓冲区写入数据,下面来讨论网卡和驱动程序是如何具体管理这个缓冲区的。
下图展示了接收描述符环形队列的结构:
初始化时,head 为 0,tail 为队列缓冲区减一。
其中,head 到 tail 的这段浅色的区域是空闲的(图有点问题, tail 指向的位置也时空闲的)。也就是说,这个区域内的数据包都已经被软件处理好了,那么如果有新的数据包到达,网卡会把数据写入这个区域的开始,也就是 head,把老的数据覆盖掉。网卡把老的数据覆盖掉后会把 head 的值加一。
而软件会按照顺序处理深色的区域。读取环形队列时,读取的是 tail + 1 位置描述符缓冲区的数据(这个位置是所有未处理数据中等待时间最长的),处理完这个缓冲区后会把 tail 增加一。
发送
描述符
发送描述符的格式如下:
// [E1000 3.3.3]
struct tx_desc
{
uint64 addr;
uint16 length;
uint8 cso;
uint8 cmd;
uint8 status;
uint8 css;
uint16 special;
};
其中 addr 和 length 的作用和接收描述符的作用相同。除了这两个,我们主要还需要用到 cmd 和 status 这两个属性。和接收标志位一样,在 status 中我们需要用到 DD 标志位,表示当前标志位指向的数据是否发送完成。而 cmd 描述了传输这个数据包时的一些设置,或者说对于网卡的命令。
这里需要用到的命令有如下几个:
- RPS (Report Packet Sent):设置之后,网卡会报告数据包发送的状态。比如,在描述符指向的数据发送完成后,网卡会设置描述符的 DD 标志位。
- EOP (End of Packet):表明这个描述符是数据包的结尾。如果要发送的数据包特别大,我们可能会用很多个描述符的缓存空间来储存一个包。那么可以给这个数据包的最后一个描述符设置 EOP 命令。只有这样才能给这个描述符加上一些别的功能,如 IC,即加入和校验。
环形队列
和接收描述符的环形队列略有不同,发送描述符的 head 到 tail 这段区域(途中浅色区域)表示我们希望发送,但是网卡还没发送出去的数据。
其中 head 指向等待时间最长的待发送数据,网卡会从这里开始发送。完成后会把 tail 加一而如果我们要新加入一个描述符,是从 tail 这个方向加入的,也会把 tail 加一。
lab
发包:
当network stack需要发送一个packet的时候,会先将这个packet存放到发送环形缓冲区tx_ring,最后通过网卡将这个packet发送出去。(每次发送packet前都需要检查一下上一次的packet发送完没,如果发送完了,要将其的释放掉)
收包:
当网卡需要接收packet的时候,网卡会直接访问内存(DMA),先将接受到的RAM的数据(即packet的内容)写入到接收环形缓冲区rx_ring中。接着,网卡会向cpu发出一个硬件中断,当cpu接受到硬件中断后,cpu就可以从接收环形缓冲区rx_ring中读取packet传递到network stack中了(net_rx())。(网卡会一次性接收全部的packets,即接收到rx_ring溢出为止)
e1000_transmit
/* Transmit Descriptor command definitions [E1000 3.3.3.1] */
#define E1000_TXD_CMD_EOP 0x01 /* End of Packet */
#define E1000_TXD_CMD_RS 0x08 /* Report Status */
只给了这两个描述符,所以可以推测出只用这两个。
int
e1000_transmit(struct mbuf *m)
{
//
// Your code here.
//
// the mbuf contains an ethernet frame; program it into
// the TX descriptor ring so that the e1000 sends it. Stash
// a pointer so that it can be freed after sending.
//
acquire(&e1000_lock);
//hint 1 cur-index
uint32 tx_index = regs[E1000_TDT];
//hint 2 检查溢出
if((tx_ring[tx_index].status & E1000_TXD_STAT_DD) == 0)
{
release(&e1000_lock);
return -1;
}
//hint 3 用mbuffree()释放mbuf
if(tx_mbufs[tx_index])
{
mbuffree(tx_mbufs[tx_index]);
}
//hint 4 添加描述符
tx_mbufs[tx_index] = m;
tx_ring[tx_index].addr = (uint64)m->head;
tx_ring[tx_index].length = m->len;
tx_ring[tx_index].cmd = E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP;
//hint 5 更新地址
regs[E1000_TDT] = (tx_index+1)%TX_RING_SIZE;
release(&e1000_lock);
return 0;
}
这里有一个问题为什么要用锁?
- 因为会有多线程测试,可能会出现多个线程会访问同一个
mbuf的情况,导致竞争出错。- 获取锁之后,如果该索引能被访问,就要将原有的(上一次的)
mbuf其释放掉,以便装入新的mbuf
e1000_recv
为什么不用锁?
因为
recv过程发生在OSI协议的数据链路层,而在这个层次的packet接收是不区分进程。既然不区分进程接收,自然就可以一直接收,直到不能再接收为止(溢出)。
static void
e1000_recv(void)
{
//
// Your code here.
//
// Check for packets that have arrived from the e1000
// Create and deliver an mbuf for each packet (using net_rx()).
//
while (1)
{
//hint 1 get next index
uint32 rx_index = (regs[E1000_RDT] + 1) % RX_RING_SIZE;
//hint 2 check status
if((rx_ring[rx_index].status & E1000_RXD_STAT_DD) == 0)
return;
//hint 3 更新length; 用net_rx将mbuf传到stack
rx_mbufs[rx_index]->len = rx_ring[rx_index].length;
net_rx(rx_mbufs[rx_index]);
//hing 4 用mvufalloc分一个新的mbuf替换
rx_mbufs[rx_index] = mbufalloc(0);
rx_ring[rx_index].addr = (uint64)rx_mbufs[rx_index]->head;
rx_ring[rx_index].status = 0;
//hint 5 更新E1000_RDT
regs[E1000_RDT] = rx_index;
}
}
这两个完全就是跟着hint写。