詳説 eBPF 実装編

執筆者:稲葉貴昭


1. はじめに

前回の概論編では、eBPF(以下、BPFと表記)がどんなものでどのように実現されているかを中心に解説いたしました。 また、BCCやBCC-Tools、CO-REについての解説も行いました。 実装編となる今回は、BCC-Toolsの後継となるlibbpf-toolsよりopensnoopコマンドに着目して ソースコードレベルの解説を行います。

2. 使用環境

BPFは、2022年8月現在も活発に開発が進められているため、理由がなければ新しいカーネルバージョンを使ったほうがいいです。
特にCO-REについては、libbpfのバージョンの関係でUbuntuだと20.10以降にする必要があります。 今回の調査は、最新のLTS版であるUbuntu 22.04 LTSを使用しました。

調査を行ったのは、執筆時点で最新のバージョンだったBCC v0.24.0です。*1
libbpf-toolsのリポジトリを見ると*.bpf.cと*.cというファイルが大量にあることがわかります。
ファイル名が「*.bpf.c」となっているのは、前回の記事におけるBPFプログラムに当たります。 また、ファイル名が「*.c」になっているのは、前回の記事におけるBPFアプリケーションになります。

さて、本記事ではこれらコマンドの中からopensnoopというコマンドの解説を軸にします。 ただし、すべてのコードについて解説をいれると相当な量になるため、筆者がポイントになっていると感じた箇所のみ解説する形にいたします。

3. コンパイル

以下の手順でcloneとコンパイルができます。

sudo apt install make gcc libelf-dev clang llvm 
git clone https://github.com/iovisor/bcc.git
cd bcc
git checkout -b v0.24.0 refs/tags/v0.24.0
git submodule update --init --recursive
cd libbpf-tools
make

4. opensnoopとは

opensnoopは、openシステムコール が呼ばれた際に、どのプロセスがどのファイルをopenしたのかをトレースするためのコマンドです。
usageは以下のようになっています。

Usage: opensnoop [OPTION...]
Trace open family syscalls

USAGE: opensnoop [-h] [-T] [-U] [-x] [-p PID] [-t TID] [-u UID] [-d DURATION]
                 [-n NAME] [-e]

EXAMPLES:
    ./opensnoop           # trace all open() syscalls
    ./opensnoop -T        # include timestamps
    ./opensnoop -U        # include UID
    ./opensnoop -x        # only show failed opens
    ./opensnoop -p 181    # only trace PID 181
    ./opensnoop -t 123    # only trace TID 123
    ./opensnoop -u 1000   # only trace UID 1000
    ./opensnoop -d 10     # trace for 10 seconds only
    ./opensnoop -n main   # only print process names containing "main"
    ./opensnoop -e        # show extended fields

  -d, --duration=DURATION    Duration to trace
  -e, --extended-fields      Print extended fields
  -n, --name=NAME            Trace process names containing this
  -p, --pid=PID              Process ID to trace
  -t, --tid=TID              Thread ID to trace
  -T, --timestamp            Print timestamp
  -u, --uid=UID              User ID to trace
  -U, --print-uid            Print UID
  -v, --verbose              Verbose debug output
  -x, --failed               Failed opens only
  -?, --help                 Give this help list
      --usage                Give a short usage message
  -V, --version              Print program version

以下はオプション指定なしで実行したときの出力結果です。 実行には管理者権限が必要になります。
左から、「openを呼んだプロセスID」、「プロセス名」、「openの戻り値であるファイルディスクリプタ」、 「errno」、「openしたファイルパス」となっています。

PID    COMM              FD ERR PATH
2870   systemd-journal   39   0 /proc/618498/comm
2870   systemd-journal   39   0 /proc/618498/cmdline
...
618500 opensnoop         24   0 /etc/localtime
2870   systemd-journal   39   0 /proc/618498/loginuid
2870   systemd-journal   39   0 /proc/618498/cgroup
...
67345  tmux: server       9   0 /proc/618498/cmdline
24927  irqbalance         6   0 /proc/interrupts

5. opensnoop.bpf.cの解説

opensnoop.bpf.cは、opensnoopにおけるBPFプログラムにあたる部分です。

5.1 mapの宣言

10 const volatile __u64 min_us = 0;
11 const volatile pid_t targ_pid = 0;
12 const volatile pid_t targ_tgid = 0;
13 const volatile uid_t targ_uid = 0;
14 const volatile bool targ_failed = false;
15
16 struct {
17         __uint(type, BPF_MAP_TYPE_HASH);
18         __uint(max_entries, 10240);
19         __type(key, u32);
20         __type(value, struct args_t);
21 } start SEC(".maps");
22
23 struct {
24         __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
25         __uint(key_size, sizeof(u32));
26         __uint(value_size, sizeof(u32));
27 } events SEC(".maps");

ここでは、mapの宣言を行っているのですが、BPF特有のC言語の書き方になっています。 まず、先頭4行の変数ですが、これらはopensnoopの-p/-t/-u/-fオプションを指定するときに使用されるものです。
例えば、-pオプションは引数で指定したプロセスIDがopenシステムコールを呼んだ時のみ出力するオプションなので、 BPFアプリケーションの引数解析処理が終わった後で引数指定したプロセスIDがセットされます。 C言語を知っている人からすれば、「constで宣言しているのに後で書き換える?」、「コンパイル単位が違うプログラムで 値を書き換える?」と思われるかもしれません。 この辺の疑問はBPFアプリケーションの解説時に後述するので、ここでは「constで宣言しているからrodataセクションに配置」 されるということだけご認識ください。

下の2つの構造体は、mapの宣言になります。
startは、プロセスIDをkeyに、openシステムコールに渡しているfilename、flagsをvalueにした最大エントリ数10240のhash mapで、 引数を一時的に格納するために使用されます。 eventsは、BPFプログラムの処理結果をBPFアプリケーション側と共有するためのものです。

SEC()は指定したセクションに変数を配置するためのマクロです。 SEC(".maps")となっているので、ELFの.mapセクションに配置されることになります。

5.2 BPFプログラムのメイン処理

ここからは、opensnoop.bpf.cのメイン処理です。 opensnoopは、openシステムコールとopenatシステムコールの両方をトレースしているのですが、 やっていることは同じなので本記事ではopenに関する部分のみ解説します。

 33 static __always_inline
 34 bool trace_allowed(u32 tgid, u32 pid)
 35 {
 36         u32 uid;
 37
 38         /* filters */
 39         if (targ_tgid && targ_tgid != tgid)
 40                 return false;
 41         if (targ_pid && targ_pid != pid)
 42                 return false;
 43         if (valid_uid(targ_uid)) {
 44                 uid = (u32)bpf_get_current_uid_gid();
 45                 if (targ_uid != uid) {
 46                         return false;
 47                 }
 48         }
 49         return true;
 50 }
 51
 52 SEC("tracepoint/syscalls/sys_enter_open")
 53 int tracepoint__syscalls__sys_enter_open(struct trace_event_raw_sys_enter* ctx)
 54 {
 55         u64 id = bpf_get_current_pid_tgid();
 56         /* use kernel terminology here for tgid/pid: */
 57         u32 tgid = id >> 32;
 58         u32 pid = id;
 59
 60         /* store arg info for later lookup */
 61         if (trace_allowed(tgid, pid)) {
 62                 struct args_t args = {};
 63                 args.fname = (const char *)ctx->args[0];
 64                 args.flags = (int)ctx->args[1];
 65                 bpf_map_update_elem(&start, &pid, &args, 0);
 66         }
 67         return 0;
 68 }
....
 88 static __always_inline
 89 int trace_exit(struct trace_event_raw_sys_exit* ctx)
 90 {
 91         struct event event = {};
 92         struct args_t *ap;
 93         int ret;
 94         u32 pid = bpf_get_current_pid_tgid();
 95
 96         ap = bpf_map_lookup_elem(&start, &pid);
 97         if (!ap)
 98                 return 0;       /* missed entry */
 99         ret = ctx->ret;
100         if (targ_failed && ret >= 0)
101                 goto cleanup;   /* want failed only */
102
103         /* event data */
104         event.pid = bpf_get_current_pid_tgid() >> 32;
105         event.uid = bpf_get_current_uid_gid();
106         bpf_get_current_comm(&event.comm, sizeof(event.comm));
107         bpf_probe_read_user_str(&event.fname, sizeof(event.fname), ap->fname);
108         event.flags = ap->flags;
109         event.ret = ret;
110
111         /* emit event */
112         bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
113                               &event, sizeof(event));
114
115 cleanup:
116         bpf_map_delete_elem(&start, &pid);
117         return 0;
118 }
119
120 SEC("tracepoint/syscalls/sys_exit_open")
121 int tracepoint__syscalls__sys_exit_open(struct trace_event_raw_sys_exit* ctx)
122 {
123         return trace_exit(ctx);
124 }

5.2.1 33-68行目

tracepoint__syscalls__sys_enter_openは、openシステムコールの呼び出しをイベントとして呼ばれる関数です。 55行目のbpf_get_current_pid_tgidはBPFヘルパー関数の1つで、呼び出した時点でのプロセスのスレッドIDとプロセスIDをカーネルが持つデータ構造から取得します。
処理の実体は、カーネル内に存在しています。
trace_allowedを使って判定(61行目)し、トレース対象であればopenシステムコールの引数であるファイル名とフラグをBPFヘルパー関数の bpf_map_update_elemを使ってmapに格納します。

5.2.2 88-124行目

tracepoint__syscalls__sys_exit_openは、openシステムコールが終了するときに呼ばれる関数です。 trace_exitでは、tracepoint__syscalls__sys_enter_openでmapに格納したデータを取り出し(96行目)、 さらにBPFアプリケーション側で必要になる情報をevent構造体にコピーしています(104-109行目)。
最終的に、BPFヘルパー関数であるbpf_perf_event_outputを呼び出し、BPFアプリケーションからデータが読みだせるようにします。

5.2.3 プログラムタイプとコンテキスト

BPFプログラムが呼ばれたときにカーネル側から渡される引数のことをコンテキストと呼びます。 tracepoint__syscalls__sys_exit_openとtracepoint__syscalls__sys_enter_openに渡されているctxがそれにあたります。
このコンテキストですが、何が渡されるかはプログラムタイプによって決まります。 プログラムタイプは、ユーザが作成したBPFプログラムの種別を表すもので、 カーネル内に定義値があり、BPFアプリケーションでBPFプログラムをロードする際に指定する必要があります。
プログラムタイプは、BPFプログラムから呼び出せるBPFヘルパー関数の種類や、そもそもどのタイミングでBPFプログラムがカーネルから呼ばれるかにも影響します。
プログラムタイプごとに呼び出し可能なBPFヘルパー関数は、BCCのドキュメントにまとめられています。

BPFアプリケーションがどのようにプログラムタイプを指定しているのかは後述しますが、先に結論を 書くとopensnoopは「BPF_PROG_TYPE_TRACEPOINT」を使用しています。
opensnoopのようにシステムコールに対して「BPF_PROG_TYPE_TRACEPOINT」を指定した場合、 BPFプログラムに渡されるコンテキストは以下のコマンドで調べることができます。 また、sys_exit_openに渡されるコンテキストは、sys_enter_openの部分をsys_exit_openに変更することで調べられます。

sudo cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_open/format

5.3 opensnoop.bpf.cのコンパイル

「make opensnoop」と実行すると、以下の流れでopensnoopの実行形式ファイルが生成されます。

clang -g -O2 -Wall -target bpf -D__TARGET_ARCH_x86 -Ix86/ -I.output -I../src/cc/libbpf/include/uapi -c opensnoop.bpf.c -o .output/opensnoop.bpf.o && llvm-strip -g .output/opensnoop.bpf.o
bin/bpftool gen skeleton .output/opensnoop.bpf.o > .output/opensnoop.skel.h
cc -g -O2 -Wall -I.output -I../src/cc/libbpf/include/uapi -c opensnoop.c -o .output/opensnoop.o
cc -g -O2 -Wall .output/opensnoop.o bcc/libbpf-tools/.output/libbpf.a .output/trace_helpers.o .output/syscall_helpers.o .output/errno_helpers.o .output/map_helpers.o .output/uprobe_helpers.o  -lelf -lz -o opensnoop

1行目は、Clang/LLVMでopensnoop.bpf.cをBPFバイトコードとしてコンパイルしています。
2行目は、bpftoolというBPFの開発ツールを使って、opensnoop.bpf.oからopensnoop.skel.hというヘッダファイルを 生成しています。この*.skel.hというのは、スケルトンヘッダと言われopensnoop.bpf.oの骨組みを抽出したものになります。
3行目でopensnoop.cをコンパイルし、4行目でlibbpfなどとリンクしてopensnoopが完成します。
BPFプログラムはカーネルにロードする必要があるため、opensnoop自体にリンクはされていません。 BPFアプリケーション(opensnoop.c)がどうやってBPFプログラムを読み込むかは、2行目で生成されるスケルトンヘッダ が関係しています。

6. opensnoop.cの解説

opensnoop.cは、opensnoopにおけるBPFアプリケーションにあたる部分です。
6.1、6.2章でBPFアプリケーションのメイン処理であるopensnoop.cの自体の解説を行い、 以降はメイン処理から呼ばれる各種処理について深追いしていきます。
自動生成のスケルトン部分とlibbpf内部の関数が入り乱れてしまいますが、 見分け方としては関数名が「opensnoop_」で始まるものがスケルトンヘッダ内に存在しているもので、 「bpf_」で始まるものがlibbpf内に存在しているものになります。

6.1 ヘッダファイルのインクルード

17 #include "opensnoop.h"
18 #include "opensnoop.skel.h"
19 #include "trace_helpers.h"

17行目でBPFプログラムと共有する構造体が定義されているopensnoop.hをインクルードしています。
また、18行目でBPFプログラムを使って自動生成したスケルトンヘッダをインクルードすることで、 スケルトンヘッダ内のバイトコードをメモリに読み込みます。

6.2 BPFアプリケーションのメイン処理

BPFアプリケーションのメイン処理は以下のようになっています。 処理概要としては以下のようになっています。

  1. opensnoop_bpf__open:スケルトンヘッダの読み込み、各種構造体の実体化、プログラムタイプの設定
  2. opensnoop_bpf__load:mapの作成、BPFプログラムのカーネルへのロード
  3. opensnoop_bpf__attach:BPFプログラムをイベントにアタッチ
  4. perf_buffer__poll:BPFプログラムがmapに書き込みをしたことを契機にハンドラを実行
215 int main(int argc, char **argv)
216 {
...
231         libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
232         libbpf_set_print(libbpf_print_fn);
233
234         obj = opensnoop_bpf__open();
...
241         obj->rodata->targ_tgid = env.pid;
242         obj->rodata->targ_pid = env.tid;
243         obj->rodata->targ_uid = env.uid;
244         obj->rodata->targ_failed = env.failed;
...
256         err = opensnoop_bpf__load(obj);
...
262         err = opensnoop_bpf__attach(obj);
...
278         /* setup event callbacks */
279         pb = perf_buffer__new(bpf_map__fd(obj->maps.events), PERF_BUFFER_PAGES,
280                               handle_event, handle_lost_events, NULL, NULL);
...
297         /* main: poll */
298         while (!exiting) {
299                 err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
...
308         }
...
315 }

6.3 231-232行目

231行目のlibbpf_set_strict_modeは、近い将来行われるlibbpfの大規模変更にあらかじめ適合するためのものです。 libbpfは2022年8月現在の最新版がv0.8.1ですが、v1.0では大規模な変更が行われるとアナウンスされています。

232行目は、opensnoopの出力関数の差し替えを行っています。 libbpf_print_fnは、vfprintfを使い標準エラー出力に出力しています。

6.4 opensnoop_bpf__open

opensnoop_bpf__openは、スケルトンヘッダの読み込み、各種構造体の実体化、プログラムタイプの設定など様々な処理を行い、opensnoop_bpf構造体を構築します。 opensnoop_bpf構造体の定義はopensnoop.skel.hにあります。

 11 struct opensnoop_bpf {
 12         struct bpf_object_skeleton *skeleton;
 13         struct bpf_object *obj;
 14         struct {
 15                 struct bpf_map *start;
 16                 struct bpf_map *events;
 17                 struct bpf_map *rodata;
 18         } maps;
 19         struct {
 20                 struct bpf_program *tracepoint__syscalls__sys_enter_open;
 21                 struct bpf_program *tracepoint__syscalls__sys_enter_openat;
 22                 struct bpf_program *tracepoint__syscalls__sys_exit_open;
 23                 struct bpf_program *tracepoint__syscalls__sys_exit_openat;
 24         } progs;
 25         struct {
 26                 struct bpf_link *tracepoint__syscalls__sys_enter_open;
 27                 struct bpf_link *tracepoint__syscalls__sys_enter_openat;
 28                 struct bpf_link *tracepoint__syscalls__sys_exit_open;
 29                 struct bpf_link *tracepoint__syscalls__sys_exit_openat;
 30         } links;
 31         struct opensnoop_bpf__rodata {
 32                 __u64 min_us;
 33                 pid_t targ_pid;
 34                 pid_t targ_tgid;
 35                 uid_t targ_uid;
 36                 bool targ_failed;
 37         } *rodata;
 38 };

前述の通り、スケルトンヘッダはbpftoolによりBPFプログラムから自動生成されるものであるため、構造体メンバの名前の多くがBPFプログラムで設定したものになっています。 それぞれのメンバの役割は必要に応じて後述しますが、opensnoop_bpf__openは、opensnoop_bpf構造体を作成して、値をセットするのがひとつの役割になっています。

opensnoop_bpf__openは、以下のようにopensnoop_bpf__open_optsを呼び出すだけです。

 53 static inline struct opensnoop_bpf *
 54 opensnoop_bpf__open_opts(const struct bpf_object_open_opts *opts)
 55 {
...
 59         obj = (struct opensnoop_bpf *)calloc(1, sizeof(*obj));
 ...
 65         err = opensnoop_bpf__create_skeleton(obj);
...
 69         err = bpf_object__open_skeleton(obj->skeleton, opts);
...
 78 }
 79
 80 static inline struct opensnoop_bpf *
 81 opensnoop_bpf__open(void)
 82 {
 83         return opensnoop_bpf__open_opts(NULL);
 84 }

59行目でopensnoop_bpf構造体をアロケートし、65行目でopensnoop_bpf__create_skeletonを呼び出し、スケルトン構造体を構築します。 opensnoop_bpf__create_skeletonは以下のようになっています。

124 static inline int
125 opensnoop_bpf__create_skeleton(struct opensnoop_bpf *obj)
126 {
127         struct bpf_object_skeleton *s;
128
129         s = (struct bpf_object_skeleton *)calloc(1, sizeof(*s));
130         if (!s)
131                 goto err;
132         obj->skeleton = s;
133
134         s->sz = sizeof(*s);
135         s->name = "opensnoop_bpf";
136         s->obj = &obj->obj;
137
138         /* maps */
139         s->map_cnt = 3;
140         s->map_skel_sz = sizeof(*s->maps);
141         s->maps = (struct bpf_map_skeleton *)calloc(s->map_cnt, s->map_skel_sz);
142         if (!s->maps)
143                 goto err;
144
145         s->maps[0].name = "start";
146         s->maps[0].map = &obj->maps.start;
147
148         s->maps[1].name = "events";
149         s->maps[1].map = &obj->maps.events;
150
151         s->maps[2].name = "opensnoo.rodata";
152         s->maps[2].map = &obj->maps.rodata;
153         s->maps[2].mmaped = (void **)&obj->rodata;
...
158         s->progs = (struct bpf_prog_skeleton *)calloc(s->prog_cnt, s->prog_skel_sz);
159         if (!s->progs)
160                 goto err;
161
162         s->progs[0].name = "tracepoint__syscalls__sys_enter_open";
163         s->progs[0].prog = &obj->progs.tracepoint__syscalls__sys_enter_open;
164         s->progs[0].link = &obj->links.tracepoint__syscalls__sys_enter_open;
...
170         s->progs[2].name = "tracepoint__syscalls__sys_exit_open";
171         s->progs[2].prog = &obj->progs.tracepoint__syscalls__sys_exit_open;
172         s->progs[2].link = &obj->links.tracepoint__syscalls__sys_exit_open;
...
178         s->data = (void *)opensnoop_bpf__elf_bytes(&s->data_sz);
...
186 static inline const void *opensnoop_bpf__elf_bytes(size_t *sz)
187 {
188         *sz = 12752;
189         return (const void *) "
190 \x7f\x45\x4c\x46\x02\x01\x01\0\0\0\0\0\0\0\0\0\x01\0\xf7\0\x01\0\0\0\0\0\0\0\0\
191 \0\0\0\0\0\0\0\0\0\0\0\xd0\x2c\0\0\0\0\0\0\0\0\0\0\x40\0\0\0\0\0\x40\0\x14\0\
192 \x01\0\xbf\x16\0\0\0\0\0\0\x85\0\0\0\x0e\0\0\0\x63\x0a\xfc\xff\0\0\0\0\x18\x01\

opensnoop_bpf__elf_bytesでreturnしているバイト列がBPFプログラムのバイトコードになります。 後の工程でこのELFバイナリをlibelfで解析して、必要な構造体に移し替えたりして最終的に カーネルにロードするような構造になっています。 opensnoopのリンクで-lelfを指定しているのはそのためです。
さて、opensnoop_bpf__create_skeletonを呼び出した結果、opensnoop_bpf構造体は図1のようになります。

図1 opensnoop_bpf構造体

図1の通り、bpf_object_skeleton構造体の中身はほとんどがopensnoop_bpf構造体への参照になっていますが、 これは以降の工程でlibbpfの処理に入っていくため、opensnoop_bpf構造体というopensnoopに特化した構造体からbpf_object_skeleton構造体という汎用的な構造体へデータを関連付けるためです。

bpf_object__open_skeletonは、以下のようになっています。

6957 static struct bpf_object *bpf_object_open(const char *path, const void *obj_buf, size_t obj_buf_sz,
6958                                           const struct bpf_object_open_opts *opts)
6959 {
...
6997         obj = bpf_object__new(path, obj_buf, obj_buf_sz, obj_name);
...
7032         err = err ? : bpf_object__init_maps(obj, opts);
7033         err = err ? : bpf_object_init_progs(obj, opts);
...
7044 }
...
7087 struct bpf_object *
7088 bpf_object__open_mem(const void *obj_buf, size_t obj_buf_sz,
7089                      const struct bpf_object_open_opts *opts)
7090 {
...
7094         return libbpf_ptr(bpf_object_open(NULL, obj_buf, obj_buf_sz, opts));
7095 }
...
11643 int bpf_object__open_skeleton(struct bpf_object_skeleton *s,
11644                               const struct bpf_object_open_opts *opts)
11645 {
...
11664         obj = bpf_object__open_mem(s->data, s->data_sz, &skel_opts);
...
11672         *s->obj = obj;
...
11699         }
...

libbpfの中はコード量が多いため、解説を行わない箇所は大胆に割愛しています。

bpf_object__open_skeletonは、 bpf_object__open_mem→bpf_object_openを呼びbpf_object構造体を作成します。 bpf_object構造体は、最終的にはbpfシステムコールを呼ぶときにカーネルに渡す引数を含んでいる重要な構造体です。 11672行目で、opensnoop_bpf構造体から参照できるようにセットされています。
6997行目のbpf_object__newではbpf_object構造体をアロケートとopensnoop.bpf.oのELFバイナリのバイト列の参照のセットなどを行っています。

6.4.1 bpf_object__init_maps

7032行目のbpf_object__init_mapsでは、mapの初期化の過程でBPFプログラム内のSEC(".maps")を使って宣言した変数に対する処理を以下の流れで行っています。
bpf_object__init_maps→bpf_object__init_user_btf_maps→bpf_object__init_user_btf_map→parse_btf_map_def
最終的には、BPFプログラム内で宣言したstartやeventsは、bpf_object構造体のmaps配列に格納され、後工程でbpfシステムコールを呼んでmapを作成する際に使われます。

また、以下の流れでBPFプログラム内でconst付きで宣言していたグローバル変数に関する処理も行っています。
bpf_object__init_maps→bpf_object__init_global_data_maps→bpf_object__init_internal_map

BPFプログラム内でグローバル変数に代入していた初期値は、1579行目でmmapシステムコールで確保した領域にコピーされています。

6.4.2 bpf_object_init_progs

6931行目でカーネルにロードするプログラムのプログラムタイプをセットしています。
前述の通り、opensnoopは「BPF_PROG_TYPE_TRACEPOINT」をセットするわけですが、 これは実はBPFプログラム内にあるSEC("tracepoint/syscalls/sys_enter_open")という指定方法が関係しています。

6923行目のfind_sec_defでは、以下のsection_defsテーブルを探索しセクション名がsection_defsテーブルに指定した文字列リテラルと一致するオブジェクトを探索しています。 その結果、以下のテーブルの8585行目に一致し、prog->sec_def->prog_typeが「BPF_PROG_TYPE_TRACEPOINT」になるというわけです。

8558 #define SEC_DEF(sec_pfx, ptype, atype, flags, ...) {                        \
8559         .sec = sec_pfx,                                                     \
8560         .prog_type = BPF_PROG_TYPE_##ptype,                                 \
8561         .expected_attach_type = atype,                                      \
8562         .cookie = (long)(flags),                                            \
8563         .preload_fn = libbpf_preload_prog,                                  \
8564         __VA_ARGS__                                                         \
8565 }
...
8574 static const struct bpf_sec_def section_defs[] = {
8575         SEC_DEF("socket",               SOCKET_FILTER, 0, SEC_NONE | SEC_SLOPPY_PFX),
...
8578         SEC_DEF("kprobe/",              KPROBE, 0, SEC_NONE, attach_kprobe),
8579         SEC_DEF("uprobe/",              KPROBE, 0, SEC_NONE),
8580         SEC_DEF("kretprobe/",           KPROBE, 0, SEC_NONE, attach_kprobe),
8581         SEC_DEF("uretprobe/",           KPROBE, 0, SEC_NONE),
...
8585         SEC_DEF("tracepoint/",          TRACEPOINT, 0, SEC_NONE, attach_tp),
8586         SEC_DEF("tp/",                  TRACEPOINT, 0, SEC_NONE, attach_tp),
...

6.5 241-245行目

ユーザが引数で指定したフィルタのオプション値を代入しています。 BPFプログラム内でtarg_tgidやtarg_pidなどを参照していますが、最終的にはここで設定した値を参照しています。 BPFプログラムのコンパイル単位では、const付きで宣言していたので変更不可ですが obj->rodataで参照するのは、bpf_object__init_internal_map内でmmapが確保した領域になっているため、変更ができるというわけです。
ただグローバル変数の変更が有効なのはopensnoop_bpf__open後、opensnoop_bpf__load前の区間だけになります。 opensnoop_bpf__load以降は、BPFプログラムはカーネルにロードされるため、BPFプログラム内で参照している変数の変更ができないためです。

6.6 opensnoop_bpf__load

opensnoop_bpf__loadは、スケルトンヘッダファイルの中に処理がありますが、 やっていることは以下のようにlibbpfのbpf_object__load_skeletonを呼ぶだけです。

86 static inline int
87 opensnoop_bpf__load(struct opensnoop_bpf *obj)
88 {
89         return bpf_object__load_skeleton(obj->skeleton);
90 }

bpf_object__load_skeletonは bpf_object__load→bpf_object_loadを呼びます。

7464 static int bpf_object_load(struct bpf_object *obj, int extra_log_level, const char *target_btf_path)
7465 {
...
7485         err = err ? : bpf_object__create_maps(obj);
...
7487         err = err ? : bpf_object__load_progs(obj, extra_log_level);
...
7530 }

6.6.1 bpf_object__create_maps

bpf_object_loadでは、7485行目でbpfシステムコールを使ってmapを作成しています。

bpf_object__create_maps→bpf_object__create_map→bpf_map_create→sys_bpf_fd→sys_bpf という流れで処理され、最終的にはbpfシステムコールをcmd=BPF_MAP_CREATEで呼び出すことで、mapを作成しています。
ちなみにrodataセクションに配置されたmapについては、 ここでbpf_object__populate_internal_mapを呼び出し、BPF_MAP_FREEZEでbpfシステムコールを呼び出すことで、read onlyにしています。

6.6.2 bpf_object__load_progs

bpf_object__load_progsでは、tracepoint__syscalls__sys_enter_openなどのBPFプログラムのロジック部分をカーネルにロードします。
bpf_object__load_progs→bpf_object_load_prog→bpf_object_load_prog_instance→bpf_prog_load→sys_bpf_prog_load
の流れで処理され、最終的にはbpfシステムコールをcmd=BPF_PROG_LOADで呼び出すことで、プログラム部分をカーネルにロードしています。

6.7 opensnoop_bpf__attach

opensnoop_bpf__loadでbpfシステムコールを呼び出し、Verifierで検証された後でJITコンパイルが行われネイティブコードになりカーネルのロードが完了します。
イベント発生時にBPFプログラムを動作させるには、ロードしたBPFプログラムをフックポイントにアタッチする必要があります。
これを行うのがopensnoop_bpf__attachです。 opensnoop_bpf__attachのコードは、スケルトンヘッダに存在し、以下のようにlibbpfのbpf_object__attach_skeletonを呼ぶだけです。

110 static inline int
111 opensnoop_bpf__attach(struct opensnoop_bpf *obj)
112 {
113         return bpf_object__attach_skeleton(obj->skeleton);
114 }

bpf_object__attach_skeletonはカーネルにロードした個々のBPFプログラムについて、bpf_program__attachを呼びます。
attach_fnは、ここで宣言されており、BPFプログラムをどこのセクションに配置したかで変わります。
tracepointセクションに配置された場合に呼ばれるattach_tpは、内部で bpf_program__attach_tracepoint→bpf_program__attach_tracepoint_optsと処理が進められます。 bpf_program__attach_tracepoint_optsがアタッチ処理のメイン部分になっていて、以下のようになっています。

10318 struct bpf_link *bpf_program__attach_tracepoint_opts(const struct bpf_program *prog,
10319                                                      const char *tp_category,
10320                                                      const char *tp_name,
10321                                                      const struct bpf_tracepoint_opts *opts)
10322 {
...
10333         pfd = perf_event_open_tracepoint(tp_category, tp_name);
...
10340         link = bpf_program__attach_perf_event_opts(prog, pfd, &pe_opts);
...
10350 }

perf_event_open_tracepointでperf_event_openシステムコールを呼び出し、PERF_TYPE_TRACEPOINTに対応したファイルディスクリプタを作成します。
perfとは、Linuxにおけるパフォーマンスモニタリングを行うための仕組みです。 CPUのカウンタ値を取得する際などに使用するperfコマンドは、内部でperf_event_openシステムコールを呼び出しているようです。
上記の通り、BPFでtracepointにアタッチする場合だと、perfの仕組みを利用していますが、 例えばBPFプログラム内のSEC()で"kprobe/"のように指定した場合は、kprobeの仕組みを利用します。
libbpf-toolsだと、例えばbindsnoopなどがkprobeの仕組みを使用しています。

bpf_program__attach_perf_event_opts→bpf_link_createと処理が行われ、最終的にはbpfシステムコールをcmd=BPF_LINK_CREATEで呼び出してアタッチしています。 ただし、カーネル側がBPF_LINK_CREATEに対応していない場合は、ioctl(PERF_EVENT_IOC_SET_BPF)でアタッチするようです。 その後、ioctl(PERF_EVENT_IOC_ENABLE)でperfイベントを有効することで、perfイベントが発生したときにBPFプログラムが実行されるようにしています。

6.8 279-308行目

アタッチが完了すれば、あとはBPFプログラムがmapに出力する内容をBPFアプリケーション側で読み取って出力するだけです。
perf_buffer__newとperf_buffer__pollは、まさにそれを行っており、 perf_buffer__newでポーリングするmapとイベントが発生したときに呼び出されるハンドラ(handle_event)をセットし、 終了条件を満たすまでperf_buffer__pollでイベント(mapへの書き込み)を待っています。
画面出力部分は、総じて難しいことは行っていないので割愛します。
また余談ですが、現在ではopensnoopで使用しているperf_bufferを使ったやり方よりも、ringbufを使う方がいいみたいです。本記事はopensnoopの解説がテーマなのでringbufは追及しません。

さて、perf_buffer__newは関数マクロになっており、実際にはperf_buffer__new_v0_6_0が呼ばれ、そこからメイン処理である __perf_buffer__newが呼ばれます。
__perf_buffer__newでは、cpu*という変数名がありますが、これはperf_bufferがCPUごとに持つバッファのためです。

__perf_buffer__newは以下のようになっています。

10945 static struct perf_buffer *__perf_buffer__new(int map_fd, size_t page_cnt,
10946                                               struct perf_buffer_params *p)
10947 {
...
10965         err = bpf_obj_get_info_by_fd(map_fd, &map, &map_info_len);
...
10991         pb->sample_cb = p->sample_cb;
...
10999         pb->epoll_fd = epoll_create1(EPOLL_CLOEXEC);
...
11038         for (i = 0, j = 0; i < pb->cpu_cnt; i++) {
...
11050
11051                 cpu_buf = perf_buffer__open_cpu_buf(pb, p->attr, cpu, map_key);
...
11059                 err = bpf_map_update_elem(pb->map_fd, &map_key,
11060                                           &cpu_buf->fd, 0);
...
11068
11069                 pb->events[j].events = EPOLLIN;
11070                 pb->events[j].data.ptr = cpu_buf;
11071                 if (epoll_ctl(pb->epoll_fd, EPOLL_CTL_ADD, cpu_buf->fd,
11072                               &pb->events[j]) < 0) {
...
11078                 }
...
11080         }
...
11091 }

10965行目でカーネル内に作成したmapのファイルディスクリプタを取得しています。 このmapはBPFプログラムでeventsとして宣言していたものです。
その後、pb(perf_buffer構造体)をアロケートし、10991行目でp->sample_cbを代入していますが、 このsample_cbがopensnoop.cから渡されるhandle_eventになります。

10999行目でepollのファイルディスクリプタを作成していますが、このファイルディスクリプタが後でperf_buffer__pollするときにイベント待ちに使われるものです。
11051行目でperf_buffer__open_cpu_bufを呼び、CPU数分バッファの作成とperf_event_openシステムコールの呼び出しなどを行っています。 perf_buffer__open_cpu_bufでやっていることは、BPFプログラム側で呼んでいるbpf_perf_event_outputを使うために必要なことのようです。
bpf_perf_event_outputのマニュアルにユーザ空間で読みだすためにはどのような手続きが必要かの記載があります。

11059行目でeventsとCPUごとのバッファを紐づけています。keyは、ループのインデックス(i)です。
11071行目でepoll_ctlを呼び、CPUごとのバッファをepollの監視対象にします。

次にperf_buffer__pollですが、以下のようなコードになっています。

11106 static enum bpf_perf_event_ret
11107 perf_buffer__process_record(struct perf_event_header *e, void *ctx)
11108 {
11109         struct perf_cpu_buf *cpu_buf = ctx;
11110         struct perf_buffer *pb = cpu_buf->pb;
11111         void *data = e;
...
11117         switch (e->type) {
11118         case PERF_RECORD_SAMPLE: {
11119                 struct perf_sample_raw *s = data;
11120
11121                 if (pb->sample_cb)
11122                         pb->sample_cb(pb->ctx, cpu_buf->cpu, s->data, s->size);
11123                 break;
11124         }
...
11135         }
11136         return LIBBPF_PERF_EVENT_CONT;
11137 }
...
11139 static int perf_buffer__process_records(struct perf_buffer *pb,
11140                                         struct perf_cpu_buf *cpu_buf)
11141 {
...
11144         ret = perf_event_read_simple(cpu_buf->base, pb->mmap_size,
11145                                      pb->page_size, &cpu_buf->buf,
11146                                      &cpu_buf->buf_size,
11147                                      perf_buffer__process_record, cpu_buf);
...
11151 }
...
11158 int perf_buffer__poll(struct perf_buffer *pb, int timeout_ms)
11159 {
...
11162         cnt = epoll_wait(pb->epoll_fd, pb->events, pb->cpu_cnt, timeout_ms);
...
11165
11166         for (i = 0; i < cnt; i++) {
11167                 struct perf_cpu_buf *cpu_buf = pb->events[i].data.ptr;
11168
11169                 err = perf_buffer__process_records(pb, cpu_buf);
...
11174         }
...
11176 }

11162行目でepoll_waitでCPUごとのバッファが読み出し可能になるかタイムアウト(100ミリ秒)になるまでブロックします。
バッファに書き込みが発生したら、11167行目でバッファへのポインタを取り出してperf_buffer__process_recordsを呼び出します。 __perf_buffer__newで作成したバッファは、リングバッファとして使用されており、perf_event_read_simpleでは、実際にバッファに書き込まれたデータを取り出し、 perf_buffer__process_recordに渡します。 最終的には、こちらで呼ばれるsample_cbの呼び出しが、opensnoop.cで渡したhandle_eventになっており、BPFプログラムから渡されたデータの出力が行われます。

7. おわりに

BPFは、ツールも含めまだまだ絶賛開発中の技術です。 昨年は、WindowsでBPFをサポートすることをマイクロソフトが発表するなどLinux以外のOSでもBPFの仕組みは広がりつつあります。
ちなみにBPFがどのような方向に進んでいっているのかは、 Linux Kernel Developers' bpfconf 2022の資料が参考になるかもしれません。

8. 参考文献

Liz Rice. What is eBPF? . O'Reilly Media, Inc, April 2022,

*1:2022年8月11日にv0.25.0がリリースされましたが、本記事では執筆時点で最新だったv0.24.0を対象にしています