0%

MIT-6.828-JOS 笔记 - Lab 6: Network Driver

本实验是6.828默认的最后一个实验,围绕网络展开,主要就一件事情:实现一个网络驱动。

本实验代码:https://github.com/epis2048/MIT_6.828_2018/tree/lab6

一、引言

1. QEMU的虚拟网络

默认情况下,QEMU提供运行在IP 10.0.2.2上的虚拟路由器,并将为JOS分配IP地址10.0.2.15。为了简单起见,我们将这些默认值硬编码到net/ns.h中的网络服务器中。

2. 网络服务

从0开始写协议栈是很困难的,我们将使用lwIP,轻量级的TCP/IP实现,更多lwIP信息可以参考lwIP官网。对于我们来说lwIP就像一个实现了BSD socket接口的黑盒,分别有一个包输入和输出端口。
JOS的网络网络服务由四个进程组成:(绿色部分是我们需要实现的)https://pdos.csail.mit.edu/6.828/2018/labs/lab6/ns.png

  1. 核心网络进程:
    核心网络进程由socket调用分发器和lwIP组成。socket调用分发器和文件服务一样。用户进程发送IPC消息给核心网络进程。
    用户进程不直接使用nsipc_*开头的函数调用,而是使用lib/socket.c中的函数。这样用户进程通过文件描述符来访问socket。
    文件服务和网络服务有很多相似的地方,但是最大的不同点在于,BSD socket调用accept和recv可能会阻塞,如果分发器调用lwIP这些阻塞的函数,自己也会阻塞,这样就只能提供一个网络服务了。显然是不能接受的,网络服务将使用用户级的线程来避免这种情况。
  2. 包输出进程:
    lwIP通过IPC发送packets到输出进程,然后输出进程负责通过系统调用将这些packets转发给设备驱动。
  3. 包输入进程:
    对于每个从设备驱动收到的packet,输入进程从内核取出这些packet,然后使用IPC转发给核心网络进程。
  4. 定时器进程:
    定时器进程周期性地发送消息给核心网络进程,通知它一段时间已经过了,这种消息被lwIP用来实现网络超时。

通过这些我们大致知道这个网络的流程了,实际上核心服务器和文件服务器是一模一样的,让我们再做一次实际上也就是把上次的代码在看一遍。至于输出环境,输入环境和时钟环境,就是让我们实现的东西。

3. 线程

(这部分实验指导书里没有,我自己加的)
此外,我发现在net/lwip/jos/arch/thread.c中多了一部分线程的实现,可以看下。另外可以看下threadq.h中的一些定义,了解下他的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// net/lwip/jos/arch/threadq.h
struct thread_queue // 线程队列
{
struct thread_context *tq_first;
struct thread_context *tq_last;
};

struct thread_context { // 线程结构,TCB
thread_id_t tc_tid; // ID
void *tc_stack_bottom; // 线程栈
char tc_name[name_size]; // 线程名
void (*tc_entry)(uint32_t); // 指令地址
uint32_t tc_arg; // 参数
struct jos_jmp_buf tc_jb; // CPU的内容
volatile uint32_t *tc_wait_addr;
volatile char tc_wakeup;
void (*tc_onhalt[THREAD_NUM_ONHALT])(thread_id_t);
int tc_nonhalt;
struct thread_context *tc_queue_link;
};

二、初始化和发送报文

现在内核还没有时间的概念,硬件每隔10ms都会发送一个时钟中断。每次时钟中断,我们可以给某个变量加一,来表明时间过去了10ms,具体实现在kern/time.c中。

Exercise 1
在kern/trap.c中添加对time_tick()调用。实现sys_time_msec()系统调用。sys_time_msec()可以配合sys_yield()实现sleep()(见user/testtime.c)。
修改trap_dispatch()函数,这里直接在Lab4的代码基础上修改了。

1
2
3
4
5
6
7
8
9
10
// kern/trap.c
// 时钟中断
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
lapic_eoi();
if (cpunum() == 0) { //lab6
time_tick();
}
sched_yield();
return;
}

还要修改sys_time_msec的代码,调用time_msec()即可。

1
2
3
4
5
6
7
8
// kern/syscall.c
static int
sys_time_msec(void)
{
// LAB 6: Your code here.
// panic("sys_time_msec not implemented");
return time_msec();
}

不要忘了在syscall()中添加该调用。

1
2
3
4
// kern/syscall.c
case SYS_time_msec:
ret = sys_time_msec();
break;

1. The Network Interface Card

编写驱动需要很深的硬件以及硬件接口知识,本lab会提供一些E1000比较表层的知识,你需要学会看E1000的开发者手册。

Exercise 2
浏览英特尔的E1000软件开发人员手册。本手册涵盖了几个密切相关的以太网控制器。QEMU模拟82540EM。
大段英文看起来头疼,告辞。

1.1 PCI Interface

E1000是PCI设备,意味着E1000将插到主板上的PCI总线上。PCI总线有地址,数据,中断线允许CPU和PCI设备进行交互。PCI设备在被使用前需要被发现和初始化。发现的过程是遍历PCI总线寻找相应的设备。初始化的过程是分配I/O和内存空间,包括协商IRQ线。
我们已经在kern/pic.c中提供了PCI代码。为了在启动阶段初始化PCI,PCI代码遍历PCI总线寻找设备,当它找到一个设备,便会读取该设备的厂商ID和设备ID,然后使用这两个值作为键搜索pci_attach_vendor数组,该数组由struct pci_driver结构组成。struct pci_driver结构如下:

1
2
3
4
struct pci_driver {
uint32_t key1, key2;
int (*attachfn) (struct pci_func *pcif);
};

如果找到一个struct pci_driver结构,PCI代码将会执行struct pci_driver结构的attachfn函数指针指向的函数执行初始化。attachfn函数指针指向的函数传入一个struct pci_func结构指针。struct pci_func结构的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct pci_func {
struct pci_bus *bus;

uint32_t dev;
uint32_t func;

uint32_t dev_id;
uint32_t dev_class;

uint32_t reg_base[6];
uint32_t reg_size[6];
uint8_t irq_line;
};

其中reg_base数组保存了内存映射I/O的基地址, reg_size保存了以字节为单位的大小。 irq_line包含了IRQ线。
当attachfn函数指针指向的函数执行后,该设备就算被找到了,但还没有启用,attachfn函数指针指向的函数应该调用pci_func_enable(),该函数启动设备,协商资源,并且填充传入的struct pci_func结构。

Exercise 3
实现attach函数来初始化E1000。在kern/pci.c的pci_attach_vendor数组中添加一个元素。82540EM的厂商ID和设备ID可以在手册5.2节找到。实验已经提供了kern/e1000.c和kern/e1000.h,补充这两个文件完成实验。添加一个函数,并将该函数地址添加到pci_attach_vendor这个数组中。

1
2
3
4
5
6
7
8
// kern/e1000.c
// LAB 6: Your driver code here
int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
return 0;
}
1
2
3
4
5
6
7
8
// kern/e1000.h
#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H
#include <kern/pci.h>
int e1000_attachfn(struct pci_func *pcif);
#define E1000_VENDER_ID_82540EM 0x8086
#define E1000_DEV_ID_82540EM 0x100E
#endif // SOL >= 6
1
2
3
4
5
6
7
// kern/pci.c
// pci_attach_vendor matches the vendor ID and device ID of a PCI device. key1
// and key2 should be the vendor ID and device ID respectively
struct pci_driver pci_attach_vendor[] = {
{ E1000_VENDER_ID_82540EM, E1000_DEV_ID_82540EM, &e1000_attachfn },
{ 0, 0, 0 },
};

1.2 Memory-mapped I/O

程序通过内存映射IO(MMIO)和E1000交互。通过MMIO这种方式,允许通过读写”memory”进行控制设备,这里的”memory”并非DRAM,而是直接读写设备。pci_func_enable()协商MMIO范围,并将基地址和大小保存在基地址寄存器0(reg_base[0] and reg_size[0])中,这是一个物理地址范围,我们需要通过虚拟地址来访问,所以需要创建一个新的内核内存映射。

Exercise 4
使用mmio_map_region()建立内存映射。至此我们能通过虚拟地址bar_va来访问E1000的寄存器。
继续修改e1000.c和e100.h文件。

1
2
3
4
5
6
7
8
9
10
11
12
// kern/e1000.c
// LAB 6: Your driver code here
int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
// 该函数从线性地址MMIOBASE开始映射物理地址pa开始的size大小的内存,并返回pa对应的线性地址。
bar_va = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
uint32_t *status_reg = (uint32_t *)E1000REG(E1000_STATUS);
assert(*status_reg == 0x80080783);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kern/e1000.h
#ifndef JOS_KERN_E1000_H
#define JOS_KERN_E1000_H

#include <kern/pci.h>

#define E1000_VENDER_ID_82540EM 0x8086
#define E1000_DEV_ID_82540EM 0x100E
#define E1000REG(offset) (void *)(bar_va + offset)

volatile void *bar_va;

int e1000_attachfn(struct pci_func *pcif);

#endif // SOL >= 6

1.3 DMA

DMA简单说就是允许外部设备直接访问内存,而不需要CPU参与。
我们可以通过读写E1000的寄存器来发送和接收数据包,但是这种方式非常慢。E1000使用DMA直接读写内存,不需要CPU参与。驱动负责分配内存作为发送和接受队列,设置DMA描述符,配置E1000这些队列的位置,之后的操作都是异步的。
发送一个数据包:驱动将该数据包拷贝到发送队列中的一个DMA描述符中,通知E1000,E1000从发送队列的DMA描述符中拿到数据发送出去。
接收数据包:E1000将数据拷贝到接收队列的一个DMA描述符中,驱动可以从该DMA描述符中读取数据包。
发送和接收队列非常相似,都由DMA描述符组成,DMA描述符的确切结构不是固定的,但是都包含一些标志和包数据的物理地址。发送和接收队列可以由环形数组实现,都有一个头指针和一个尾指针。
这些数组的指针和描述符中的包缓冲地址都应该是物理地址,因为硬件操作DMA读写物理内存不需要通过MMU。

2. Transmitting Packets

E1000的发送和接收功能基本上彼此独立,因此我们可以一次完成一个工作。我们将首先攻击发送数据包的原因仅仅是因为我们无法在不发送“I’m here!”的情况下测试接收。数据包优先。

C Structures

您会发现使用C struct来描述E1000的结构很方便。如您所见struct Trapframe,使用C struct 等结构可以 使您精确地在内存中布置数据。C可以在字段之间插入填充,但是E1000的结构布局使得这不成问题。

1
2
3
4
5
6
//  63            48 47   40 39   32 31   24 23   16 15             0
// +---------------------------------------------------------------+
// | Buffer address |
// +---------------+-------+-------+-------+-------+---------------+
// | Special | CSS | Status| Cmd | CSO | Length |
// +---------------+-------+-------+-------+-------+---------------+

结构的第一个字节从右上角开始,因此要将其转换为C struct,从右到左,从上到下读取。如果布局正确,您会发现所有字段甚至都非常适合标准大小的类型:

1
2
3
4
5
6
7
8
9
10
struct tx_desc
{
uint64_t addr;
uint16_t length;
uint8_t cso;
uint8_t cmd;
uint8_t status;
uint8_t css;
uint16_t special;
};

Exercise 5
按照14.5节的描述初始化。步骤如下:

  1. 分配一块内存用作发送描述符队列,起始地址要16字节对齐。用基地址填充(TDBAL/TDBAH) 寄存器。
  2. 设置(TDLEN)寄存器,该寄存器保存发送描述符队列长度,必须128字节对齐。
  3. 设置(TDH/TDT)寄存器,这两个寄存器都是发送描述符队列的下标。分别指向头部和尾部。应该初始化为0。
  4. 初始化TCTL寄存器。设置TCTL.EN位为1,设置TCTL.PSP位为1。设置TCTL.CT为10h。设置TCTL.COLD为40h。
  5. 设置TIPG寄存器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// kern/e1000.c
void
e1000_transmit_init(){
int i;
// 初始化tx_desc_array数组
for (i = 0; i < TXDESCS; i++) {
tx_desc_array[i].addr = PADDR(tx_buffer_array[i]);
tx_desc_array[i].cmd = 0;
tx_desc_array[i].status |= E1000_TXD_STAT_DD;
}

// 下面初始化一些寄存器状态
//TDLEN register
struct e1000_tdlen *tdlen = (struct e1000_tdlen *)E1000REG(E1000_TDLEN);
tdlen->len = TXDESCS;

//TDBAL register
uint32_t *tdbal = (uint32_t *)E1000REG(E1000_TDBAL);
*tdbal = PADDR(tx_desc_array);

//TDBAH regsiter
uint32_t *tdbah = (uint32_t *)E1000REG(E1000_TDBAH);
*tdbah = 0;

//TDH register, should be init 0
tdh = (struct e1000_tdh *)E1000REG(E1000_TDH);
tdh->tdh = 0;

//TDT register, should be init 0
tdt = (struct e1000_tdt *)E1000REG(E1000_TDT);
tdt->tdt = 0;

//TCTL register
struct e1000_tctl *tctl = (struct e1000_tctl *)E1000REG(E1000_TCTL);
tctl->en = 1;
tctl->psp = 1;
tctl->ct = 0x10;
tctl->cold = 0x40;

//TIPG register
struct e1000_tipg *tipg = (struct e1000_tipg *)E1000REG(E1000_TIPG);
tipg->ipgt = 10;
tipg->ipgr1 = 4;
tipg->ipgr2 = 6;
}

此外,还需要在手册中补充一些宏定义在e1000.h中

现在初始化已经完成,接着需要编写代码发送数据包,提供系统调用给用户代码使用。要发送一个数据包,需要将数据拷贝到数据下一个数据缓存区,然后更新TDT寄存器来通知网卡新的数据包已经就绪。

Exercise 6
编写发送数据包的函数,处理好发送队列已满的情况。如果发送队列满了怎么办?
怎么检测发送队列已满:如果设置了发送描述符的RS位,那么当网卡发送了一个描述符指向的数据包后,会设置该描述符的DD位,通过这个标志位就能知道某个描述符是否能被回收。
检测到发送队列已满后怎么办:可以简单的丢弃准备发送的数据包。也可以告诉用户进程进程当前发送队列已满,请重试,就像sys_ipc_try_send()一样。我们采用重试的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// kern/e1000.c
int
e1000_transmit(void *data, size_t len)
{
uint32_t current = tdt->tdt; //tail index in queue
if(!(tx_desc_array[current].status & E1000_TXD_STAT_DD)) {
return -E_TRANSMIT_RETRY;
}
tx_desc_array[current].length = len;
tx_desc_array[current].status &= ~E1000_TXD_STAT_DD;
tx_desc_array[current].cmd |= (E1000_TXD_CMD_EOP | E1000_TXD_CMD_RS);
memcpy(tx_buffer_array[current], data, len);
uint32_t next = (current + 1) % TXDESCS;
tdt->tdt = next;
return 0;
}

对于发送队列来说是一个典型的生产者-消费者模型:

  1. 生产者:用户进程。通过系统调用往tail指向的描述符的缓存区添加包数据,并且移动tail。
  2. 消费者:网卡。通过DMA的方式直接从head指向的描述符对应的缓冲区拿包数据发送出去,并移动head。

接收队列也类似。

Exercise 7
实现发送数据包的系统调用。
添加一个调用函数,并修改syscall()中添加他。这个函数需要我们自己起名字,自己定义。

1
2
3
4
5
6
7
8
9
10
11
// kern/syscall.c
#include <kern/e1000.h>

static int
sys_packet_try_send(void *addr, uint32_t len){
return e1000_transmit(addr, len);
}

case (SYS_packet_try_send):
ret = sys_packet_try_send((void *)a1,a2);
break;

注意这个SYS_packet_try_send是没有的 要在syscall.h 的头文件里面的enum添加。
还要修改库函数。

1
2
3
4
// lib/syscall.c
int sys_packet_try_send(void *data_va, int len){
return (int) syscall(SYS_packet_try_send, 0 , (uint32_t)data_va, len, 0, 0, 0);
}
1
2
// inc/lib.h
int sys_packet_try_send(void *data_va, int len);

3. Transmitting Packets: Network Server

输出协助进程的任务是,执行一个无限循环,在该循环中接收核心网络进程的IPC请求,解析该请求,然后使用系统调用发送数据。

Exercise 8
实现output.c.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// net/output.c
void
output(envid_t ns_envid)
{
binaryname = "ns_output";

// LAB 6: Your code here:
// - read a packet from the network server
// - send the packet to the device driver
envid_t from_env;
int perm;
while(1) {
if (ipc_recv(&from_env, &nsipcbuf, &perm) != NSREQ_OUTPUT) {
continue;
}
else {
while (sys_packet_try_send(nsipcbuf.pkt.jp_data, nsipcbuf.pkt.jp_len) < 0) {
sys_yield();
}
}
}
}

三、接收报文和Web服务器

总的来说接收数据包和发送数据包很相似。

1. Receiving Packets

Exercise 9 - 11
直接贴代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// kern/e1000.c
int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
// 该函数从线性地址MMIOBASE开始映射物理地址pa开始的size大小的内存,并返回pa对应的线性地址。
bar_va = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
uint32_t *status_reg = (uint32_t *)E1000REG(E1000_STATUS);
assert(*status_reg == 0x80080783);
e1000_transmit_init();
e1000_receive_init();
return 0;
}

static void
get_ra_address(uint32_t mac[], uint32_t *ral, uint32_t *rah)
{
uint32_t low = 0, high = 0;
int i;

for (i = 0; i < 4; i++) {
low |= mac[i] << (8 * i);
}

for (i = 4; i < 6; i++) {
high |= mac[i] << (8 * i);
}

*ral = low;
*rah = high | E1000_RAH_AV;
}

static void
e1000_receive_init()
{ //RDBAL and RDBAH register
uint32_t *rdbal = (uint32_t *)E1000REG(E1000_RDBAL);
uint32_t *rdbah = (uint32_t *)E1000REG(E1000_RDBAH);
*rdbal = PADDR(rx_desc_array);
*rdbah = 0;

int i;
for (i = 0; i < RXDESCS; i++) {
rx_desc_array[i].addr = PADDR(rx_buffer_array[i]);
}
//RDLEN register
struct e1000_rdlen *rdlen = (struct e1000_rdlen *)E1000REG(E1000_RDLEN);
rdlen->len = RXDESCS;

//RDH and RDT register
rdh = (struct e1000_rdh *)E1000REG(E1000_RDH);
rdt = (struct e1000_rdt *)E1000REG(E1000_RDT);
rdh->rdh = 0;
rdt->rdt = RXDESCS-1;

uint32_t *rctl = (uint32_t *)E1000REG(E1000_RCTL);
*rctl = E1000_RCTL_EN | E1000_RCTL_BAM | E1000_RCTL_SECRC;

uint32_t *ra = (uint32_t *)E1000REG(E1000_RA);
uint32_t ral, rah;
get_ra_address(E1000_MAC, &ral, &rah);
ra[0] = ral;
ra[1] = rah;
}

int
e1000_receive(void *addr, size_t *len)
{
static int32_t next = 0;
if(!(rx_desc_array[next].status & E1000_RXD_STAT_DD)) { //simply tell client to retry
return -E_RECEIVE_RETRY;
}
if(rx_desc_array[next].errors) {
cprintf("receive errors\n");
return -E_RECEIVE_RETRY;
}
*len = rx_desc_array[next].length;
memcpy(addr, rx_buffer_array[next], *len);

rdt->rdt = (rdt->rdt + 1) % RXDESCS;
next = (next + 1) % RXDESCS;
return 0;
}

1
2
3
4
5
6
7
8
// kern/syscall.c
static int
sys_packet_receive(void *addr, uint32_t *len) {
return e1000_receive(addr, len);
}
case (SYS_packet_receive):
ret = sys_packet_receive((void *)a1,(size_t *)a2);
break;
1
2
3
4
// lib/syscall.c
int sys_packet_receive(void *addr, int *len){
return (int) syscall(SYS_packet_receive, 0 , (uint32_t)addr, (uint32_t)len, 0, 0, 0);
}
1
2
3
// inc/lib.h
int sys_packet_try_send(void *data_va, int len);
int sys_packet_receive(void *data_va, int *len);

2. Receiving Packets: Network Server

Exercise 12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// net/input.c
void
sleep(int msec)//简单的延迟函数
{
unsigned now = sys_time_msec();
unsigned end = now + msec;

if ((int)now < 0 && (int)now > -MAXERROR)
panic("sys_time_msec: %e", (int)now);
while (sys_time_msec() < end)
sys_yield();
}

void
input(envid_t ns_envid)
{
binaryname = "ns_input";

// LAB 6: Your code here:
// - read a packet from the device driver
// - send it to the network server
// Hint: When you IPC a page to the network server, it will be
// reading from it for a while, so don't immediately receive
// another packet in to the same physical page.
int len;
char buf[2048];
while (1) {
if (sys_packet_receive(buf, &len) < 0) {
continue;
}
memcpy(nsipcbuf.pkt.jp_data, buf, len);
nsipcbuf.pkt.jp_len = len;
ipc_send(ns_envid, NSREQ_INPUT, &nsipcbuf, PTE_P|PTE_U|PTE_W);
sleep(50);
}
}

3. The Web Server

Exercise 13

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// user/httpd.c
static int
send_data(struct http_request *req, int fd)
{
// LAB 6: Your code here.
// panic("send_data not implemented");
int n;
char buf[BUFFSIZE];
while((n=read(fd,buf,(long)sizeof(buf)))>0){
if(write(req->sock,buf,n)!=n){
die("Failed to send file to client");
}
}
return n;
}

send_file()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// LAB 6: Your code here.
// panic("send_file not implemented");
if ((fd = open(req->url, O_RDONLY)) < 0) {
send_error(req, 404);
goto end;
}

struct Stat stat;
fstat(fd, &stat);
if (stat.st_isdir) {
send_error(req, 404);
goto end;
}

总结

  1. 实现网卡驱动。
    通过MMIO方式访问网卡,直接通过内存就能设置网卡的工作方式和特性。
    通过DMA方式,使得网卡在不需要CPU干预的情况下直接和内存交互。
  2. 用户级线程实现。主要关注三个函数就能明白原理:
    thread_init()
    thread_create()
    thread_yield()