Caffeinated 6.828:實驗 6:網路驅動程序
簡介
這個實驗是默認你能夠自己完成的最終項目。
現在你已經有了一個文件系統,一個典型的操作系統都應該有一個網路棧。在本實驗中,你將繼續為一個網卡去寫一個驅動程序。這個網卡基於 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 的文檔的這裡有更多關於用戶網路的內容。我們更新後的 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 能夠給我們提供一個人類可讀格式的報告、它的內部狀態以及它遇到的任何問題。通常情況下,對祼機上做驅動程序開發的人來說,這是非常難能可貴的。
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)
- 輸入環境
- 輸出環境
- 定時器環境
下圖展示了各個環境和它們之間的關係。下圖展示了包括設備驅動的整個系統,我們將在後面詳細講到它。在本實驗中,你將去實現圖中綠色高亮的部分。
核心網路伺服器環境
核心網路伺服器環境由套接字調用派發器和 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 的 軟體開發者手冊。這個手冊涵蓋了幾個與乙太網控制器緊密相關的東西。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 頭文件做為基準。我們不建議完全照抄它,因為它定義的值遠超過你所需要,並且定義的東西也不見得就是你所需要的,但它仍是一個很好的參考。
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 地址運行在用戶模式中,這將是非常有用的。接收系統調用將決定它需要哪個環境來轉發每個入站的包。
注意,當前的介面並不知道兩個包之間有何不同,並且如果多個環境去調用包接收的系統調用,各個環境將得到一個入站包的子集,而那個子集可能並不包含調用環境指定的那個包。
在 這篇 外內核論文的 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 選題:lujun9972 譯者:qhwdw 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive