1. 程式人生 > 實用技巧 >【DPDK】談談DPDK如何實現bypass核心的原理 其二 DPDK部分的實現

【DPDK】談談DPDK如何實現bypass核心的原理 其二 DPDK部分的實現

【前言】

  關於DPDK如果實現bypass核心的原理,在上一篇《【DPDK】談談DPDK如何實現bypass核心的原理 其一 PCI裝置與UIO驅動》中已經描述了在DPDK啟動前做的準備工作,那麼本篇文章將著重分析DPDK部分的職責,也就是從軟體的的角度來分析在第一篇文章的基礎上,如何做到真正的操作裝置。

注意:

  1. 本篇文章將會更著重分析軟體部分的實現,也就是分析程式碼實現;
  2. 同樣,本篇會跨過中斷部分與vfio部分,中斷部分與vfio會在以後另開文章繼續分析;
  3. 人能力以及水平有限,沒辦法保證沒有疏漏,如有疏漏還請各路神仙進行指正,本篇內容都是本人個人理解,也就是原創內容。
  4. 另外在分析程式碼的過程中,為了防止一些無掛緊要的邏輯顯得程式碼又臭又長,會對其中不重要或者與主要邏輯不相關的程式碼進行省略,包括且不限於,變數宣告、部分不重要資料的初始化、異常處理、無關主要邏輯的模組函式呼叫等。

【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(int
argc, 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內部做了兩件事:

  1. 校驗rte_bus結構中的方法以及屬性,也就是引數的前置檢查;
  2. 將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這個巨集的出現至少帶給我們如下幾個問題:

  1. 這個巨集裡面實際上是一個函式,那這個函式是在哪呼叫的?
  2. 啥時候遍歷這個連結串列然後執行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就是用來對付這種警告/錯誤的,一般在內聯彙編函式上用的比較多)

  那到了這裡,第一個問題的答案已經逐漸明瞭

  1. 這個巨集裡面實際上是一個函式,那這個函式是在哪呼叫的?
    • 答: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至少告訴我們這麼幾個資訊:

  1. DPDK拿到PCI BAR不是通過UIO驅動拿到的,而是直接通過Kernel對使用者空間的介面,也就是通過sysfs拿到的,具體就是對映resource0..N檔案。這裡在上一篇文章中已經介紹過;
  2. 對映後的虛擬地址已經存至了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裝置把包直接扔到的使用者態,這部分將會放在本系列的第三章中講解。