1. 程式人生 > >linux核心分析--核心中的資料結構之雙鏈表(一)

linux核心分析--核心中的資料結構之雙鏈表(一)

關於核心中使用到的資料結構這一系列會有五篇文章,

分別介紹

   連結串列

   佇列

   雜湊

   對映

   紅黑樹

一、首先介紹核心中連結串列

        核心中定義的連結串列是雙向連結串列,在上篇文章--libevent原始碼分析-- queue.h中TAILQ_QUEUE的理解中介紹了FreeBSD中如何定義連結串列佇列,和linux核心中的定義還是有區別的,但同樣經典。

        核心中關於連結串列定義的程式碼位於: include/linux/list.h。list.h檔案中對每個函式都有註釋,這裡就不詳細說了。其實剛開始只要先了解一個常用的連結串列操作(追加,刪除,遍歷)的實現方法,其他方法基本都是基於這些常用操作的。

       介紹核心中連結串列的定義之前,回想資料結構中定義連結串列的方式,兩者是有區別的。
一般的雙向連結串列一般是如下的結構,

有個單獨的頭結點(head)
每個節點(node)除了包含必要的資料之外,還有2個指標(pre,next)
pre指標指向前一個節點(node),next指標指向後一個節點(node)
頭結點(head)的pre指標指向連結串列的最後一個節點
最後一個節點的next指標指向頭結點(head)

傳統的連結串列有個最大的缺點就是不好共通化,因為每個node中的data1,data2等等都是不確定的(無論是個數還是型別)。

linux中的連結串列巧妙的解決了這個問題,linux的連結串列不是將使用者資料儲存在連結串列節點中,而是將連結串列節點儲存在使用者資料中。

linux的連結串列節點只有2個指標(pre和next),這樣的話,連結串列的節點將獨立於使用者資料之外,便於實現連結串列的共同操作。

如下圖所示:


這個圖畫的非常的標準,好好揣摩。

在include/linxu/list.h中的定義也是非常簡單:

 struct list_head {
      struct list_head *next, *prev;
  };


在使用的時候,自己定義結構體,但是結構體中除了使用者的資料就是這個結構體。這樣便可構造自己定義的雙向連結串列。

在瞭解了基本內容看具體實現,只知道資料成員list的地址,怎樣去訪問自身以及其他成員呢?

linux連結串列中的最大問題是怎樣通過連結串列的節點來取得使用者資料?

和傳統的連結串列不同,linux的連結串列節點(node)中沒有包含使用者的使用者data1,data2等。

下面進入正題:
在include/linux/list.h標頭檔案中可以看到這段程式碼!

#define list_entry(ptr,type,member)    /
    container_of(ptr,type,member)

其中container_of這個巨集在/include/linux/kernel.h的標頭檔案中。
 

 #define container_of(ptr, type, member) ({          \
     const typeof( ((type *)0)->member ) *__mptr = (ptr);    \
     (type *)( (char *)__mptr - offsetof(type,member) );})

//這裡面的type一般是個結構體,也就是包含使用者資料和連結串列節點的結構體。

//ptr是指向type中連結串列節點的指標

//member則是type中定義連結串列節點是用的名字


關於這個巨集解釋有幾點需要解釋,

1、typeof(type),這是一個巨集,這個巨集返回一個type的型別,例如:int a; typeof(a) b;等價於int b;

2、offsetof(type,member)巨集  它定義在include/linx/stddef.h中,如下:
#define offsetof(TYPE, MEMBER)  ((size_t) &((TYPE *)0)->MEMBER)
這個巨集返回member在type型別中的偏移量,type是一個結構,例如:
typeof(list_head,next);返回0,也就是返回相對於結構起始地址的偏移量。可能會有疑問為何將0強制轉化為某一個型別的指標,然後這個指標指向這個型別中的某一個成員,指標所指成員的地址就是這個成員在這個型別中的偏移量。

       這種情況一般都使用在獲取結構體中某一成員的偏移。因為首地址是從0開始,那麼結構成員的地址從數值上看就是他的偏移量。可能還不怎麼明白,那麼指標是什麼,是一個地址,指標的內容是某個變數的首地址,將0強轉為指標型別,也就是說指標值為零,而這個值就是所指物件的首地址。(偏移量+首地址=成員地址,這裡只不過將首地址變為0,那麼成員地址就是偏移量。)

可以用一個簡單的例子說明:

struct student
{
    int id;
    char* name;
    struct list_head list;
};
<ul><li>type是struct student</li><li>ptr是指向stuct list的指標,也就是指向member型別的指標</li><li>member就是 list
</li></ul>


下面的圖以sturct student為例進行說明這個巨集:
首先需要知道 ((TYPE *)0) 表示將地址0轉換為 TYPE 型別的地址

由於TYPE的地址是0,所以((TYPE *)0)->MEMBER 也就是 MEMBER的地址和TYPE地址的差,如下圖所示:


3、使用typeof(((type *)0)->member)來定義指標 __ptr,而不是這樣:const typeof(member) *__ptr=ptr;?
    其實,這個很簡單,因為member是結構的成員,只能通過結構來訪問!

4、在這句話中(type *)( (char *)__mptr - offsetof(type,member) ); 減號前就是成員的地址,減號後是這個成員在結構中的偏移量,兩者相減便是這個結構的首地址。

連結串列中資料的訪問:

在檔案include/linux/list.h中,有訪問連結串列資料的程式碼

#define list_for_each_entry(pos, head, member)
    for(pos=list_entry((head)->next,typeof(*pos),member);...)

#define list_for_each_entry(pos, head, member)
    for(pos=list_entry((head)->next,typeof(*pos),member);...)
從上面的使用來看,替換list_entry巨集以及container_of巨集後,變成如下:
    pos=({const typeof(((typeof(*pos) *)0)->member) *__ptr=(head)->next;

pos=({const typeof(((typeof(*pos) *)0)->member) *__ptr=(head)->next;
            (typeof(*pos) *)((char *)__ptr - offsetof(typeof(typeof(*pos)),member));});


二、還有一種連結串列,作為雙向連結串列使用

struct hlist_head{
    struct hlist_node *first;
};
struct hlist_node{
    struct hlist_node *next, **pprev;
};


這個雙向連結串列不是真正的雙向連結串列,因為表頭只有一個first域,為什麼這樣設計?程式碼中的註釋解釋:為了節約記憶體,特別適合作為Hash表的衝突鏈,但Hash表很大時,那麼表頭節約下來的記憶體就相當客觀了,雖然每個表頭只節約一個指標。
    同時,表頭的不一致性也會帶來連結串列操作上的困難,顯然就是在表頭和首資料節點之間插入節點時需要特別處理,這也就是為什麼會設計二級指標pprev的原因。看看程式碼

static inline void hlist_add_before(struct hlist_node *n,struct hlist_node *next)
{
    n->pprev=next->pprev;
    n->next=next;
    next->pprev=&n->next;
    *(n->pprev)=n;
}


解釋:指標n指向新節點,指標next指向將要在它之前插入新節點的那個節點。
看上面的程式碼,就可以看到二級指標pprev的威力了!有沒有看到,當next就是第一個資料節點時,這裡的插入也就是在表頭和首資料節點之間插入一個節點,但是並不需要特別處理!而是統一使用*(n->pprev)來訪問前驅的指標域(在普通節點中是next,而在表頭中是first)。看到這個和上篇文章中講解的TAILQ_QUEUE是不是很相似!其實在FreeBSD中也講解了這種資料結構!
精益求精的Linux連結串列設計者(因為list.h沒有署名,所以很可能就是Linus Torvalds)認為雙頭(next、prev)的雙鏈表對於HASH表來說"過於浪費",因而另行設計了一套用於HASH表應用的hlist資料結構--單指標表頭雙迴圈連結串列,從上圖可以看出,hlist的表頭僅有一個指向首節點的指標,而沒有指向尾節點的指標,這樣在可能是海量的HASH表中儲存的表頭就能減少一半的空間消耗。

因為表頭和節點的資料結構不同,插入操作如果發生在表頭和首節點之間,以往的方法就行不通了:表頭的first指標必須修改指向新插入的節點,卻不能使用類似list_add()這樣統一的描述。為此,hlist節點的prev不再是指向前一個節點的指標,而是指向前一個節點(可能是表頭)中的next(對於表頭則是first)指標(struct list_head **pprev),從而在表頭插入的操作可以通過一致的"*(node->pprev)"訪問和修改前驅節點的next(或first)指標。