【DPDK】談談DPDK如何實現bypass核心的原理 其二 DPDK部分的實現
【前言】
關於DPDK如果實現bypass核心的原理,在上一篇《【DPDK】談談DPDK如何實現bypass核心的原理 其一 PCI裝置與UIO驅動》中已經描述了在DPDK啟動前做的準備工作,那麼本篇文章將著重分析DPDK部分的職責,也就是從軟體的的角度來分析在第一篇文章的基礎上,如何做到真正的操作裝置。
注意:
- 本篇文章將會更著重分析軟體部分的實現,也就是分析程式碼實現;
- 同樣,本篇會跨過中斷部分與vfio部分,中斷部分與vfio會在以後另開文章繼續分析;
- 人能力以及水平有限,沒辦法保證沒有疏漏,如有疏漏還請各路神仙進行指正,本篇內容都是本人個人理解,也就是原創內容。
- 另外在分析程式碼的過程中,為了防止一些無掛緊要的邏輯顯得程式碼又臭又長,會對其中不重要或者與主要邏輯不相關的程式碼進行省略,包括且不限於,變數宣告、部分不重要資料的初始化、異常處理、無關主要邏輯的模組函式呼叫等。
【1.DPDK的初始化】
再次回顧第一篇文章中的三個Questions:
Q:igb_uio/vfio-pci的作用是什麼?為什麼要用這兩個驅動?這裡的“驅動”和dpdk內部對網絡卡的“驅動”(dpdk/driver/)有什麼區別呢?
Q:dpdk-devbinds是如何做到的將核心驅動解綁後繫結新的驅動呢?
Q:dpdk應用內部是如何操作pci裝置的呢?是怎麼讓pci裝置可以將資料包直接扔到使用者態的呢?
其中第一個和第二個Questions便是DPDK應用啟動前的前奏,其原理在第一篇文章已經闡述完畢,現在回到第三個Questions,DPDK應用內部是如何操作pci裝置的。
回想DPDK應用的啟動過程,以最標準的l3fwd應用啟動為例,其啟動的引數格式如下:
l3fwd [eal params] -- [config params]
引數分為兩部分,第一部分為所有DPDK應用基本都要輸入的引數,也就是eal引數,關於eal引數的解釋可以看DPDK官方的doc:
https://doc.dpdk.org/guides/linux_gsg/linux_eal_parameters.html
其中,eal引數的作用主要是DPDK初始化時使用,閱讀過DPDK example的原始碼或在DPDK的基礎上開發的應用,對一個函式應該頗為熟悉:
int rte_eal_init(intargc, char **argv)
其中eal引數便是給rte_eal_init進行初始化,指示DPDK應用“該怎麼初始化”。
【2.準備工作】
在進行PCI的資源掃描之前有一些準備工作,這部分的工作不是在main函式中完成的,也更不是在rte_eal_init這個DPDK初始化函式中完成的,來到DPDK原始碼中的drivers/bus/pci/pci_common.c檔案中,在這個.c檔案中的最後部分我們可以看到如下的程式碼:
struct rte_pci_bus rte_pci_bus = { .bus = { .scan = rte_pci_scan, .probe = rte_pci_probe, .find_device = pci_find_device, .plug = pci_plug, .unplug = pci_unplug, .parse = pci_parse, .dma_map = pci_dma_map, .dma_unmap = pci_dma_unmap, .get_iommu_class = rte_pci_get_iommu_class, .dev_iterate = rte_pci_dev_iterate, .hot_unplug_handler = pci_hot_unplug_handler, .sigbus_handler = pci_sigbus_handler, }, .device_list = TAILQ_HEAD_INITIALIZER(rte_pci_bus.device_list), .driver_list = TAILQ_HEAD_INITIALIZER(rte_pci_bus.driver_list), };
RTE_REGISTER_BUS(pci, rte_pci_bus.bus);
程式碼1.
如果看過核心程式碼,那麼對這種“操作”應該會比較親切,程式碼1中的操作是一種利用C語言實現類似於面嚮物件語言泛型的一種常見方式,例如C++。其中資料結構struct rte_pci_bus 可以看作一類匯流排的抽象,那麼這個程式碼1中描述的便是PCI這種匯流排的例項。但是同樣要注意一點,程式碼1中的struct rte_pci_bus rte_pci_bus這個變數的型別和變數名字長得他孃的一模一樣....接下來可以看一下RTE_REGISTER_BUS這個奇怪的巨集:
#define RTE_REGISTER_BUS(nm, bus) \ RTE_INIT_PRIO(businitfn_ ##nm, BUS) \ {\ (bus).name = RTE_STR(nm);\ rte_bus_register(&bus); \ } void rte_bus_register(struct rte_bus *bus) { RTE_VERIFY(bus); RTE_VERIFY(bus->name && strlen(bus->name)); /* A bus should mandatorily have the scan implemented */ RTE_VERIFY(bus->scan); RTE_VERIFY(bus->probe); RTE_VERIFY(bus->find_device); /* Buses supporting driver plug also require unplug. */ RTE_VERIFY(!bus->plug || bus->unplug); //將rte_bus結構插入至rte_bus_list連結串列中 TAILQ_INSERT_TAIL(&rte_bus_list, bus, next); RTE_LOG(DEBUG, EAL, "Registered [%s] bus.\n", bus->name); }
程式碼2.
可以看到RTE_REGISTER_BUS其實是一個巨集函式,內部實現是rte_bus_register,而rte_bus_register內部做了兩件事:
- 校驗rte_bus結構中的方法以及屬性,也就是引數的前置檢查;
- 將rte_bus結構,也就是入參插入到rte_bus_list這個連結串列中;
那麼這裡我們可以初步得出一個結論:
- 呼叫RTE_REGISTER_BUS這個巨集進行註冊的匯流排(rte_bus)會被一個連結串列串起來做集中管理,以後想對某個bus呼叫對應的方法,只需要遍歷這個連結串列然後找到想要操作的bus,再呼叫方法即可。那它的虛擬碼我們至少可以腦補出如程式碼3中描述的一樣:
foreach list_node in list: if list_node is we want: list_node->method()
程式碼3.
但是RTE_REGISTER_BUS這個巨集的出現至少帶給我們如下幾個問題:
- 這個巨集裡面實際上是一個函式,那這個函式是在哪呼叫的?
- 啥時候遍歷這個連結串列然後執行rte_bus的方法(method)呢?
接下來便重點看這兩個問題,先看第一個問題,這個函式是在哪呼叫的,通常我們看一個函式在哪呼叫的最常見的方法便是搜尋整個專案,或用一些IDE自帶的分析關聯功能去找在哪個位置呼叫的這個巨集,或這個函式,但是在RTE_REGISTER_BUS這個巨集面前,沒有任何一個地方呼叫這個巨集。
還記得一個經典的問題麼?
一個程式的啟動過程中,main函式是最先執行的麼?
在這裡便可以順便解答這個問題,再重新看程式碼2中的RTE_REGISTER_BUS這個巨集,裡面還夾雜著一個令人注意的巨集,RTE_INIT_PRO,接下來為了便於分析,我們將巨集裡面的內容全部展開,見程式碼4.
/******展開前******/ /* 位於lib/librte_eal/common/include/rte_common.h */ #define RTE_PRIO(prio) \ RTE_PRIORITY_ ## prio #ifndef RTE_INIT_PRIO #define RTE_INIT_PRIO(func, prio) static void __attribute__((constructor(RTE_PRIO(prio)), used)) func(void) #endif #define _RTE_STR(x) #x #define RTE_STR(x) _RTE_STR(x) /* 位於lib/librte_eal/common/include/rte_bus.h */ #define RTE_REGISTER_BUS(nm, bus) \ RTE_INIT_PRIO(businitfn_ ##nm, BUS) \ {\ (bus).name = RTE_STR(nm);\ rte_bus_register(&bus); \ } /******展開後******/ /* 這裡以RTE_REGISTER_BUS(pci, rte_pci_bus.bus)為例 */ #define RTE_REGISTER_BUS(nm, bus) \ static void __attribute__((constructor(RTE_PRIORITY_BUS), used)) businitfn_pci(void) { rte_pci_bus.bus.name = "pci" rte_bus_register(&rte_pci_bus.bus); }
程式碼4.
另外注意的一點是,這裡如果想順利展開,必須得知道在C語言中的巨集中,出現“#”意味著什麼:
- #:一個井號,代表著後續連著的字元轉換成字串,例如#BUS,那麼在預編譯完成後就會變成“BUS”
- ##:兩個井號,代表著連線,這個地方通常可以用來實現C++中的模板功能,例如MY_##NAME,那麼在預編譯完成後就會變成MY_NAME
再次回到程式碼4中的程式碼,其中最令人值得注意的細節便是“__attribute__((constructor(RTE_PRIORITY_BUS), used))”,這個地方實際上使用GCC的屬性將這個函式進行宣告,我們可以查閱GCC的doc來看一下constructor這個屬性是什麼作用,以gcc 4.85為例,見圖1:
圖1.GCC文件中關於constructor屬性的描述
其實GCC文件中已經說的很明白了,constructor會在main函式呼叫前而被呼叫,並且如果程式中如果出現了多個用GCC的constructor屬性宣告的函式,可以利用優先順序對其進行排序,當然在這裡,優先順序數值越大的constructor優先順序越小,執行的順序越靠後。
- P.S. RTE_REGISTER_BUS展開時,另一個”used“的函式宣告比較常見,就是告訴編譯器,這個函式有用,別給老子報警(通常我們編譯時在gcc的CFLAGS中加上-Wall -Werror的引數時,一個你沒有使用的函式,gcc在編譯的時候會直接爆出一個error,”xxx define but not used“,這個used就是用來對付這種警告/錯誤的,一般在內聯彙編函式上用的比較多)
那到了這裡,第一個問題的答案已經逐漸明瞭
- 這個巨集裡面實際上是一個函式,那這個函式是在哪呼叫的?
- 答:RTE_REGISTER_BUS內部的函式被gcc用constructor屬性進行了宣告,因此會在main函式被呼叫之前而執行,也就是在main函式被呼叫之前,rte_bus就已經加入到全域性的”bus“連結串列中了。
接下來再看第二個問題,”啥時候遍歷這個連結串列然後執行rte_bus的方法(method)呢?“,答案在dpdk的初始化函式rte_eal_init中。
【2.資源的掃描】
在準備工作完成後,我們現在有了一個全域性連結串列,這個連結串列中儲存著一個個匯流排的例項,也就是”struct rte_bus“結果,那麼此時這個全域性連結串列可以看作一個管理結構,想要完成對應的任務,只需要遍歷這個連結串列就可以了。
來到DPDK的初始化函式rte_eal_init函式,這個函式呼叫非常複雜, 而且涉及的模組眾多,根本沒有辦法進行一次性全面的分析,但是好處是我們只需要找到我們關注的地方即可,見程式碼5:
int rte_eal_init(int argc, char **argv) { ......;//初始化的模組過多,並且無關,直接忽略 if (rte_bus_scan()) { rte_eal_init_alert("Cannot scan the buses for devices"); rte_errno = ENODEV; rte_atomic32_clear(&run_once); return -1; } ......;//初始化的模組過多,並且無關,直接忽略 } /* 掃描事先註冊好的全域性匯流排連結串列,呼叫scan方法進行掃描 */ int rte_bus_scan(void) { int ret; struct rte_bus *bus = NULL; //遍歷匯流排連結串列 TAILQ_FOREACH(bus, &rte_bus_list, next) { //呼叫某一匯流排的scan函式鉤子 ret = bus->scan(); if (ret) RTE_LOG(ERR, EAL, "Scan for (%s) bus failed.\n", bus->name); } return 0; }
程式碼5.
在程式碼5中的程式碼中,rte_eal_init函式呼叫了rte_bus_scan函式,而rte_bus_scan函式是一段非常簡單的程式碼,功能就是是對匯流排進行掃描,然後呼叫事先註冊好的某一匯流排例項的scan函式鉤子,那麼回到程式碼1.中,我們來看一下PCI匯流排的scan函式是什麼,答案便是rte_pci_scan函式,那麼接下來的任務便是進入rte_pci_scan函式,看了下PCI這種匯流排的掃描函式做了哪些事情。
/* * PCI匯流排的掃描函式 */ int rte_pci_scan(void) { ......//變數宣告,省略 //1.開啟/sys/bus/pci/devices/目錄 dir = opendir(rte_pci_get_sysfs_path()); ......//異常處理,省略 //2.接下來的內容便是掃描devices目錄下所有的PCI地址目錄 while ((e = readdir(dir)) != NULL) { if (e->d_name[0] == '.') continue; ......//格式化字串,省略 //3.掃描某個PCI地址目錄 if (pci_scan_one(dirname, &addr) < 0) goto error; } ......//異常處理,資源釋放,省略 }
程式碼6.
其中程式碼6的邏輯非常簡單,就是進入/sys/bus/pci/devices目錄掃描目錄下所有的PCI裝置,然後再進入PCI裝置的目錄下掃描PCI裝置的資源,如圖2所示。
圖2.rte_pci_scan的原理
進入pci_scan_one函式後,便開始對這個PCI裝置目錄中的每一個檔案進行讀取,拿到對應的資訊,在第一篇文章中也提到過,核心會將PCI裝置的資訊通過檔案系統這種特殊的介面暴露給使用者態,供使用者態程式讀取,那麼pci_scan_one的邏輯便如圖3所示。
圖3.pci_scan_one的函式執行邏輯
可以看到圖3中pci_scan_one函式的執行邏輯,其實同樣非常簡單,就是將PCI裝置目錄下的sysfs進行讀取、解析。這11步中值得注意的有3步,分別是第9、第10以及第11步,接下來將重點觀察這3步的內容,先從第9步說起。
其實第9步呼叫pci_parse_sysfs_resource函式執行的內容就是去解析/sys/bus/pci/devices/0000:81:00.0/resource這個檔案,之前在第一篇文章中也提到過,這個resource檔案中包含著PCI BAR的資訊,其中有分為三列,第一列為PCI BAR的起始地址,第二列為PCI BAR的終止地址,第三列為PCI BAR的標識,那麼這個函式便是用於解析resource檔案,拿到對應的PCI BAR資訊,見程式碼7.
/* * 解析[pci_addr]/resource檔案 * @param filename resource檔案所在的目錄,例如/sys/bus/pci/devices/0000:81:00.0/resource * @param dev PCI裝置的例項 */ static int pci_parse_sysfs_resource(const char *filename, struct rte_pci_device *dev) { ......//變數宣告,省略 //1.open resource檔案 f = fopen(filename, "r"); ......//異常處理,省略 //2.遍歷6個PCI BAR,關於PCI BAR的數量與作用在上一篇文章中已經闡述 for (i = 0; i<PCI_MAX_RESOURCE; i++) { ......//異常處理,省略 //3.解析某一行PCI BAR的字串,拿到PCI BAR的起始地址、結束地址以及標識 if (pci_parse_one_sysfs_resource(buf, sizeof(buf), &phys_addr, &end_addr, &flags) < 0) goto error; //4.只要Memory BAR,把資訊拿到,存至資料結構中,至於為什麼只需要Memory BAR,在上一篇文章中已經闡述完畢 if (flags & IORESOURCE_MEM) { dev->mem_resource[i].phys_addr = phys_addr; dev->mem_resource[i].len = end_addr - phys_addr + 1; dev->mem_resource[i].addr = NULL; } } ......//異常處理,資源釋放,省略 }
程式碼7.
可以看到,pci_parse_sysfs_resource函式內部的執行邏輯同樣非常簡單,就是解析resource檔案,把Memory型別的PCI BAR資訊提去並拿出來(這裡注意,關於為什麼只拿Memory型別的PCI BAR在上一篇文章中已經闡述),那麼圖3中的步驟9的作用便分析完畢,接下來看圖3中的步驟10.
圖3中的步驟10主要是拿到當前PCI裝置用的驅動型別,但是是怎麼拿到的呢?答案也很簡單,看軟連線的連結資訊就可以得知,見圖4.所以說這個pci_get_kernel_driver_by_path的函式名命名可以說是非常到位了。
圖4.pci_get_kernel_driver_by_path的實現原理
那麼至此,圖3中的步驟10的原理也闡述完畢,接著看步驟11,步驟11主要涉及資料結構的關係,其中rte_pci_bus這個結構體象徵著PCI匯流排,而同樣已知的是,一條總線上會掛在一些數量的匯流排裝置,舉個例子,PCI總線上會有一些PCI裝置,那麼這些PCI裝置的抽象型別便是rte_pci_devices型別,那麼這也同樣是一個包含的關係,即rte_pci_bus這個結構從概念上是包含rte_pci_devices這個型別的,所以在rte_pci_bus這個結構上有一個devices_list連結串列用來集中管理總線上的裝置,資料結構關係可以如圖5所示。
圖5.rte_pci_bus與rte_pci_device資料結構關係圖
可以看到在圖5中,rte_pci_device這個結構被串在了rte_pci_bus結構中的devices_list這個連結串列中,同樣需要值得注意的是rte_pci_device這個結構體物件,其中很多的成員屬性在這裡先說一下
- TAILQ_ENTRY(rte_pci_device) next:這個物件就是一個連結串列結構,用來將前後的rte_pci_device串起來,方便管理,沒什麼實際意義;
- struct rte_device device:struct rte_device這個結構體象徵著裝置的一些通用資訊,舉個例子,不管是什麼型別裝置,PCI裝置還是啥SDIO裝置,他們都具有“名字”、“驅動”這些共性的特徵,那麼關於這些共性特徵描述便抽象成struct rte_device這個結構體型別;
- struct rte_pci_addr addr:struct rte_pci_addr這個結構體象徵著一個PCI地址,舉個例子,0000:81:00.1,這便是一個PCI裝置的地址,並且實際上這個地址是由四部分組成的,第一個部分叫做"domain",也就是第一個冒號之前的4個數字0000,第二個部分叫做“bus”,也就是第一個冒號和第二個冒號之間的2個數字81,第三個部分叫做“device_id”,在第二個冒號和最後一個句號之間的2個數字00,最後一個部分也就是第四個部分叫做"function",也就是最後一個句號之後的1個數字1。但是關於PCI地址為啥這麼分,本人也不知道...;
- struct rte_pci_id id:struct rte_pci_id這個結構體象徵著PCI驅動的一些ID號,包括之前反覆提過的class id、vendor id、device id、subsystem vendor id以及subsystem device id;
- struct rte_mem_resource mem_resource[6]:這個是重點,mem_resource,類似於核心中的resource結構體,裡面存著解析完resource檔案後的PCI BAR資訊,也就是圖3中的步驟9將resource檔案中的資訊提去後存志這個mem_resource物件中;
- struct rte_intr_handle intr_handle:中斷控制代碼,本篇文章不包含中斷相關內容,關於中斷的原理解析會放到以後的文章中介紹;
- struct rte_pci_driver driver:這個也是重點,描述這個PCI裝置用的是何種驅動,但是這裡需要注意的是,這裡的驅動可不是指的核心中的那些驅動,也不是指的igb_uio/vfio-pci,而是指的DPDK的使用者態PMD驅動;
- max_vfs:這個主要是與sriov相關,指的是這個PCI裝置最大能虛擬出幾個VF,sriov是網路虛擬化領域中常用的一種技術;
- kdrv:核心驅動,但是也要注意,只要不是igb_uio/vfio-pci驅動,其他的驅動一律變成UNKNOWN,比如現在的網絡卡是一個核心的ixgbe驅動,DPDK應用不關心,它只關心是不是igb_uio/vfio-pci驅動,所以一律賦值為UNKNOWN;
- vfio_req_intr_handle:這個同樣是重點,vfio驅動的中斷控制代碼,但是本篇文章不涉及中斷,也不涉及vfio,關於這兩個地方以後會專門開文章來介紹。
至此,DPDK啟動過程中,PCI資源的掃描任務就此完成,在這一階段完成後,可以得到一個非常重要的結論:
- 掃描的PCI裝置資源、屬性資訊全部被存到了圖5中的rte_pci_bus.device_list這個連結串列中
那麼根據這個結論,也可以推匯出接下來要做什麼事情,那便是去遍歷這個device_list,對每一個PCI裝置做接管、初始化工作。
【3.PCI裝置載入PMD驅動】
接下來便是核心的地方,根據第二章的描述,現在已經將每一個PCI裝置掃描完成,拿到了關鍵的資訊,接下來便是怎麼根據這些資訊來完成PMD驅動的載入。再次回到rte_eal_init這個DPDK初始化的關鍵函式。
1 int rte_eal_init(int argc, char **argv) 2 { 3 ......//其他模組初始化,省略 4 //1.掃描匯流排,第二章已經分析完畢 5 if (rte_bus_scan()) { 6 ......//異常處理,省略 7 } 8 ......//其他模組初始化,省略 9 //2.匯流排探測 10 if (rte_bus_probe()) { 11 ......//異常處理省略 12 } 13 .......//其他處理,省略 14 }
程式碼8.
第二個關鍵函式便是rte_bus_probe函式,這個函式就是負責將匯流排資料結構上的裝置進行驅動的載入,進入rte_bus_scan的函式邏輯。
//匯流排掃描函式 int rte_bus_probe(void) { int ret; struct rte_bus *bus, *vbus = NULL; //1.遍歷rte_bus_list連結串列,拿到事先註冊的所有rte_pci_bus資料結構 TAILQ_FOREACH(bus, &rte_bus_list, next) { if (!strcmp(bus->name, "vdev")) { vbus = bus; continue; } //2.呼叫匯流排資料結構的probe鉤子函式,對於pci裝置來說,那麼就是rte_pci_probe函式 ret = bus->probe(); if (ret) RTE_LOG(ERR, EAL, "Bus (%s) probe failed.\n", bus->name); } ......//省略 return 0; }
程式碼9.
可以看到rte_bus_probe函式的實現邏輯同樣非常簡單,見程式碼1中的rte_pci_bus物件的註冊,可以看到probe這個函式鉤子就是rte_pci_bus這個結構中的rte_pci_probe函式,那麼接下來便可以著重分析PCI匯流排的probe函式,也就是rte_pci_probe函式。
//PCI匯流排的探測函式 int rte_pci_probe(void) { ......//初始化,變數宣告,省略 //1.遍歷rte_pci_bus的device_list連結串列,拿到每一個PCI裝置物件 FOREACH_DEVICE_ON_PCIBUS(dev) { probed++; devargs = dev->device.devargs; //對PCI裝置物件呼叫pci_probe_all_drivers函式,這裡的決策是要麼探測所有,要麼根據白名單進行選擇性探測,在DPDK初始化時可以指定白名單引數,對指定的PCI裝置進行探測 if (probe_all) ret = pci_probe_all_drivers(dev); else if (devargs != NULL && devargs->policy == RTE_DEV_WHITELISTED) ret = pci_probe_all_drivers(dev); ......//異常處理,省略 } return (probed && probed == failed) ? -1 : 0; } //用PMD驅動對pci裝置進行掛載 static int pci_probe_all_drivers(struct rte_pci_device *dev) { ......//異常處理、變數宣告,省略 //1.遍歷事先註冊好的驅動連結串列,注意這裡的PMD驅動的註冊原理與匯流排的註冊邏輯類似,可以自行分析 FOREACH_DRIVER_ON_PCIBUS(dr) { //2.拿驅動去探測裝置,這裡的邏輯是事先註冊的驅動挨個探測一遍,匹配和過濾的規則在函式內部裡實現 rc = rte_pci_probe_one_driver(dr, dev); ......//異常處理,省略 return 0; } return 1; }
程式碼10.
接著再進入rte_pci_probe_one_driver,看PCI裝置如何關聯上對應的PMD驅動,再如何載入驅動的,程式碼分析見程式碼11.
static int rte_pci_probe_one_driver(struct rte_pci_driver *dr, struct rte_pci_device *dev) { ......//引數檢查,變數初始化,省略 //1.對PCI裝置和驅動進行匹配,道理也很簡單,一個I350的卡不可能給他上i40e的驅動 if (!rte_pci_match(dr, dev)) /* Match of device and driver failed */ return 1; //2.看這個裝置是否是在黑名單引數裡,如果在,那就跳過,類似於白名單,在DPDK初始化時可以指定黑名單 if (dev->device.devargs != NULL && dev->device.devargs->policy == RTE_DEV_BLACKLISTED) { return 1; } //3.檢查numa節點的有效性 if (dev->device.numa_node < 0) { ......//異常處理,省略 } //4.檢查裝置是否已經載入過驅動,都載入了那還載入個屁,接著跳過 already_probed = rte_dev_is_probed(&dev->device); if (already_probed && !(dr->drv_flags & RTE_PCI_DRV_PROBE_AGAIN)) { return -EEXIST; } //5.邏輯到了這裡,那麼裝置是已經確認了沒有載入驅動,並且已經和驅動配對成功,那麼進行指標賦值 if (!already_probed) dev->driver = dr; //6.驅動是否需要PCI BAR資源對映,對於大多數驅動,ixgbe、igb、i40e等驅動,都是需要進行重新對映的,不對映拿不到PCI BAR if (!already_probed && (dr->drv_flags & RTE_PCI_DRV_NEED_MAPPING)) { //7.呼叫rte_pci_map_device對裝置進行PCI BAR資源對映 ret = rte_pci_map_device(dev); ......//異常處理,省略 } //8.呼叫驅動的probe函式進行驅動的載入 ret = dr->probe(dr, dev); ......//異常處理,省略 return ret; }
程式碼11.
程式碼11分析了rte_pci_probe_one_driver函式的執行邏輯,到這裡,我們重新梳理一下從rte_eal_init函式到rte_pci_probe_one_driver的函式呼叫流程以及邏輯流程,見圖6與圖7.
圖6.rte_eal_init中PCI裝置的掃描到載入函式呼叫過程
在進入PMD驅動具體的載入函式前,先說一下圖6中的粉色框標識的函式rte_pci_device_map,這個函式執行了重要的PCI BAR對映邏輯,因此這個函式屬於一個重要的關鍵函式,所以先分析一下rte_pci_device_map這個函式的實現,見程式碼12.
//對PCI裝置進行對映,這裡實際說的比較籠統,起始是對PCI裝置的PCI BAR資源進行對映到使用者空間,讓應用程式可以訪問、操作以及配置PCI BAR int rte_pci_map_device(struct rte_pci_device *dev) { switch (dev->kdrv) { case RTE_KDRV_VFIO: #ifdef VFIO_PRESENT //如果是VFIO驅動接管,則進入pci_vfio_map_resource,也就是進入vfio的邏輯來對映資源 if (pci_vfio_is_enabled()) ret = pci_vfio_map_resource(dev); #endif break; case RTE_KDRV_IGB_UIO: case RTE_KDRV_UIO_GENERIC: //如果是uio驅動,那麼就進入pci_uio_map_resource,也就是進入uio的邏輯來對映資源 if (rte_eal_using_phys_addrs()) { ret = pci_uio_map_resource(dev); } break; } ......//異常處理,省略 } //uio驅動框架下的對映PCI裝置資源 int pci_uio_map_resource(struct rte_pci_device *dev) { ......//引數檢查、變數初始化,省略 //1.申請uio資源 ret = pci_uio_alloc_resource(dev, &uio_res); //2.對6個PCI BAR進行對映 for (i = 0; i != PCI_MAX_RESOURCE; i++) { //跳過無效BAR phaddr = dev->mem_resource[i].phys_addr; if (phaddr == 0) continue; //其實對於intel的網絡卡,只有BAR0 & BAR1能進行對映,其中在64bit的工作模式下,BAR 0和BAR 1被歸為同一個PCI BAR,這裡的原理可以看上一篇文章 //3.呼叫pci_uio_map_resource_by_index函式對具體的一塊PCI BAR進行對映 ret = pci_uio_map_resource_by_index(dev, i, uio_res, map_idx); map_idx++; } uio_res->nb_maps = map_idx; TAILQ_INSERT_TAIL(uio_res_list, uio_res, next); ......//異常處理,省略 } //PCI裝置對某個BAR進行對映 int pci_uio_map_resource_by_index(struct rte_pci_device *dev, int res_idx, struct mapped_pci_resource *uio_res, int map_idx) { ......//變數初始化、異常處理,省略 if (!wc_activate || fd < 0) { ......//字串處理,拿到resource0..N的檔案路徑,舉個例子/sys/bus/pci/devices/0000:81:00.0/resource0 //1.對resource0..N開始open fd = open(devname, O_RDWR); ......//異常處理,省略 } //2.對這個resource0..N進行對映 mapaddr = pci_map_resource(pci_map_addr, fd, 0, (size_t)dev->mem_resource[res_idx].len, 0); ......//異常處理,省略 //3.對對映完成的空間進行長度累加,從這裡可以看出,如果要對映多個PCI BAR,dpdk會讓這些對映後的虛擬空間是連續的 pci_map_addr = RTE_PTR_ADD(mapaddr, (size_t)dev->mem_resource[res_idx].len); //4.賦值,其中最重要的就是這個mapaddr,這個指標內部的地址,就是PCI BAR對映到使用者空間的虛擬地址,最終這個地址會被儲存在mem_resource結構中的addr至真中 maps[map_idx].phaddr = dev->mem_resource[res_idx].phys_addr; maps[map_idx].size = dev->mem_resource[res_idx].len; maps[map_idx].addr = mapaddr; maps[map_idx].offset = 0; strcpy(maps[map_idx].path, devname); dev->mem_resource[res_idx].addr = mapaddr; ......//異常處理,省略 } /* * 對resource0..N資源進行對映 * @param requested_addr 請求的地址,告訴從哪個虛擬地址開始對映,主要是為了讓多個PCI BAR的情況下,對映後的虛擬地址是連續的,這樣方便管理 * @param fd resource0..N檔案的檔案描述符 * @param offset 偏移,注意,對映PCI裝置的資原始檔resource0..N,這裡的偏移必須是0,關於為什麼是0,Linux Kernel Doc有規定,可以見上一篇文章 * @param size 對映的空間大小,這個可以通過PCI BAR的結束地址 - PCI BAR的起始地址 + 1計算出來 * @param additional_flags 控制標識,為0 * @return 成功返回對映後的虛擬地址,失敗返回NULL */ void * pci_map_resource(void *requested_addr, int fd, off_t offset, size_t size, int additional_flags) { void *mapaddr; //1.對映PCI resource0..N檔案 mapaddr = mmap(requested_addr, size, PROT_READ | PROT_WRITE, MAP_SHARED | additional_flags, fd, offset); ......//異常處理,省略 return mapaddr; }
程式碼12.
程式碼12由於涉及到4個函式,並且關係是強相關的,拆解後不利於分析,因此插入到一塊程式碼區域中,顯得有些長,但是非常重要。其中程式碼12的函式呼叫流程如圖7所示。
圖7.PCI BAR資源對映的函式呼叫關係
其中PCI BAR的資源對映函式rte_pci_map_device至少告訴我們這麼幾個資訊:
- DPDK拿到PCI BAR不是通過UIO驅動拿到的,而是直接通過Kernel對使用者空間的介面,也就是通過sysfs拿到的,具體就是對映resource0..N檔案。這裡在上一篇文章中已經介紹過;
- 對映後的虛擬地址已經存至了rte_pci_device->mem_resouce[PCI_BAR_INDEX].add指標中。
那麼至此,rte_bus_probe這個匯流排掛載函式的內部執行流程已經分析完畢,同樣也拿到了關鍵的資源,也就是PCI BAR對映到使用者空間後的地址,通過這個地址,便拿到了暫存器的基地址,接下來對PCI裝置的配置以及操作只需要將這個基地址 + 暫存器地址偏移,即可拿到暫存器地址,便可以對其進行讀寫。在進一步分析之前,我會先給出rte_bus_scan函式執行的邏輯圖,請注意的是,函式執行的邏輯圖會從巨集觀上闡述執行的邏輯,所以會忽略函式呼叫的維度,關於函式呼叫關係的維度,見圖6以及圖7即可。接下來rte_bus_probe函式的執行邏輯圖請見圖8.
圖8.rte_bus_probe函式的執行邏輯
說完了rte_bus_probe的函式執行邏輯,再來完善一下圖5的資料結構關係,完善後見圖8。
圖8.圖5資料結構的完善
但是到了這裡還沒有結束,接下來便進入PMD驅動的載入函式。
【4.PMD驅動的載入】
第四章將著重以ixgbe驅動為例,講解PMD驅動是如何載入的,先進入ixgbe的probe函式,也就是圖8中的eth_ixgb_pci_probe函式。
tatic int eth_ixgbe_pci_probe(struct rte_pci_driver *pci_drv __rte_unused, struct rte_pci_device *pci_dev) { ......//初始化以及其他處理,省略 retval = rte_eth_dev_create(&pci_dev->device, pci_dev->device.name, sizeof(struct ixgbe_adapter), eth_dev_pci_specific_init, pci_dev, eth_ixgbe_dev_init, NULL); ......//其他處理,省略 return 0; }
程式碼13.
可以看到eth_ixgbe_pci_probe的主要處理還是非常簡單的,就是呼叫rte_eth_dev_create去建立PMD驅動,那麼接著進入rte_eth_dev_create函式進行分析,見程式碼14.這個函式較為重要,會重點分析
/* * 建立PMD驅動 * @param device[in] rte_pci_device->rte_device,在圖8中已經說明為裝置的通用資訊結構
* @param name[in] 裝置名
* @param priv_data_size 私有資料的大小,這個私有資料很重要,可以理解指的就是PMD驅動,因為每個網絡卡的資訊都可能不一樣,所以將這些私有資料打成一個void *來實現泛型
* @param ethdev_bus_specific_init 一個函式指標,為eth_dev_bus_specific_init函式,這個函式有BUG,在multiprocess模型下,此BUG已被本人解決並提交了patch,目前已經被intel社群採納,在20.02版本以後修復,BUG可以看這篇文章https://www.cnblogs.com/jungle1996/p/12191070.html * @param bus_init_params 就是rte_pci_device結構,這個結構在圖8中已經說明為PCI裝置的描述結構
* @param ethdev_init 函式指標,為PMD驅動初始化函式,在ixgbe這個驅動下為eth_ixgbe_dev_init
* @param init_param PMD驅動初始化的引數,一般為NULL
*/ int __rte_experimental rte_eth_dev_create(struct rte_device *device, const char *name, size_t priv_data_size, ethdev_bus_specific_init ethdev_bus_specific_init, void *bus_init_params, ethdev_init_t ethdev_init, void *init_params) { ......//變數宣告,引數檢查,省略 //1.拿到ete_eth_dev結構,對於不同的型別的程序拿到方法不一樣,至於為啥這樣,是因為這個結構中的一些屬性來自於共享記憶體,
//因此對於secondary程序需要attach到primary程序中的共享記憶體中,拿到這些共享記憶體資料。 if (rte_eal_process_type() == RTE_PROC_PRIMARY) {
//2.申請記憶體,得到rte_eth_dev結構,注意這個結構並不是來自於共享記憶體,而是這個結構中的一些屬性來自於共享記憶體,這個結構只是一個local變數
//但是請注意,在這個函式的內部實現中,已經拿到了共享記憶體地址,並賦值至rte_eth_dev->data這個指標 ethdev = rte_eth_dev_allocate(name);
//3.如果指定了有私有資料,那就申請這個私有資料 if (priv_data_size) { ethdev->data->dev_private = rte_zmalloc_socket( name, priv_data_size, RTE_CACHE_LINE_SIZE, device->numa_node); ......//異常處理,省略 } } else {
//4.由於secondary程序的許可權比較低,沒有掌控記憶體的許可權,因此關鍵資料只能通過attach到primary暴露的共享記憶體中,拿到關鍵資料
//其實這個地方主要是要拿到rte_eth_dev->data這個指標指向的共享記憶體(因為這裡面有PCI BAR對映後的地址) ethdev = rte_eth_dev_attach_secondary(name); ......//異常處理,省略 } //5.指標賦值,沒啥說的,就是讓PMD驅動也可以通過device來拿到PCI裝置的資訊 ethdev->device = device; //6.呼叫eth_dev_bus_specific_init函式,這個函式內部有BUG,請注意 if (ethdev_bus_specific_init) { retval = ethdev_bus_specific_init(ethdev, bus_init_params); ......//異常處理,省略 } //7.呼叫PMD驅動的初始化,對PMD驅動進行初始化,在xigbe驅動下為eth_ixgbe_dev_init retval = ethdev_init(ethdev, init_params); ......//異常處理,省略 rte_eth_dev_probing_finish(ethdev); }
程式碼14.
可以看到,程式碼14中的rte_eth_dev_create函式還是比較重要的,可以說是銜接PCI裝置與PMD驅動的介面層函式。所以懶得看程式碼中的註釋的可以直接看圖9給出的rte_eth_dev_create的函式內部流程圖。見圖9.
圖9.rte_eth_dev_create函式的執行流程
分析完rte_eth_dev_create函式後,便自然的進入了PMD驅動的初始化函式,接下來會以ixgbe這種驅動進行分析,那麼在ixgbe驅動下,初始化函式為eth_ixgbe_dev_init。
接下來不會全面分析,因為對於驅動而言,他的初始化邏輯是巨他媽的長的...分析這部分程式碼,我們只需要記住我們的初衷即可,我們的初衷即為:
dpdk應用內部是如何操作pci裝置的呢?是怎麼讓pci裝置可以將資料包直接扔到使用者態的呢?
我們之前也闡述了,為了實現這個初衷,我們一定要不惜一切代價讓PMD驅動拿到PCI BAR,然後通過PCI BAR去操作暫存器,並且同過第2章和第3章的分析,我們其實已經拿到了PCI BAR,通過mmap對映resource0..N這個核心通過sysfs開放的介面,現在這個PCI BAR經過對映後的虛擬機器地址正在rte_pci_device->mem_resource[idx].addr中沉睡,我們的任務只不過是讓PMD驅動結構拿到這個地址而已,換而言之,其實就是等號左右賦值一下就可以完成,那麼我們來看eth_ixgbe_dev_init函式,見圖10.
圖10.PCI BAR虛擬地址的賦值
#define IXGBE_DEV_PRIVATE_TO_HW(adapter)\ (&((struct ixgbe_adapter *)adapter)->hw) /* * ixgbe驅動的初始化函式 * @param eth_dev[in] PMD驅動描述結構 */ static int eth_ixgbe_dev_init(struct rte_eth_dev *eth_dev, void *init_params __rte_unused) { ......//無關邏輯,省略 //1.將PMD驅動中的私有空間進行轉換成ixgbe_adapter結構,再拿到ixgbe_adatper其中的hw屬性,注意這個變數的記憶體位於共享記憶體中,因此secondary也是拿得到的,這就是secondary為啥可以讀網絡卡暫存器狀態,因此secondary其實是可以通過共享記憶體拿到PCI BAR的 struct ixgbe_hw *hw = IXGBE_DEV_PRIVATE_TO_HW(eth_dev->data->dev_private); ......//無關邏輯,省略 //2.掛鉤子函式,給ixgbe這個PMD驅動指定收發包函式 eth_dev->dev_ops = &ixgbe_eth_dev_ops; eth_dev->rx_pkt_burst = &ixgbe_recv_pkts; eth_dev->tx_pkt_burst = &ixgbe_xmit_pkts; eth_dev->tx_pkt_prepare = &ixgbe_prep_pkts; if (rte_eal_process_type() != RTE_PROC_PRIMARY) { ......//secondary程序的相關邏輯,省略 } ......//拷貝PCI裝置資訊 rte_eth_copy_pci_info(eth_dev, pci_dev); //3.最重要的一步,拿到PCI BAR以及裝置號還有廠商號 //至此,PMD驅動成功拿到經過對映到程序虛擬空間的PCI BAR hw->device_id = pci_dev->id.device_id; hw->vendor_id = pci_dev->id.vendor_id; hw->hw_addr = (void *)pci_dev->mem_resource[0].addr; hw->allow_unsupported_sfp = 1; //4.其他部分的初始化工作,先暫時省略 return 0; }
程式碼15.
經過程式碼15所示,我們可以看到在eth_ixgbe_dev_init這個函式中,PMD驅動已經拿到了經過mmap對映後的在程序使用者空間的PCI BAR地址,接下來對PCI裝置的配置,通過這個PCI BAR + 暫存器地址偏移拿到暫存器地址,便可以對暫存器進行讀寫、配置。到這裡我們先暫停一下腳步,過一下資料結構之間的關係。見圖11.
從圖11中可以看出,一番操作後,PCI BAR已經被ixgbe_adapter-hw指標指向,接下來想拿到PCI BAR只需要對rte_dev->data->dev_private呼叫IXGBE_DEV_PRIVATE_TO_HW即可拿到PCI BAR。而且還要注意的是由於rte_dev->data指標指向的空間為共享記憶體,因此PCI BAR實際上也在共享記憶體中,這也就是Secondary程序可以讀取網絡卡的暫存器配置以及狀態,就是因為Seconday程序實際上可以通過共享記憶體拿到PCI BAR,然後想讀暫存器資訊以及狀態,和primary程序相同,只需要PCI BAR + 暫存器地址偏移拿到暫存器地址,便可以實現對暫存器狀態資訊的讀取。
但是這還沒有結束,我們還差最後一個問題沒有解決,那便是,DPDK怎麼讓PCI裝置把包直接扔到的使用者態,這部分將會放在本系列的第三章中講解。