MIT 6.S081 Lab 7: Network driver
6.S081课程的第七个Lab,为 xv6 编写网卡设备驱动程序。
Background
我们会使用一个名为E1000的网络设备来处理网络通信。对于xv6(以及编写的驱动程序)来说,E1000看起来就像一个连接到真正的以太网局域网(LAN)的真正硬件。实际上,您的驱动程序将要与之通信的E1000是qemu提供的一个仿真实体,它连接到一个同样由qemu仿真的局域网。在这个模拟LAN上,xv6(guest)的IP地址是 10.0.2.15
。Qemu还安排运行Qemu的计算机以IP地址10.0.2.2出现在LAN上。当xv6使用E1000将包发送到10.0.2.2时,qemu将包发送到运行qemu的计算机(实际)上的适当应用程序(“host”)。
我们将使用QEMU的“用户模式网络堆栈”。QEMU的文档在这里有更多关于用户模式堆栈的信息。Lab的Makefile已经更新,以启用QEMU的用户模式网络堆栈和E1000网卡。
Job
我们的工作就是阅读E1000的软件开发手册然后实现驱动发送和接收网络包。驱动从 RAM 读取包以发送,将接收包写入 RAM 。这项技术被称为 DMA (direct memory access)。也是 E1000 硬件直接读写 RAM 的关键。关于DMA的知识可看文章I/O 操作的那些事儿:轮询 ,中断 , DMA ,通道。
由于数据包突发到达的速度可能快于驱动程序处理它们的速度,e1000_init()
为E1000提供了多个缓冲区,E1000可以将数据包写入其中。E1000要求这些缓冲区由RAM中的“描述符”数组描述;每个描述符在内存中包含一个地址,E1000可以像其中写入接收到的数据包。struct rx_desc
描述了描述符格式。描述符数组称为接收环或接收队列。这是一个环形,当网卡或驱动程序到达数组的末尾时,它会返回到开头。e1000_init()
使用mbufalloc()
为E1000的DMA分配mbuf
数据包缓冲区。还有一个传输环,驱动程序在其中放置它想要E1000发送的数据包。e1000_init()
配置两个RING的大小为RX_RING_SIZE
和TX_RING_SIZE
。
当net.c
中的网络堆栈需要发送一个包时,它调用e1000_transmit()
,并使用mbuf
保存要发送的包。传输代码必须在TX(传输)环的描述符中放置一个指向数据包数据的指针。struct tx_desc
描述了描述符格式。软件需要确保每个mbuf
最终被释放,但只有在E1000完成传输数据包之后(E1000在描述符中设置E1000_TXD_STAT_DD
位来指示这一点)。
当E1000从以太网接收每个数据包时,它首先将数据包直写内存到下一个RX(接收)环描述符所指向的mbuf
,然后生成中断。中断处理会调用 e1000_recv()
,扫描RX环,并通过调用net_rx()
将每个新数据包的mbuf
传递到网络堆栈(在net.c中),传入网络栈的mbuf
由网络栈负责回收。然后,软件需要分配一个新的mbuf
并将其放入描述符中,以便当E1000再次到达RX环中的那个点时,它会找到一个新的缓冲区,并将一个新数据包DMA写到其中。
除了在RAM中读写描述符环之外,驱动程序还需要通过其内存映射的控制寄存器与E1000进行交互,以检测何时接收到可用的数据包,并通知E1000驱动程序已经填充了一些TX描述符以发送数据包。全局变量regs
保存一个指向E1000的第一个控制寄存器的指针;驱动程序可以通过将regs
索引为数组来获取其他寄存器。尤其需要使用索引E1000_RDT
和E1000 TDT
来获取环中第一个属于驱动(软件)操作的描述符索引。
transmit
实现代码如下,
// in kernel/e1000.c file
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.
//
int ind;
acquire(&e1000_lock);
ind = regs[E1000_TDT];
if ( 0 == (tx_ring[ind].status & E1000_TXD_STAT_DD)) {
release(&e1000_lock);
return -1;
}
if (tx_mbufs[ind])
mbuffree(tx_mbufs[ind]);
tx_mbufs[ind] = m;
tx_ring[ind].addr = (uint64) m->head;
tx_ring[ind].length = m->len;
tx_ring[ind].cmd = (E1000_TXD_CMD_RS | E1000_TXD_CMD_EOP);
regs[E1000_TDT] = (ind + 1) % TX_RING_SIZE;
release(&e1000_lock);
return 0;
}
实现的要点如下,
- 首先,通过读取
E1000_TDT
控制寄存器,向E1000询问其期待的下一个数据包的TX环索引。 - 然后检查环是否溢出。如果
E1000_TDT
索引的描述符中没有设置E1000_TXD_STAT_DD
,则E1000还没有完成相应的前一个传输请求,因此返回一个错误。 - 否则,使用
mbuffree()
释放从该描述符传输的最后一个mbuf(如果有的话)。 - 然后填充描述符。
m->head
指向数据包在内存中的内容,m->len
表示数据包的长度。设置必要的cmd标志(请参阅E1000手册中的3.3节),这里用RS位指示记录,用EOP位指示包结尾(假设只发送一个包),并保存一个指向mbuf
的指针,以便稍后释放。 - 最后,更新
E1000_TDT
。 - 如果
e1000_transmit()
成功将mbuf
添加到环,则返回0。在失败时(例如,没有可用的描述符来传输mbuf),返回-1,以便调用者知道释放mbuf
。 - 因为可能有多个进程使用 E1000 这里需要加锁。
receive
实现代码如下,
// in kernel/e1000.c file
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()).
//
int ind;
ind = (regs[E1000_RDT] + 1) % RX_RING_SIZE;
while (rx_ring[ind].status & E1000_RXD_STAT_DD) {
rx_mbufs[ind]->len = rx_ring[ind].length;
net_rx(rx_mbufs[ind]);
rx_mbufs[ind] = mbufalloc(0);
if(!rx_mbufs[ind])
panic("e1000");
memset(&rx_ring[ind], 0, sizeof(rx_ring[ind]));
rx_ring[ind].addr = (uint64) rx_mbufs[ind]->head;
ind = (ind + 1) % RX_RING_SIZE;
}
regs[E1000_RDT] = (ind - 1 + RX_RING_SIZE) % RX_RING_SIZE;
}
实现的要点如下,
- 首先,通过获取
E1000_RDT
控制寄存器并加一取模RX_RING_SIZE
,向E1000询问下一个等待接收的数据包(如果有)所在的环索引。 - 然后通过检查描述符的状态部分中的
E1000_RXD_STAT_DD
位来检查是否新的数据包可用(已经被硬件处理完)。如果不是,那就停下来。 - 否则,将
mbuf
的m->len
更新为描述符中记录的长度。使用net_rx()
将mbuf
交付到网络堆栈。 - 然后使用
mbufalloc()
分配一个新的mbuf
来替换刚刚给net_rx()
的mbuf
。将其数据指针m->head
记录到描述符中。将描述符的状态位清除为零。 - 最后,将
E1000_RDT
寄存器更新。 e1000_init()
使用mbufs
初始化RX环,了解它是如何实现的,也许可以借鉴代码。- 在某个时刻,已经到达的数据包总数可能将超过环的大小(16),可以用循环不断向网络栈传
mbufs
处理。 - 接收时该函数只会被中断处理函数
e1000_intr()
调用, 因此不会出现并发的情况,无需加锁。
difference in ring
细心阅读可以发现,同样是操作 FIFO 环,发送与接收对环的索引的操作不同,这是由于两个环的 head
与 tail
的记录意义不一样的缘故。平常的环形队列,一般用 [head, tail)
左闭右开的形式指示存储的内容,用 head == tail
指示空队列,用 head == (tail + 1) % LEN
指示满队列,这往往会浪费存储空间的一个 slot (tail
所在的 slot 在满队列时不会填充)。而在手册中,发送环与接收环的形式有不同。
负责发送的 TX_RING
也是左闭右开的形式,在软件开发手册的 3.4
节有,
The tail pointer points one entry beyond the last hardware owned descriptor (but at a point still within the descriptor ring).
软件要用到的(software ownership)下一个描述符在 tail
所指的位置。而为了不浪费存储空间,tail
可以与 head
相等,但用 E1000_TXD_STAT_DD
指示,是否被硬件完成操作。所以我们可以看到,在 e1000_init()
函数中 TX_RING
的初始化,
// [E1000 14.5] Transmit initialization
memset(tx_ring, 0, sizeof(tx_ring));
for (i = 0; i < TX_RING_SIZE; i++) {
tx_ring[i].status = E1000_TXD_STAT_DD;
tx_mbufs[i] = 0;
}
regs[E1000_TDBAL] = (uint64) tx_ring;
if(sizeof(tx_ring) % 128 != 0)
panic("e1000");
regs[E1000_TDLEN] = sizeof(tx_ring);
regs[E1000_TDH] = regs[E1000_TDT] = 0;
E1000_TDH
与 E1000_TDT
置为 0
且描述符的 status
都置 E1000_TXD_STAT_DD
位。这表示,初始化后的 TX_RING
所有 entry
都属于软件( software owns ) ,软件可以操作。
而负责接收的 RX_RING
是左闭右闭的形式,
HARDWARE OWNS ALL DESCRIPTORS BETWEEN [HEAD AND TAIL]
软件为了写,需要用到 tail
之后的下一个 entry
。这也是为什么e1000_recv()
要用到 E1000_RDT
加一的索引。这一点在其他学习者,如Fan Xiao’s blog中也有记录。
Conclusion
阅读手册写驱动的心得,
- 了解哪些操作是硬件(E1000设备)完成;
- 软件(OS驱动)需要做什么工作;
- 软件与硬件可以同时看到的数据本质上只有硬件控制寄存器的内容(需要映射到内存),只不过硬件通过控制寄存器记录的描述符,可以找到RAM的地址通过DMA读写;
- 阅读手册就是了解硬件与软件需要如何协同工作。
不同硬件其实可以提供不同的特性,如手册中记录的 TCP/IP checksum 计算与 TCP 分段都可以交由硬件完成。优秀的软件应该最大限度利用硬件的特性。