MIT-6.828 Lab 6: Network Driver (default final project)

tags: mit-6.828 os


概述

本lab是6.828默认的最后一个实验,围绕网络展开。主要就做了一件事情。

从0实现网络驱动。

还提到一些比较重要的概念:

  1. 内存映射I/O
  2. DMA
  3. 用户级线程实现原理

The Network Server

从0开始写协议栈是很困难的,我们将使用lwIP,轻量级的TCP/IP实现,更多lwIP信息可以参考lwIP官网。对于我们来说lwIP就像一个实现了BSD socket接口的黑盒,分别有一个包输入和输出端口。

JOS的网络网络服务由四个进程组成:

  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用来实现网络超时。

仔细看上图,绿颜色的部分是本lab需要实现的部分。分别是:

  1. E1000网卡驱动,并对外提供两个系统调用,分别用来接收和发送数据。
  2. 输入进程。
  3. 输出进程。
  4. 用户程序httpd的一部分。

Part A: Initialization and transmitting packets

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

Exercise 1

在kern/trap.c中添加对time_tick()调用。实现sys_time_msec()系统调用。sys_time_msec()可以配合sys_yield()实现sleep()(见user/testtime.c)。很简单,代码省略了。

The Network Interface Card

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

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结构如下:

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结构的结构如下:

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这个数组中。

kern/e1000.c:

int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
return 0;
}

kern/pci.c:

 struct pci_driver pci_attach_vendor[] = {
{ E1000_VENDER_ID_82540EM, E1000_DEV_ID_82540EM, &e1000_attachfn },
{ 0, 0, 0 },
};

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的寄存器。

volatile void *bar_va;

#define E1000REG(offset) (void *)(bar_va + offset)
int
e1000_attachfn(struct pci_func *pcif)
{
pci_func_enable(pcif);
bar_va = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]); //mmio_map_region()这个函数之前已经在kern/pmap.c中实现了。
//该函数从线性地址MMIOBASE开始映射物理地址pa开始的size大小的内存,并返回pa对应的线性地址。 uint32_t *status_reg = (uint32_t *)E1000REG(E1000_STATUS);
assert(*status_reg == 0x80080783);
return 0;
}

lab3和lab4的结果是,我们可以通过直接访问bar_va开始的内存区域来设置E1000的特性和工作方式。

DMA

什么是DMA?简单来说就是允许外部设备直接访问内存,而不需要CPU参与。https://en.wikipedia.org/wiki/Direct_memory_access

我们可以通过读写E1000的寄存器来发送和接收数据包,但是这种方式非常慢。E1000使用DMA直接读写内存,不需要CPU参与。驱动负责分配内存作为发送和接受队列,设置DMA描述符,配置E1000这些队列的位置,之后的操作都是异步的。

发送一个数据包:驱动将该数据包拷贝到发送队列中的一个DMA描述符中,通知E1000,E1000从发送队列的DMA描述符中拿到数据发送出去。

接收数据包:E1000将数据拷贝到接收队列的一个DMA描述符中,驱动可以从该DMA描述符中读取数据包。

发送和接收队列非常相似,都由DMA描述符组成,DMA描述符的确切结构不是固定的,但是都包含一些标志和包数据的物理地址。发送和接收队列可以由环形数组实现,都有一个头指针和一个尾指针。

这些数组的指针和描述符中的包缓冲地址都应该是物理地址,因为硬件操作DMA读写物理内存不需要通过MMU。

Transmitting Packets

首先我们需要初始化E1000来支持发送包。第一步是建立发送队列,队列的具体结构在3.4节,描述符的结构在3.3.3节。驱动必须为发送描述符数组和数据缓冲区域分配内存。有多种方式分配数据缓冲区。最简单的是在驱动初始化的时候就为每个描述符分配一个对应的数据缓冲区。最大的包是1518字节。

发送队列和发送队列描述符如下:





更加详细的信息参见说明手册。

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寄存器。
struct e1000_tdh *tdh;
struct e1000_tdt *tdt;
struct e1000_tx_desc tx_desc_array[TXDESCS];
char tx_buffer_array[TXDESCS][TX_PKT_SIZE]; static void
e1000_transmit_init()
{
int i;
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;
}
//设置队列长度寄存器
struct e1000_tdlen *tdlen = (struct e1000_tdlen *)E1000REG(E1000_TDLEN);
tdlen->len = TXDESCS; //设置队列基址低32位
uint32_t *tdbal = (uint32_t *)E1000REG(E1000_TDBAL);
*tdbal = PADDR(tx_desc_array); //设置队列基址高32位
uint32_t *tdbah = (uint32_t *)E1000REG(E1000_TDBAH);
*tdbah = 0; //设置头指针寄存器
tdh = (struct e1000_tdh *)E1000REG(E1000_TDH);
tdh->tdh = 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;
}

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

Exercise 6

编写发送数据包的函数,处理好发送队列已满的情况。如果发送队列满了怎么办?

怎么检测发送队列已满:如果设置了发送描述符的RS位,那么当网卡发送了一个描述符指向的数据包后,会设置该描述符的DD位,通过这个标志位就能知道某个描述符是否能被回收。

检测到发送队列已满后怎么办:可以简单的丢弃准备发送的数据包。也可以告诉用户进程进程当前发送队列已满,请重试,就像sys_ipc_try_send()一样。我们采用重试的方式。

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

实现发送数据包的系统调用。很简单呀,不贴代码了。

Transmitting Packets: Network Server

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

Exercise 8

实现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
uint32_t whom;
int perm;
int32_t req; while (1) {
req = ipc_recv((envid_t *)&whom, &nsipcbuf, &perm); //接收核心网络进程发来的请求
if (req != NSREQ_OUTPUT) {
cprintf("not a nsreq output\n");
continue;
} struct jif_pkt *pkt = &(nsipcbuf.pkt);
while (sys_pkt_send(pkt->jp_data, pkt->jp_len) < 0) { //通过系统调用发送数据包
sys_yield();
}
}
}

发送一个数据包的流程

有必要总结下发送数据包的流程,我画了个图,总的来说还是图一的细化:

Part B: Receiving packets and the web server

总的来说接收数据包和发送数据包很相似。直接看原文就行。

有必要总结下用户级线程实现。

用户级线程实现:

具体实现在net/lwip/jos/arch/thread.c中。有几个重要的函数重点说下。

  1. thread_init(void):
void
thread_init(void) {
threadq_init(&thread_queue);
max_tid = 0;
} static inline void
threadq_init(struct thread_queue *tq)
{
tq->tq_first = 0;
tq->tq_last = 0;
}

初始化thread_queue全局变量。该变量维护两个thread_context结构指针。分别指向链表的头和尾。

线程相关数据结构:

struct thread_queue
{
struct thread_context *tq_first;
struct thread_context *tq_last;
}; struct thread_context {
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;
};

其中每个thread_context结构对应一个线程,thread_queue结构维护两个thread_context指针,分别指向链表的头和尾。

2. thread_create(thread_id_t *tid, const char name, void (entry)(uint32_t), uint32_t arg):

int
thread_create(thread_id_t *tid, const char *name,
void (*entry)(uint32_t), uint32_t arg) {
struct thread_context *tc = malloc(sizeof(struct thread_context)); //分配一个thread_context结构
if (!tc)
return -E_NO_MEM; memset(tc, 0, sizeof(struct thread_context)); thread_set_name(tc, name); //设置线程名
tc->tc_tid = alloc_tid(); //线程id tc->tc_stack_bottom = malloc(stack_size); //每个线程应该有独立的栈,但是一个进程的线程内存是共享的,因为共用一个页表。
if (!tc->tc_stack_bottom) {
free(tc);
return -E_NO_MEM;
} void *stacktop = tc->tc_stack_bottom + stack_size;
// Terminate stack unwinding
stacktop = stacktop - 4;
memset(stacktop, 0, 4); memset(&tc->tc_jb, 0, sizeof(tc->tc_jb));
tc->tc_jb.jb_esp = (uint32_t)stacktop; //eip快照
tc->tc_jb.jb_eip = (uint32_t)&thread_entry; //线程代码入口
tc->tc_entry = entry;
tc->tc_arg = arg; //参数 threadq_push(&thread_queue, tc); //加入队列中 if (tid)
*tid = tc->tc_tid;
return 0;
}

该函数很好理解,直接看注释就能看懂。

3. thread_yield(void):

void
thread_yield(void) {
struct thread_context *next_tc = threadq_pop(&thread_queue); if (!next_tc)
return; if (cur_tc) {
if (jos_setjmp(&cur_tc->tc_jb) != 0) //保存当前线程的CPU状态到thread_context结构的tc_jb字段中。
return;
threadq_push(&thread_queue, cur_tc);
} cur_tc = next_tc;
jos_longjmp(&cur_tc->tc_jb, 1); //将下一个线程对应的thread_context结构的tc_jb字段恢复到CPU继续执行
}

该函数保存当前进程的寄存器信息到thread_context结构的tc_jb字段中,然后从链表中取下一个thread_context结构,并将其tc_jb字段恢复到对应的寄存器中,继续执行。

jos_setjmp()和jos_longjmp()由汇编实现,因为要访问寄存器嘛。

ENTRY(jos_setjmp)
movl 4(%esp), %ecx // jos_jmp_buf movl 0(%esp), %edx // %eip as pushed by call
movl %edx, 0(%ecx) leal 4(%esp), %edx // where %esp will point when we return
movl %edx, 4(%ecx) movl %ebp, 8(%ecx)
movl %ebx, 12(%ecx)
movl %esi, 16(%ecx)
movl %edi, 20(%ecx) movl $0, %eax
ret ENTRY(jos_longjmp)
// %eax is the jos_jmp_buf*
// %edx is the return value movl 0(%eax), %ecx // %eip
movl 4(%eax), %esp
movl 8(%eax), %ebp
movl 12(%eax), %ebx
movl 16(%eax), %esi
movl 20(%eax), %edi movl %edx, %eax
jmp *%ecx

总结回顾

  1. 实现网卡驱动。

    1. 通过MMIO方式访问网卡,直接通过内存就能设置网卡的工作方式和特性。
    2. 通过DMA方式,使得网卡在不需要CPU干预的情况下直接和内存交互。具体工作方式如下: 以发送数据为例,维护一个发送队列,生产者将要发送的数据放到发送队列中tail指向的描述符对应的缓冲区,同时更新tail指针。网卡作为消费者,从head指向的描述符对应的缓冲区拿到数据并发送出去,然后更新head指针。
  2. 用户级线程实现。主要关注三个函数就能明白原理:
    1. thread_init()
    2. thread_create()
    3. thread_yield()

最后老规矩

具体代码在:https://github.com/gatsbyd/mit_6.828_jos

如有错误,欢迎指正(_):

15313676365

MIT-6.828-JOS-lab6:Network Driver的更多相关文章

  1. MIT 6.828 JOS学习笔记2. Lab 1 Part 1.2: PC bootstrap

    Lab 1 Part 1: PC bootstrap 我们继续~ PC机的物理地址空间 这一节我们将深入的探究到底PC是如何启动的.首先我们看一下通常一个PC的物理地址空间是如何布局的:        ...

  2. MIT 6.828 | JOS | 关于虚拟空间和物理空间的总结

    Question: 做lab过程中越来越迷糊,为什么一会儿虚拟地址是4G 物理地址也是4G ,那这有什么作用呢? 解决途径: 停下来,根据当前lab的进展,再回头看上学期操作系统的ppt & ...

  3. MIT 6.828 JOS学习笔记0. 写在前面的话

    0. 简介 操作系统是计算机科学中十分重要的一门基础学科,是一名计算机专业毕业生必须要具备的基础知识.但是在学习这门课时,如果仅仅把目光停留在课本上一些关于操作系统概念上的叙述,并不能对操作系统有着深 ...

  4. MIT 6.828 JOS学习笔记7. Lab 1 Part 2.2: The Boot Loader

    Lab 1 Part 2 The Boot Loader Loading the Kernel 我们现在可以进一步的讨论一下boot loader中的C语言的部分,即boot/main.c.但是在我们 ...

  5. MIT 6.828 JOS学习笔记18. Lab 3.2 Part B: Page Faults, Breakpoints Exceptions, and System Calls

    现在你的操作系统内核已经具备一定的异常处理能力了,在这部分实验中,我们将会进一步完善它,使它能够处理不同类型的中断/异常. Handling Page Fault 缺页中断是一个非常重要的中断,因为我 ...

  6. MIT 6.828 JOS学习笔记17. Lab 3.1 Part A User Environments

    Introduction 在这个实验中,我们将实现操作系统的一些基本功能,来实现用户环境下的进程的正常运行.你将会加强JOS内核的功能,为它增添一些重要的数据结构,用来记录用户进程环境的一些信息:创建 ...

  7. MIT 6.828 JOS学习笔记16. Lab 2.2

    Part 3 Kernel Address Space JOS把32位线性地址虚拟空间划分成两个部分.其中用户环境(进程运行环境)通常占据低地址的那部分,叫用户地址空间.而操作系统内核总是占据高地址的 ...

  8. MIT 6.828 JOS学习笔记15. Lab 2.1

    Lab 2: Memory Management lab2中多出来的几个文件: inc/memlayout.h kern/pmap.c kern/pmap.h kern/kclock.h kern/k ...

  9. MIT 6.828 JOS学习笔记10. Lab 1 Part 3: The kernel

    Lab 1 Part 3: The kernel 现在我们将开始具体讨论一下JOS内核了.就像boot loader一样,内核开始的时候也是一些汇编语句,用于设置一些东西,来保证C语言的程序能够正确的 ...

  10. MIT 6.828 JOS学习笔记4. Lab 1 Part 2.1: The Boot Loader

    Part 2: The Boot Loader 对于PC来说,软盘,硬盘都可以被划分为一个个大小为512字节的区域,叫做扇区.一个扇区是一次磁盘操作的最小粒度.每一次读取或者写入操作都必须是一个或多个 ...

随机推荐

  1. 解题:SPOJ 3734 Periodni

    题面 按列高建立笛卡尔树,转成树上问题...... 笛卡尔树是什么? 它一般是针对序列建立的,是下标的BST和权值的堆(即中序遍历是原序列连续区间,节点权值满足堆性质),这里不讲具体怎么建树(放在知识 ...

  2. Java 泛型类型基础

    为什么要使用泛型? 未使用泛型的情况: // 创建列表类 List list = new ArrayList(); // 添加一个类型为 String 的列表元素 list.add("hel ...

  3. NO.5: 了解C++编译器默认为你生成的构造/赋值/析构

    1.编译器可以暗自位class生成default构造,copy构造,copy assigned函数,析构函数; note1:如果没有自定义构造函数,编译器会为你生成合成默认构造函数.如果有定义则不生成 ...

  4. RabbitMQ的生产者和消费者

    低级错误:启动程序的时候报错:socket close: 原因在配置文件中写的端口是:15672,应该是5672: client端通信口5672管理口15672server间内部通信口25672erl ...

  5. C#利用Zxing.net生成条形码和二维码并实现打印的功能

        开篇:zxing.net是.net平台下编解条形码和二维码的工具. 下载地址:http://pan.baidu.com/s/1kTr3Vuf Step1:使用VS2010新建一个窗体程序项目: ...

  6. Oracle笔记 - unfinished

    1. plsql查看xmltype字段的xml格式时,出现中文乱码问题,可通过该字段.getClobVal():查询到的xml将是中文不乱码的. 2. extract函数查询xml某节点下的所有节点, ...

  7. nova-api源码分析(APP的创建)

    目录结构如下: 上面介绍了nova-api发布所用到的一些lib库,有了上面的基础知识,再来分析nova-api的发布流程,就比较轻松了.nova-api可以提供多种api服务:ec2, osapi_ ...

  8. 【Linux】MySQL安装及允许远程访问

    安装环境/工具  Linux( centOS 版) MySQL(MySQL-5.6.28-1.el7.x86_64.rpm-bundle.tar版) 安装步骤 1.解压mysql安装文件 命令:tar ...

  9. [原] eclipse 无法找到 run as junit

    碰见这个问题,折磨我好一下! 问题根源和解决方式 第一,保证有junit jar包,基本不会犯这错误: 第二,保证你这个类是Source可编译文件,要是这个类在普通文件夹下,工程是不会编译它的,也就找 ...

  10. CentOS配置源

    一.源列表 aliyun源 #各系统版本repo文件对应的下载操作 CentOS wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.al ...