
作者 | Csail.mit
譯者 | LCTT / qhwdw
簡介
這個實驗是預設你能夠自己完成的最終專案。
現在你已經有了一個檔案系統,一個典型的操作系統都應該有一個網絡棧。在本實驗中,你將繼續為一個網卡去寫一個驅動程式。這個網卡基於 Intel 82540EM 芯片,也就是眾所周知的 E1000 芯片。
預備知識
使用 Git 去提交你的實驗 5 的原始碼(如果還沒有提交的話),獲取課程倉庫的最新版本,然後創建一個名為 lab6
的本地分支,它跟蹤我們的遠程分支 origin/lab6
:
athena% cd ~/6.828/lab
athena% add git
athena% git commit -am 'my solution to lab5'
nothing to commit (working directory clean)
athena% git pull
Already up-to-date.
athena% git checkout -b lab6 origin/lab6
Branch lab6 set up to track remote branch refs/remotes/origin/lab6.
Switched to a new branch "lab6"
athena% git merge lab5
Merge made by recursive.
fs/fs.c | 42 +++++++++++++++++++
1 files changed, 42 insertions(+), 0 deletions(-)
athena%
然後,僅有網卡驅動程式並不能夠讓你的操作系統接入互聯網。在新的實驗 6 的代碼中,我們為你提供了網絡棧和一個網絡服務器。與以前的實驗一樣,使用 git 去拉取這個實驗的代碼,合併到你自己的代碼中,並去瀏覽新的 net/
目錄中的內容,以及在 kern/
中的新檔案。
除了寫這個驅動程式以外,你還需要去創建一個訪問你的驅動程式的系統呼叫。你將要去實現那些在網絡服務器中缺失的代碼,以便於在網絡棧和你的驅動程式之間傳輸包。你還需要通過完成一個 web 服務器來將所有的東西連接到一起。你的新 web 服務器還需要你的檔案系統來提供所需要的檔案。
大部分的內核設備驅動程式代碼都需要你自己去從頭開始編寫。本實驗提供的指導比起前面的實驗要少一些:沒有框架檔案、沒有現成的系統呼叫接口、並且很多設計都由你自己決定。因此,我們建議你在開始任何單獨練習之前,閱讀全部的編寫任務。許多學生都反應這個實驗比前面的實驗都難,因此請根據你的實際情況計劃你的時間。
實驗要求
與以前一樣,你需要做實驗中全部的常規練習和至少一個挑戰問題。在實驗中寫出你的詳細答案,並將挑戰問題的方案描述寫入到 answers-lab6.txt
檔案中。
QEMU 的虛擬網絡
我們將使用 QEMU 的用戶樣式網絡棧,因為它不需要以管理員權限運行。QEMU 的文件的這裡[1]有更多關於用戶網絡的內容。我們更新後的 makefile 啟用了 QEMU 的用戶樣式網絡棧和虛擬的 E1000 網卡。
預設情況下,QEMU 提供一個運行在 IP 地址 10.2.2.2 上的虛擬路由器,它給 JOS 分配的 IP 地址是 10.0.2.15。為了簡單起見,我們在 net/ns.h
中將這些預設值硬編碼到網絡服務器上。
雖然 QEMU 的虛擬網絡允許 JOS 隨意連接互聯網,但 JOS 的 10.0.2.15 的地址並不能在 QEMU 中的虛擬網絡之外使用(也就是說,QEMU 還得做一個 NAT),因此我們並不能直接連接到 JOS 上運行的服務器,即便是從運行 QEMU 的主機上連接也不行。為解決這個問題,我們配置 QEMU 在主機的某些端口上運行一個服務器,這個服務器簡單地連接到 JOS 中的一些端口上,併在你的真實主機和虛擬網絡之間傳遞資料。
你將在端口 7(echo)和端口 80(http)上運行 JOS,為避免在共享的 Athena 機器上發生衝突,makefile 將為這些端口基於你的用戶 ID 來生成轉髮端口。你可以運行 make which-ports
去找出是哪個 QEMU 端口轉發到你的開發主機上。為方便起見,makefile 也提供 make nc-7
和 make nc-80
,它允許你在終端上直接與運行這些端口的服務器去交互。(這些標的僅能連接到一個運行中的 QEMU 實體上;你必須分別去啟動它自己的 QEMU)
包檢查
makefile 也可以配置 QEMU 的網絡棧去記錄所有的入站和出站資料包,並將它儲存到你的實驗目錄中的 qemu.pcap
檔案中。
使用 tcpdump
命令去獲取一個捕獲的 hex/ASCII 包轉儲:
tcpdump -XXnr qemu.pcap
或者,你可以使用 Wireshark[2] 以圖形化界面去檢查 pcap 檔案。Wireshark 也知道如何去解碼和檢查成百上千的網絡協議。如果你在 Athena 上,你可以使用 Wireshark 的前輩:ethereal,它運行在加鎖的保密互聯網協議網絡中。
除錯 E1000
我們非常幸運能夠去使用仿真硬體。由於 E1000 是在軟體中運行的,仿真的 E1000 能夠給我們提供一個人類可讀格式的報告、它的內部狀態以及它遇到的任何問題。通常情況下,對祼機上做驅動程式開發的人來說,這是非常難能可貴的。
E1000 能夠產生一些除錯輸出,因此你可以去打開一個專門的日誌通道。其中一些對你有用的通道如下:
標誌 | 含義 |
---|---|
tx | 包發送日誌 |
txerr | 包發送錯誤日誌 |
rx | 到 RCTL 的日誌通道 |
rxfilter | 入站包過濾日誌 |
rxerr | 接收錯誤日誌 |
unknown | 未知暫存器的讀寫日誌 |
eeprom | 讀取 EEPROM 的日誌 |
interrupt | 中斷和中斷暫存器變更日誌 |
例如,你可以使用 make E1000_DEBUG=tx,txerr
去打開 “tx” 和 “txerr” 日誌功能。
註意:E1000_DEBUG
標誌僅能在打了 6.828 補丁的 QEMU 版本上工作。
你可以使用軟體去仿真硬體,來做進一步的除錯工作。如果你使用它時卡殼了,不明白為什麼 E1000 沒有如你預期那樣響應你,你可以查看在 hw/e1000.c
中的 QEMU 的 E1000 實現。
網絡服務器
從頭開始寫一個網絡棧是很困難的。因此我們將使用 lwIP,它是一個開源的、輕量級 TCP/IP 協議套件,它能做包括一個網絡棧在內的很多事情。你能在 這裡[3] 找到很多關於 lwIP 的信息。在這個任務中,對我們而言,lwIP 就是一個實現了一個 BSD 套接字接口和擁有一個包輸入端口和包輸出端口的黑盒子。
一個網絡服務器其實就是一個有以下四個環境的混合體:
下圖展示了各個環境和它們之間的關係。下圖展示了包括設備驅動的整個系統,我們將在後面詳細講到它。在本實驗中,你將去實現圖中綠色高亮的部分。
Network server architecture
核心網絡服務器環境
核心網絡服務器環境由套接字呼叫派發器和 lwIP 自身組成的。套接字呼叫派發器就像一個檔案服務器一樣。用戶環境使用 stubs(可以在 lib/nsipc.c
中找到它)去發送 IPC 訊息到核心網絡服務器環境。如果你看了 lib/nsipc.c
,你就會發現核心網絡服務器與我們創建的檔案服務器 i386_init
的工作方式是一樣的,i386_init
是使用 NSTYPENS 創建的 NS 環境,因此我們檢查 envs
,去查找這個特殊的環境型別。對於每個用戶環境的 IPC,網絡服務器中的派發器將呼叫相應的、由 lwIP 提供的、代表用戶的 BSD 套接字接口函式。
普通用戶環境不能直接使用 nsipc_*
呼叫。而是通過在 lib/sockets.c
中的函式來使用它們,這些函式提供了基於檔案描述符的套接字 API。以這種方式,用戶環境通過檔案描述符來取用套接字,就像它們取用磁盤上的檔案一樣。一些操作(connect
、accept
等等)是特定於套接字的,但 read
、write
和 close
是通過 lib/fd.c
中一般的檔案描述符設備派發代碼的。就像檔案服務器對所有的打開的檔案維護唯一的內部 ID 一樣,lwIP 也為所有的打開的套接字生成唯一的 ID。不論是檔案服務器還是網絡服務器,我們都使用儲存在 struct Fd
中的信息去映射每個環境的檔案描述符到這些唯一的 ID 空間上。
儘管看起來檔案服務器的網絡服務器的 IPC 派發器行為是一樣的,但它們之間還有很重要的差別。BSD 套接字呼叫(像 accept
和 recv
)能夠無限期阻塞。如果派發器讓 lwIP 去執行其中一個呼叫阻塞,派發器也將被阻塞,並且在整個系統中,同一時間只能有一個未完成的網絡呼叫。由於這種情況是無法接受的,所以網絡服務器使用用戶級執行緒以避免阻塞整個服務器環境。對於每個入站 IPC 訊息,派發器將創建一個執行緒,然後在新創建的執行緒上來處理請求。如果執行緒被阻塞,那麼只有那個執行緒被置入休眠狀態,而其它執行緒仍然處於運行中。
除了核心網絡環境外,還有三個輔助環境。核心網絡服務器環境除了接收來自用戶應用程式的訊息之外,它的派發器也接收來自輸入環境和定時器環境的訊息。
輸出環境
在為用戶環境套接字呼叫提供服務時,lwIP 將為網卡生成用於發送的包。lwIP 將使用 NSREQ_OUTPUT
去發送在 IPC 訊息頁引數中附加了包的 IPC 訊息。輸出環境負責接收這些訊息,並通過你稍後創建的系統呼叫接口來轉發這些包到設備驅動程式上。
輸入環境
網卡接收到的包需要傳遞到 lwIP 中。輸入環境將每個由設備驅動程式接收到的包拉進內核空間(使用你將要實現的內核系統呼叫),並使用 NSREQ_INPUT
IPC 訊息將這些包發送到核心網絡服務器環境。
包輸入功能是獨立於核心網絡環境的,因為在 JOS 上同時實現接收 IPC 訊息並從設備驅動程式中查詢或等待包有點困難。我們在 JOS 中沒有實現 select
系統呼叫,這是一個允許環境去監視多個輸入源以識別準備處理哪個輸入的系統呼叫。
如果你查看了 net/input.c
和 net/output.c
,你將會看到在它們中都需要去實現那個系統呼叫。這主要是因為實現它要依賴你的系統呼叫接口。在你實現了驅動程式和系統呼叫接口之後,你將要為這兩個輔助環境寫這個代碼。
定時器環境
定時器環境周期性發送 NSREQ_TIMER
型別的訊息到核心服務器,以提醒它那個定時器已過期。lwIP 使用來自執行緒的定時器訊息來實現各種網絡超時。
Part A:初始化和發送包
你的內核還沒有一個時間概念,因此我們需要去添加它。這裡有一個由硬體產生的每 10 ms 一次的時鐘中斷。每收到一個時鐘中斷,我們將增加一個變數值,以表示時間已過去 10 ms。它在 kern/time.c
中已實現,但還沒有完全集成到你的內核中。
練習 1、為
kern/trap.c
中的每個時鐘中斷增加一個到time_tick
的呼叫。實現sys_time_msec
並增加到kern/syscall.c
中的syscall
,以便於用戶空間能夠訪問時間。
使用 make INIT_CFLAGS=-DTEST_NO_NS run-testtime
去測試你的代碼。你應該會看到環境計數從 5 開始以 1 秒為間隔減少。-DTEST_NO_NS
引數禁止在網絡服務器環境上啟動,因為在當前它將導致 JOS 崩潰。
網卡
寫驅動程式要求你必須深入瞭解硬體和軟體中的接口。本實驗將給你提供一個如何使用 E1000 接口的高度概括的文件,但是你在寫驅動程式時還需要大量去查詢 Intel 的手冊。
練習 2、為開發 E1000 驅動,去瀏覽 Intel 的 軟體開發者手冊[4]。這個手冊涵蓋了幾個與以太網控制器緊密相關的東西。QEMU 仿真了 82540EM。
現在,你應該去瀏覽第 2 章,以對設備獲得一個整體概念。寫驅動程式時,你需要熟悉第 3 到 14 章,以及 4.1(不包括 4.1 的子節)。你也應該去參考第 13 章。其它章涵蓋了 E1000 的組件,你的驅動程式並不與這些組件去交互。現在你不用擔心過多細節的東西;只需要瞭解文件的整體結構,以便於你後面需要時容易查找。
在閱讀手冊時,記住,E1000 是一個擁有很多高級特性的很複雜的設備,一個能讓 E1000 工作的驅動程式僅需要它一小部分的特性和 NIC 提供的接口即可。仔細考慮一下,如何使用最簡單的方式去使用網卡的接口。我們強烈推薦你在使用高級特性之前,只去寫一個基本的、能夠讓網卡工作的驅動程式即可。
PCI 接口
E1000 是一個 PCI 設備,也就是說它是插到主板的 PCI 總線插槽上的。PCI 總線有地址、資料、和中斷線,並且 PCI 總線允許 CPU 與 PCI 設備通訊,以及 PCI 設備去讀取和寫入記憶體。一個 PCI 設備在它能夠被使用之前,需要先發現它併進行初始化。發現 PCI 設備是 PCI 總線查找已安裝設備的過程。初始化是分配 I/O 和記憶體空間、以及協商設備所使用的 IRQ 線的過程。
我們在 kern/pci.c
中已經為你提供了使用 PCI 的代碼。PCI 初始化是在引導期間執行的,PCI 代碼遍歷PCI 總線來查找設備。當它找到一個設備時,它讀取它的供應商 ID 和設備 ID,然後使用這兩個值作為關鍵字去搜索 pci_attach_vendor
陣列。這個陣列是由像下麵這樣的 struct pci_driver
條目組成:
struct pci_driver {
uint32_t key1, key2;
int (*attachfn) (struct pci_func *pcif);
};
如果發現的設備的供應商 ID 和設備 ID 與陣列中條目匹配,那麼 PCI 代碼將呼叫那個條目的 attachfn
去執行設備初始化。(設備也可以按類別識別,那是通過 kern/pci.c
中其它的驅動程式表來實現的。)
系結函式是傳遞一個 PCI 函式 去初始化。一個 PCI 卡能夠發佈多個函式,雖然這個 E1000 僅發佈了一個。下麵是在 JOS 中如何去表示一個 PCI 函式:
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;
};
上面的結構反映了在 Intel 開發者手冊里第 4.1 節的表 4-1 中找到的一些條目。struct pci_func
的最後三個條目我們特別感興趣的,因為它們將記錄這個設備協商的記憶體、I/O、以及中斷資源。reg_base
和 reg_size
陣列包含最多六個基址暫存器或 BAR。reg_base
為映射到記憶體中的 I/O 區域(對於 I/O 端口而言是基 I/O 端口)儲存了記憶體的基地址,reg_size
包含了以位元組表示的大小或來自 reg_base
的相關基值的 I/O 端口號,而 irq_line
包含了為中斷分配給設備的 IRQ 線。在表 4-2 的後半部分給出了 E1000 BAR 的具體涵義。
當設備呼叫了系結函式後,設備已經被髮現,但沒有被啟用。這意味著 PCI 代碼還沒有確定分配給設備的資源,比如地址空間和 IRQ 線,也就是說,struct pci_func
結構的最後三個元素還沒有被填入。系結函式將呼叫 pci_func_enable
,它將去啟用設備、協商這些資源、併在結構 struct pci_func
中填入它。
練習 3、實現一個系結函式去初始化 E1000。添加一個條目到
kern/pci.c
中的陣列pci_attach_vendor
上,如果找到一個匹配的 PCI 設備就去觸發你的函式(確保一定要把它放在表末尾的{0, 0, 0}
條目之前)。你在 5.2 節中能找到 QEMU 仿真的 82540EM 的供應商 ID 和設備 ID。在引導期間,當 JOS 掃描 PCI 總線時,你也可以看到列出來的這些信息。到目前為止,我們通過
pci_func_enable
啟用了 E1000 設備。通過本實驗我們將添加更多的初始化。我們已經為你提供了
kern/e1000.c
和kern/e1000.h
檔案,這樣你就不會把構建系統搞糊塗了。不過它們現在都是空的;你需要在本練習中去填充它們。你還可能在內核的其它地方包含這個e1000.h
檔案。當你引導你的內核時,你應該會看到它輸出的信息顯示 E1000 的 PCI 函式已經啟用。這時你的代碼已經能夠通過
make grade
的pci attach
測試了。
記憶體映射的 I/O
軟體與 E1000 通過記憶體映射的 I/O(MMIO)來溝通。你在 JOS 的前面部分可能看到過 MMIO 兩次:CGA 控制台和 LAPIC 都是通過寫入和讀取“記憶體”來控制和查詢設備的。但這些讀取和寫入不是去往記憶體芯片的,而是直接到這些設備的。
pci_func_enable
為 E1000 協調一個 MMIO 區域,來儲存它在 BAR 0 的基址和大小(也就是 reg_base[0]
和 reg_size[0]
),這是一個分配給設備的一段物理記憶體地址,也就是說你可以通過虛擬地址訪問它來做一些事情。由於 MMIO 區域一般分配高位物理地址(一般是 3GB 以上的位置),因此你不能使用 KADDR
去訪問它們,因為 JOS 被限製為最大使用 256MB。因此,你可以去創建一個新的記憶體映射。我們將使用 MMIOBASE
(從實驗 4 開始,你的 mmio_map_region
區域應該確保不能被 LAPIC 使用的映射所改寫)以上的部分。由於在 JOS 創建用戶環境之前,PCI 設備就已經初始化了,因此你可以在 kern_pgdir
處創建映射,並且讓它始終可用。
練習 4、在你的系結函式中,通過呼叫
mmio_map_region
(它就是你在實驗 4 中寫的,是為了支持 LAPIC 記憶體映射)為 E1000 的 BAR 0 創建一個虛擬地址映射。你將希望在一個變數中記錄這個映射的位置,以便於後面訪問你映射的暫存器。去看一下
kern/lapic.c
中的lapic
變數,它就是一個這樣的例子。如果你使用一個指標指向設備暫存器映射,一定要宣告它為volatile
;否則,編譯器將允許快取它的值,並可以在記憶體中再次訪問它。為測試你的映射,嘗試去輸出設備狀態暫存器(第 12.4.2 節)。這是一個在暫存器空間中以位元組 8 開頭的 4 位元組暫存器。你應該會得到
0x80080783
,它表示以 1000 MB/s 的速度啟用一個全雙工的鏈路,以及其它信息。
提示:你將需要一些常數,像暫存器位置和掩碼位數。如果從開發者手冊中複製這些東西很容易出錯,並且導致除錯過程很痛苦。我們建議你使用 QEMU 的 e1000_hw.h[5] 頭檔案做為基準。我們不建議完全照抄它,因為它定義的值遠超過你所需要,並且定義的東西也不見得就是你所需要的,但它仍是一個很好的參考。
DMA
你可能會認為是從 E1000 的暫存器中通過寫入和讀取來傳送和接收資料包的,其實這樣做會非常慢,並且還要求 E1000 在其中去快取資料包。相反,E1000 使用直接記憶體訪問(DMA)從記憶體中直接讀取和寫入資料包,而且不需要 CPU 參與其中。驅動程式負責為發送和接收佇列分配記憶體、設置 DMA 描述符、以及配置 E1000 使用的佇列位置,而在這些設置完成之後的其它工作都是異步方式進行的。發送包的時候,驅動程式複製它到發送佇列的下一個 DMA 描述符中,並且通知 E1000 下一個發送包已就緒;當輪到這個包發送時,E1000 將從描述符中複製出資料。同樣,當 E1000 接收一個包時,它從接收佇列中將它複製到下一個 DMA 描述符中,驅動程式將能在下一次讀取到它。
總體來看,接收佇列和發送佇列非常相似。它們都是由一系列的描述符組成。雖然這些描述符的結構細節有所不同,但每個描述符都包含一些標誌和包含了包資料的一個快取的物理地址(發送到網卡的資料包,或網卡將接收到的資料包寫入到由操作系統分配的快取中)。
佇列被實現為一個環形陣列,意味著當網卡或驅動到達陣列末端時,它將重新回到開始位置。它有一個頭指標和尾指標,佇列的內容就是這兩個指標之間的描述符。硬體就是從頭開始移動頭指標去消費描述符,在這期間驅動程式不停地添加描述符到尾部,並移動尾指標到最後一個描述符上。發送佇列中的描述符表示等待發送的包(因此,在平靜狀態下,發送佇列是空的)。對於接收佇列,佇列中的描述符是表示網卡能夠接收包的空描述符(因此,在平靜狀態下,接收佇列是由所有的可用接收描述符組成的)。正確的更新尾指標暫存器而不讓 E1000 產生混亂是很有難度的;要小心!
指向到這些陣列及描述符中的包快取地址的指標都必須是物理地址,因為硬體是直接在物理記憶體中且不通過 MMU 來執行 DMA 的讀寫操作的。
發送包
E1000 中的發送和接收功能本質上是獨立的,因此我們可以同時進行發送接收。我們首先去攻剋簡單的資料包發送,因為我們在沒有先去發送一個 “I’m here!” 包之前是無法測試接收包功能的。
首先,你需要初始化網卡以準備發送,詳細步驟查看 14.5 節(不必著急看子節)。發送初始化的第一步是設置發送佇列。佇列的詳細結構在 3.4 節中,描述符的結構在 3.3.3 節中。我們先不要使用 E1000 的 TCP offload 特性,因此你只需專註於 “傳統的發送描述符格式” 即可。你應該現在就去閱讀這些章節,並要熟悉這些結構。
C 結構
你可以用 C struct
很方便地描述 E1000 的結構。正如你在 struct Trapframe
中所看到的結構那樣,C struct
可以讓你很方便地在記憶體中描述準確的資料佈局。C 可以在欄位中插入資料,但是 E1000 的結構就是這樣佈局的,這樣就不會是個問題。如果你遇到欄位對齊問題,進入 GCC 查看它的 “packed” 屬性。
查看手冊中表 3-8 所給出的一個傳統的發送描述符,將它複製到這裡作為一個示例:
63 48 47 40 39 32 31 24 23 16 15 0
+---------------------------------------------------------------+
| Buffer address |
+---------------|-------|-------|-------|-------|---------------+
| Special | CSS | Status| Cmd | CSO | Length |
+---------------|-------|-------|-------|-------|---------------+
從結構右上角第一個位元組開始,我們將它轉變成一個 C 結構,從上到下,從右到左讀取。如果你從右往左看,你將看到所有的欄位,都非常適合一個標準大小的型別:
struct tx_desc
{
uint64_t addr;
uint16_t length;
uint8_t cso;
uint8_t cmd;
uint8_t status;
uint8_t css;
uint16_t special;
};
你的驅動程式將為發送描述符陣列去保留記憶體,並由發送描述符指向到包緩衝區。有幾種方式可以做到,從動態分配頁到在全域性變數中簡單地宣告它們。無論你如何選擇,記住,E1000 是直接訪問物理記憶體的,意味著它能訪問的任何快取區在物理記憶體中必須是連續的。
處理包快取也有幾種方式。我們推薦從最簡單的開始,那就是在驅動程式初始化期間,為每個描述符保留包快取空間,並簡單地將包資料複製進預留的緩衝區中或從其中複製出來。一個以太網包最大的尺寸是 1518 位元組,這就限制了這些快取區的大小。主流的成熟驅動程式都能夠動態分配包快取區(即:當網絡使用率很低時,減少記憶體使用量),或甚至跳過快取區,直接由用戶空間提供(就是“零複製”技術),但我們還是從簡單開始為好。
練習 5、執行一個 14.5 節中的初始化步驟(它的子節除外)。對於暫存器的初始化過程使用 13 節作為參考,對發送描述符和發送描述符陣列參考 3.3.3 節和 3.4 節。
要記住,在發送描述符陣列中要求對齊,並且陣列長度上有限制。因為 TDLEN 必須是 128 位元組對齊的,而每個發送描述符是 16 位元組,你的發送描述符陣列必須是 8 個發送描述符的倍數。並且不能使用超過 64 個描述符,以及不能在我們的發送環形快取測試中上限溢位。
對於 TCTL.COLD,你可以假設為全雙工操作。對於 TIPG、IEEE 802.3 標準的 IPG(不要使用 14.5 節中表上的值),參考在 13.4.34 節中表 13-77 中描述的預設值。
嘗試運行 make E1000_DEBUG=TXERR,TX qemu
。如果你使用的是打了 6.828 補丁的 QEMU,當你設置 TDT(發送描述符尾部)暫存器時你應該會看到一個 “e1000: tx disabled” 的信息,並且不會有更多 “e1000” 信息了。
現在,發送初始化已經完成,你可以寫一些代碼去發送一個資料包,並且通過一個系統呼叫使它可以訪問用戶空間。你可以將要發送的資料包添加到發送佇列的尾部,也就是說複製資料包到下一個包緩衝區中,然後更新 TDT 暫存器去通知網卡在發送佇列中有另外的資料包。(註意,TDT 是一個進入發送描述符陣列的索引,不是一個位元組偏移量;關於這一點文件中說明的不是很清楚。)
但是,發送佇列只有這麼大。如果網卡在發送資料包時卡住或發送佇列填滿時會發生什麼狀況?為了檢測這種情況,你需要一些來自 E1000 的反饋。不幸的是,你不能只使用 TDH(發送描述符頭)暫存器;文件上明確說明,從軟體上讀取這個暫存器是不可靠的。但是,如果你在發送描述符的命令欄位中設置 RS 位,那麼,當網卡去發送在那個描述符中的資料包時,網卡將設置描述符中狀態欄位的 DD 位,如果一個描述符中的 DD 位被設置,你就應該知道那個描述符可以安全地回收,並且可以用它去發送其它資料包。
如果用戶呼叫你的發送系統呼叫,但是下一個描述符的 DD 位沒有設置,表示那個發送佇列已滿,該怎麼辦?在這種情況下,你該去決定怎麼辦了。你可以簡單地丟棄資料包。網絡協議對這種情況的處理很靈活,但如果你丟棄大量的突發資料包,協議可能不會去重新獲得它們。可能需要你替代網絡協議告訴用戶環境讓它重傳,就像你在 sys_ipc_try_send
中做的那樣。在環境上回推產生的資料是有好處的。
練習 6、寫一個函式去發送一個資料包,它需要檢查下一個描述符是否空閑、複製包資料到下一個描述符並更新 TDT。確保你處理的發送佇列是滿的。
現在,應該去測試你的包發送代碼了。通過從內核中直接呼叫你的發送函式來嘗試發送幾個包。在測試時,你不需要去創建符合任何特定網絡協議的資料包。運行 make E1000_DEBUG=TXERR,TX qemu
去測試你的代碼。你應該看到類似下麵的信息:
e1000: index 0: 0x271f00 : 9000002a 0
...
在你發送包時,每行都給出了在發送陣列中的序號、那個發送的描述符的快取地址、cmd/CSO/length
欄位、以及 special/CSS/status
欄位。如果 QEMU 沒有從你的發送描述符中輸出你預期的值,檢查你的描述符中是否有合適的值和你配置的正確的 TDBAL 和 TDBAH。如果你收到的是 “e1000: TDH wraparound @0, TDT x, TDLEN y” 的信息,意味著 E1000 的發送佇列持續不斷地運行(如果 QEMU 不去檢查它,它將是一個無限迴圈),這意味著你沒有正確地維護 TDT。如果你收到了許多 “e1000: tx disabled” 的信息,那麼意味著你沒有正確設置發送控制暫存器。
一旦 QEMU 運行,你就可以運行 tcpdump -XXnr qemu.pcap
去查看你發送的包資料。如果從 QEMU 中看到預期的 “e1000: index” 信息,但你捕獲的包是空的,再次檢查你發送的描述符,是否填充了每個必需的欄位和位。(E1000 或許已經遍歷了你的發送描述符,但它認為不需要去發送)
練習 7、添加一個系統呼叫,讓你從用戶空間中發送資料包。詳細的接口由你來決定。但是不要忘了檢查從用戶空間傳遞給內核的所有指標。
發送包:網絡服務器
現在,你已經有一個系統呼叫接口可以發送包到你的設備驅動程式端了。輸出輔助環境的標的是在一個迴圈中做下麵的事情:從核心網絡服務器中接收 NSREQ_OUTPUT
IPC 訊息,並使用你在上面增加的系統呼叫去發送伴隨這些 IPC 訊息的資料包。這個 NSREQ_OUTPUT
IPC 是通過 net/lwip/jos/jif/jif.c
中的 low_level_output
函式來發送的。它集成 lwIP 棧到 JOS 的網絡系統。每個 IPC 將包含一個頁,這個頁由一個 union Nsipc
和在 struct jif_pkt pkt
欄位中的一個包組成(查看 inc/ns.h
)。struct jif_pkt
看起來像下麵這樣:
struct jif_pkt {
int jp_len;
char jp_data[0];
};
jp_len
表示包的長度。在 IPC 頁上的所有後續位元組都是為了包內容。在結構的結尾處使用一個長度為 0 的陣列來表示快取沒有一個預先確定的長度(像 jp_data
一樣),這是一個常見的 C 技巧(也有人說這是一個令人討厭的做法)。因為 C 並不做陣列邊界的檢查,只要你確保結構後面有足夠的未使用記憶體即可,你可以把 jp_data
作為一個任意大小的陣列來使用。
當設備驅動程式的發送佇列中沒有足夠的空間時,一定要註意在設備驅動程式、輸出環境和核心網絡服務器之間的交互。核心網絡服務器使用 IPC 發送包到輸出環境。如果輸出環境在由於一個發送包的系統呼叫而掛起,導致驅動程式沒有足夠的快取去容納新資料包,這時核心網絡服務器將阻塞以等待輸出服務器去接收 IPC 呼叫。
練習 8、實現
net/output.c
。
你可以使用 net/testoutput.c
去測試你的輸出代碼而無需整個網絡服務器參與。嘗試運行 make E1000_DEBUG=TXERR,TX run-net_testoutput
。你將看到如下的輸出:
Transmitting packet 0
e1000: index 0: 0x271f00 : 9000009 0
Transmitting packet 1
e1000: index 1: 0x2724ee : 9000009 0
...
運行 tcpdump -XXnr qemu.pcap
將輸出:
reading from file qemu.pcap, link-type EN10MB (Ethernet)
-5:00:00.600186 [|ether]
0x0000: 5061 636b 6574 2030 30 Packet.00
-5:00:00.610080 [|ether]
0x0000: 5061 636b 6574 2030 31 Packet.01
...
使用更多的資料包去測試,可以運行 make E1000_DEBUG=TXERR,TX NET_CFLAGS=-DTESTOUTPUT_COUNT=100 run-net_testoutput
。如果它導致你的發送佇列上限溢位,再次檢查你的 DD 狀態位是否正確,以及是否告訴硬體去設置 DD 狀態位(使用 RS 命令位)。
你的代碼應該會通過 make grade
的 testoutput
測試。
問題 1、你是如何構造你的發送實現的?在實踐中,如果發送快取區滿了,你該如何處理?
Part B:接收包和 web 服務器
接收包
就像你在發送包中做的那樣,你將去配置 E1000 去接收資料包,並提供一個接收描述符佇列和接收描述符。在 3.2 節中描述了接收包的操作,包括接收佇列結構和接收描述符、以及在 14.4 節中描述的詳細的初始化過程。
練習 9、閱讀 3.2 節。你可以忽略關於中斷和 offload 校驗和方面的內容(如果在後面你想去使用這些特性,可以再傳回去閱讀),你現在不需要去考慮閾值的細節和網卡內部快取是如何工作的。
除了接收佇列是由一系列的等待入站資料包去填充的空快取包以外,接收佇列的其它部分與發送佇列非常相似。所以,當網絡空閑時,發送佇列是空的(因為所有的包已經被髮送出去了),而接收佇列是滿的(全部都是空快取包)。
當 E1000 接收一個包時,它首先與網卡的過濾器進行匹配檢查(例如,去檢查這個包的標的地址是否為這個 E1000 的 MAC 地址),如果這個包不匹配任何過濾器,它將忽略這個包。否則,E1000 嘗試從接收佇列頭部去檢索下一個接收描述符。如果頭(RDH)追上了尾(RDT),那麼說明接收佇列已經沒有空閑的描述符了,所以網卡將丟棄這個包。如果有空閑的接收描述符,它將複製這個包的資料到描述符指向的快取中,設置這個描述符的 DD 和 EOP 狀態位,並遞增 RDH。
如果 E1000 在一個接收描述符中接收到了一個比包快取還要大的資料包,它將按需從接收佇列中檢索盡可能多的描述符以儲存資料包的全部內容。為表示發生了這種情況,它將在所有的這些描述符上設置 DD 狀態位,但僅在這些描述符的最後一個上設置 EOP 狀態位。在你的驅動程式上,你可以去處理這種情況,也可以簡單地配置網卡拒絕接收這種”長包“(這種包也被稱為”巨幀“),你要確保接收快取有足夠的空間盡可能地去儲存最大的標準以太網資料包(1518 位元組)。
練習 10、設置接收佇列並按 14.4 節中的流程去配置 E1000。你可以不用支持 ”長包“ 或多播。到目前為止,我們不用去配置網卡使用中斷;如果你在後面決定去使用接收中斷時可以再去改。另外,配置 E1000 去除以太網的 CRC 校驗,因為我們的評級腳本要求必須去掉校驗。
預設情況下,網卡將過濾掉所有的資料包。你必須使用網卡的 MAC 地址去配置接收地址暫存器(RAL 和 RAH)以接收發送到這個網卡的資料包。你可以簡單地硬編碼 QEMU 的預設 MAC 地址 52:54:00:12:34:56(我們已經在 lwIP 中硬編碼了這個地址,因此這樣做不會有問題)。使用位元組順序時要註意;MAC 地址是從低位位元組到高位位元組的方式來寫的,因此 52:54:00:12 是 MAC 地址的低 32 位,而 34:56 是它的高 16 位。
E1000 的接收快取區大小僅支持幾個指定的設置值(在 13.4.22 節中描述的 RCTL.BSIZE 值)。如果你的接收包快取夠大,並且拒絕長包,那你就不用擔心跨越多個快取區的包。另外,要記住的是,和發送一樣,接收佇列和包快取必須是連接的物理記憶體。
你應該使用至少 128 個接收描述符。
現在,你可以做接收功能的基本測試了,甚至都無需寫代碼去接收包了。運行 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput
。testinput
將發送一個 ARP(地址解析協議)通告包(使用你的包發送的系統呼叫),而 QEMU 將自動回覆它,即便是你的驅動尚不能接收這個回覆,你也應該會看到一個 “e1000: unicast match[0]: 52:54:00:12:34:56” 的訊息,表示 E1000 接收到一個包,並且匹配了配置的接收過濾器。如果你看到的是一個 “e1000: unicast mismatch: 52:54:00:12:34:56” 訊息,表示 E1000 過濾掉了這個包,意味著你的 RAL 和 RAH 的配置不正確。確保你按正確的順序收到了位元組,並不要忘記設置 RAH 中的 “Address Valid” 位。如果你沒有收到任何 “e1000” 訊息,或許是你沒有正確地啟用接收功能。
現在,你準備去實現接收資料包。為了接收資料包,你的驅動程式必須持續跟蹤希望去儲存下一下接收到的包的描述符(提示:按你的設計,這個功能或許已經在 E1000 中的一個暫存器來實現了)。與發送類似,官方文件上表示,RDH 暫存器狀態並不能從軟體中可靠地讀取,因為確定一個包是否被髮送到描述符的包快取中,你需要去讀取描述符中的 DD 狀態位。如果 DD 位被設置,你就可以從那個描述符的快取中複製出這個資料包,然後通過更新佇列的尾索引 RDT 來告訴網卡那個描述符是空閑的。
如果 DD 位沒有被設置,表明沒有接收到包。這就與發送佇列滿的情況一樣,這時你可以有幾種做法。你可以簡單地傳回一個 ”重傳“ 錯誤來要求對端重發一次。對於滿的發送佇列,由於那是個臨時狀況,這種做法還是很好的,但對於空的接收佇列來說就不太合理了,因為接收佇列可能會保持好長一段時間的空的狀態。第二個方法是掛起呼叫環境,直到在接收佇列中處理了這個包為止。這個策略非常類似於 sys_ipc_recv
。就像在 IPC 的案例中,因為我們每個 CPU 僅有一個內核棧,一旦我們離開內核,棧上的狀態就會被丟棄。我們需要設置一個標誌去表示那個環境由於接收佇列下溢被掛起並記錄系統呼叫引數。這種方法的缺點是過於複雜:E1000 必須被指示去產生接收中斷,並且驅動程式為了恢復被阻塞等待一個包的環境,必須處理這個中斷。
練習 11、寫一個函式從 E1000 中接收一個包,然後通過一個系統呼叫將它發佈到用戶空間。確保你將接收佇列處理成空的。
小挑戰!如果發送佇列是滿的或接收佇列是空的,環境和你的驅動程式可能會花費大量的 CPU 周期是輪詢、等待一個描述符。一旦完成發送或接收描述符,E1000 能夠產生一個中斷,以避免輪詢。修改你的驅動程式,處理髮送和接收佇列是以中斷而不是輪詢的方式進行。
註意,一旦確定為中斷,它將一直處於中斷狀態,直到你的驅動程式明確處理完中斷為止。在你的中斷服務程式中,一旦處理完成要確保清除掉中斷狀態。如果你不那樣做,從你的中斷服務程式中傳回後,CPU 將再次跳轉到你的中斷服務程式中。除了在 E1000 網卡上清除中斷外,也需要使用
lapic_eoi
在 LAPIC 上清除中斷。
接收包:網絡服務器
在網絡服務器輸入環境中,你需要去使用你的新的接收系統呼叫以接收資料包,並使用 NSREQ_INPUT
IPC 訊息將它傳遞到核心網絡服務器環境。這些 IPC 輸入訊息應該會有一個頁,這個頁上系結了一個 union Nsipc
,它的 struct jif_pkt pkt
欄位中有從網絡上接收到的包。
練習 12、實現
net/input.c
。
使用 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput
再次運行 testinput
,你應該會看到:
Sending ARP announcement...
Waiting for packets...
e1000: index 0: 0x26dea0 : 900002a 0
e1000: unicast match[0]: 52:54:00:12:34:56
input: 0000 5254 0012 3456 5255 0a00 0202 0806 0001
input: 0010 0800 0604 0002 5255 0a00 0202 0a00 0202
input: 0020 5254 0012 3456 0a00 020f 0000 0000 0000
input: 0030 0000 0000 0000 0000 0000 0000 0000 0000
“input:” 打頭的行是一個 QEMU 的 ARP 回覆的十六進制轉儲。
你的代碼應該會通過 make grade
的 testinput
測試。註意,在沒有發送至少一個包去通知 QEMU 中的 JOS 的 IP 地址上時,是沒法去測試包接收的,因此在你的發送代碼中的 bug 可能會導致測試失敗。
為徹底地測試你的網絡代碼,我們提供了一個稱為 echosrv
的守護程式,它在端口 7 上設置運行 echo
的服務器,它將回顯通過 TCP 連接發送給它的任何內容。使用 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-echosrv
在一個終端中啟動 echo
服務器,然後在另一個終端中通過 make nc-7
去連接它。你輸入的每一行都被這個服務器回顯出來。每次在仿真的 E1000 上接收到一個包,QEMU 將在控制臺上輸出像下麵這樣的內容:
e1000: unicast match[0]: 52:54:00:12:34:56
e1000: index 2: 0x26ea7c : 9000036 0
e1000: index 3: 0x26f06a : 9000039 0
e1000: unicast match[0]: 52:54:00:12:34:56
做到這一點後,你應該也就能通過 echosrv
的測試了。
問題 2、你如何構造你的接收實現?在實踐中,如果接收佇列是空的並且一個用戶環境要求下一個入站包,你怎麼辦?
小挑戰!在開發者手冊中閱讀關於 EEPROM 的內容,並寫出從 EEPROM 中加載 E1000 的 MAC 地址的代碼。目前,QEMU 的預設 MAC 地址是硬編碼到你的接收初始化代碼和 lwIP 中的。修複你的初始化代碼,讓它能夠從 EEPROM 中讀取 MAC 地址,和增加一個系統呼叫去傳遞 MAC 地址到 lwIP 中,並修改 lwIP 去從網卡上讀取 MAC 地址。通過配置 QEMU 使用一個不同的 MAC 地址去測試你的變更。
.
小挑戰!修改你的 E1000 驅動程式去使用
零複製技術。目前,資料包是從用戶空間快取中複製到發送包快取中,和從接收包快取中複製回到用戶空間快取中。一個使用 ”零複製“ 技術的驅動程式可以通過直接讓用戶空間和 E1000 共享包快取記憶體來實現。還有許多不同的方法去實現 ”零複製“,包括映射內容分配的結構到用戶空間或直接傳遞用戶提供的快取到 E1000。不論你選擇哪種方法,都要註意你如何利用快取的問題,因為你不能在用戶空間代碼和 E1000 之間產生爭用。
小挑戰!把 “零複製” 的概念用到 lwIP 中。
一個典型的包是由許多頭構成的。用戶發送的資料被髮送到 lwIP 中的一個快取中。TCP 層要添加一個 TCP 包頭,IP 層要添加一個 IP 包頭,而 MAC 層有一個以太網頭。甚至還有更多的部分增加到包上,這些部分要正確地連接到一起,以便於設備驅動程式能夠發送最終的包。
E1000 的發送描述符設計是非常適合收集分散在記憶體中的包片段的,像在 lwIP 中創建的包的幀。如果你排隊多個發送描述符,但僅設置最後一個描述符的 EOP 命令位,那麼 E1000 將在內部把這些描述符串成包快取,併在它們標記完 EOP 後僅發送串起來的快取。因此,獨立的包片段不需要在記憶體中把它們連接到一起。
修改你的驅動程式,以使它能夠發送由多個快取且無需複製的片段組成的包,並且修改 lwIP 去避免它合併包片段,因為它現在能夠正確處理了。
小挑戰!增加你的系統呼叫接口,以便於它能夠為多於一個的用戶環境提供服務。如果有多個網絡棧(和多個網絡服務器)並且它們各自都有自己的 IP 地址運行在用戶樣式中,這將是非常有用的。接收系統呼叫將決定它需要哪個環境來轉發每個入站的包。
註意,當前的接口並不知道兩個包之間有何不同,並且如果多個環境去呼叫包接收的系統呼叫,各個環境將得到一個入站包的子集,而那個子集可能並不包含呼叫環境指定的那個包。
在 這篇[6] 外內核論文的 2.2 節和 3 節中對這個問題做了深度解釋,並解釋了在內核中(如 JOS)處理它的一個方法。用這個論文中的方法去解決這個問題,你不需要一個像論文中那麼複雜的方案。
Web 服務器
一個最簡單的 web 服務器型別是發送一個檔案的內容到請求的客戶端。我們在 user/httpd.c
中提供了一個非常簡單的 web 服務器的框架代碼。這個框架內碼處理入站連接並解析請求頭。
練習 13、這個 web 服務器中缺失了發送一個檔案的內容到客戶端的處理代碼。通過實現
send_file
和send_data
完成這個 web 服務器。
在你完成了這個 web 服務器後,啟動這個 web 服務器(make run-httpd-nox
),使用你喜歡的瀏覽器去瀏覽 http://host:port/index.html
地址。其中 host 是運行 QEMU 的計算機的名字(如果你在 athena 上運行 QEMU,使用 hostname.mit.edu
(其中 hostname 是在 athena 上運行 hostname
命令的輸出,或者如果你在運行 QEMU 的機器上運行 web 瀏覽器的話,直接使用 localhost
),而 port 是 web 服務器運行 make which-ports
命令報告的端口號。你應該會看到一個由運行在 JOS 中的 HTTP 服務器提供的一個 web 頁面。
到目前為止,你的評級測試得分應該是 105 分(滿分為 105)。
小挑戰!在 JOS 中添加一個簡單的聊天服務器,多個人可以連接到這個服務器上,並且任何用戶輸入的內容都被髮送到其它用戶。為實現它,你需要找到一個一次與多個套接字通訊的方法,並且在同一時間能夠在同一個套接字上同時實現發送和接收。有多個方法可以達到這個目的。lwIP 為
recv
(查看net/lwip/api/sockets.c
中的lwip_recvfrom
)提供了一個 MSG_DONTWAIT 標誌,以便於你不斷地輪詢所有打開的套接字。註意,雖然網絡服務器的 IPC 支持recv
標誌,但是通過普通的read
函式並不能訪問它們,因此你需要一個方法來傳遞這個標誌。一個更高效的方法是為每個連接去啟動一個或多個環境,並且使用 IPC 去協調它們。而且碰巧的是,對於一個套接字,在結構 Fd 中找到的 lwIP 套接字 ID 是全域性的(不是每個環境私有的),因此,比如一個fork
的子環境繼承了它的父環境的套接字。或者,一個環境通過構建一個包含了正確套接字 ID 的 Fd 就能夠發送到另一個環境的套接字上。
問題 3、由 JOS 的 web 服務器提供的 web 頁面顯示了什麼?
問題 4、你做這個實驗大約花了多長的時間?
本實驗到此結束了。一如既往,不要忘了運行 make grade
並去寫下你的答案和挑戰問題的解決方案的描述。在你動手之前,使用 git status
和 git diff
去檢查你的變更,並不要忘了去 git add answers-lab6.txt
。當你完成之後,使用 git commit -am 'my solutions to lab 6’
去提交你的變更,然後 make handin
並關註它的動向。
via: https://pdos.csail.mit.edu/6.828/2018/labs/lab6/
作者:csail.mit[8] 選題:lujun9972 譯者:qhwdw 校對:wxy
本文由 LCTT 原創編譯,Linux中國 榮譽推出