本文是 6.S081 操作系统课程学习最后一个 lab,编写一个 intel 的 e1000 网卡的驱动在 xv6 下。需要复习知识有:操作系统知识,计算机组成原理 DMA 相关,循环缓冲区的概念,e1000 的粗略 spec 和其具备两个环形缓冲区和其引发中断的方式,理论上感觉做这个 lab 只需要 livelock 课程的前面讲网络的基础知识部分记忆和通读 lab 的 handout(包括 hint) 就能很快做出来。下面记录以下具体过程,穿插学习 Linux 下如何编写网卡驱动的 Real World 实现(毕竟 xv6 只是个 toy)。希望写完本文的时候能够具备一些 Linux low level 的驱动知识。
PCI 标准驱动实现和规格
Peripheral Component Interconnect 外部互联标准,PCI 总线是一种并行同步系统总线,集中式独立请求仲裁(每个 dev 都有一条请求线和总线使用线),具体仲裁优先级和算法由 PCI 具体实现。同步获取总线是利用REQ#和GNT#两个信号线实现的,前者用于某一个设备占用总线的请求,后者允许某一设备占用总线和应答。
先看 PCI 接口 DMA 技术网卡的模型:
其中 Packet Buffer 就是 RAM 了, 网卡就是右边那块 TX 和 RX 的 MAC 硬件,这里网卡通过 DMA Engine 来把卡上的存储之类的东西的内容一次复制到内存中去。而 PCI 的作用就是负责管理这些外设卡。
PCI 编址
PCI 的地址编码来访问不同的设备。
直接看 xv6 的 pci.c 我们探测 PCI 设备的时候直接遍历 dev 和 func 等。注意这里的 bus 是总线编号,观察一下 window 的设备管理器有惊喜,可以发现核显和主板自带的一些外设件都在 bus 0 下,而涉及主板上的 PCIE 口(笔记本pcie网卡和独立显卡)都接到 PCI bus 1,2去了(当然 PCIE 和 PCI 的机制不一样)如下图:
我们把 PCI 的 bus 0 叫做 up-stream 总线,bus 1 到以后的(还有到 bus 20 的)涉及一些下级的桥接,他再桥接到 bus 0 上的叫做 down-stream, 具体不究太多了。我们这里认为 Intel 的这个 e1000 是接到 bus0 上的, 实际上我们需要 PCI probe 所有的设备的,这个 lab 我们直接指定了。然后 function 的编号是因为一个 device 可能有多个 function,不过我一开始以为笔记本的 pci或者pcie网卡的 wifi 和 蓝牙 是按这个分 function 走的,结果发现实际是 wlan 走 pcie,蓝牙走 usb(minipcie、ngff 的 pci 接口都自带兼容 usb 接口),实际是两个分开的芯片。所以 function 这方面很难举例了。工业上比如4通道的采样卡就是用 function 实现多通道数据并行传输的。
直接看代码,我们根据上面的 PFA 来遍历 Bus Device Function 来查找我们要的卡。探测卡的信息就涉及到一个 convention 了,PCI 约定了 pci 上的地址的低位 offset 的一部分地址空间用于登记设备的信息,具体实现就不是 O/S 做的了, 他大概是 PCI 相关的南桥来搞的. (这一点保留意见).
// PCI address:
// |31 enable bit|30:24 Reserved|-
// -|23:16 Bus num|15:11 Dev num|10:8 func num|7:2 off|1:0 0|.
uint32 off = (bus << 16) | (dev << 11) | (func << 8) | (offset);
PCI 设备元数据规格
我们看 Intel 的 dev manual 给的 PCI 的设备信息(针对本 e1000 网卡). 但是头部的 Device ID 等内容是通用的. 所以我们很容易能读到 0h offset 的一行的 ID 信息来判断并装入驱动. 我们应当要记住,PCI 的作用就是完成 register 的 mapping 从而实现能够让 C 程序通过读取内存(vm)来访问设备的寄存器从而实现控制设备,之后的数据传输则是通过操纵那些寄存器来实现的(即控制设备)。一句话就是 PCI 在内存建了一个控制台之后程序就只用操作控制台了。
下面结合代码来看:
void
pci_init()
{
// we'll place the e1000 registers at this address.
// vm.c maps this range.
uint64 e1000_regs = 0x40000000L;
// qemu -machine virt puts PCIe config space here.
// vm.c maps this range.
uint32 *ecam = (uint32 *) 0x30000000L;
// look at each possible PCI device on bus 0.
for(int dev = 0; dev < 32; dev++){
int bus = 0;
int func = 0;
int offset = 0;
// PCI address:
// |31 enable bit|30:24 Reserved|-
// -|23:16 Bus num|15:11 Dev num|10:8 func num|7:2 off|1:0 0|.
uint32 off = (bus << 16) | (dev << 11) | (func << 8) | (offset);
volatile uint32 *base = ecam + off;
// PCI address space header:
// Byte Off | 3 | 2 | 1 | 0 |
// 0h| Device ID | Vendor ID |
uint32 id = base[0]; // read the first line.
// 10 0e (device id):80 86(vendor id) is an e1000
if(id == 0x100e8086){
// PCI address space header:
// Byte Off | 3 | 2 | 1 | 0 |
// 4h |Status register | command register |
// command and status register.
// bit 0 : I/O access enable
// bit 1 : memory access enable
// bit 2 : enable mastering
base[1] = 7;
__sync_synchronize();
for(int i = 0; i < 6; i++){
// Byte Off | 3 | 2 | 1 | 0 |
// 16b/4b = 4 10h | Base Address 0 |
// 5 14h | Base Address 1 |
// 6 18h | Base Address 2 |
// 7 1ch~24h | .... 3, 4, 5 |
uint32 old = base[4+i];
// writing all 1's to the BAR causes it to be
// replaced with its size.
base[4+i] = 0xffffffff;
__sync_synchronize();
// if we need a dynamic allocation, we can read the base[4+i] again, remove the low bits
// and calc it's one's complement then plus 1 to get it's BAR size (a dma area).
base[4+i] = old;
}
// tell the e1000 to reveal its registers at
// physical address 0x40000000.
base[4+0] = e1000_regs;
e1000_init((uint32*)e1000_regs);
}
}
}
计算的部分和用 uint32 读 bytes 我看代码注释都很清楚了,下面讲解其中几个要点。第一个是 0xffffffff 的意义。网卡内部有 flash 的而且拷贝数据不可能一个 bit 或者 byte 地拷贝效率太慢了,他的编地址机制应该是整数对齐的,所以会有一部分 bit 必须是0,我们写的时候无论你 low bits 填了1还是0,之后再 load 就会发现 low bits 始终 hard-wired to be 0b(b是二进制计数的意思…)(见下面表格的 Description). 这里 base address 的意思是注册一个内存地址给 PCI 设备,让他把 register 和NIC 的 flash 缓存内容往这个内存地址去 map。下面看一下 Intel 的 Manual 里面是怎么说这个 0xffffffff 的意思的。
这是上面的那个表格的部分详细版,然后看具体的字段意思。
这就很好理解了这个东西了。顺便摘录一段方便理解:
The Base Address Registers (or BARs) are used to map the Ethernet con-troller’s register space and flash to system memory space. In PCI-X mode or in PCI mode when the BAR32 bit of the EEPROM is 0b, two registers are used for each of the register space and the flash memory in order to map 64-bit addresses. In PCI mode, if the BAR32 bit in the EEPROM is 1b, one register is used for each to map 32-bit addresses.
初始化完 PCI 完成了一些 vm 的 mapping,之后就能够通过访问 vm address 来访问 register 了(注意看上面的表格,我们10h offset 即代码中的 base+4是 register 的地方放入 e1000_regs),之后我们转到 e1000_init() 去看怎么完成另一部分的初始化。
E1000 网卡驱动实现
e1000_init 做的事情主要有以下亿点:
- reset 网卡,关闭网卡中断。(记住 e1000 是通过 interrupt 来告知操作系统他的一个 DMA 操作完成了)。
- lazy allocate 地把 tx_ring 里面的全部状态设置为 done(即可以支持 OS 传来新的 tx 任务)。
- allocate 所有的 rx_mbuf 以及设置 rx_ring 对应的 addr。
- 设置网卡的记录 rx_ring 和 tx_ring 的 register,以及登记各种和循环缓冲区有关的 control registers 值。
- 设置 MAC 地址
- 在网卡上做一个空的多播表
- 通过设置控制位来启动网卡 Transmit 部分和 Receive 部分(开机)。
- 允许接受 Interrupt,即开网卡中断。
具体的这些到底是怎么样的详细机制我们下文再议,这里网卡的 init 完成之后,就会启动网卡。之后 lab 需要编写的 transmit 和 recv 函数到底在哪被调用呢? 我们上面讲了 e1000 是 DMA 到 buf 之后引发一个中断的,所以对!我们需要回到 trap.c 。
e1000_intr 要做的也很简单,就是调用 recv 来把 buf 的内容拿走。让 buffer 能够满足一个流动的条件。那么还有一个问题 transmit 是谁调用的? 这就是涉及我们的网络栈的部分了。我们从 lab 提供的 nettests.c 自顶向下来看。
首先看 ping 函数,该函数通过调用一个 syscall 来创建一个 file descriptor 来读写。
connect(dst, sport, dport))
我们进入看他作为 syscall 就是调用了 sockalloc 来创建 socket 接口。然后再 read 和 write 的时候调用 sockread 或者 sockwrite(sysnet.c 下)。sockwrite 将会调用 net_tx_udp 来完成一个 buffer 数据的写入。结论是 xv6 的 write 作用于 socket file 只支持 udp 调用(net.c 只实现了 udp)。我们再来看 net_tx_udp 不过是 encapsulate 一些 udp 头,还是通过 net_tx_ip 封装下层,然后 net_tx_eth 封装 ethernet frame, 进入 net_tx_eth 就看到了 e1000_transmit() 的调用了。
xv6 用户网络栈与驱动的调用结构
摘要 xv6 代码结构的图以下方便理解:
Linux 中的网络驱动
这里我要讲一个问题,这里 network stack 里 udp 怎么能直接调用 e1000 的函数呢,这对于计算机多样性(思考支持多种网卡的系统应该使用一种抽象封装的通用函数调用方案)而言是不好的。我们事实上 Linux 的实现必须用一套驱动管理系统。下面就来分析 Linux 的 RealWorld 版本的 network device driver。
由于这里我不打算 dive deep into the linux kernel,这里我们假定某些 infrastructure 已经给好了。我们需要提供一个驱动文件给 kernel 用。首先是对于 linux 的一些给 driver 用的 api 说明以下。
Linux 内核模块简介
首先是内核模块的概念,对于驱动我们是以内核模块的形式加载进入的,每个驱动的程序就编程层一个内核模块。Linux 在运行的时候 start_kernel 时会加载那些内核模块,其通过一个 do_initcalls 函数把一系列的 module_init() / init_module() 函数给调用了(他们两的区别暂且不管,涉及东西太多了,实际就是宏和入口的区别而已)。下面给出一个模块的例子(Linux Kernel Development 3rd):
这里的 module_init(hello_init) 就是把一个函数注册为模块的入口。当然也可以直接编写一个 init_module() 函数作为入口(这一点对于 main 函数经过 C runtime 包装后作为入口异曲同工)。至于怎么加载内核模块则太 technical 这里不讲了。当然这个 hello module 只有在加载和卸载的时候 print 一些东西。(至于学网卡驱动有什么用考虑虚拟网卡的好处)对于驱动而言,我们需要提供更多注册动作。
Linux 网卡驱动的层次结构
我们需要注册 net_device 结构体登记网卡信息,在不同的 Linux 内核版本中,这些结构体的内容多种多样,我选取其中一种来讲解。思想实验可以想到我们规定一个结构体来存储一些网卡信息同时存储一些在模块里的函数指针即可,然后利用订阅机制来给内核添加一个网卡。我写一部分伪代码在这里:
struct net{ // in kernel.
struct info some_info;
struct pointer some_pointer;
}
struct net my_net;
void send(){
do_send();
}
void recv(){
do_recv();
}
int init_module(){
// PCI api 探测出网卡的地址
my_card = pci_probe(id, vendor);
// 进行上面提到的那些 register 的 vm mapping
map_registers(my_card);
//写入一些信息如 MAC 地址混淆模式,多播广播信息等
set_info(my_net);
// 注册事件处理器(发送和接受)
my_net.some_info.send = send;
my_net.some_info.recv = recv;
// 把网卡注册到内核里
register_netdev(my_net);
return 0;
}
void exit_module(){
unregister_netdev(my_net);
}
当然具体还会涉及一些数据结构(如 xv6 的 mbuf),但是这些编程太 dirty 太多 spec 内容(而且不同 linux 版本千差万别,比如你可以把一个 net_device 来存所有的 info 和 function pointers 或者分开来(net_device_ops),对 interrupt recv 的实现可以规定一个默认入口,也可以同样使用 function pointer 等等等等) 了,我们还要做 lab,这部分就不看下去了。讲解 Linux 的具体实现思路是因为 xv6 的过于简陋了思想实验就无法令人接受,也顺带帮助了解一下 Linux kernel module 的知识。
上文我们说具体的这些到底是怎么样的详细机制我们下文再议,好现在就来做这个 lab 了。本质上还是练习一个 lock 数据结构的访问的编程练习。所以这下我们的重点回到数据结构上。目前对那个循环的 buffer 实际上是有一个模糊的印象而已。我们必须分开来分析和编程。先从 tx 开始吧。
Ring Buffer 数据结构分析
lecture 上已经讲过了 network stack 的内容了,我这里也不想再做笔记了。下面给出 circular buffer 的结构以及要用的 register 指针的宏定义(红色字样为相应寄存器在 regs 数组的索引宏别名)。
我们这里要用到 TDT,因为 TDT 是他发送出去的一个空位置。正常来说全程由我们软件跟踪(因为他负责把包发送出去,所以硬件递增的只有 Head,Tail 只是标记让硬件暂停 transmitting 的一个 flag)所以看到 init 的时候把 TDT 和 TDH 都设置为 0.
然后我们读这里的操作 HINT 。
- First ask the E1000 for the TX ring index at which it's expecting the next packet, by reading the E1000_TDT control register.
- Then check if the the ring is overflowing. If E1000_TXD_STAT_DD is not set in the descriptor indexed by E1000_TDT, the E1000 hasn't finished the corresponding previous transmission request, so return an error.
- Otherwise, use mbuffree() to free the last mbuf that was transmitted from that descriptor (if there was one).
- Then fill in the descriptor. m->head points to the packet's content in memory, and m->len is the packet length. Set the necessary cmd flags (look at Section 3.3 in the E1000 manual) and stash away a pointer to the mbuf for later freeing.
- Finally, update the ring position by adding one to E1000_TDT modulo TX_RING_SIZE.
- If e1000_transmit() added the mbuf successfully to the ring, return 0. On failure (e.g., there is no descriptor available to transmit the mbuf), return -1 so that the caller knows to free the mbuf.
解释一下我们的数据结构,这里由一个 status 数组来跟踪我们的 circular buffer,他不负责数据。为了能保持跟踪我们的 mbuf,还要设置一个 mbuf 指针数组,这是回想我们 transmit 的 api 是上层用户提供一个 mbuf 给我们发的,但是我们放到到 ring buffer 的时候只是 local comitting,只有等到他的那个对应的 status 被网卡更新了(remote push,不过 spec 说了你可以指定网卡一 copy 到 flash 就 update status,也可以指定等到 sent 之后再 update)才能 free 掉我们的 mbuf 原件(销毁本地备份)。这个 status 是由硬件写进来的(handout 说的 the E1000 sets the E1000_TXD_STAT_DD bit in the descriptor to indicate this)。所以具体的数据结构如下:
其中 mbuf 指针数组 tx_mbufs 做的事情不过是做 hint 里要求的 stash away pointers to the mbufs presented in tx_rings 而已。(感觉这部分全部不写好让自己写反而更方便做这个 lab?因为 mbuf 的一些字段好像就没用到,为了理解这个好像有点花时间,不过这样就要涉及更多的读 specification 的工作了)代码如下给出:
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);
uint32 tail = regs[E1000_TDT];
// overflow
if (tx_ring[tail].status != E1000_TXD_STAT_DD) {
release(&e1000_lock);
return -1;
}
if(tx_mbufs[tail]){
mbuffree(tx_mbufs[tail]);
}
tx_ring[tail].length = (uint16)m->len;
tx_ring[tail].addr = (uint64)m->head;
tx_ring[tail].cmd = 9;
tx_mbufs[tail] = m;
regs[E1000_TDT] = (tail+1)%TX_RING_SIZE;
release(&e1000_lock);
return 0;
}
recv 的则类似这里不赘述了,上图,
对 Intel Spec 里面的这幅图我也是物语了😓,他画错图了又在下面文字附上(HARDWARE OWNS ALL DESCRIPTORS BETWEEN [HEAD AND TAIL]. 浪费我还以为出 bug 了用 python 写 socket 试了一下。这里建议 google 学习一下 python socket 编程然后用来模拟 nettests 里面的内容来测试一下(等于有一个正确的结果的程序),端口号就看 make server 的提示和 makefile 里面显示的以及 handout 说的那一个了。
区别的是我们要从 tail +1(HARDWARE OWNS ALL DESCRIPTORS BETWEEN [HEAD AND TAIL].) 去拿包出来,把 rx_ring 的这个 buffer 空间给 hardware。更重要的是,我们需要把全部阴影部分都拿走。出现多个灰色的原因是我们为了减少 interrupt 的次数(复习前面的 lecture,receive livelock 就是因为 packet 接收速率很快,而每个收到的packet都会生成一个中断,最后,100%的CPU时间都被消耗用来处理网卡的输入中断,CPU没有任何时间用来转发 packet 到上层,同时由于每次只从 buffer copyout 一个也容易导致网卡 throw away 快速到达的 packets)。Spec 里说:
The Receive Timer Interrupt is used to signal most packet reception events (the Small Receive
Packet Detect interrupt is also used in some cases as described later in this section). In order to
minimize the interrupts per work accomplished, the Ethernet controller provides two timers to
control how often interrupts are generated.
不过我们查看 init 发现 timer 设置为 0. 所以我们实际不需要复制全部的灰色因为已经约定了一个一个地 interrupt,出于学习目的我们还是写一个 while 循环吧。HINT 中说:At some point the total number of packets that have ever arrived will exceed the ring size (16); make sure your code can handle that. 我的理解是我不能理解,可能看 Q&A 不知道会不会讲这个。如果有人知道这个情况会出现什么事情吗可以告诉我。代码如下给出:
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 tail = regs[E1000_RDT];
int i = (tail+1)%RX_RING_SIZE; // tail is owned by Hardware!
while (rx_ring[i].status & E1000_RXD_STAT_DD) {
rx_mbufs[i]->len = rx_ring[i].length;
// send mbuf to upper level (the network stack in net.c).
net_rx(rx_mbufs[i]);
// get a new buffer for next recv.
rx_mbufs[i] = mbufalloc(0);
rx_ring[i].addr = (uint64)rx_mbufs[i]->head;
// update status for next recv.
rx_ring[i].status = 0;
i = (i + 1) % RX_RING_SIZE;
}
regs[E1000_RDT] = i - 1; // - 1 for the while loop.
}
最后指出一个锁的应用问题:You'll need locks to cope with the possibility that xv6 might use the E1000 from more than one process, or might be using the E1000 in a kernel thread when an interrupt arrives. 就能理解为什么一个要用 spinlock 一个不用 spinlock。