MIT-6.828-JOS-lab6:Network Driver

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

tags: mit-6.828 osgit


概述

本lab是6.828默認的最後一個實驗,圍繞網絡展開。主要就作了一件事情。
從0實現網絡驅動。
還提到一些比較重要的概念:github

  1. 內存映射I/O
  2. DMA
  3. 用戶級線程實現原理

The Network Server

從0開始寫協議棧是很困難的,咱們將使用lwIP,輕量級的TCP/IP實現,更多lwIP信息能夠參考lwIP官網。對於咱們來講lwIP就像一個實現了BSD socket接口的黑盒,分別有一個包輸入和輸出端口。
JOS的網絡網絡服務由四個進程組成:
JOS網絡服務web

  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結構的結構以下:app

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 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指針,分別指向鏈表的頭和尾。

  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

相關文章
相關標籤/搜索