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的网络网络服务由四个进程组成:
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的特性和工作方式。
mmio

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 outputn");
            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指针,分别指向链表的头和尾。

  1. 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;
}

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

  1. 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

内容来源于网络如有侵权请私信删除
你还没有登录,请先登录注册
  • 还没有人评论,欢迎说说您的想法!