內核數據結構
本章介紹幾種Linux內核常用的內建數據結構,其中最常用的有:
鏈表
隊列
映射
二叉樹
1. 鏈表
鏈表是一種存放和操作可變數量節點的數據結構。
鏈表和靜態數組的區別:
1. 鏈表包含的元素都是動態創建并插入鏈表的,在編譯時不知道需要創建多少個元素。
2. 因為鏈表中每個節點的創建時間各不相同,所以各節點在內存中無需占用連續內存區
單向鏈表和雙向鏈表
單向鏈表:
在鏈表中,每個節點都包含有一個指向下一個節點的指針,只能向后連續的鏈表,即單向鏈表。如下:
// 單向鏈表中的一個節點
struct list_element {
void *data; // 有效數據
struct list_element *next; // 指向下一個節點的指針
};
雙向鏈表:
在鏈表中,每個節點都包含一個指向下一個和指向前一個節點的指針,可以同時向前和向后相互連接,即雙向鏈表。如下:
// 雙向鏈表中的一個節點
struct list_element {
void *data; // 有效數據
struct list_element *next; // 指向下一個節點的指針
struct list_element *prev; // 指向前一個節點的指針
};
環形鏈表
通常情況下,鏈表的最后一個節點沒有下一個節點,所以鏈表末尾節點的向后指針設置為NULL。如果將鏈表末尾節點的向后節點指向鏈表的首節點,鏈表首尾相連,即構成環形鏈表。
在環形雙向鏈表中,尾節點的向后指針指向鏈表的首節點,鏈表首節點的向前指針指向鏈表的尾節點,構成環形雙向鏈表。
Linux內核的標準鏈表采用環形雙向鏈表形式實現。具有最大的靈活性。
沿鏈表移動
鏈表的訪問方法是沿鏈表線性移動。即先訪問某個元素,再沿該節點的向后指針訪問下一個節點,依次向后移動。
在有的鏈表中,首元素會用一個特殊指針表示,即頭指針,構成有頭鏈表。可以利用頭指針找到鏈表的起始節點。
在非環形鏈表中,向后指針指向NULL的節點是尾節點。
在環形鏈表中,向后指針指向頭節點的節點是尾節點。
遍歷一個雙向鏈表需要線性地訪問從第一個節點到最后一個節點之間的所有節點。也可以從鏈表中的任意指定節點開始向前或向后訪問別的節點。
Linux內核中的實現
普通的鏈表實現方式,通過在數據結構體中添加向前和向后的節點指針,將節點串聯在鏈表中。如:
struct node {
int data; // 節點數據
struct node *next; // 指向下一個節點的指針
struct node *prev; // 指向上一個節點的指針
};
Linux內核中的鏈表實現方式不一樣,不是將數據結構填入鏈表節點,而是將鏈表節點填入數據結構中。
1. 鏈表數據結構
include <linux/list.h>
struct list_head {
struct list_head *next;
struct list_head *prev;
};
內核鏈表使用方法:
include <linux/list.h>
struct node {
int data; // 節點數據
struct list_head list; // 雙向循環鏈表頭
};
在Linux中提供了一組對鏈表的操作,這些操作只接受list_head結構作為參數。
2. 定義一個鏈表
list_head本身沒有意義,需要被嵌入到數據結構中使用。如上所示。
鏈表需要在使用前初始化,動態創建并初始化鏈表:
struct node *list_node;
list_node = kmalloc(sizeof(*list_node), GFP_KERNEL);
list_node->data = 1;
INIT_LIST_HEAD(list_node->list);
如果結構在編譯期靜態創建:
struct list_node {
.data = 1;
.list = LIST_HEAD_INIT(lise_node.list);
};
3. 鏈表頭
鏈表的鏈表頭,用來作為指向整個鏈表的索引。
static LIST_HEAD(list_node);
定義并初始化一個名為list_node的鏈表。
操作鏈表
Linux內核提供的鏈表操作,在<linux/list.h>文件中實現。所有函數的時間復雜度都是O(1)。
1. 向鏈表中增加一個節點
list_add(struct list_head *new, struct list_head *head); // 在head節點后插入new節點
list_add_tail(struct list_head *new, struct list_head *head); // 在head節點前插入new節點
因為鏈表是環形循環的,并且沒有首尾節點概念,所以可以把任何一個節點當成head.
2. 從鏈表中刪除一個節點
list_del(struct list_head *entry); // 將entry節點從鏈表中刪除
注意,該操作不會釋放entry或者包含entry的數據結構占用的內存,還需要再撤銷這些內存。
從鏈表中刪除一個節點并對其重新初始化:
list_del_init();
list_del_init(struct list_head *entry); // 刪除entry節點,并初始化entry節點
3. 移動和合并鏈表節點
把節點從一個鏈表移到另一個鏈表:
list_move(struct list_head *list, struct list_head *head); // 從一個鏈表中移除list節點,并將其加入到另一個鏈表的head節點后
list_move_tail(struct list_head *list, struct list_head *head); // 從一個鏈表中移除list節點,并將其加入到另一個鏈表的head節點前
檢查鏈表是否為空:
list_empty(struct list_head *head); // 檢查鏈表是否為空
如果鏈表為空,返回非0值;否則返回0.
把兩個未連接的鏈表合并為一個鏈表:
list_splice(struct list_head *list, struct list_head *head); // 合并兩個鏈表,將list指向的鏈表插入到指定鏈表的head節點后
list_splice_init(struct list_head *list, struct list_head *head); // 合并兩個鏈表,將list指向的鏈表插入到指定鏈表的head節點后,并初始化list指向的鏈表
遍歷鏈表
鏈表是一個能夠包含重要數據的容器,需要用鏈表移動來訪問指定的節點數據。
遍歷鏈表的時間復雜度為O(n),n為鏈表包含節點數目。
1. 基本方法
遍歷鏈表最簡單的方法是使用list_for_each()宏。使用兩個list_head類型的參數,第一個參數用來指定當前項,臨時變量;第二個參數是需要遍歷的鏈表以頭節點形式存在的list_head。每次遍歷時,第一個參數在鏈表中不斷移動指向下一個節點,知道鏈表中所有節點都被訪問為止。用法如下:
struct list_head *p;
list_for_each(p, list) {
// p指向鏈表中的節點
}
通過list_for_each()宏,可以得到指向鏈表結構的指針,但是這個指針對于用戶來說并無實際用處,用戶需要的是一個指向包含list_head的結構體的指針,而不是list_head類型的指針。
可以通過list_entry()宏來通過list_head指針獲取到包含給定list_head的數據結構。如:
struct list_head *p;
struct fox *f;
list_for_each(p, &node_list) {
f = list_entry(p, struct fox, list);
}
2. 可用的方法
Linux內核采用list_for_each_entry()宏來遍歷鏈表,該宏內部使用了list_entry()宏。
list_for_each_entry(pos, head, member);
pos是一個指向包含list_head節點對象的指針,可以當做是list_entry()宏的返回值。
head是一個指向頭節點的指針,即遍歷開始的位置。
member是pos中list_head結構的變量名。
用法如下:
struct list_node *f;
list_for_each_entry(f, &node_list, list) {
// ...
}
3. 反向遍歷鏈表
list_for_each_entry_reverse()和list_for_each_entry()類似,不同點是,反向遍歷。用法相同:
list_for_each_entry_reverse(pos, head, member);
可能用到反向遍歷鏈表的情況:
1. 性能原因,如:已知節點在搜索起始點前邊
2. 順序很重要時,如:堆棧中的先進先出
4. 遍歷的同時刪除
list_for_each_entry_safe(pos, next, head, member);
比list_for_each_entry(pos, head, member)安全是因為,標準的鏈表遍歷在遍歷鏈表的同時是不能刪除的。
// 反向遍歷鏈表的同時刪除節點
list_for_each_entry_safe_reverse(pos, n, head, member);
注意:可能會有并發地刪除鏈表中的不同節點,list_for_each_entry_safe()刪除節點時需要鎖定鏈表。
2. 隊列
生產者和消費者模型中,生產者創造數據,而消費者讀取消息和處理包,或者以其他方式消費這些數據。
最簡單的實現方法是隊列,生產者將數據推進隊列,消費者從隊列中讀取數據。消費者獲取數據的順序和生產者推入隊列的順序一致。
隊列,即FIFO(First in first out,先進先出)。Linux內核通用隊列實現稱為kfifo,在kernel/kfifo.c文件中實現,聲明在<linux/kfifo.h>文件中。
1. kfifo
linux的kfifo,兩個主要操作:enqueue(入隊列)和dequeue(出隊列)。
有兩個偏移量:入口偏移(下一次入隊列時的位置)和出口偏移(下一次出隊列時的位置)。出口偏移總是小于等于入口偏移。
enqueue操作拷貝數據到隊列中的入口偏移位置,并將入口偏移加上推入元素數目。
dequeue操作從隊列中出口偏移處拷貝數據,并出口偏移減去摘取的元素數目。
隊列空:出口偏移等于入口偏移時。
隊列滿:入口偏移等于隊列長度時。
2. 創建隊列
定義和初始化
// 動態
int kfifo_alloc(struct kfifo *fifo, unsigned int size, gfp_t gfp_mask); // 創建并初始化一個大小為size的kfifo
內核使用gfp_mask標識分配隊列。成功返回0;錯誤返回一個負數錯誤碼。例如:
// 創建一個隊列,名為fifo,大小為PAGE_SIZE
struct kfifo fifo;
int ret;
ret = kfifo_alloc(&kfifo, PAGE_SIZE, GFP_KERNEL);
if (ret) {
return ret;
}
或者
void kfifo_init(struct kfifo *fifo, void *buffer, unsigned int size);
// 創建并初始化一個kfifo對象,將使用由buffer指向的size字節大小的內存
kfifo_alloc()和kfifo_init()的size必須是2的冪。
// 靜態聲明
DECLARE_KFIFO(name, size); // size必須是2的冪
INIT_KFIFO(name);
3. 推入隊列數據
unsigned int kfifo_in(struct kfifo *fifo, const void *from, unsigned int len);
// 把from指針所指的len字節數據拷貝到fifo所指的隊列中。成功返回推入數據字節大小。
4. 摘取隊列數據
unsigned int kfifo_out(struct kfifo *fifo, void *to, unsigned int len);
// 從fifo所指的隊列中拷貝出長度為len字節的數據到to所指的緩沖中。成功返回拷貝的數據長度。
kfifo_out操作后的隊列中不再有讀取的數據。
unsigned int kfifo_out_peek(struct kfifo *fifo, void *to, unsigned int len, unsigned offset);
// offset指向隊列中的索引位置
// 和kfifo_out類似,不同之處是出口偏移不增加,即摘取的數據在下次摘取時還在隊列中。
5. 獲取隊列長度
// 獲取kfifo隊列的空間總體大小(以字節為單位)
static inline unsigned int kfifo_size(struct kfifo *fifo);
// 獲取kfifo隊列中已推入的數據大小
static inline unsigned int kfifo_len(struct kfifo *fifo);
// 獲取kfifo隊列中剩余可用空間大小
static inline unsigned int kfifo_avail(struct kfifo *fifo);
// 判斷kfifo隊列是否為空
static inline int kfifo_is_empty(struct kfifo *fifo);
// 判斷kfifo隊列是否滿
static inline int kfifo_if_full(struct kfifo *fifo);
// 這兩個函數判斷為空或滿時,返回非0值;否則返回0
6. 重置和撤銷隊列
重置kfifo,即拋棄隊列中的所有內容
static inline void kfifo_reset(struct kfifo *fifo);
撤銷使用kfifo_alloc()分配的隊列
void kfifo_free(struct kfifo *fifo);
撤銷使用kfifo_init()創建的隊列時,需要釋放相關的緩沖
7. 隊列使用舉例
已經創建一個名為fifo的8KB大小的kfifo
// 向隊列中推入數據
unsigned int i;
for (i = 0; i < 32; i++) {
kfifo_in(fifo, &i, sizeof(i));
}
// 查看隊列中數據
unsigned int val;
int ret;
ret = kfifo_out_peek(fifo, &val, sizeof(val), 0);
if (ret != sizeof(val)) {
return -EINVAL;
}
printk(KERN_INFO "%u\n", val);
// 摘取并打印kfifo中的所有數據
while (kfifo_avail(fifo)) {
unsigned int val;
int ret;
ret = kfifo_out(fifo, &val, sizeof(val));
if (ret != sizeof(val)) {
return -EINVAL;
}
printk(KERN_INFO "%u\n", val);
}
3. 映射
一個映射,也稱為關聯數組。是一個由唯一鍵組成的集合,每個鍵必然關聯一個特定的值,這種鍵到值的關聯關系稱為映射。
映射至少支持三個操作:
Add (key, value)
Remove (key)
value = Lookup (key)
還沒有學明白,明白之后再補充上
4. 二叉樹
樹結構是一個能提供分層的樹型數據結構的特定數據結構。
二叉樹是每個節點最多只有兩個子節點的樹。
1. 二叉搜索樹
二叉搜索樹(簡稱BST)是一個節點有序的二叉樹,有以下法則:
1. 根的左分支節點值都小于根節點值
2. 右分支節點值都大于根節點值
3. 所有的子樹也都是二叉搜索樹
2. 自平衡二叉搜索樹
平衡二叉搜索樹是一個所有葉子節點深度差不超過1的二叉搜索樹。
自平衡二叉搜索樹是指其操作都試圖維持(半)平衡的二叉搜索樹。
1. 紅黑樹
紅黑樹是一種自平衡二叉搜索樹。Linux主要的平衡二叉樹數據結構就是紅黑樹。
之所以能維持半平衡結構,是因為有以下屬性:
1. 所有的節點要么著紅色,要么著黑色
2. 葉子節點都是黑色
3. 葉子節點不包含數據
4. 所有非葉子節點都是兩個子節點
5. 如果一個節點是紅色,則它的子節點都是黑色
6. 在一個節點到其葉子節點的路徑中,如果總是包含同樣數目的黑色節點,則該路徑相比其他路徑是最短的。
上述條件保證了最深的葉子節點的深度不會大于兩倍的最淺葉子節點的深度。所以紅黑樹總是半平衡的。
2. rbtree
Linux實現的紅黑樹稱為rbtree。定義在lib/rbtree.c文件,聲明在<linux/rbtree.h>文件。
rbtree的根節點由數據結構rb_root表示。
// 創建一個紅黑樹
struct rb_root root = RB_ROOT;
樹里的其他節點由結構rb_node表示。
紅黑樹的時間復雜度是對數關系。
rbtree的實現沒有提供搜索和插入例程,需要用戶定義。
5. 數據結構以及選擇
Linux中最重要的四種數據結構:鏈表、隊列、映射、紅黑樹。
優先選擇鏈表的情況:
1)對數據集合的主要操作是遍歷數據。
2)當性能并非首要考慮因素時
3)需要存儲相對較少的數據項時
4)需要和內核中其他使用鏈表的代碼交互時
5)需要存儲一個大小不明的數據集合時
選擇使用隊列的情況:
1)符合生產者/消費者模式(即FIFO)時
2)需要一個定長緩沖時
選擇使用映射的情況:
1)需要映射一個UID到一個對象時
2)Linux的映射接口針對UID到指針的映射
3)需要處理發給用戶空間的描述符時
選擇使用紅黑樹的情況:
1)需要存儲大量數據,并且檢索迅速時
如果沒有執行太多次時間緊迫的查找操作,最好使用鏈表。
Linux還有一些其他的數據結構,比如:基樹(trie類型)和位圖。
6. 算法復雜度
漸進行為,指當算法的輸入變得非常大或接近于無限大時算法的行為。
算法的伸縮度,當輸入增大時算法執行的變化。
1. 算法
算法,是一系列的指令,可能有一個或多個輸入,最后產生一個結果或輸出。
從數學角度講,一個算法好比一個函數。
2. 大O符號
大O符號用來描述增長率。
4. 時間復雜度
7. 小結
本節主要講述了鏈表、隊列、映射、二叉樹數據結構。應該重用已經存在的內核基礎設施。