本文從最基本的內核鏈表出發,引出初始化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環境。
大多數人在學習數據結構的時候,鏈表都是第一個接觸的內容,筆者也不列外,雖然自己實現過幾種鏈表,但是在實際工作中,還是Linux內核的鏈表最為常用(同時筆者也建議大家使用內核鏈表,因為會了這個,其他的都會了),故總結一篇Linux內核鏈表的文章。
閱讀本文之前,我假設你已經具備基本的鏈表編寫經驗。
內核鏈表的結構是個雙向循環鏈表,只有指針域,數據域根據使用鏈表的人的具體需求而定。內核鏈表設計哲學:
既然鏈表不能包含萬事萬物,那么就讓萬事萬物來包含鏈表。
假設以如下方式組織我們的數據結構:
創建一個結構體,並將鏈表放在結構體第一個成員地址處(后面會分析不在首地址時的情況)。
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 }
本例中定義了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; }
鏈表刪除之后,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 }
由於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.