ホームページ  >  記事  >  システムチュートリアル  >  Linuxカーネルのリンクリストの詳細な分析

Linuxカーネルのリンクリストの詳細な分析

WBOY
WBOY転載
2024-02-14 08:54:261141ブラウズ

1. リンク リスト データ構造の概要

リンク リストは、順序付けされたデータを整理するために一般的に使用されるデータ構造です。これは、ポインターを介して一連のデータ ノードをデータ チェーンに接続し、線形テーブルの重要な実装方法です。配列と比較して、リンク リストはより動的です。リンク リストを作成する場合、データの総量を事前に知る必要はなく、スペースをランダムに割り当てることができ、リンク リスト内の任意の場所にデータをリアルタイムで効率的に挿入または削除できます。リンク リストの主なオーバーヘッドは、アクセスの順序性とチェーンの編成によるスペースの損失です。

通常、リンク リスト データ構造には、データ フィールドとポインター フィールドという少なくとも 2 つのフィールドが含まれている必要があります。データフィールドはデータを保存するために使用され、ポインタフィールドは次のノードとの接続を確立するために使用されます。ポインタフィールドの構成と各ノード間の接続形式に応じて、リンクリストはシングルリンクリスト、ダブルリンクリスト、循環リンクリストなどのさまざまなタイプに分類できます。以下は、これらの一般的なリンク リスト タイプの概略図です:

1.単一リスト

単一リンク リストは、最も単純なタイプのリンク リストです。その特徴は、後続のノード (next) を指すポインタ フィールドが 1 つだけあることです。したがって、単一リンク リストは最初から最後までしかトラバースできません (通常は NULL)ヌルポインタ)。

2.二重リンクリスト 3

先行および後続の 2 つのポインター フィールドを設計することにより、二重リンク リストは 2 方向からトラバースできるようになり、これが単一リンク リストとの違いになります。先行ノードと後続ノードの間の依存関係が破壊された場合、最初のノードの先行ノードが連結リストの末尾ノードを指し、末尾ノードの後続ノードが最初のノードを指す場合、「二分木」が形成される可能性があります。 (図 2 の点線で示すように)、循環リンク リストが形成されます。さらに多くのポインター フィールドを設計すると、さまざまな複雑なツリー状のデータ構造を形成できます。

3.循環リンクリスト

循環リンク リストの特徴は、末尾ノードの後続ノードが最初のノードを指すことです。二重循環リンクリストの模式図は以前に示しましたが、その特徴は、どのノードから開始しても、どちらの方向に行っても、リンクリスト内の任意のデータを見つけることができることです。先行ポインタが削除されると、それは単一の循環リンク リストになります。

Linux カーネルでは、デバイス リストやさまざまな機能モジュールのデータ編成などのデータを編成するために、多数のリンク リスト構造が使用されています。これらのリンク リストのほとんどは、[include/linux/list.h] に実装された非常に素晴らしいリンク リスト データ構造を使用しています。この記事の後続のセクションでは、例を通じてこのデータ構造の構成と使用法を詳しく紹介します。

2. Linux 2.6 カーネルのリンク リスト データ構造の実装

ここでは説明の基礎として 2.6 カーネルを使用しますが、実際には 2.4 カーネルのリンク リスト構造は 2.6 のそれと変わりません。違いは、2.6 ではリンク リストの読み取りコピー更新 (rcu) と HASH リンク リスト (hlist) という 2 つのリンク リスト データ構造が拡張されていることです。どちらの拡張機能も最も基本的なリスト構造に基づいているため、この記事では主に基本的なリンク リスト構造を紹介し、その後で rcu と hlist について簡単に紹介します。

リンク リスト データ構造の定義は非常に簡単です ([include/linux/list.h] から抜粋。特に明記されていない限り、以下のコードはすべてこのファイルから取得されています):

リーリー

list_head 構造体には、list_head 構造体を指す 2 つのポインタ prev と next が含まれています。カーネルのリンク リストには二重リンク リストの機能があることがわかります。実際、通常、これは二重循環リンク リストに編成されます。

最初のセクションで紹介した二重リンク リスト構造モデルとは異なり、ここの list_head にはデータ フィールドがありません。 Linux カーネルのリンク リストでは、リンク リスト構造にデータが含まれるのではなく、リンク リスト ノードがデータ構造に含まれます。

データ構造の教科書では、リンク リストの古典的な定義は通常次のようになります (単一リンク リストを例にします):

リーリー

ElemType のため、各データ項目タイプは独自のリンク リスト構造を定義する必要があります。経験豊富な C プログラマは、標準テンプレート ライブラリが C テンプレートを使用していることを知っている必要があります。C テンプレートは、データ項目タイプに依存しないリンク リスト操作インターフェイスを抽象化するためにテンプレートを使用します。

在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的简捷实用、不求完美和标准的风格,在这里体现得相当充分。

详解分析 Linux 内核链表

三、 链表操作接口

\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)

详解分析 Linux 内核链表

当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结构中定义的用于链表操作的节点成员变量名。

list_entry的使用相当简单,相比之下,它的实现则有一些难懂:

#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()宏的原理

详解分析 Linux 内核链表

对于给定一个结构,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() は、先頭の次のポインタがそれ自体を指しているかどうかに基づいて、リンク リストが空かどうかを判断するだけです。Linux リンク リストには、先頭の次と前を同時に決定する list_empty_careful() マクロが用意されています。ポインタ。両方が一致する場合のみ、それ自体を指す場合にのみ true を返します。これは主に、別の CPU が同じリンク リストを処理しているために next と prev が矛盾する状況に対処するためです。ただし、コードのコメントでは、このセキュリティ保証機能には限界があることも認めています。他の CPU のリンク リスト操作が list_del_init() のみでない限り、セキュリティは保証できません。つまり、ロック保護が依然として必要です。

b) トラバーサル中のノードの削除

リンク リスト トラバーサル用のマクロをいくつか紹介しましたが、それらはすべて pos ポインタを移動することによってトラバーサルの目的を達成します。ただし、トラバーサル操作に pos ポインタが指すノードの削除が含まれる場合、list_del(pos) が pos の next と prev を LIST_POSITION2 と LIST_POSITION2 の特別な値に設定するため、pos ポインタの移動は中断されます。 LIST_POSITION1。

もちろん、呼び出し元は次のポインタを自分でキャッシュしてトラバーサル操作を一貫性のあるものにすることができますが、プログラミングの一貫性を保つために、Linux リンク リストは依然として基本的なトラバーサル操作に対応する 2 つの "_safe" インターフェイスを提供します: list_for_each_safe(pos, n 、head)、list_for_each_entry_safe(pos, n, head, member) の場合、呼び出し元は pos と同じ型の追加のポインタ n を提供し、pos の次のノードのアドレスを for ループに一時的に格納して、可能性を回避する必要があります。 pos ノードの解放によるエラー。チェーンの切断が発生しました。

4.拡張子

\1.hリスト

図 6 リストと hlist

详解分析 Linux 内核链表

卓越性を追求する Linux リンク リストの設計者 (list.h は署名されていないため、おそらく Linus Torvalds です) は、双頭 (次、前) の二重リンク リストは「無駄が多すぎる」と考えています。 " HASH テーブル用に、彼は別の HList データ構造を作成しました。 HASH テーブル アプリケーション用の一連の hlist データ構造が設計されています - シングル ポインター ヘッダーの二重循環リンク リスト。 上の図からわかるように、hlist ヘッダーにはポインターのみが含まれています。このようにして、大規模になる可能性がある HASH テーブルにヘッダーを格納すると、スペースの消費を半分に減らすことができます。

ヘッダーとノードのデータ構造が異なるため、ヘッダーと最初のノードの間で挿入操作が発生した場合、以前の方法は機能しません。ヘッダーの最初のポインターは、新しいノードを指すように変更する必要があります。ノードを挿入しましたが、 list_add() のような統一された記述は使用できません。このため、hlist ノードの prev は前のノードへのポインタではなく、前のノード (おそらくヘッダー) の次へのポインタ (ヘッダーの最初) (struct list_head **pprev) になります。テーブルの先頭に挿入する操作では、一貫した「*(node->pprev)」を通じて先行ノードの次 (または最初) ポインターにアクセスして変更できます。

\2. 読み取りコピー更新

Linux リンク リスト関数インターフェイスには、「_rcu」で終わる一連のマクロもあります。これらは、上で紹介した関数の多くに対応します。 RCU (Read-Copy Update) は、2.5/2.6 カーネルで導入された新しいテクノロジーで、書き込み操作を遅延させることで同期パフォーマンスを向上させます。

システムでは書き込み操作よりもはるかに多くのデータ読み取り操作が行われており、SMP 環境でプロセッサの数が増加すると、rwlock メカニズムのパフォーマンスが急速に低下することがわかっています (参考資料 4 を参照)。このような応用背景を受けて、IBM Linux テクノロジー センターの Paul E. McKenney は「リード コピー アップデート」テクノロジーを提案し、Linux カーネルに適用しました。 RCU テクノロジの中核は、書き込み操作が書き込みと更新の 2 つのステップに分割され、ブロックすることなく読み取り操作がいつでもアクセスできるようにすることです。システムに書き込み操作がある場合、更新アクションは、システム上のすべての読み取り操作が行われるまで遅延されます。データが完成しました。 Linux リンク リストの RCU 機能は、Linux RCU のほんの一部にすぎません。RCU の実装分析は、この記事の範囲を超えています。興味のある読者は、この記事の参考資料を参照してください。また、RCU リンク リストと基本的なリンクリストの使い方は基本的に同じです。

5.例

添付ファイルのプログラムは、ファイルを順方向および逆方向に出力できるという点を除けば、実際的な効果はありません。Linux リンク リストの使用方法を示すためにのみ使用されています。

簡単にするために、この例ではユーザー モード プログラム テンプレートを使用しています。これを実行する必要がある場合は、次のコマンドを使用してコンパイルできます。

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

因为内核链表限制在内核态使用,但实际上对于数据结构本身而言并非只能在核态运行,因此,在笔者的编译中使用”-D__KERNEL__”开关”欺骗”编译器。

以上がLinuxカーネルのリンクリストの詳細な分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はlxlinux.netで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。