Linux內核鏈表——看這一篇文章就夠了

本文從最基本的內核鏈表出發,引出初始化INIT_LIST_HEAD函數,而後介紹list_add,經過改變鏈表位置的問題引出list_for_each函數,而後爲了獲取容器結構地址,引出offsetof和container_of宏,並對內核鏈表設計緣由做出瞭解釋,一步步引導到list_for_each_entry,而後介紹list_del函數,經過在遍歷時list_del鏈表的不安全行爲,引出list_for_each_entry_safe函數,經過本文,我但願讀者能夠獲得以下三個技能點:緩存

1.可以熟練使用內核鏈表的相關宏和函數,並應用在項目中;安全

2.明白內核鏈表設計者們的意圖,爲何要那樣去設計鏈表的操做和提供那樣的函數接口;數據結構

3.可以將內核鏈表移植到非GNU環境。ide

 

大多數人在學習數據結構的時候,鏈表都是第一個接觸的內容,筆者也不列外,雖然本身實現過幾種鏈表,可是在實際工做中,仍是Linux內核的鏈表最爲經常使用(同時筆者也建議你們使用內核鏈表,由於會了這個,其餘的都會了),故總結一篇Linux內核鏈表的文章。函數

閱讀本文以前,我假設你已經具有基本的鏈表編寫經驗。性能

 內核鏈表的結構是個雙向循環鏈表,只有指針域,數據域根據使用鏈表的人的具體需求而定。內核鏈表設計哲學:學習

既然鏈表不能包含萬事萬物,那麼就讓萬事萬物來包含鏈表。測試

假設以以下方式組織咱們的數據結構:fetch

 建立一個結構體,並將鏈表放在結構體第一個成員地址處(後面會分析不在首地址時的狀況)。spa

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 #include "list.h"
 5 
 6 struct person 
 7 {
 8     struct list_head list;
 9     int age;
10 };
11 
12 int main(int argc,char **argv)
13 {
14     int i;
15     struct person *p;
16     struct person person1;
17     struct list_head *pos;
18     
19     INIT_LIST_HEAD(&person1.list);
20 
21     for (i = 0;i < 5;i++) {
22         p = (struct person *)malloc(sizeof(struct person ));
23         p->age=i*10;
24         list_add(&p->list,&person1.list);
25     }
26 
27     list_for_each(pos, &person1.list) {
28         printf("age = %d\n",((struct person *)pos)->age);
29     }
30     
31     return 0;
32 }

咱們先定義struct person person1;此時person1就是一個咱們須要使用鏈表來連接的節點,使用鏈表以前,須要先對鏈表進行初始化,LIST_HEAD和INIT_LIST_HEAD均可以初始化一個鏈表,二者的區別是,前者只須要傳入鏈表的名字,就能夠初始化完畢了;然後者須要先定義出鏈表的實體,如前面的person1同樣,而後將person1的地址傳遞給初始化函數便可完成鏈表的初始化。內核鏈表的初始化是很是簡潔的,讓前驅和後繼都指向本身。

完成了初始化以後,咱們能夠像鏈表中增長節點,先以頭插法爲例:

 

 list_add函數,能夠在鏈中增長節點,改函數爲頭插法,即每次插入的節點都位於上一個節點以前,好比上一個節點是head->1->head,本次使用頭插法插入以後,鏈表結構變成了 head->2->1->head。也就是使用list_add頭插法,最後第一個插入的節點,將是鏈表結構中的第一個節點。

 list_add函數的實現步驟也很是簡潔,必定要本身去推演一下這個過程,O(1)的時間複雜度,就4條指針操做,不本身去推演一下這個過程你會少不少心得體會,尤爲是在接觸過其餘的還要考慮頭部和尾部特殊狀況的鏈表以後,更會以爲內核鏈表設計簡潔的妙處。list_add函數的第一個參數就是要增長到頭結點鏈表中的數據結構,第二個參數就是頭結點,本例中爲&person1.list。

本例中增長5個節點,頭結點的數據域不重要,能夠根據須要利用頭結點的數據域,通常而言,頭結點數據域不使用,在使用頭結點數據域的狀況下,通常也僅僅記錄鏈表的長度信息,這個在後面咱們能夠本身實現一下。

在增長了5個節點以後,咱們須要遍歷鏈表,訪問其數據域的內容,此時,咱們先使用list_for_each函數,遍歷鏈表。

 該函數就是遍歷鏈表,直到出現pos == head時,循環鏈表就編譯完畢了。對於其中的prefetch(pos->next)函數,若是你是在GNU中使用gcc進行程序開發,能夠不作更改,直接使用上面的函數便可;但若是你想把其移植到Windows環境中進行使用,能夠直接將prefetch(pos->next)該條語句刪除便可,由於prefetch函數它經過對數據手工預取的方法,減小了讀取延遲,從而提升了性能,也就是prefetch是gcc用來提升效率的函數,若是要移植到非GNU環境,能夠換成相應環境的預取函數或者直接刪除也可,它並不影響鏈表的功能。

list_for_each的第一個參數pos,表明位置,須要是struct list_head * 類型,它其實至關於臨時變量,在本例中,定義了一個指針pos, struct list_head *pos;用其來遍歷鏈表。

能夠遍歷鏈表以後,那麼就須要對數據進行打印了。

 本例中的輸出,將pos強制換成struct person *類型,而後訪問age元素,獲得程序輸出入下:

 能夠發現,list_add頭插法,果真是最後插入的先打印,最早插入的最後打印。

其次,爲何筆者要使用printf("age = %d\n",((struct person *)pos)->age);這樣的強制類型轉換來打印呢?能這樣打印的原理是什麼呢?

如今回到咱們的數據結構:

struct person 
{
  struct list_head list;
  int age;
};

因爲咱們將鏈表放在結構體的首地址處,那麼此時鏈表list的地址,和struct person 的地址是一致的,因此經過pos的地址,將其強制轉換成struct person *就能夠訪問age元素了。

前面說到,內核鏈表是有頭結點的,通常而言頭結點的數據域咱們不使用,但也有使用頭結點數據域記錄鏈表長度的實現方法。頭結點其實不是必需的,但做爲學習,咱們能夠實現一下,瞭解其過程:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 #include "list.h"
 5 
 6 struct person_head
 7 {
 8     struct list_head list;
 9     int len;
10 };
11 
12 struct person 
13 {
14     struct list_head list;
15     int age;
16 };
17 
18 int main(int argc,char **argv)
19 {
20     int i;
21     struct person *p;
22     struct person_head head;
23     struct list_head *pos;
24     
25     INIT_LIST_HEAD(&head.list);
26     head.len=0;
27 
28     for (i = 0;i < 5;i++) {
29         p = (struct person *)malloc(sizeof(struct person ));
30         p->age=i*10;
31         list_add(&p->list,&head.list);
32     }
33 
34     list_for_each(pos, &head.list) {
35         printf("age = %d\n",((struct person *)pos)->age);
36         head.len++;
37     }
38     printf("list len =%d\n",head.len);
39     
40     return 0;
41 }
View Code

 本例中定義了person_head結構,其數據域保存鏈表的長度,因爲list_for_each會遍歷鏈表,本例僅做爲功能說明的實現,記錄了鏈表的長度信息,並打印了鏈表長度。若是實際開發中須要記錄鏈表的長度或者其餘信息,應該封裝成相應的函數,同時,增長節點的時候,增長len的計數,刪除節點的時候,減小len的計數。

在筆者最先接觸到將鏈表放在結構體第一個成員地址處時,以爲Linux內核鏈表後面的container_of,offsetof宏爲何如此多餘,由於按照上面的方法,根本再也不須要container_of,offsetof這樣的宏了,甚至當時還以爲內核爲何這麼笨,還不更新代碼(固然,這也是當時聽了某個老師的課說現代的鏈表已經發展成爲上面例子的狀況,而內核鏈表處於不斷髮展的過程,並無使用這樣最新的方式)。因此筆者在學生時代時學到這裏就收手沒有再繼續下去了,由於我當時認爲按照這樣的方法就夠用了。但是,當我進入到企業工做以後,我發現並非這樣的,由於沒有人能夠保證鏈表能夠放在結構體的第一個成員地址處,哪怕可以保證,那麼在複雜數據結構中,有多個鏈表怎麼辦?哪怕你可以保證有一個鏈表位於結構體的首地址處,那其餘的鏈表怎麼辦呢?直到那時,我才發現Linux內核那幫設計者們並不笨,而是本身當時的知識面太窄而且項目經驗不足(這樣一樣證實了一個授課老師的知識水平,對學生的影響是很大的,固然,瑕不掩瑜,我心裏仍是很是感謝當初那位老師的,只是,我須要更強大的力量了^_^)。內核鏈表設計者們,考慮到了不少狀況下,咱們根本不能保證每一個鏈表都處於結構體的首地址,因此,也就出現了container_of,offsetof這兩個廣爲人知的宏。

試想,若是將我上面代碼中的person結構體位置更改一下:

 將鏈表不放置在結構體的首地址處,那麼前面的代碼將不能正常工做了:

由於此時強制類型轉換獲得地址再也不是struct person結構的首地址,進行->age操做時,指針偏移不正確。

 果真,運行以後代碼獲得的age值不正確,爲了解決這一問題,內核鏈表的開發者們設計出了兩個宏:

 咱們先來分析offsetof宏,其語法也是很是簡潔和簡單的,該宏獲得的是TYPE(結構體)類型中成員MEMBER相對於結構體的偏移地址。可是,其中有一個知識點須要注意:爲何((TYPE *)0)->MEMBER這樣的代碼不會出現段錯誤,咱們都知道,p->next,等價於(*p).next;那麼((TYPE *)0)->MEMBER,不是應該等價於(*(TYPE *)0).MEMBER嗎?這樣不就出現了對0地址的解引用操做嗎?爲何內核使用這樣的代碼卻沒有問題呢?

爲了解釋這個問題,咱們先作一個測試:

 

 沒有問題,如今咱們把for_test的參數改成NULL,看看會不會出現段錯誤:

 注意,此時傳遞給for_test的參數爲NULL,同時爲了顯示偏移數,我將地址以%u打印,程序輸出以下:

 你發現了什麼?對,程序並無奔潰,並且獲得了age和list在struct person中偏移量,一個爲0,一個爲8(筆者的Linux是64bit的)。爲何傳遞NULL空指針進去,並無發生錯誤,難道是咱們以前學習的C語言有問題?

沒有發生錯誤,是由於在ABI規範中,編譯器處理結構體地址偏移時,使用的是以下方式:

 在編譯階段,編譯器就會將結構體的地址以如上方式組織,也就是說,編譯器去取得結構體某個成員的地址,就是使用的偏移量,因此,即便傳入NULL,也不會出現錯誤,也就是說,內核的offsetof宏不會有任何問題。

 

 那麼offsetof之因此將0強制類型轉換,就是爲了獲得TYPE結構體中MEMBER的偏移量,最後將偏移量強制類型轉換爲size_t,這就是offsetof。那麼爲何要這樣求偏移呢?前面說到了,想在結構體中獲得鏈表的地址,怎麼獲得地址呢?若是咱們知道了鏈表和結構體的偏移量,那麼即便鏈表不位於結構體首地址處,咱們也可使用鏈表了啊。

下面,咱們對container_of宏作解析:

 其中typeof是GNU中獲取變量類型的關鍵字,若是要將其移植到Windows中,能夠再添加一個參數解決,有興趣的可自行實驗。

如今咱們來看,第一句,其實第一句話沒有也徹底不影響該宏的功能,可是內核鏈表設計者們爲何要增長這個一個賦值的步驟呢?這是由於宏沒有參數檢查的功能,增長這個const typeof( ((type *)0)->member ) *__mptr = (ptr)賦值語句以後,若是類型不匹配,會有警告,因此說,內核設計者們不會把沒用的東西放在上面。

如今咱們來講一下該宏的三個參數,ptr,是指向member的指針,type,是容器結構體的類型,member就是結構體中的成員。用__mptr強制轉換成char *類型 減去member在type中的偏移量,獲得結果就是容器type結構體的地址,這也就是該宏的做用。你可能會想,type的地址不是直接取地址獲得嗎?爲何還要這麼麻煩使用這個宏呢?

要解答這個問題,咱們先來看一下這兩個宏的應用場景。

前面說到在鏈表不放在結構體首地址時的問題,如今咱們使用內核鏈表的list_entry宏來解決這個問題:

 list_entry宏其實就是container_of。回憶前面咱們的問題:

 前面說到這裏獲取age是錯誤的,就是由於pos的地址不位於結構體首地址了,試想,若是咱們可以經過將pos指針傳遞給某個宏或者函數,該函數或者宏可以經過pos返回包含pos容器這個結構體的地址,那麼咱們不就能夠正常訪問age了嗎。很顯然, container_of宏,就是這個做用啊,在內核中,將其又封裝成了 list_entry宏,那麼咱們改進前面的代碼:

 如今運行以後,便可以獲得正確的結果了。

 細心的讀者可能發現了,爲何以前我使用gcc編譯時都加上了-std=c99,可是上圖中並無使用c99標準,這也是須要注意的,此時使用c99標準進行編譯或報錯,至於出錯緣由:

/* 在編譯時加上-std=c99,使用c99標準,對內核鏈表進行編譯,會報語法錯誤,那是由於c99並不支持某些gcc的語法特性,若是想在GNU中啓用c99標準,可使用-std=gnu99,使用這個選項以後,會對gnu語法進行特殊處理,並使用c99標準 */

如今咱們對內核鏈表作分析:

使用list_entry以後,咱們能夠獲得容器結構體的地址,因此天然能夠對結構體中的age元素進行操做了。前面說到,容器結構的地址,咱們直接使用取地址符&不就好了嗎,爲何還要使用這個複雜的宏list_entry去取地址呢?結合上面的應用場景,你想一想,此時你能容易取到容器結構體的地址嗎?顯然,在鏈表中,尤爲是在內核鏈表這種沒有數據域的鏈表結構中,獲取鏈表的地址是容易的,可是獲取包含鏈表容器結構的地址須要額外的存儲操做,因此內核鏈表的設計者們設計出的list_entry宏,可謂精妙。

在上面的代碼中,咱們使用:

這樣的循環遍歷鏈表,獲取容器地址,取出相應結構體的age元素,內核鏈表設計者早已考慮到了這一點,因此爲咱們封裝了另外一個宏:list_for_each_entry

 

 list_for_each_entry,經過其名字咱們也能猜想其功能,list_for_each是遍歷鏈表,增長entry後綴,表示遍歷的時候,還要獲取entry(條目),即獲取鏈表容器結構的地址。該宏中的pos類型爲容器結構類型的指針,這與前面list_for_each中的使用的類型再也不相同,不過這也是情理之中的事,畢竟如今的pos,我要使用該指針去訪問數據域的成員age了;head是你使用INIT_LIST_HEAD初始化的那個對象,即頭指針,注意,不是頭結點;member就是容器結構中的鏈表元素對象。使用該宏替代前面的方法:

 運行結果以下:

 

 在此以前,咱們都沒有使用刪除鏈表的操做,如今咱們來看一下刪除鏈表的內核函數list_del:

 

#include <stdio.h>
#include <stdlib.h>

#include "list.h"



struct person 
{
    int age;
    struct list_head list;
};

int main(int argc,char **argv)
{
    int i;
    struct person *p;
    struct person head;
    struct person *pos;
    
    INIT_LIST_HEAD(&head.list);

    for (i = 0;i < 5;i++) {
        p = (struct person *)malloc(sizeof(struct person ));
        p->age=i*10;
        list_add(&p->list,&head.list);
    }
    
    list_for_each_entry(pos,&head.list,list) {
        if (pos->age == 30) {
            list_del(&pos->list);
            break;
        }
    }
    
    list_for_each_entry(pos,&head.list,list) {
        printf("age = %d\n",pos->age);
    }
    return 0;
}
View Code

鏈表刪除以後,entry的前驅和後繼會分別指向LIST_POISON1和LIST_POISON2,這個是內核設置的一個區域,可是在本例中將其置爲了NULL。運行結果以下:

 

 能夠發現,正確地刪除了相應的鏈表,可是注意了,若是在下面代碼中不使用break;會發生異常。

 

 爲何會這樣呢?那是由於list_for_each_entry的實現方式並非安全的,若是想要在遍歷鏈表的時候執行刪除鏈表的操做,須要對list_for_each_entry進行改進。顯然,內核鏈表設計者們早已給咱們考慮到了這一狀況,因此內核又提供了一個宏:list_for_each_entry_safe

 使用這個宏,能夠在遍歷鏈表時安全地執行刪除操做,其原理就是先把後一個節點取出來使用n做爲緩存,這樣在還沒刪除節點時,就獲得了要刪除節點的笑一個節點的地址,從而避免了程序出錯。

 

 使用list_for_each_entry_safe宏,它使用了一箇中間變量緩存的方法,實現更爲安全的變量鏈表方法,其執行效果以下:

 

 對於內核鏈表的宏和函數而言,其語法都是很是簡潔和簡單的,就再也不具體分析每個語句的做用了,我相信讀者也能輕鬆地閱讀明白這些代碼,在筆者以前的學習中,就是缺乏一個練習使用這些鏈表的過程,因此必定要本身去寫一個程序推演一下整個過程。

list_del讓刪除的節點前驅和後繼指向LIST_POISON1和LIST_POISON2的位置,本例中爲NULL,內核同時提供了:

list_del_init

 根據業務須要,能夠自行選擇適合本身的函數。

 

如今,我再來講另外一種插入方式:尾插法,若是原來是head->1->head,尾插法一個節點以後變成了head->1->2->head。

內核提供的函數接口爲:list_add_tail

 咱們將前面代碼的list_add改成list_add_tail以後,獲得:

 

 對於多核系統上,內核還提供了list_add_rcu和list_add_tail_rcu等函數,其具體實現機制(主要是內存屏障相關的)須要根據cpu而定。

下面咱們介紹:list_replace,經過其名字咱們就能知道,該函數是替換鏈表的:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 #include "list.h"
 5 
 6 
 7 
 8 struct person 
 9 {
10     int age;
11     struct list_head list;
12 };
13 
14 int main(int argc,char **argv)
15 {
16     int i;
17     struct person *p;
18     struct person head;
19     struct person *pos,*n;
20     struct person new_obj={.age=100}; 
21     
22     INIT_LIST_HEAD(&head.list);
23 
24     for (i = 0;i < 5;i++) {
25         p = (struct person *)malloc(sizeof(struct person ));
26         p->age=i*10;
27         list_add_tail(&p->list,&head.list);
28     }
29     /*
30     list_for_each_entry(pos,&head.list,list) {
31         if (pos->age == 30) {
32             list_del(&pos->list);
33             break;
34         }
35     }*/
36     
37     list_for_each_entry_safe(pos,n,&head.list,list) {
38         if (pos->age == 30) {
39             //list_del(&pos->list);
40             list_replace(&pos->list,&new_obj.list);
41             //break;
42         }
43     }
44     list_for_each_entry(pos,&head.list,list) {
45         printf("age = %d\n",pos->age);
46     }
47     return 0;
48 }
View Code

 

 因爲list_replace沒有將old的前驅和後繼斷開,因此內核又提供了:list_replace_init

 這樣,替換以後會將old從新初始化,使其前驅和後繼指向自身。顯然咱們一般應該使用list_replace_init。

當項目中另一個地方處理完成一個同類型的節點數據時,能夠直接使用list_replace_init替換想要處理的節點,這樣能夠再也不作拷貝操做。

 內核鏈表還提供給咱們:list_move

 有了前面的知識累積,咱們能夠和輕鬆地明白,list_move就是刪除list指針所處的容器結構節點,而後將其從新以頭插法添加到另外一個頭結點中去,head能夠是該鏈表自身,也能夠是其餘鏈表的頭指針。

 既然有頭插法的list_move,那麼也一樣有尾插法的list_move_tail:

 將測試函數改成:

 注意,在這裏lis_move和list_move_tail都有刪除操做,可是這裏卻能夠不使用list_for_each_entry_safe而直接使用list_for_each_entry,想一想這是爲何呢?

這是由於move函數,後面有一個添加鏈表的操做,將刪除的節點前驅後繼的LIST_POISON1和LIST_POISON2(本例中爲NULL),從新賦值了。

值得注意的是,若是鏈表數據域中的元素都相等,使用list_for_each_entry_safe反而會無限循環,list_for_each_entry卻能正常工做。可是,在一般的應用場景下,數據域的判斷條件不會是所有相同鏈表,例如在本身使用鏈表實現的線程中,經常使用線程名字做爲move的條件判斷,而線程名字確定不該該是相同的。因此,具體的內核鏈表API,須要根據本身的應用場景選擇。list_for_each_entry_safe是緩存了下一個節點的地址,list_for_each_entry是無緩存的,挨個遍歷,因此在刪除節點的時候,list_for_each_entry須要注意,若是沒有將刪除節點的前驅後繼處理好,那麼將引起問題,而list_for_each_entry_safe一般不用關心,可是在你使用的條件判斷進行move操做時,不該該使用各個節點可能相同的條件。

 有list_for_each_entry日後依次遍歷,那麼也有list_for_each_entry_reverse往前依次遍歷:

 

測試代碼以下:

 

 

 

 運行結果,一個日後遍歷,一個往前遍歷:

 一樣,有安全的日後遍歷:list_for_each_entry_safe,那麼也有安全的往前遍歷:list_for_each_entry_safe_reverse

 

 測試代碼:

 運行結果和前面的一致。

另外一方面,內核鏈表還提供了獲得第一個條目的宏:

還提供了判斷鏈表是不是最後一個或者鏈表是否爲空的函數

 

 對於將GNU上的鏈表移植到Windows環境,須要注意的是,將預取指函數刪除,或者換成你所使用的環境中能夠達到相同效果的指令或函數,還有就是,typeof是gcc的特殊關鍵字,在Windows環境下,能夠經過將相應的內核鏈表宏增長一個參數,該參數用來表示類型。

最後說兩句:

動手實踐一次,比眼看100次更有收穫。

talk is cheap,show me the code.

相關文章
相關標籤/搜索