這個實驗是預設你能夠自己完成的最終專案。
現在你已經有了一個檔案系統,一個典型的作業系統都應該有一個網路棧。在本實驗中,你將繼續為一個網絡卡去寫一個驅動程式。這個網絡卡基於 Intel 82540EM 晶片,也就是眾所周知的 E1000 晶片。
使用 Git 去提交你的實驗 5 的原始碼(如果還沒有提交的話),獲取課程倉庫的最新版本,然後建立一個名為 lab6
的本地分支,它跟蹤我們的遠端分支 origin/lab6
:
athena% cd ~/6.828/labathena% add gitathena% git commit -am 'my solution to lab5'nothing to commit (working directory clean)athena% git pullAlready up-to-date.athena% git checkout -b lab6 origin/lab6Branch lab6 set up to track remote branch refs/remotes/origin/lab6.Switched to a new branch "lab6"athena% git merge lab5Merge 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 的文件的這裡有更多關於使用者網路的內容。我們更新後的 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 以圖形化介面去檢查 pcap 檔案。Wireshark 也知道如何去解碼和檢查成百上千的網路協定。如果你在 Athena 上,你可以使用 Wireshark 的前輩:ethereal,它執行在加鎖的保密網際網路協定網路中。
我們非常幸運能夠去使用模擬硬體。由於 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 協定套件,它能做包括一個網路棧在內的很多事情。你能在 這裡 找到很多關於 lwIP 的資訊。在這個任務中,對我們而言,lwIP 就是一個實現了一個 BSD 通訊端介面和擁有一個包輸入埠和包輸出埠的黑盒子。
一個網路伺服器其實就是一個有以下四個環境的混合體:
下圖展示了各個環境和它們之間的關係。下圖展示了包括裝置驅動的整個系統,我們將在後面詳細講到它。在本實驗中,你將去實現圖中綠色高亮的部分。
核心網路伺服器環境由通訊端呼叫派發器和 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 使用來自執行緒的定時器訊息來實現各種網路超時。
你的核心還沒有一個時間概念,因此我們需要去新增它。這裡有一個由硬體產生的每 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 的 軟體開發者手冊。這個手冊涵蓋了幾個與乙太網控制器緊密相關的東西。QEMU 模擬了 82540EM。
現在,你應該去瀏覽第 2 章,以對裝置獲得一個整體概念。寫驅動程式時,你需要熟悉第 3 到 14 章,以及 4.1(不包括 4.1 的子節)。你也應該去參考第 13 章。其它章涵蓋了 E1000 的元件,你的驅動程式並不與這些元件去互動。現在你不用擔心過多細節的東西;只需要了解文件的整體結構,以便於你後面需要時容易查詢。
在閱讀手冊時,記住,E1000 是一個擁有很多高階特性的很複雜的裝置,一個能讓 E1000 工作的驅動程式僅需要它一小部分的特性和 NIC 提供的介面即可。仔細考慮一下,如何使用最簡單的方式去使用網絡卡的介面。我們強烈推薦你在使用高階特性之前,只去寫一個基本的、能夠讓網絡卡工作的驅動程式即可。
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
測試了。
軟體與 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 標頭檔案做為基準。我們不建議完全照抄它,因為它定義的值遠超過你所需要,並且定義的東西也不見得就是你所需要的,但它仍是一個很好的參考。
你可能會認為是從 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 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 0e1000: index 0: 0x271f00 : 9000009 0Transmitting packet 1e1000: 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、你是如何構造你的傳送實現的?在實踐中,如果傳送快取區滿了,你該如何處理?
就像你在傳送包中做的那樣,你將去設定 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 0e1000: unicast match[0]: 52:54:00:12:34:56input: 0000 5254 0012 3456 5255 0a00 0202 0806 0001input: 0010 0800 0604 0002 5255 0a00 0202 0a00 0202input: 0020 5254 0012 3456 0a00 020f 0000 0000 0000input: 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:56e1000: index 2: 0x26ea7c : 9000036 0e1000: index 3: 0x26f06a : 9000039 0e1000: 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 地址執行在使用者模式中,這將是非常有用的。接收系統呼叫將決定它需要哪個環境來轉發每個入站的包。
注意,當前的介面並不知道兩個包之間有何不同,並且如果多個環境去呼叫包接收的系統呼叫,各個環境將得到一個入站包的子集,而那個子集可能並不包含呼叫環境指定的那個包。
在 這篇 外核心論文的 2.2 節和 3 節中對這個問題做了深度解釋,並解釋了在核心中(如 JOS)處理它的一個方法。用這個論文中的方法去解決這個問題,你不需要一個像論文中那麼複雜的方案。
一個最簡單的 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
並關注它的動向。