作業系統學習:深入分析Linux核心鏈表

來源:互聯網
上載者:User

  本文詳細分析了 2.6.x 核心中鏈表結構的實現,並通過執行個體對每個鏈表操作介面進行了詳盡的講解。

  一、 鏈表資料結構簡介
   鏈表是一種常用的組織有序資料的資料結構,它通過指標將一系列資料節點串連成一條資料鏈,是線性表的一種重要實現方式。相對於數組,鏈表具有更好的動態 性,建立鏈表時無需預Crowdsourced Security Testing道資料總量,可以隨機分配空間,可以高效地在鏈表中的任意位置即時插入或刪除資料。鏈表的開銷主要是訪問的順序性和組織鏈的空間 損失。

  通常鏈表資料結構至少應包含兩個域:資料域和指標域,資料域用於儲存資料,指標域用於建立與下一個節點的聯絡。按照指標域的組織以及各個節點之間的聯絡形式,鏈表又可以分為單鏈表、雙鏈表、迴圈鏈表等多種類型,下面分別給出這幾類常見鏈表類型的:

  1. 單鏈表

  圖 1 單鏈表

 


  單鏈表是最簡單的一類鏈表,它的特點是僅有一個指標域指向後繼節點(next),因此,對單鏈表的遍曆只能從頭至尾(通常是 NULL null 指標)順序進行。

  2. 雙鏈表

  圖 2 雙鏈表

 


   通過設計前驅和後繼兩個指標域,雙鏈表可以從兩個方向遍曆,這是它區別於單鏈表的地方。如果打亂前驅、後繼的依賴關係,就可以構成"二叉樹";如果再讓 首節點的前驅指向鏈表尾節點、尾節點的後繼指向首節點(2中虛線部分),就構成了迴圈鏈表;如果設計更多的指標域,就可以構成各種複雜的樹狀資料結 構。

  3. 迴圈鏈表
  迴圈鏈表的特點是尾節點的後繼指向首節點。前面已經給出了雙迴圈鏈表的,它的特點是從任意一個節點出發,沿兩個方向的任何一個,都能找到鏈表中的任意一個資料。如果去掉前驅指標,就是單迴圈鏈表。

  在Linux核心中使用了大量的鏈表結構來組織資料,包括裝置列表以及各種功能模組中的資料群組織。這些鏈表大多採用在[include/linux/list.h]實現的一個相當精彩的鏈表資料結構。本文的後繼部分就將通過樣本詳細介紹這一資料結構的組織和使用。

 二、 Linux 2.6 核心鏈表資料結構的實現
  儘管這裡使用2.6核心作為講解的基礎,但實際上 2.4 核心中的鏈表結構和 2.6 並沒有什麼區別。不同之處在於 2.6 擴充了兩種鏈表資料結構:鏈表的讀拷貝更新(rcu)和 HASH 鏈表(hlist)。這兩種擴充都是基於最基本的 list 結構,因此,本文主要介紹基本鏈表結構,然後再簡要介紹一下 rcu 和 hlist。

  鏈表資料結構的定義很簡單(節選自 [include/linux/list.h],以下所有代碼,除非加以說明,其餘均取自該檔案):

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

  list_head 結構包含兩個指向 list_head 結構的指標 prev 和 next,由此可見,核心的鏈表具備雙鏈表功能,實際上,通常它都組織成雙迴圈鏈表。

  和第一節介紹的雙鏈表結構模型不同,這裡的 list_head 沒有資料域。在 Linux 核心鏈表中,不是在鏈表結構中包含資料,而是在資料結構中包含鏈表節點。

  在資料結構課本中,鏈表的經典定義方式通常是這樣的(以單鏈表為例):

struct list_node { struct list_node *next; ElemType data; };

  因為 ElemType 的緣故,對每一種資料項目類型都需要定義各自的鏈表結構。有經驗的 C++ 程式員應該知道,標準模板庫中的 採用的是 C++ Template,利用模板抽象出和資料項目類型無關的鏈表操作介面。

   在 Linux 核心鏈表中,需要用鏈表組織起來的資料通常會包含一個 struct list_head 成員,例如在 [include/linux/netfilter.h] 中定義了一個 nf_sockopt_ops 結構來描述 Netfilter 為某一協議族準備的 getsockopt/setsockopt 介面,其中就有一個(struct list_head list)成員,各個協議族的 nf_sockopt_ops 結構都通過這個 list 成員組織在一個鏈表中,表頭是定義在 [net/core/netfilter.c] 中的 nf_sockopts(struct list_head)。從中我們可以看到,這種通用的鏈表結構避免了為每個資料項目類型定義自己的鏈表的麻煩。 Linux 的簡捷實用、不求完美和標準的風格,在這裡體現得相當充分。

 

  圖 3 nf_sockopts 鏈表

  三、 鏈表操作介面

  1. 聲明和初始化
  實際上 Linux 只定義了鏈表節點,並沒有專門定義鏈表頭,那麼一個鏈表結構是如何建立起來的呢?讓我們來看看 LIST_HEAD() 這個宏:

#define LIST_HEAD_INIT(name) { &(name), &(name) } #define LIST_HEAD(name) struct list_head name = LIST_HEAD_INIT(name)

當我們用 LIST_HEAD(nf_sockopts) 聲明一個名為 nf_sockopts 的鏈表頭時,它的 next、prev 指標都初始化為指向自己,這樣,我們就有了一個空鏈表,因為 Linux 用頭指標的 next 是否指向自己來判斷鏈表是否為空白:

static inline int list_empty(const struct list_head *head) { return head->next == head; }

  除了用 LIST_HEAD() 宏在聲明的時候初始化一個鏈表以外,Linux 還提供了一個 INIT_LIST_HEAD 宏用於運行時初始化鏈表:

#define INIT_LIST_HEAD(ptr) do { / (ptr)->next = (ptr); (ptr)->prev = (ptr); / } while (0)

  我們用 INIT_LIST_HEAD(&nf_sockopts) 來使用它。

  2. 插入/刪除/合并
  a) 插入

  對鏈表的插入操作有兩種:在表頭插入和在表尾插入。Linux為此提供了兩個介面:

static inline void list_add(struct list_head *new, struct list_head *head); static inline void list_add_tail(struct list_head *new, struct list_head *head);

  因為 Linux 鏈表是迴圈表,且表頭的 next、prev 分別指向鏈表中的第一個和最末一個節點,所以,list_add 和 list_add_tail 的區別並不大,實際上,Linux 分別用

__list_add(new, head, head->next);

__list_add(new, head->prev, head);

  來實現兩個介面,可見,在表頭插入是插入在 head 之後,而在表尾插入是插入在 head->prev 之後。

  假設有一個新 nf_sockopt_ops 結構變數 new_sockopt 需要添加到 nf_sockopts 鏈表頭,我們應當這樣操作:

list_add(&new_sockopt.list, &nf_sockopts);

  從這裡我們看出,nf_sockopts 鏈表中記錄的並不是 new_sockopt 的地址,而是其中的 list 元素的地址。如何通過鏈表訪問到 new_sockopt 呢?下面會有詳細介紹。

  b) 刪除

static inline void list_del(struct list_head *entry);

  當我們需要刪除 nf_sockopts 鏈表中添加的 new_sockopt 項時,我們這麼操作:

list_del(&new_sockopt.list);

   被剔除下來的 new_sockopt.list,prev、next 指標分別被設為 LIST_POSITION2 和 LIST_POSITION1 兩個特殊值,這樣設定是為了保證不在鏈表中的節點項不可訪問--對 LIST_POSITION1 和 LIST_POSITION2 的訪問都將引起頁故障。與之相對應, list_del_init() 函數將節點從鏈表中解下來之後,調用 LIST_INIT_HEAD() 將節點置為空白鏈狀態。

  c) 搬移

  Linux 提供了將原本屬於一個鏈表的節點移動到另一個鏈表的操作,並根據插入到新鏈表的位置分為兩類:

static inline void list_move(struct list_head *list, struct list_head *head); static inline void list_move_tail(struct list_head *list, struct list_head *head);

  例如 list_move(&new_sockopt.list,&nf_sockopts) 會把 new_sockopt 從它所在的鏈表上刪除,並將其再鏈入 nf_sockopts 的表頭。

 d) 合并

  除了針對節點的插入、刪除操作,Linux 鏈表還提供了整個鏈表的插入功能:

static inline void list_splice(struct list_head *list, struct list_head *head);

   假設當前有兩個鏈表,表頭分別是 list1 和 list2(都是 struct list_head 變數),當調用 list_splice(&list1,&list2) 時,只要 list1 非空,list1 鏈表的內容將被掛接在 list2 鏈表上,位於 list2 和 list2.next(原 list2 表的第一個節點)之間。新 list2 鏈表將以原 list1 表的第一個節點為首節點,而尾節點不變。(虛箭頭為next指標):

  圖 4 鏈表合并 list_splice(&list1,&list2)

 


  當 list1 被掛接到 list2 之後,作為原表頭指標的 list1 的 next、prev 仍然指向原來的節點,為了避免引起混亂,Linux 提供了一個 list_splice_init() 函數:

static inline void list_splice_init(struct list_head *list, struct list_head *head);

  該函數在將 list 合并到 head 鏈表的基礎上,調用 INIT_LIST_HEAD(list) 將 list 設定為空白鏈。

  3. 遍曆
  遍曆是鏈表最經常的操作之一,為了方便核心應用遍曆鏈表,Linux 鏈表將遍曆操作抽象成幾個宏。在介紹遍曆宏之前,我們先看看如何從鏈表中訪問到我們真正需要的資料項目。

  a) 由鏈表節點到資料項目變數

   我們知道,Linux 鏈表中僅儲存了資料項目結構中 list_head 成員變數的地址,那麼我們如何通過這個 list_head 成員訪問到作為它的所有者的節點資料呢?Linux 為此提供了一個 list_entry(ptr,type,member) 宏,其中ptr是指向該資料中 list_head 成員的指標,也就是儲存在鏈表中的地址值,type 是資料項目的類型,member 則是資料項目類型定義中 list_head 成員的變數名,例如,我們要訪問 nf_sockopts 鏈表中首個 nf_sockopt_ops 變數,則如此調用:

list_entry(nf_sockopts->next, struct nf_sockopt_ops, list);

  這裡 "list" 正是 nf_sockopt_ops 結構中定義的用於鏈表操作的節點成員變數名。

#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) );}) offsetof宏定義在[include/linux/stddef.h]中: #define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

size_t 最終定義為 unsigned int(i386)。

  這裡使用的是一個利用編譯器技術的小技巧,即先求得結構成員在與結構中的位移量,然後根據成員變數的地址反過來得出屬主結構變數的地址。

   container_of() 和 offsetof() 並不僅用於鏈表操作,這裡最有趣的地方是 ((type *)0)->member,它將0地址強制 "轉換" 為 type 結構的指標,再訪問到 type 結構中的 member 成員。在 container_of 宏中,它用來給 typeof() 提供參數(typeof() 是 gcc 的擴充,和 sizeof() 類似 ),以獲得 member 成員的資料類型;在 offsetof() 中,這個 member 成員的地址實際上就是 type 資料結構中 member 成員相對於結構變數的位移量。

  如果這麼說還不好理解的話,不妨看看下面這張圖:

 


  圖 5 offsetof() 宏的原理

  對於給定一個結構,offsetof(type,member) 是一個常量,list_entry() 正是利用這個不變的位移量來求得鏈表資料項目的變數地址。


  b) 遍曆宏

在 [net/core/netfilter.c] 的 nf_register_sockopt() 函數中有這麼一段話:

…… struct list_head *i; …… list_for_each(i, &nf_sockopts) { struct nf_sockopt_ops *ops = (struct nf_sockopt_ops *)i; …… } ……

   函數首先定義一個 (struct list_head *) 指標變數i,然後調用 list_for_each(i,&nf_sockopts) 進行遍曆。在 [include/linux/list.h] 中, list_for_each() 宏是這麼定義的:

#define list_for_each(pos, head) / for (pos = (head)->next, prefetch(pos->next); pos != (head); / pos = pos->next, prefetch(pos->next))

  它實際上是一個 for 迴圈,利用傳入的 pos 作為迴圈變數,從表頭 head 開始,逐項向後(next 方向)移動 pos,直至又回到 head(prefetch() 可以不考慮,用於預取以提高遍曆速度 )。

   那麼在 nf_register_sockopt() 中實際上就是遍曆 nf_sockopts 鏈表。為什麼能直接將獲得的 list_head 成員變數地址當成 struct nf_sockopt_ops 資料項目變數的地址呢?我們注意到在 struct nf_sockopt_ops 結構中,list是其中的第一項成員,因此,它的地址也就是結構變數的地址。更規範的獲得資料變數地址的用法應該是:

struct nf_sockopt_ops *ops = list_entry(i, struct nf_sockopt_ops, list);

  大多數情況下,遍曆鏈表的時候都需要獲得鏈表節點資料項目,也就是說 list_for_each()和list_entry() 總是同時使用。對此 Linux 給出了一個 list_for_each_entry() 宏:

#define list_for_each_entry(pos, head, member) ……

  與 list_for_each() 不同,這裡的pos是資料項目結構指標類型,而不是 (struct list_head *)。nf_register_sockopt() 函數可以利用這個宏而設計得更簡單:

…… struct nf_sockopt_ops *ops; list_for_each_entry(ops,&nf_sockopts,list){ …… } ……

   某些應用需要反向遍曆鏈表,Linux 提供了 list_for_each_prev() 和 list_for_each_entry_reverse() 來完成這一操作,使用方法和上面介紹的 list_for_each()、list_for_each_entry() 完全相同。

  如果遍曆不是從鏈表頭開始,而是從已 知的某個節點 pos 開始,則可以使用 list_for_each_entry_continue(pos,head,member)。有時還會出現這種需求,即經過一系列計算後,如果 pos 有值,則從 pos 開始遍曆,如果沒有,則從鏈表頭開始,為此,Linux 專門提供了一個 list_prepare_entry(pos,head,member) 宏,將它的傳回值作為 list_for_each_entry_continue() 的 pos 參數,就可以滿足這一要求。

  4. 安全性考慮
  在並發執行的環境下,鏈表操作通常都應該考慮同步安全性問題,為了方便,Linux 將這一操作留給應用自己處理。Linux 鏈表自己考慮的安全性主要有兩個方面:

  a) list_empty() 判斷

   基本的 list_empty() 僅以頭指標的 next 是否指向自己來判斷鏈表是否為空白,Linux 鏈表另行提供了一個 list_empty_careful() 宏,它同時判斷頭指標的 next 和 prev,僅當兩者都指向自己時才返回真。這主要是為了應付另一個 cpu 正在處理同一個鏈表而造成 next、prev 不一致的情況。但代碼注釋也承認,這一安全保障能力有限:除非其他 cpu 的鏈表操作只有 list_del_init(),否則仍然不能保證安全,也就是說,還是需要加鎖保護。

  b) 遍曆時節點刪除

   前面介紹了用於鏈表遍曆的幾個宏,它們都是通過移動 pos 指標來達到遍曆的目的。但如果遍曆的操作中包含刪除 pos 指標所指向的節點,pos 指標的移動就會被中斷,因為 list_del(pos) 將把 pos 的 next、prev 置成 LIST_POSITION2 和 LIST_POSITION1 的特殊值。

  當然,調用者完全可以自己緩衝 next 指標使遍曆操作能夠連貫起來,但為了編程的一致性,Linux 鏈表仍然提供了兩個對應於基本遍曆操作的 "_safe" 介面:list_for_each_safe(pos, n, head)、list_for_each_entry_safe(pos, n, head, member),它們要求調用者另外提供一個與 pos 同類型的指標n,在 for 迴圈中暫存 pos 下一個節點的地址,避免因 pos 節點被釋放而造成的斷鏈.

 四、 擴充

  1. hlist

  圖 6 list 和 hlist

 


   精益求精的 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)指標。

  2. read-copy update
  在 Linux 鏈表功能介面中還有一系列以 "_rcu" 結尾的宏,與以上介紹的很多函數一一對應。RCU(Read-Copy Update)是 2.5/2.6 核心中引入的新技術,它通過延遲寫操作來提高同步效能。

   我們知道,系統中資料讀取操作遠多於寫操作,而 rwlock 機制在 smp 環境下隨著處理機增多效能會迅速下降(見參考資料 4)。針對這一應用背景,IBM Linux 技術中心的 Paul E. McKenney 提出了 "讀拷貝更新" 的技術,並將其應用於 Linux 核心中。RCU 技術的核心是寫操作分為寫-更新兩步,允許讀操作在任何時候無阻訪問,當系統有寫操作時,更新動作一直延遲到對該資料的所有讀操作完成為止。Linux 鏈表中的 RCU 功能只是 Linux RCU 的很小一部分,對於 RCU 的實現分析已超出了本文所及,有興趣的讀者可以自行參閱本文的參考資料;而對 RCU 鏈表的使用和基本鏈表的使用方法基本相同。

  五、 樣本
  附件中的程式除了能正向、反向輸出檔案以外,並無實際作用,僅用於示範 Linux 鏈表的使用。

  為了簡便,例子採用的是使用者態程式模板,如果需要運行,可採用如下命令編譯:

gcc -D__KERNEL__ -I/usr/src/linux-2.6.7/include pfile.c -o pfile

  因為核心鏈表限制在核心態使用,但實際上對於資料結構本身而言並非只能在核態運行,因此,在筆者的編譯中使用 "-D__KERNEL__" 開關 "欺騙" 編譯器。

  參考資料
  1. 維基百科 http://zh.wikipedia.org,一個在 GNU Documentation License 下發布的網路辭典,自由軟體理念的延伸,本文的 "鏈表" 概念即使用它的版本。
  2. 《Linux 核心情景分析》,毛德操先生的這本關於 Linux 核心的巨著幾乎可以回答絕大部分關於核心的問題,其中也包括核心鏈表的幾個關鍵資料結構。
  3. Linux 核心 2.6.7 原始碼,所有不明白的問題,只要潛心看代碼,總能清楚。
   4. Kernel Korner: Using RCU in the Linux 2.5 Kernel,RCU 主要開發人員 Paul McKenney 2003 年 10 月發表於 Linux Journal 上的一篇介紹 RCU 的文章。在 http://www.rdrop.com/users/paulmck/rclock/ 上可以獲得更多關於 RCU 的協助。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.