1. 程式人生 > >MIT6.828 LAB6: Network Driver

MIT6.828 LAB6: Network Driver

  抽了點空把LAB6重新整理一下,作為結束符~~。
Introduction
  我們已經實現了1個檔案系統,當然OS還需要1個網路棧,在本次實驗中我們將實現1個網絡卡驅動,這個網絡卡基於Intel 82540EM晶片,也就是熟知的E1000網絡卡。
  網絡卡驅動不足以使你的OS能連線上Internet。在LAB6新增加的程式碼中,我們提供了1個網路棧(network stack)和網路伺服器(network server)在net/目錄和kern/目錄下。
  本次新增加的檔案如下:
  net/lwip目錄:開源輕量級TCP/IP協議元件包括1個網路棧
  net/timer.c:定時器功能測試程式
  net/ns.h:網絡卡驅動相關的引數巨集定義和函式宣告
  net/testinput.c:收包功能測試程式
  net/input.c:收包功能的使用者態函式
  net/testoutput.c:發包功能測試程式
  net/output.c:發包功能的使用者態函式
  net/serv.c:網路伺服器的實現
  kern/e1000.c:網絡卡驅動的核心實現
  kern/e1000.h:網絡卡驅動實現相關的引數巨集定義和函式宣告

  除了實現網絡卡驅動,我們還要實現1個系統呼叫介面來訪問驅動。我們需要實現網路伺服器程式碼來傳輸網路資料包在網路棧和驅動之間。同時網路伺服器也能使用檔案系統中的檔案。
  大部分核心驅動程式碼必須從零開始編寫,這次實驗比前面的實驗提供更少的指導:沒有骨架檔案、沒有系統呼叫介面等。總之一句話,要實現這次實驗需要閱讀很多提供的指導說明手冊,才能完成實驗。

QEMU’s virtual network
  我們將會使用QEMU使用者態網路棧,因為它執行不需要管理員許可權。關於QEMU使用者態網路棧的說明在這裡(QEMU使用者態網路棧)。我們已經更新了makefile,從而能夠使用QEMU使用者態網路棧和虛擬E1000網絡卡。
  在預設情況下,QEMU會提供一個執行在IP為10.0.2.2的虛擬路由器並且分配給JOS一個10.0.2.15的IP地址。為了簡單起見,我們把這些預設設定硬編碼在了net/ns.h中。
  儘管QEMU的虛擬網路允許JOS和網際網路做任意的連線,但是JOS的10.0.2.15 IP地址在QEMU執行的虛擬網路之外沒有任何意義(QEMU就像一個NAT),所以我們不能直接和JOS中執行的se伺服器連線,即使是執行QEMU的宿主機上也不行。為了解決這個問題,我們通過配置QEMU,讓JOS的一些埠和宿主機的某些埠相連,讓伺服器執行在這些埠上,從而讓資料在宿主機和虛擬網路之間進行交換。
  我們將在埠7(echo)和80(http)執行埠。為了避免埠衝突,makefile裡實現了埠轉發。可以通過執行make which-ports來找出QEMU轉發的埠,也可以通過make nc-7和make nc-80來和執行在這些埠上的伺服器互動。

Packet Inspection
  makefile也配置了QEMU的網路棧來記錄各種進入和出去的資料包到qemu.pcap檔案中。為了獲得hex/ASCII的轉換,我們可以使用tcpdump命令(Linux下非常有用的網路抓包分析工具,具體的引數說明可以用man tcpdump):
  tcpdump -XXnr qemu.pcap
  或者,也可以使用著名的Wireshark圖形化工具來解析pcap檔案。

Debugging the E1000  
  很幸運我們使用的是模擬硬體,E1000網絡卡執行為軟體,模擬的E1000網絡卡能以使用者可讀的形式,向我們彙報有用的資訊,比如內部狀態和問題。
  模擬E1000網絡卡能產生一系列debug輸出,通過開啟特殊的日誌通道,來捕獲輸出資訊:
  標誌

注意: E1000_DEBUG標誌只在mit6.828課程提供QEMU中有用。
  
The Network Server
  從零開始寫1個網路棧是很難的。這裡,我們使用lwIP開源TCP/IP協議元件來實現網路棧(具體可以檢視lwIP)。在這個實驗中,我們只需知道lwIP是一個黑盒,它實現了BSD的socket介面並且有一個數據包input port和資料包output port。
  網路伺服器其實是由以下四個environments組成的
  (1) 核心網路服務 environment(包括socket呼叫分發和lwIP
  (2) 輸入environment
  (3) 輸出environment
  (4) 計時environment

  下圖顯示了各個environments以及它們之間的關係。圖中展示了整個系統包括裝置驅動。在本次實驗中,我們將實現被標記為綠色的那些部分。
  架構圖
  其實整個網路伺服器實現與檔案系統的實現類似,也是通過IPC機制來在各個environment之間進行資料互動。

The Core Network Server Environment  
  核心網路服務environment由socket呼叫分發器和lwIP組成。The socket呼叫分發和檔案伺服器的工作方式類似。使用者 environment通過stubs(定義在lib/nsipc.c)向核心網路environment傳送IPC訊息。檢視lib/nsipc.c可以發現,核心網路伺服器的工作方式和檔案伺服器是類似的:i386_init建立了NS environment,型別為NS_TYPE_NS,因此我們遍歷envs,找到這個特殊的environment type。對於每一個使用者environment的IPC,網路伺服器中的IPC分發器會呼叫由lwIP提供的BSD socket介面來實現。
  普通的使用者environment不直接使用nsipc_*呼叫。通常它們都使用lib/sockets.c中提供的基於檔案描述符的sockets API。因此,使用者environment通過檔案描述符來引用socket,就像引用普通的磁碟檔案一樣。雖然socket有許多特殊的操作(比如connect、accept等等),但是像read,write,close這樣的操作也是通過lib/fd.c中正常的檔案描述符device-dispatcher程式碼。就像檔案伺服器會為所有開啟的檔案維護一個內部獨有的ID,lwIP也會為每個開啟的socket維護一個獨有的ID。在檔案伺服器或者網路伺服器中,我們使用儲存在struct Fd中的資訊來對映每個environment的檔案描述符到相應的ID空間中。
  雖然看起來檔案伺服器和網路伺服器的IPC分發器工作方式相同,但是事實上有一個非常重要的區別。有些BSD socket的操作,例如accept和recv可能會永遠阻塞。如果分發器讓lwIP執行其中一個堵塞呼叫,那麼很可能分發器會阻塞,因此整個系統在某一時刻只能有一個網路呼叫,顯然,這是不能讓人接收的。因此網路伺服器使用使用者級執行緒去避免整個伺服器environment的阻塞。對於每一個到來的IPC,分發器都會建立一個執行緒,然後由它對請求進行處理。即使這個執行緒阻塞了,那麼也僅僅只是它進入休眠狀態,而其他的執行緒照樣能繼續執行。
  除了核心網路environment之外,還有其他三個輔助的environment。除了從使用者程式中獲取訊息以外,核心網路 environment的分發器還從input environment和timer environment處獲取資訊。

The Output Environment 
  當處理使用者environment的socket呼叫時,lwIP會產生packet用於網絡卡的傳輸。lwIP會將需要傳送的packet通過NSREQ_OUTPUT IPC傳送給output helper environment,packet的內容存放在IPC的共享頁中。output environment負責接收這些資訊並且通過系統呼叫介面將這些packet轉發到相應的裝置驅動(我們即將實現)。

The Input Environment
  網絡卡得到的packet需要注入到lwIP中。對於裝置驅動獲得的每一個packet,input environment需要通過相應的系統呼叫將它們從核心中抽取出來,然後通過NSREQ_INPUT IPC 傳送給核心伺服器environment。
  packet input的功能從核心網路environment中剝離出來了,因為接收IPC並且同時接收或等待來自裝置驅動的packet對於JOS是非常困難的。因為JOS中沒有select這樣能夠允許environment監聽多個輸入源並且判斷出哪個源已經準備好了。
  net/input.c和net/output.c中就是我們要實現的2個使用者態函式,當我們實現完網絡卡驅動和系統呼叫介面後。

The Timer Environment       
  timer environment會定期地向核心網路伺服器傳送NSREQ_TIMER IPC,通知它又過去了一個時間間隔,而lwIP會利用這些時間資訊去實現各種的網路超時。

Part A: Initialization and transmitting packets
  我們的核心中還沒有時間的概念,所以我們需要加上它。現在每隔10ms都有一個由硬體產生的時鐘中斷。每次出現一個時鐘中斷的時候,我們都對一個變數進行加操作,表示過去了10ms。這實現在kern/time.c中,但是並未歸併到核心中。
  Exercise 1:
  在kern/trap.c中增加1個time_tick呼叫來處理每次時鐘中斷,實現sys_time_msec系統呼叫,使使用者空間能讀取時間。
  回答:
  首先在kern/trap.c的trap_dispatch函式中,對於IRQ_OFFSET + IRQ_TIMER中斷新增time_tick呼叫:

//kern/trap.c
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
        lapic_eoi();
        time_tick();
        sched_yield();
        return;
}

//kern/time.c
void
time_tick(void)
{   
    ticks++;
    if (ticks * 10 < ticks)
        panic("time_tick: time overflowed");
}

  接下去就是新增獲取時間的系統呼叫,具體流程和之前的一樣,主要是在kern/syscall.c的中實現sys_time_msec函式,在該函式中呼叫time_msec函式來獲得系統時間。

//kern/syscall.c
// Return the current time.
static int
sys_time_msec(void)
{
    return time_msec();
}   

//kern/time.c
unsigned int
time_msec(void)
{   
    return ticks * 10;
}

  通過執行make INIT_CFLAGS=-DTEST_NO_NS run-testtime來測試計時器共,將會看到從5到1的倒計時。其中”-DTEST_NO_NS”禁止啟動網路伺服器environment,因為我們暫時還沒實現。

The Network Interface Card
  要寫1個驅動必須要深入硬體和軟體介面,在本次實驗中我們將給1個高層次綜述關於如何與E1000網絡卡互動,但是你需要去使用Intel的幫助手冊來實現驅動。
  Exercise 2:
  瀏覽Intel關於E1000網絡卡的軟體開發手冊,該手冊包括了多個相關的網絡卡控制器,而QEMU模擬的是82540EM。
  回答:
  主要是瀏覽第2張關於裝置架構,為了編寫驅動需要閱讀第3章收發包描述符、第14章通用初始化和重置操作、第13章暫存器描述。

PCI Interface
  E1000網絡卡是一個PCI裝置,這說明它是插入主機板的PCI匯流排。PCI匯流排有地址匯流排、資料匯流排和中斷匯流排,從而允許CPU能訪問PCI裝置,PCI裝置也能讀寫記憶體。一個PCI裝置在使用之前需要被發現並且初始化。發現的過程是指遍歷PCI匯流排找到已經連線的裝置。初始化是指為裝置分配IO和記憶體空間並且指定IRQ線的過程。
  PCI是外圍裝置互連(Peripheral Component Interconnect)的簡稱,是在目前計算機系統中得到廣泛應用的通用匯流排介面標準:  

  • 在一個PCI系統中,最多可以有256根PCI匯流排,一般主機上只會用到其中很少的幾條。
  • 在一根PCI總線上可以連線多個物理裝置,可以是一個網絡卡、顯示卡或者音效卡等,最多不超過32個。
  • 一個PCI物理裝置可以有多個功能,比如同時提供視訊解析和聲音解析,最多可提供8個功能。
  • 每個功能對應1個256位元組的PCI配置空間。

      我們在kern/pci.c中已經提供了PCI相關的程式碼。為了在啟動過程中實現PCI的初始化,相關的PCI程式碼遍歷了PCI匯流排進行裝置查詢。當發現一個裝置時,它會讀取它的vendor ID和device ID,把這兩個值作為key去查詢pci_attach_vendor陣列。該陣列元素是struct pci_driver型別的,如下所示:

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

  如果被發現裝置的vendor ID和device ID和陣列中的某個表項是匹配的,那麼接下來就會呼叫該表項的attachfn函式進行初始化工作。(裝置也能被class識別,我們在kern/pci.c中也提供了其它驅動表)
  每一個PCI裝置都有它對映的記憶體地址空間和I/O區域,除此之外,PCI裝置還有配置空間,一共有256位元組,其中前64位元組是標準化的,提供了廠商號、裝置號、版本號等資訊,唯一標示1個PCI裝置,同時提供最多6個的IO地址區域。
  配置空間
  PCI配置空間1
  PCI配置空間2
  當我們向查詢1個特定PCI裝置的配置空間時,需要向I/O地址[0cf8,0cfb]寫入1個4位元組的查詢碼指定匯流排號:裝置號:功能號以及其配置地址空間中的查詢位置。PCI Host Bridge將監聽對於這個I/O埠的寫入,並將查詢結果寫入到[0cfc,0cff],我們可以從這個地址讀出1個32位整數表示查詢到的相應資訊。
  attachfn函式的引數是一個PCI function。一個PCI card可以有多個function,雖然E1000只有一個。下面就是我們在JOS中呈現PCI function的方式:

struct pci_func {
  struct pci_bus   *bus;
  uint32_t     dev;
  uint32_t     func;
  uint32_t     dev_id;
  uint32_t     dev_clasee;
  uint32_t     reg_base[6];
  uint32_t     reg_size[6];
  uint8_t       irq_line;
}

  上述結構的最後三個表項是最吸引我們的地方,其中記錄了該裝置的記憶體、IO和中斷資源的資訊。reg_base和reg_size陣列包含了最多6個Base Address Register(BAR)的資訊。reg_base記錄了memory-mapped IO region的基記憶體地址或者基IO埠,reg_size則記錄了reg_base對應的記憶體區域的大小或者IO埠的數目,irq_line則表示分配給裝置中斷用的IRQ線。
  當裝置的attachfn被呼叫時,裝置已經被找到了,但是還不能用。這說明相關程式碼還沒有確定分配給裝置的資源,比如地址空間和IRQ線,其實就是struct pci_fun中的後三項還沒被填充。attachfn函式需要哦呼叫pci_func_enable來分配相應的資源,填充struct pci_func,使裝置執行起來。
  Exercise 3:
  實現1個attach函式來初始化E1000網絡卡,在pci_attach_vendor陣列中增加1個表項來觸發,可以在參考手冊的5.2章節來找到82450EM的vendor ID和device ID。目前暫時使用pci_func_enable來使能E1000網絡卡裝置,初始化工作放到後面。
  回答:
  在JOS中是如何對PCI裝置進行初始化的,這部分模組主要定義在pci.c中,JOS會在系統初始化時呼叫pci_init函式來進行裝置初始化(在kern/init.c的i386_init函式中)。
  首先來看一些最基本的變數和函式:

// pci_attach_class matches the class and subclass of a PCI device
struct pci_driver pci_attach_class[] = {
    { PCI_CLASS_BRIDGE, PCI_SUBCLASS_BRIDGE_PCI, &pci_bridge_attach },
    { 0, 0, 0 },
};

// 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[] = {
    { PCI_E1000_VENDOR, PCI_E1000_DEVICE, &pci_e1000_attach },
    { 0, 0, 0 },
};

static void
pci_conf1_set_addr(uint32_t bus,
           uint32_t dev,
           uint32_t func,
           uint32_t offset)
{
    assert(bus < 256);
    assert(dev < 32);
    assert(func < 8);
    assert(offset < 256);
    assert((offset & 0x3) == 0);

    uint32_t v = (1 << 31) |        // config-space
        (bus << 16) | (dev << 11) | (func << 8) | (offset);
    outl(pci_conf1_addr_ioport, v);
}

static uint32_t
pci_conf_read(struct pci_func *f, uint32_t off)
{
    pci_conf1_set_addr(f->bus->busno, f->dev, f->func, off);
    return inl(pci_conf1_data_ioport);
}

static void
pci_conf_write(struct pci_func *f, uint32_t off, uint32_t v)
{
    pci_conf1_set_addr(f->bus->busno, f->dev, f->func, off);
    outl(pci_conf1_data_ioport, v);
}

   pci_attach_class和pci_attach_vendor2個數組就是裝置陣列,3個函式是堆PCI裝置最基本的讀狀態和寫狀態的函式:   

  • pci_conf_read函式是讀取PCI配置空間中特定位置的配置值
  • pci_conf_write函式是設定PCI配置空間中特定位置的配置值
  • pci_conf1_set_addr函式是負責設定需要讀寫的具體裝置

       這裡涉及的2個I/O埠正是我們上面提到的操作PCI裝置的IO埠。
       接下來我們看看如何初始化PCI裝置,進入pic_init函式

int
pci_init(void)
{
    static struct pci_bus root_bus;
    memset(&root_bus, 0, sizeof(root_bus));

    return pci_scan_bus(&root_bus);
}

static int
pci_scan_bus(struct pci_bus *bus)
{
    int totaldev = 0;
    struct pci_func df;
    memset(&df, 0, sizeof(df));
    df.bus = bus;

    for (df.dev = 0; df.dev < 32; df.dev++) {
        uint32_t bhlc = pci_conf_read(&df, PCI_BHLC_REG);
        if (PCI_HDRTYPE_TYPE(bhlc) > 1)     // Unsupported or no device
            continue;

        totaldev++;

        struct pci_func f = df;
        for (f.func = 0; f.func < (PCI_HDRTYPE_MULTIFN(bhlc) ? 8 : 1);
             f.func++) {
            struct pci_func af = f;

            af.dev_id = pci_conf_read(&f, PCI_ID_REG);
            if (PCI_VENDOR(af.dev_id) == 0xffff)
                continue;

            uint32_t intr = pci_conf_read(&af, PCI_INTERRUPT_REG);
            af.irq_line = PCI_INTERRUPT_LINE(intr);

            af.dev_class = pci_conf_read(&af, PCI_CLASS_REG);
            if (pci_show_devs)
                pci_print_func(&af);
            pci_attach(&af);
        }
    }

    return totaldev;
}

  在pci_init函式中,root_bus被全部清0,然後交給pci_scan_bus函式來掃描這條總線上的所有裝置,說明在JOS中E1000網絡卡是連線在0號總線上的。pci_scan_bus函式來順次查詢0號總線上的32個裝置,如果發現其存在,那麼順次掃描它們每個功能對應的配置地址空間,將一些關鍵的控制引數讀入到pci_func中進行儲存。
  得到pci_func函式後,被傳入pci_attach函式去查詢是否為已存在的裝置,並用相應的初始化函式來初始化裝置。
  通過查閱手冊,我們知道E1000網絡卡的Vendor ID為0x8086,Device ID為0x100E,所以我們先實現1個e1000網絡卡初始化函式:

int
pci_e1000_attach(struct pci_func *pcif)
{
    pci_func_enable(pcif);
    return 1;
}

  這裡呼叫了pci_func _enable函式來設定PCI裝置配置即pci_func結構體,具體填充可以檢視上面的流程介紹。
  最後修改kern/pci.c中的pci_attach_vendor陣列,把E1000網絡卡的初始化程式新增進入:

// pci_attach_class matches the class and subclass of a PCI device
struct pci_driver pci_attach_class[] = {
    { PCI_CLASS_BRIDGE, PCI_SUBCLASS_BRIDGE_PCI, &pci_bridge_attach },
    { 0, 0, 0 },
};

  那麼在JOS啟動時,你就能看到E1000網絡卡被啟用的資訊。

Memory-mapped I/O
  軟體通過memory-mapped IO(MMIO)和E1000網絡卡進行通訊。我們已經在JOS兩次見到過它了:對於CGA和LAPIC都是通過直接讀寫“記憶體”來控制和訪問的。但是這些讀寫操作都是不經過DRAM的,而是直接進入裝置。
  pci_func_enable為E1000網絡卡分配了一個MMIO區域,並且將它的基地址和大小儲存在了BAR0中,也就是reg_base[0]和reg_size[0]中。這是一段為裝置分配的實體地址,意味著你需要通過虛擬記憶體訪問它。因為MMIO區域通常都被放在非常高的實體地址上(通常高於3GB),因此我們不能直接使用KADDR去訪問它,因為JOS 256MB的記憶體限制。所以我們需要建立一個新的記憶體對映。我們將會使用高於MMIOBASE的區域(lab4中的mmio_map_region將會保證我們不會複寫LAPIC的對映)。因為PCI裝置的初始化發生在JOS建立user environment之前,所以我們可以在kern_pgdir建立對映,從而保證它永遠可用。
  Exercise 4:
  在E1000網絡卡的初始化函式中,通過呼叫mmio_map_region函式來為E1000網絡卡的BAR0建立一個虛擬記憶體對映。你需要使用1個變數記錄下該對映地址以便之後可以訪問對映的暫存器。檢視在kern/lapic.c中的lapic變數,效仿它的做法。假如你使用1個指標指向裝置暫存器對映地址,那麼你必須宣告它為volatile,否則編譯器會執行快取該值和重新排序記憶體訪問序列。
  為了測試你的對映,可以嘗試答應處裝置狀態寄出去,該暫存器為4個位元組,值為0x80080783,表示全雙工1000MB/S。
  回答:
  根據練習的提示,仿照lapic中的做法,在kern/e1000.c中宣告1個全域性變數e1000,該變數是1個指標,指向對映地址。然後呼叫mmio_map_region函式來申請記憶體建立對映,輸出狀態暫存器的值。關於暫存器位置和相關掩碼,我們需要檢視開發手冊,設定巨集定義,這一步可以借鑑QEMU的e1000_hw.h檔案,拷貝相關定義到kern/e1000.h中。程式碼如下,具體的巨集定義可以參考github。

int
pci_e1000_attach(struct pci_func *pcif)
{
    pci_func_enable(pcif);

    e1000 = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
    cprintf("e1000: bar0  %x size0 %x\n", pcif->reg_base[0], pcif->reg_size[0]);
    cprintf("e1000: status %x\n", e1000[STATUS/4]);

DMA
  我們可以想象通過讀寫E1000網絡卡的暫存器來發送和接收packet,但這實在是太慢了,而且需要E1000暫存packets。因此E1000使用Direct Access Memory(DMA)來直接從記憶體中讀寫packets而不通過CPU。驅動的作用就是負責為傳送和接收佇列分配記憶體,建立DMA描述符,以及配置E1000網絡卡,讓它知道這些佇列的位置,不過之後的所有事情都是非同步。在傳送packet的時候,驅動會將它拷貝到transmit佇列的下一個DMA描述符中,然後通知E1000網絡卡另外一個包到了。E1000網絡卡會在能夠傳送下一個packet的時候,將packet從描述符中拷貝出來。同樣,當E1000網絡卡接收到一個packet的時候,就會將它拷貝到接收佇列的下一個DMA描述符中,並且在合適的時機,驅動會將它從中讀取出來。
  從高層次來看,接收和傳送佇列是非常相似的,都是由一系列的描述符組成。但是這些descriptor具體的結構是不同的,每個描述符都包含了一些flag以及儲存packet資料的實體地址。
  佇列由迴圈陣列構成,這表示當網絡卡或者驅動到達了陣列的末尾時,它又會轉回陣列的頭部。每個迴圈陣列都有一個head指標和tail指標,這兩個指標之間的部分就是佇列的內容。網絡卡總是從head消耗描述符並且移動head指標,同時,驅動總是向尾部新增描述符並且移動tail指標。傳送佇列的描述符代表等待被髮送的packet。對於接收佇列,佇列中的描述符是一些閒置的描述符,網絡卡可以將收到的packet放進去。
  這些指向陣列的指標和描述符中packet buffer的地址都必須是實體地址,因為硬體直接和物理RAM發生DMA,並不經過MMU。

Transmitting Packets
  E1000網絡卡的傳送和接收函式是獨立的,因此我們能一次處理其中一個。我們將首先實現傳送packet的操作,因為沒有傳送就不能接收。
  首先,我們要做的是初始化網絡卡的發包。根據14.5章節描述的步驟,傳送操作初始化的第一步就是建立傳送佇列,具體佇列結構的描述在3.4章節,描述符的結構在3.3.3章節。我們不會使用E1000網絡卡的TCP offload特性,所以我們專注於”legacy transmit descriptor format”。

C Structures
  我們會發現用C的結構描述E1000網絡卡的結構是相當容易的。就像我們之前遇到過的struct Trapframe,C結構能讓你精確地控制資料在記憶體中的佈局。C會在結構的各個元素間插入空白用於對齊,但是對於E1000裡的結構這都不是問題。例如,傳統的傳送描述符如下圖所示:
  傳送描述符
  按照從上往下,從右往左的順序讀取,我們可以發現,struct tx_desc剛好是對齊的,因此不會有空白填充。

struct tx_desc
{
    uint64_t addr;
    uint16_t length;
    uint8_t cso;
    uint8_t cmd;
    uint8_t status;
    uint8_t css;
    uint16_t special;
};

  我們的驅動需要為傳送描述符陣列和傳送描述符指向的packet buffers預留記憶體。對於這一點,我們有很多實現方法,包括可以通過動態地分配頁面並將它們存放在全域性變數中。我們用哪種方法,需要記住的是E1000總是直接訪問實體記憶體的,這意味著任何它訪問的buffer都必須在物理空間上是連續的。
  同樣,我們有很多方法處理packet buffer。最簡單的就是像最開始我們說的那樣,在驅動初始化的時候為每個描述符的packet buffer預留空間,之後就在這些預留的buffer中對packet進行進出拷貝。Ethernet packet最大有1518個byte,這就表明了這些buffer至少要多大。更加複雜的驅動可以動態地獲取packet buffer(為了降低網路使用率比較低的時候帶來的浪費)或者直接提供由使用者空間提供的buffers,不過一開始簡單點總是好的。
  Exercise 5:
  根據14.5章節的描述,實現發包初始化,同時借鑑13章節(暫存器初始化)、3.3.3章節((傳送描述符)和3.4章節(傳送描述符陣列)。
  記住傳送描述陣列的對弈要求和陣列長度的限制。TDLEN必須是128位元組對齊的,每個傳送描述符是16位元組的,你的傳送描述符陣列大小需要是8的倍數。在JOS中不要超過64個描述符,以防不好測試傳送環形佇列溢位情況。
  對於TCTL.COLD,你可以認為是全雙工的。對於TIPG,要參考13.4.34章節表13-77關於IEEE802.3標準IPG的預設值描述(不要使用14.5章節的預設值)
  回答:
  這裡需要檢視開發手冊14.5章節關於傳送初始化的描述,主要步驟如下:

  • 為傳送描述符佇列分配一塊連續空間,設定TDBAL和TDBAH暫存器的值指向起始地址,其中TDBAL為32位地址,TDBAL和TDBAH表示64位地址。
  • 設定TDLEN暫存器的值為描述符佇列的大小,以位元組計算。
  • 設定傳送佇列的Head指標(TDH)和Tail指標(TDT)暫存器的值為0。
  • 初始化傳送控制TCTL暫存器的值,包括設定Enable位為1(TCTL.EN)、TCTL.PSP位為1、TCTL.CT位為10h、TCTL.COLD位為40h。
  • 設定TIPG暫存器為期望值
      
      首先是傳送佇列的設定,這裡採用最簡單的方法,聲明發送描述符結構體和packet buffer結構體,並定義1個64大小的全域性傳送描述符陣列和1個64大小的packet buffer陣列,即都使用靜態分配的方法。由於packet最大為1518位元組,根據後面接收描述符的配置,將packet buffer設定為2048位元組。
//kern/e1000.h
struct tx_desc
{
    uint64_t addr;
    uint16_t length;
    uint8_t cso;
    uint8_t cmd;
    uint8_t status;
    uint8_t css;
    uint16_t special;
} __attribute__((packed));

struct packet
{       
    char body[2048];
};

//kern/e1000.c
struct tx_desc tx_d[TXRING_LEN] __attribute__((aligned (PGSIZE)))
        = {{0, 0, 0, 0, 0, 0, 0}};
struct packet pbuf[TXRING_LEN] __attribute__((aligned (PGSIZE)))
        = {{{0}}};

   在pci_enable_attach函式中初始化相關暫存器的設定和傳送描述符初始化。

static void
init_desc(){
    int i;

    for(i = 0; i < TXRING_LEN; i++){
        memset(&tx_d[i], 0, sizeof(tx_d[i]));
        tx_d[i].addr = PADDR(&pbuf[i]);
        tx_d[i].status = TXD_STAT_DD;
        tx_d[i].cmd = TXD_CMD_RS | TXD_CMD_EOP;
    }
}

int
pci_e1000_attach(struct pci_func *pcif)
{
    pci_func_enable(pcif);
    init_desc();

    e1000 = mmio_map_region(pcif->reg_base[0], pcif->reg_size[0]);
    cprintf("e1000: bar0  %x size0 %x\n", pcif->reg_base[0], pcif->reg_size[0]);

    e1000[TDBAL/4] = PADDR(tx_d);
    e1000[TDBAH/4] = 0;
    e1000[TDLEN/4] = TXRING_LEN * sizeof(struct tx_desc);
    e1000[TDH/4] = 0;
    e1000[TDT/4] = 0;
    e1000[TCTL/4] = TCTL_EN | TCTL_PSP | (TCTL_CT & (0x10 << 4)) | (TCTL_COLD & (0x40 << 12));
    e1000[TIPG/4] = 10 | (8 << 10) | (12 << 20);
    cprintf("e1000: status %x\n", e1000[STATUS/4]);
    return 1;
}

  在完成了exercise 5之後,傳送已經初始化完成。我們需要實現包的傳送工作,然後讓使用者空間能夠通過系統呼叫獲取這些包。為了傳送一個包,我們需要將它加入到傳送佇列的尾部,這意味著我們要將packet拷貝到下一個packet buffer,並且更新TDT暫存器,從而告訴網絡卡,已經有另一個packet進入傳送隊列了。(需要注意的是,TDT是一個指向transmit descriptor array的index,而不是一個byte offset)
   但是,傳送佇列只有這麼大。如果網絡卡遲遲沒有傳送packet,傳送佇列滿了怎麼辦?為了檢測這種情況,我們需要反饋給E1000網絡卡一些資訊。不幸的是,我們並不能直接使用TDH暫存器,文件中明確宣告,讀取該暫存器的值是不可靠的。然而,如果我們在傳送描述符的command filed設定了RS位,那麼當網絡卡傳送了這個描述符中的包之後,就會設定該描述符的status域的DD位。如果一個描述符的DD位被設定了,那麼我們就可以知道迴圈利用這個描述符是安全的,可以利用它去傳送下一個packet。
   如果當用戶呼叫了發包的系統呼叫,但是下一個描述符的DD位沒有設定怎麼辦?這是否代表傳送佇列滿了麼?遇到這種情況我們應該如何處理?我們可以選擇簡單地直接丟棄這個packet。許多網路協議都對這種情況有彈性的設定,但是如果我們丟棄了很多packet的話,協議可能就無法恢復了。我們也許可以告訴user environment我們需要重新發送,就像sys_ipc_try_send中做的一樣。我們可以讓驅動一直處於自旋狀態,直到有一個傳送描述符被釋放,但是這可能會造成比較大的效能問題,因為JOS核心不是設計成能阻塞的。最後,我們可以讓transmitting environment睡眠並且要求網絡卡在有transmit descriptor被釋放的時候傳送一箇中斷。
   Exercise 6:
   寫一個函式通過檢查下一個描述符是否可用來發送一個包,拷貝資料包內容到下一個描述符中,更新TDT,確保你能正確解決傳送佇列滿了的情況。
   回答:
   在初始化工作中我們已經設定傳送描述符的狀態位為DD,即表示可用,只要在傳送函式裡獲取Tail指標暫存器的值,判斷該指標指向的傳送描述符是否可用,如果可用將資料包內容拷貝到描述符中,並更新描述符的狀態位和TDT暫存器。

int
e1000_transmit(void *addr, size_t len)
{
    uint32_t tail = e1000[TDT/4];
    struct tx_desc *nxt = &tx_d[tail];

    if((nxt->status & TXD_STAT_DD) != TXD_STAT_DD)
        return -1;
    if(len > TBUFFSIZE)
        len = TBUFFSIZE;

    memmove(&pbuf[tail], addr, len);
    nxt->length = (uint16_t)len;
    nxt->status &= !TXD_STAT_DD;
    e1000[TDT/4] = (tail + 1) % TXRING_LEN;
    return 0;
}

  當你完成發包程式碼後,可以在核心中呼叫該函式來測試程式碼正確性(比如可以在monitor.c中新增呼叫)。執行make E1000_DEBUG=TXERR,TX qemu測試,你會看到如下輸出:
  e1000: index 0: 0x271f00 : 9000002a 0
  其中每一行表示1個傳送的資料包,index給出了在傳送描述符陣列中的索引,之後的為該描述符中packet buffer的地址,然後是cmd/CSO/length標誌位,最後是special/CSS/status標誌位。
  Exercise 7:
  新增1個系統呼叫來讓使用者空間可以傳送資料包。具體的介面實現取決於自己。
  回答:
  仿照sys_ipc_try_send呼叫,在系統呼叫涉及的檔案中新增呼叫號和介面函式。

//kern/syscall.c
// Send network packet
static int
sys_netpacket_try_send(void *addr, size_t len)
{
    user_mem_assert(curenv, addr, len, PTE_U);
    return e1000_transmit(addr, len);
}

Transmitting Packets: Network Server
  現在我們已經有了訪問裝置驅動傳送端的系統呼叫介面,那麼該傳送一些packets了。output helper environment的作用就是不斷做如下的迴圈:從核心網路伺服器中接收NSREQ_OUTPUT型別的IPC訊息,然後用我們自己寫的系統呼叫將含有這些IPC訊息的packet傳送到網絡卡驅動。NSREQ_OUTPUT 的IPC訊息是由net/lwip/jos/jif/jif.c中的low_level_output傳送的,它將lwIP stack和JOS的網路系統連在了一起。每一個IPC都會包含一個由union Nsipc組成的頁,其中packet存放在struct jif_pkt欄位中(見inc/ns.h)。struct jif_pkt如下所示:

struct jif_pkt {
  int   jp_len;
  char   jp_data[0];
}

  其中jp_len代表了packet的長度。IPC page之後的所有位元組都代表了packet的內容。使用一個長度為0的陣列,例如jp_data,在struct 的結尾,是C中一種比較通用的方式,用於代表一個未提前指定長度的buffer。因為C中並沒有做任何邊界檢測,只要你確定struct之後有足夠的未被使用的記憶體,我們就可以認為jp_data是任意大小的陣列。
  我們需要搞清楚當裝置驅動的傳送佇列中沒有空間的時候,裝置驅動,output environment和核心網路伺服器三者之間的關係。核心網路伺服器通過IPC將packet傳送給output environment。如果output environment因為驅動中沒有足夠的快取空間用於存放新的packet而阻塞,核心網路伺服器會一直阻塞直到output environment接受了IPC為止。
  Exercise 8:
  實現net/output.c。
  回答:
  這裡主要是實現output environment的工作。net/testoutput.c是測試發包的程式碼。

static envid_t output_envid;
static struct jif_pkt *pkt = (struct jif_pkt*)REQVA;

void
umain(int argc, char **argv)
{
    envid_t ns_envid = sys_getenvid();
    int i, r;

    binaryname = "testoutput";

    output_envid = fork();
    if (output_envid < 0)
        panic("error forking");
    else if (output_envid == 0) {
        output(ns_envid);
        return;
    }       

    for (i = 0; i < TESTOUTPUT_COUNT; i++) {
        if ((r = sys_page_alloc(0, pkt, PTE_P|PTE_U|PTE_W)) < 0)
            panic("sys_page_alloc: %e", r);
        pkt->jp_len = snprintf(pkt->jp_data,
                       PGSIZE - sizeof(pkt->jp_len),
                       "Packet %02d", i);
        cprintf("Transmitting packet %d\n", i);
        ipc_send(output_envid, NSREQ_OUTPUT, pkt, PTE_P|PTE_W|PTE_U);
        sys_page_unmap(0, pkt);
    }

    // Spin for a while, just in case IPC's or packets need to be flushed
    for (i = 0; i < TESTOUTPUT_COUNT*2; i++)
        sys_yield();

  在testoutput.c中,先fork1個environment,即output environment,然後執行需要實現的output函式,在原先environment中通過ipc_send傳送資料包的內容。所以在output environment中,就需要實現通過ipc_recv接受到IPC資訊時,如果為NSREQ_OUTPUT,那麼呼叫發包系統呼叫來發送資料包到網絡卡驅動。

void
output(envid_t ns_envid)
{
    binaryname = "ns_output";

    int perm;
    envid_t eid;

    while(1) {
        if (ipc_recv(&eid, &nsipcbuf, &perm) != NSREQ_OUTPUT)
            continue;
                                                while(sys_netpacket_try_send(nsipcbuf.pkt.jp_data, nsipcbuf.pkt.jp_len) < 0)
            sys_yield();
    }
}

  至此整個發包流程就完成了,執行 make E1000_DEBUG=TXERR,TX run-net_testoutput來測試發包功能。
  Q1:
  你是如何組織你的發包流程,當傳送佇列滿時你會怎麼做?
  回答:
  整個流程可以檢視上面的程式碼說明,當傳送佇列滿時,那麼output environment就會重發,但是sleep一會兒,其實最好使用中斷機制。

Part B: Receiving packets and the web server
Receiving Packets
  與發包類似,我們必須配置E1000網絡卡來接受包並提供接收描述符佇列和接收描述符。3.2章節描述了收包是如何工作的,包括接收佇列結構和接收描述符,14.4章節描述了初始化過程。
  Exercise 9:
  閱讀32.章節,忽略關於中斷和校驗負載。
  回答:
  主要是瞭解收包工作的流程。
  
  接收佇列和傳送佇列非常相似,不同的是它由空的packet buffer組成,等待被即將到來的packet填充。因此,當網路暫停的時候,傳送佇列是空的,但是接收佇列是滿的。當E1000接收到一個packet時,它會首先檢查這個packet是否滿足該網絡卡的configured filters(比如,這個包的目的地址是不是該E1000的MAC地址)並且忽略那些不符合這些filter的packet。否則,E1000嘗試獲取從接收佇列獲取下一個空閒的描述符。如果Head指標(RDH)已經追趕上了Tail指標(RDT),那麼說明接收佇列已經用完了空閒的descriptor,因此網絡卡就會丟棄這個packet。如果還有空閒的接收描述符,它會將packet data拷貝到描述符包含的buffer中,並且設定描述符的DD(descriptor done)和EOP(End of Packet)狀態位,然後增加RDH。
  如果E1000網絡卡收到一個packet,它的資料大於一個接收描述符的packet buffer,它會繼續從接收佇列中獲取儘可能多的描述符,用來存放packet的所有內容。為了表明這樣的情況,它會在每個descriptor中都設定DD狀態位,但只在最後一個descriptor中設定EOP狀態位。我們可以讓驅動對這種情況進行處理,或者只是簡單地對對網絡卡進行配置,讓它不接收這樣的“long packet”,但是我們要確保我們的receive buffer能夠接收最大的標誌Ethernet packet(1518位元組)。
  Exercise 10:
  根據14.4章節建立接收佇列和配置E1000網絡卡,無須支援”long packets”和multicast。暫時不要配置使用中斷,同時忽略CRC。
  預設情況下,網絡卡會過濾所有的packet,我們必須配置接收地址暫存器(RAL和RAH)為網絡卡的MAC地址以使得能接受傳送給該網絡卡的包。目前可以簡單地硬編碼QEMU的預設MAC地址52:54:00:12:34:56。注意位元組順序MAC地址從左到右是從低地址到高地址的,所以52:54:00:12為低32位,34:56為高16位
  E1000網絡卡只支援一系列特殊的receive buffer大小(可檢視13.4.22章節關於RCTL.BSIZE的描述)。假如我們配置receive packet buffers足夠大並關閉long packets,那麼我們就無需擔心跨越多個receive buffer的包。同時記住接收佇列和packet buffer也必須是連續的實體記憶體。我們必需使用至少128個接收描述符。
  回答:
  整個流程跟發包初始化配置類似,檢視開發手冊14.4章節關於接收初始化的描述。主要相關工作如下:

  • 設定接受地址暫存器(RAL/RAH)為網絡卡的MAC地址。
  • 初始化multicast表陣列為0。
  • 設定中斷相關暫存器的值,這裡我們關閉中斷
  • 為接收描述符佇列分配一塊連續空間,設定RDBAL和RDBAH暫存器的值指向起始地址,其中RDBAL為32位地址,RDBAL和RDBAH表示64位地址。
  • 設定RDLEN暫存器的值為描述符佇列的大小,以位元組計算。
  • 設定接收佇列的Head指標(RDH)和Tail指標(RDT)暫存器的值為0。Head指標指向第1個可用的描述符,Tail指向最後1個可用描述符的下一個描述符。(這裡存在問題,如果將Head指標和Tail指標初始化為0,那麼將接收不到資料包,應該將Tail指標初始化為最後1個可用描述符即RDLEN-1,因為像上面描述的當RDH等於RDT的時候,網絡卡認為佇列滿了,會丟棄資料包)。
  • 設定接收控制暫存器RCTL的值,主要包括設定RCTL.EN標誌位為1(啟用)、RCTL.LBM標誌位為00(關閉迴環)、RCTL.BSIZE標誌位為00和RCTL.BSEX位為0(buffer大小為2048位元組)、RCTL.SECRC標誌位為1(忽略校驗)。

      上面最重要的就是紅色部分的描述,第一次做的時候卡在這裡,測試通不過,仔細查看了多次手冊。具體初始化程式碼如下:

static void
init_desc(){
    ......
    for(i = 0; i < RXRING_LEN; i++){
        memset(&rx_d[i], 0, sizeof(rx_d[i]));
        rx_d[i].addr = PADDR(&prbuf[i]);
        rx_d[i].status = 0;
    }
}

int
pci_e1000_attach(struct pci_func *pcif)
{
    ......
   e1000[RA/4] = mac[0];
    e1000[RA/4+1] = mac[1];
    e1000[RA/4+1] |= RAV;

    cprintf("e1000: mac address %x:%x\n", mac[1], mac[0]);

    memset((void*)&e1000[MTA/4], 0, 128 * 4);
    e1000[ICS/4] = 0;
    e1000[IMS/4] = 0;
    //e1000[IMC/4] = 0xFFFF;
    e1000[RDBAL/4] = PADDR(rx_d);
    e1000[RDBAH/4] = 0;
    e1000[RDLEN/4] = RXRING_LEN * sizeof(struct rx_desc);
    e1000[RDH/4] = 0;
    e1000[RDT/4] = RXRING_LEN - 1;
    e1000[RCTL/4] = RCTL_EN | RCTL_LBM_NO | RCTL_SECRC | RCTL_BSIZE;
    return 1;
}

  完成後,執行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput,testinput會發送ARP廣播,QEMU會自動迴應。雖然現在我們的驅動無法接收該回應,但是使用debug標誌可以看到:
  “e1000: unicast match[0]: 52:54:00:12:34:56”
  現在我們要實現接收包。為了接收packet,我們的驅動需要跟蹤到底從哪個描述符中獲取下一個received packet。和傳送時相似,文件中說明從軟體中讀取RDH暫存器也是不可靠的。所以,為了確定一個packet是否被髮送到描述符的packet buffer中,我們需要讀取該描述符的DD狀態位。如果DD已經被置位,那麼我們可以將packet data從描述符的packet buffer中拷貝出來,然後通過更新佇列的RDT告訴網絡卡該描述符已經被釋放了。
  如果DD沒有被置位,那麼說明沒有接收到任何packet。這和傳送端佇列已滿的情況是一樣的,在這種情況下,我們可以做很多事情。我們可以簡單地返回一個“try again”的error並且要求呼叫者繼續嘗試。這種方法對於傳送佇列已滿的情況是有效的,因為那種情況是短暫的,但是對於空的接收佇列就不合適了,因為接收佇列可能很長時間處於空的狀態。第二種方法就是將calling environment掛起,直到接收佇列中有packet可以處理。這種方法和sys_ipc_recv和相似。就像在IPC中所做的,每個CPU只有一個kernel stack,一旦我們離開kernel,那麼棧上的state就會消失。我們需要設定一個flag來表明這個environment是因為接收佇列被掛起的並且記錄下系統呼叫引數。這種方法的缺點有點複雜:E1000網絡卡必須被配置成能產生接收中斷並且驅動還需要能夠對中斷進行處理,為了讓等待packet的environment能恢復過來。
  Exercise 11:
  寫1個函式來從E1000網絡卡接收1個包,並新增1個系統呼叫暴露給使用者空間。確保你能處理接收佇列為空的情況。
  當然也可以使用中斷來處理接收流程。
  回答:
  與發包類似,讀取RDT暫存器的值,判斷最後1個可用描述符的下一個描述符的標誌位是否為DD,如果是則拷貝該描述符中的buffer,清除DD位,並增加RDT。

int
e1000_receive(void *addr, size_t buflen)
{
    uint32_t tail = (e1000[RDT/4] + 1) % RXRING_LEN;
    struct rx_desc *nxt = &rx_d[tail];

    if((nxt->status & RXD_STAT_DD) != RXD_STAT_DD) {
        return -1;
    }
    if(nxt->length < buflen)
        buflen = nxt->length;

    memmove(addr, &prbuf[tail], buflen);
    nxt->status &= !RXD_STAT_DD;
    e1000[RDT/4] = tail;

    return buflen;
}

  關於系統呼叫的新增這裡就不再描述了。

Receiving Packets: Network Server
  在網路伺服器input environment中,我們將需要使用新新增的收包系統呼叫來接收資料包並通過NSREQ_INPUT IPC訊息傳遞給核心網路伺服器environment。
  Exercise 12:
  實現net/input.c
  回答:
  這裡主要是實現input environment的工作。net/testinput.c是測試收包的程式碼。

void
umain(int argc, char **argv)
{
    envid_t ns_envid = sys_getenvid();
    int i, r, first = 1;

    binaryname = "testinput";

    output_envid = fork();
    if (output_envid < 0)
        panic("error forking");
    else if (output_envid == 0) {
        output(ns_envid);
        return;
    }

    input_envid = fork();
    if (input_envid < 0)
        panic("error forking");
    else if (input_envid == 0) {
        input(ns_envid);
        return;
    }

    cprintf("Sending ARP announcement...\n");
    announce();

    while (1) {
        envid_t whom;
        int perm;

        int32_t req = ipc_recv((int32_t *)&whom, pkt, &perm);
        if (req < 0)
            panic("ipc_recv: %e", req);
        if (whom != input_envid)
            panic("IPC from unexpected environment %08x", whom);
        if (req != NSREQ_INPUT)
            panic("Unexpected IPC %d", req);

        hexdump("input: ", pkt->jp_data, pkt->jp_len);
        cprintf("\n");

        // Only indicate that we're waiting for packets once
        // we've received the ARP reply
        if (first)
            cprintf("Waiting for packets...\n");
        first = 0;
    }
}

  fork了2個新的environment,其中1個執行output,傳送ARP廣播,另外1個執行input,接收QEMU的迴應。通過ipc_recv來獲得input environment收到的資料包。
  在net/input.c的input函式中通過呼叫收包系統呼叫從網絡卡驅動處獲得資料包,這裡的注意點是根據註釋有可能收包太快,傳送給網路伺服器,但是網路伺服器可能讀取過慢,導致相應的內容被沖刷,所以我們採用10頁的緩衝來存放從網絡卡驅動獲得的資料包。

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

    int i, r;
    int32_t length;
    struct jif_pkt *cpkt = pkt;

    for(i = 0; i < 10; i++)
        if ((r = sys_page_alloc(0, (void*)((uintptr_t)pkt + i * PGSIZE), PTE_P | PTE_U | PTE_W)) < 0)
            panic("sys_page_alloc: %e", r);

    i = 0; 
    while(1) {
        while((length = sys_netpacket_recv((void*)((uintptr_t)cpkt + sizeof(cpkt->jp_len)), PGSIZE - sizeof(cpkt->jp_len))) < 0) {
            // cprintf("len: %d\n", length);
            sys_yield();
        }

        cpkt->jp_len = length;
        ipc_send(ns_envid, NSREQ_INPUT, cpkt, PTE_P | PTE_U);
        i = (i + 1) % 10;
        cpkt = (struct jif_pkt*)((uintptr_t)pkt + i * PGSIZE);
        sys_yield();
    }
}

  完成之後,執行make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput,將看到如下的資訊:

Sending ARP announcement...
Waiting for packets...
e1000: index