本文是 eBPF 系列的第二篇文章,我們來學習 eBPF BCC 框架的進階用法,對上一篇文章中的程式碼進行升級,動態輸出程式執行時的引數情況。
主要內容包括:
- 透過
kprobe
掛載核心事件的 eBPF 程式要如何編寫?- 透過
tracepoint
掛載核心事件的 eBPF 程式要如何編寫?- eBPF 的程式事件型別有哪些?
在開始之前,我們來回顧一下前一篇文章的內容。
前一篇文章介紹瞭如何透過 BCC 框架來編寫一個簡單的 eBPF 程式。在核心空間,使用 c
程式實現 eBPF 的核心邏輯;在使用者空間,使用 python
指令碼作為 eBPF 程式的控制、載入和展示。其中,核心態透過若干 eBPF helper 函式,獲取核心觀測資料,並透過 PERF
區域,將這些資料傳遞到使用者空間;使用者態使用attach_kprobe()
將核心 eBPF 函式繫結到某個核心事件上。
整個流程如下圖所示:
在上面的實現過程中,使用者態透過 kprobe
的方式,為某個核心事件掛載自定義處理邏輯(圖中是指定了核心中 do_execve
函式)。透過這種方式,我們能夠監測絕大部分的核心函式,這正是 eBPF 技術牛逼的原因。
對於這種 kprobe
型別的 eBPF 程式,我們再來看一個例子(改編自 Brendan Gregg 大神的 execsnoop
工具:https://github.com/iovisor/bcc/blob/master/tools/execsnoop.py )
1 程式執行引數的監控
接下來,我們要對上圖中的工具再次進行功能升級,我希望這個工具在執行時,能夠輸出當前執行程式的引數資訊。
如果將 eBPF 程式等同於 C 程式來看,這個問題似乎沒那麼困難。何以見得?
1.1 分析
sys_execve
系統呼叫的函式簽名為:int execve(const char *filename, char *const argv[], char *const envp[])
, 其中,argv[]
便記錄了程式執行的引數。我們大可以像提取 filename
的方式那樣,提取 argv[]
,並將其傳入到使用者空間中。
但實際上,eBPF 程式與 C 程式並不等同。eBPF 程式設計中有 “兩座大山” 般的限制,分別是:
限制一:eBPF 程式執行棧僅有 512 位元組。
限制二:eBPF 程式可以呼叫的介面極其有限。
因此,如果我們想嘗試在 512 位元組的 eBPF 執行棧中完整拼接整理不定長的 argv[]
引數列表,是根本不可能的。
基於以上分析,本文給出一個比較合理的解決方案:
Q:如何防止執行棧爆棧?
1)既然執行棧有大小限制,不如直接將拼接操作轉移到使用者態完成。eBPF 程式只需要將 argv[]
陣列中每個 argv
傳輸到使用者態程式中。
2)對於長度過長的 argv
,沒辦法了,只能手動截斷了。
Q:使用者態何時進行引數拼接?何時進行引數展示?
1)既然需要使用者態完成拼接,那麼,可以分為兩個階段。STEP-1,僅專注字串的拼接;STEP-2,僅專注字串展示。
2)對於 execve
系統呼叫,我們可以在 enter 時執行 STEP-1 操作,在 exit 是執行 STEP-2 操作。
接下來更新程式碼。
1.2 定義
首先,對於用於互動的結構體,增加兩個個欄位,其一用於記錄 execve
呼叫的每個引數,其二用於記錄 eBPF 執行的階段;同時,去掉冗餘欄位 fname
#define ARGSIZE 128
#define MAXARG 60
enum event_step {
STEP_1, // STEP 1: 執行 argv 拼接
STEP_2, // STEP 2: 執行 argv 展示
};
struct data_t {
u32 pid;
enum event_step step; // 記錄 eBPF 執行階段
char comm[TASK_COMM_LEN];
char argv[ARGSIZE]; // 記錄每一個引數
};
定義 BPF_PERF_OUTPUT
:
BPF_PERF_OUTPUT(events);
1.3 處理
實現 execve
系統呼叫 enter
和 exit
回撥函式:
// exter execve
int syscall__execve(struct pt_regs *ctx, const char __user *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp) {
struct data_t data = {};
// 設定 step = STEP 1
data.step = STEP_1;
// 設定 pid
data.pid = bpf_get_current_pid_tgid() >> 32;
// 設定 comm
bpf_get_current_comm(&data.comm, sizeof(data.comm));
// 設定每一個 argv,並匯出
...
return 0;
}
// exit execve
int do_ret_sys_execve(struct pt_regs *ctx) {
struct data_t data = {};
// 設定 step = STEP 1
data.step = STEP_2;
// 設定 pid
data.pid = bpf_get_current_pid_tgid() >> 32;
// 設定 comm
bpf_get_current_comm(&data.comm, sizeof(data.comm));
// 提交 perf
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
注意,這裡 bpf_get_current_pid_tgid()
輔助函式返回值高 32 為核心視角下的 process ID
(使用者視角下為 TID),低 32 位為核心視角下的 thread group ID
(使用者視角下的 PID)。這裡右移 32 位,是獲取使用者視角的 PID。
1.4 繫結
使用者態繫結 kprobe
事件:
b = BPF(src_file="execsnoop.c")
execve_fnname = b.get_syscall_fnname("execve")
# enter 事件
b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve")
# exit 事件
b.attach_kretprobe(event=execve_fnname, fn_name="do_ret_sys_execve")
1.5 難點
核心態如何設定並匯出每一個 argv[]
?
// 字串提交
static int __submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {
// 提交 perf 之前,需要複製到使用者態變數中
bpf_probe_read_user(data->argv, sizeof(data->argv), ptr);
// 將這個 argv 提交
events.perf_submit(ctx, data, sizeof(struct data_t));
return 1;
}
// 字串控制
static int submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {
const char *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), ptr);
// 是否到達末尾字串
if (argp) {
return __submit_arg(ctx, (void *)(argp), data);
}
return 0;
}
int syscall__execve(struct pt_regs *ctx, const char __user *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp) {
// 設定過程
...
// (A) 設定每一個 argv,並匯出
#pragma unroll
for (int i = 1; i < MAXARG; i++) {
if (submit_arg(ctx, (void *)&__argv[i], &data) == 0)
goto out;
}
// (B) 如果當前的 argv[] 太長了,進行截斷操作
char ellipsis[] = "...";
__submit_arg(ctx, (void *)ellipsis, &data);
out:
return 0;
}
關注核心的兩個步驟:
(A) MAXARG
代表一個 argv[]
的最大監測數量。首先要遍歷這個 argv[]
的每一個字串,如果這個字元不為 NULL
(說明沒有到當前 argv[]
結尾)或不超過最大值 MAXARG
,那麼將每個字串提交到 PERF
區域。
注意:
低版本(5.3 以前)的 eBPF 程式不支援迴圈。5.3 版本後也僅支援有界迴圈。在低版本的 eBPF 中使用迴圈有一個小技巧,那就是透過#pragma unroll
進行編譯器迴圈展開預處理。
(B) 如果超過了這個最大數量 MAXARG
,後面及時再有引數,也進行截斷處理。
1.6 拼接
使用者態獲取和拼接引數列表是基於 eBPF 階段的。
from collections import defaultdict
argv = defaultdict(list)
class EventStep(object):
STEP_1 = 0
STEP_2 = 1
# PERF 事件回撥處理
def print_event(cpu, data, size):
event = b["events"].event(data)
# STEP 1:拼接
if event.step == EventStep.STEP_1:
argv[event.pid].append(event.argv)
# STEP 2:顯示
elif event.step == EventStep.STEP_2:
argv_text = b' '.join(argv[event.pid]).replace(b'\n', b'\\n')
printb(b"%-16s %-7d %s" % (event.comm, event.pid, argv_text))
try:
del(argv[event.pid])
except Exception:
pass
# 繫結 PERF 事件回撥處理
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
使用者態程式需要注意:event
事件透過 PERF
獲取的結構資料為 Byte
型別,需要透過 decode('utf-8')
/encode()
與 str
型別進行轉換。
1.7 完整程式碼和執行效果
// execsnoop.c
#include <linux/sched.h>
#include <linux/fs.h>
#define ARGSIZE 128
#define MAXARG 60
enum event_step {
STEP_1,
STEP_2,
};
struct data_t {
u32 pid;
enum event_step step;
char comm[TASK_COMM_LEN];
char argv[ARGSIZE];
};
BPF_PERF_OUTPUT(events);
static int __submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {
bpf_probe_read_user(data->argv, sizeof(data->argv), ptr);
events.perf_submit(ctx, data, sizeof(struct data_t));
return 1;
}
static int submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {
const char *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), ptr);
if (argp) {
return __submit_arg(ctx, (void *)(argp), data);
}
return 0;
}
// exter execve
int syscall__execve(struct pt_regs *ctx, const char __user *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp) {
struct data_t data = {};
data.step = STEP_1;
data.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
#pragma unroll
for (int i = 1; i < MAXARG; i++) {
if (submit_arg(ctx, (void *)&__argv[i], &data) == 0)
goto out;
}
char ellipsis[] = "...";
__submit_arg(ctx, (void *)ellipsis, &data);
out:
return 0;
}
// exit execve
int do_ret_sys_execve(struct pt_regs *ctx) {
struct data_t data = {};
data.step = STEP_2;
data.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
# execsnoop.py
#!/usr/bin/python3
from bcc import BPF
from bcc.utils import printb
from collections import defaultdict
argv = defaultdict(list)
class EventStep(object):
STEP_1 = 0
STEP_2 = 1
b = BPF(src_file="execsnoop.c")
execve_fnname = b.get_syscall_fnname("execve")
b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve")
b.attach_kretprobe(event=execve_fnname, fn_name="do_ret_sys_execve")
print("%-7s %-16s %s" % ("PID", "PCOMM", "ARGS"))
# process event
def print_event(cpu, data, size):
event = b["events"].event(data)
fname = ""
if event.step == EventStep.STEP_1:
argv[event.pid].append(event.argv)
elif event.step == EventStep.STEP_2:
argv_text = b' '.join(argv[event.pid]).replace(b'\n', b'\\n')
printb(b"%-7d %-16s %s" % (event.pid, event.comm, argv_text))
try:
del(argv[event.pid])
except Exception:
pass
# loop with callback to print_event
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
執行效果:
2 Tracepoint 追蹤點
前文提到過,kprobe
方式,幾乎可以使 eBPF 掛載到核心中任意一個函式事件上,隨著核心函式的執行而觸發。但是,由於不同的核心版本,其某個具體函式的定義、引數和實現可能會有所不同(kprobe
實現的事件處理函式要求和掛載點函式擁有相同的引數)。因此,使用 kprobe
方式實現的 eBPF 程式可能無法在其他核心的主機上執行。此外,kprobe
無法掛載到靜態函式或行內函數上。而出於效能考慮,大部分網路相關的內層函式都是內聯或者靜態的,因此,kprobe
方式在這些領域也只能望洋興嘆了。
上述兩點,均為 kprobe
方式的侷限性,它並不具備很好的可移植性。於是,從 Linux 核心 4.7 開始,能讓 eBPF 使用的 tracepoint
出現了(官方文件)。tracepoint
是由核心開發人員在程式碼中設定的靜態 hook 點,具有穩定的 API 介面,不會隨著核心版本的變化而變化。但由於 tracepoint 是需要核心研發人員引數編寫,其數量有限,並不是所有的核心函式中都具有類似的跟蹤點,所以從靈活性上不如 kprobes 這種方式。
2.1 kprobe 和 tracepoint 對比
在 3.10 核心中,kprobe
與 tracepoint
方式對比如下:
內容 | kprobe | tracepoint |
---|---|---|
追蹤型別 | 動態 | 靜態 |
Hook 點數量 | 100000+ | 1200+ |
穩定的 API | 否 | 是 |
可以使用以下命令檢視系統支援的 tracepoint
,支援 grep
檢索。
perf list
perf list | grep execve
上面的執行結果可以看到,execve
系統呼叫具有兩個 syscalls
型別的靜態跟蹤點,並且,tracepoint
已經對 enter 和 exit 做了區分,其功能基本等同於 kprobe
/kretprobe
。
在使用 tracepoint
之前,我們需要了解 tracepoint
相關引數的格式。syscalls:sys_enter_execve
格式定義在 /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format
檔案中。
# 檢視 syscalls:sys_enter_execve 引數
cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format
2.2 重構程式碼
接下來,使用 tracepoint
方式重構第 1 節的程式碼,如下:
// execsnoop.c
#include <linux/sched.h>
#include <linux/fs.h>
#define ARGSIZE 128
#define MAXARG 60
enum event_step {
STEP_1,
STEP_2,
};
struct data_t {
u32 pid;
char comm[TASK_COMM_LEN];
enum event_step step;
char argv[ARGSIZE];
};
BPF_PERF_OUTPUT(events);
static int __submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {
bpf_probe_read_user(data->argv, sizeof(data->argv), ptr);
events.perf_submit(ctx, data, sizeof(struct data_t));
return 1;
}
static int submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {
const char *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), ptr);
if (argp) {
return __submit_arg(ctx, (void *)(argp), data);
}
return 0;
}
// (A) sys_enter_execve tracepoint
TRACEPOINT_PROBE(syscalls, sys_enter_execve) {
struct data_t data = {};
const char **argv = (const char **) (args->argv);
data.step = STEP_1;
data.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
#pragma unroll
for (int i = 1; i < MAXARG; i++) {
// (B) args 強制轉換為 ctx
if (submit_arg((struct pt_regs *)args, (void *)&argv[i], &data) == 0)
goto out;
}
char ellipsis[] = "...";
__submit_arg((struct pt_regs *)args, (void *)ellipsis, &data);
out:
return 0;
}
// sys_exit_execve tracepoint
TRACEPOINT_PROBE(syscalls, sys_exit_execve) {
struct data_t data = {};
data.step = STEP_2;
data.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
events.perf_submit(args, &data, sizeof(data));
return 0;
}
# execsnoop.py
#!/usr/bin/python3
from bcc import BPF
from bcc.utils import printb
from collections import defaultdict
argv = defaultdict(list)
class EventStep(object):
STEP_1 = 0
STEP_2 = 1
# (C) 不再透過 kprobe 繫結
b = BPF(src_file="execsnoop.c")
print("%-7s %-16s %s" % ("PID", "PCOMM", "ARGS"))
# process event
def print_event(cpu, data, size):
event = b["events"].event(data)
if event.step == EventStep.STEP_1:
argv[event.pid].append(event.argv)
elif event.step == EventStep.STEP_2:
argv_text = b' '.join(argv[event.pid]).replace(b'\n', b'\\n')
printb(b"%-7d %-16s %s" % (event.pid, event.comm, argv_text))
try:
del(argv[event.pid])
except Exception:
pass
# loop with callback to print_event
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
注意:
A)一個 tracepoint
定義接收兩個引數,TRACEPOINT_PROBE(syscalls, sys_enter_execve)
第一個為子系統名稱,第二個為事件名稱。
B)tracepoint
中的所有引數都會包含在一個固定名稱的 args
的結構體中。args
型別為 struct tracepoint__syscalls__sys_enter_open
,其第一個欄位為 u64 __do_not_use__;
,該欄位為 ctx
的保留位置。因此,args
可以被強制轉換為 ctx
。
ctx
是啥?在《Linux 核心觀測技術 BPF》一書中,
ctx
被稱為“上下文”,提供了訪問核心正在處理的資訊。我們可以透過PT_REGS_RC(ctx)
來獲取當前函式的返回值。
C)使用者態程式碼不再需要 attach_kprobe
手動繫結。
3 eBPF 程式事件型別
像是 kprobe
、tracepoint
將 eBPF 程式掛載到核心事件的方式,可以暫且被稱為 eBPF 事件型別。事實上,除了以上列出的兩種,eBPF 事件型別還有很多,選取其中一些列舉如下:
kprobes/kretprobes
:核心函式事件。不再贅述。tracepoint
:核心跟蹤點事件。不再贅述。uprobes/uretprobes
:使用者空間函式事件,可以繫結監聽一個使用者空間的函式。USDT probes
:使用者自定義的靜態追蹤點。使用者可以在使用者空間的程式中插入靜態追蹤點,用於掛載 eBPF。LSM Probes
:LSM Hook 掛載點。需要核心版本 5.7 以上。
由於篇幅限制,不再列舉其他 eBPF 事件型別了,後面如果有精力,再補一篇文章。
4 總結
本文在前一篇文章的基礎上,對程式執行監控工具(execsnoop)進行了升級,實時列印程式執行時傳入的引數列表;並透過 kprobe
和 tracepoint
兩種方式,繫結 eBPF 程式,給出了程式碼實現。同時,對這兩種 eBPF 事件型別進行了簡單比較。顯然,在你手動開發一個 eBPF 程式時,建議使用 tracepoint
,以追求更好的穩定性和可移植性。文章的最後,簡單列出了一些支援的 eBPF 事件型別。
以上拋磚引玉,如有不正確指出,請大家及時斧正。如果你喜歡這篇文章,請點個推薦吧!