BPF前端工具BCC与bpftrace
一、概述
BCC和bpftrace到底是什么,与BPF是什么关系呢?
经过上一篇的介绍,BPF是内核中的执行引擎,BCC和bpftrace则是两个前端工具,比如用户可以直接使用的命令行工具。
BCC与bpftrace又有何区别?
bpftrace是基于BPF和BCC开发出来的,bpftrace适合临时创造单行程序和短小脚本进行观测,而BCC更适合编写复杂的工具和守护进程。BCC提供的API分为内核态和用户态的,而bpftrace只有一种API即bpftrace编程语言。
BCC与bpftrace都是基于libbcc和libbpf库进行构建的。
二、BCC
BCC全称是BPF Compiler Collection,BPF编译器集合,简称BCC,它包含了构建BPF软件的编译器框架和库,是BPF的主要前端项目。
组件
BCC组件可以用Brendan Gregg的一张图来表示:
安装
BPF组件是在内核4.1至4.9之间开发,最好选择内核4.9及以上的系统。
内核配置需要开启(一般默认开启了):
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_JIT=y
CONFIG_HAVE_EBPF_JIT=y
Ubuntu
ubuntu上安装一般可能出现的问题较少,包名字叫bpfcc-tools。
sudo apt install bpfcc-tools linux-headers-$(uname -r)
# 或者
sudo snap install gcc
RHEL
Centos7我试了很久,安装问题总是不断,不推荐。
sudo yum install bcc-tools
# 问题可能出在:
# 1.内核版本过低; 2. 缺少对应的kernel-devel(yum install kernel-devel-$(unamr -r); 3. 缺少kernel-headers)
工具
BCC工具有很多,单一用途或多用途工具都有,可以主要按以下分类:
功能点 | 工具 |
---|---|
调试手段 | trace、argdist、funccount、stackcount、opensnoop |
CPU相关 | execsnoop、runqlat、runqlen、cpudist、profile、offcputime、syscount、softirq、hardirq |
内存相关 | menleak |
文件系统 | opensnoop、filelife、vfsstat、fileslower、cachestat、writeback、dcstat、xfsslower、xfsdist、ext4dist |
磁盘I/O | biolatency、biosnoop、biotop、bitesize |
网络相关 | tcpconnect、tcpaccept、tcplife、tcpretrans |
安全相关 | capable |
三、bpftrace
组件
所有的bpftrace工具都是以.bt作为文件后缀名。前端使用lex和yacc来对bpftrace语言进行词法和语法分析,使用Clang来解析结构体。后端则将bpftrace程序编译成LLVM中间表示形式,再通过LLVM库将其编译为BPF代码。
安装
Ubuntu
sudo apt update
sudo apt install bpftrace
工具
主要分类:
功能点 | 工具 |
---|---|
调试手段 | execsnoop.bt、threadsnoop.bt、opensnoop.bt、killsnoop.bt、signals.bt |
CPU相关 | execsnoop.bt、runqlat.bt、runqlen.bt、cpuwalk.bt、offcputime.bt |
内存相关 | oomkill.bt、failts.bt、vmscan.bt、swapin.bt |
文件系统 | vfsstat.bt、filelife.bt、xfsdist.bt |
存储I/O | biosnoop.bt、biolatency.bt、bitesize.bt、biostacks.bt、scsilatency.bt、nvmelatency.bt |
网络相关 | tcpaccept.bt、tcpconnect.bt、tcpdrop.bt、tcpretrans.bt、gethostlatency.bt |
安全相关 | ttysnoop.bt、elfsnoop.bt、setuids.bt |
编程相关 | jnistacks.bt、javacalls.bt |
应用相关 | threadsnoop.bt、pmheld.bt、naptime.bt、mysqld_qslower.bt |
内核相关 | mlock.bt、mheld.bt、kmem.bt、kpages.bt、workq.bt |
容器相关 | pidnss.bt、blkthrot.bt |
虚拟机 | xenhyper.bt、cpustolen.bt、kvmexits.bt |
bpftrace编程
基础语法
1. 程序结构
bpftrace程序结构是一系列探针加对应的动作
probes { actions }
probes { actions }
...
探针被激活时,相应动作会被执行。也可以在探针前设置一个可选的过滤表达式
probes /filter/ { actions }
/pattern/ { actions }
2. 注释
单行代码使用"//"来注释
// this is a comment
多行代码注释和C语言一样
/*
* this is
* a muti-line
* comment
*/
3. 探针格式
探针(probe)以类型名开始,然后是一系列冒号分隔的标识符
type:identifier1[:identifier2[...]]
标识符的组织形式由探针类型决定,比如
kprobe:vfs_read
uprobe:/bin/bash:readline
- kprobe探针类型,内核态函数插桩,只需要一个标识符:内核函数名
- uprobe探针类型,用户态函数插桩,需要两个标识符:二进制文件路径和函数名
可以使用逗号将多个探针并列,指向同一个执行动作,如
probe1,probe2,... { actions }
有两个特殊的探针类型不需要标识符:BEGIN和END,它们会在bpftrace程序启动和结束时触发(类似awk命令)
4. 探针通配符
有些探针接受通配符,比如下面这个,会对有的以"vfs_"开头的内核函数进行插桩
kprobe:vfs_*
但如果同时开启很多的函数插桩,对性能有影响。环境变量"BPFTRACE_MAX_PROBES"可以设置探针数量上限。
也可以插桩之前使用"bpftrace -l"命令进行统计有多少个探针数量比配,做个评估
bpftrace -l "kprobe:vfs_*" | wc -l
5. 过滤器
过滤器是一个布尔表达式,决定一个动作是否被执行
//过滤进程pid为123
/pid == 123/
//筛选pid为非零
/pid/
//过滤器也可使布尔运算组合
/pid > 100 && pid < 1000/
6. 执行动作
一个动作既可以是单条语句,也可以是分号分隔的多条语句
{ action one; action two; action three }
全部语句最后也可以加分号。语句采用bpftrace语法,类似C语言,可以操作变量和执行bpftrace函数调用
//创建变量x并赋值42,然后打印出来
{ $x = 42; printf("$x is %d", $x); }
7. 变量
变量类型 | 含义 | 举例 |
---|---|---|
内置变量 | bpftrace预先定义好并提供 | pid,comm(进程名),nsecs(纳秒时间戳),curtask(当前线程task_struct结构体地址) |
临时变量 | 临时计算使用,"$"开头 |
x
=
1
;
<
b
r
/
>
x = 1;<br />
x=1;<br/>y = “hello”; $z = (struct task_struct*) curtask; |
映射表变量 | "@"前缀,用于存储对象 | @start[tid] = nsecs; //内置变量nsecs赋值给一个名为start、以tid为key的映射表,这允许每个线程存储自己的时间戳 @path[pid, f d ] = s t r ( a r g 0 ) ; / / 这是一个复合键的映射表,同时使用内置变量 p i d 和 fd] = str(arg0); //这是一个复合键的映射表,同时使用内置变量pid和 fd]=str(arg0);//这是一个复合键的映射表,同时使用内置变量pid和fd组合key |
8. 映射表函数
映射表可以通过特定的统计函数赋值,这些函数以特殊方式来存储和打印数据,这里使用了每个CPU独立的映射表
@x = count(); //对时间进行累计统计,打印时会答应出累计结果
下面这个语句也会对事件进行计数,但使用的是全局映射表,非每个CPU独立的映射表,@x的类型是整数
@x++;
其他举例:
@y = sum($x); //对$x求和,打印时打印出总数
@z = hist($x); //将$x保存在一个以2的幂为区间的直方图中
delete(@start[tid]); //从@start中删除key为tid的值
9. 实例
统计内核函数vfs_read()计时并以直方图打印
#!/usr/bin/bpftrace
kprobe:vfs_read
{
@start[tid] = nsecs;
}
kretprobe:vfs_read
/@start[tid]/
{
$duration_us = (nsecs - @start[tid]) / 1000;
@us = hist($duration_us);
delete(@start[tid]);
}
执行效果:
探针类型
类型 | 缩写 | 描述 | 访问参数 |
---|---|---|---|
tracepoint | t | 内核静态插桩点 | 内置变量args |
usdt | U | 用户静态定义插桩点 | 内置变量args |
kprobe | k | 内核动态函数插桩 | arg0, arg1, …, argN |
kretprobe | kr | 内核动态函数返回值插桩 | retval |
uprobe | u | 用户态动态函数插桩 | arg0, arg1, …, argN |
uretprobe | ur | 用户态动态函数返回值插桩 | retval |
software | s | 内核软件事件 | |
hardware | h | 硬件基于计数器的插桩 | |
profile | p | 对全部CPU进行时间采样 | |
interval | i | 周期性报告(从一个CPU上) | |
BEGIN | bpftrace启动 | ||
END | bpftrace推出 |
1. tracepoint
tracepoint会对内核跟踪点进行插桩,格式
tracepoint: tracepoint_name
tracepoint_name是跟踪点的全名,包括用来将跟踪点所在的类别和事件名字分隔开的冒号。如tracepoint:net:netif_rx是对net:netif_rx这个跟踪点进行插桩。
跟踪点通常带有参数,bpftrace可以通过内置变量args来访问这些参数。如net:netif_rx有一个代表数据包长度的参数len,可以通过args->len 来访问。可以通过"bpftrace -lv"来查看
root@pc:test# bpftrace -lv tracepoint:net:netif_rx
tracepoint:net:netif_rx
void * skbaddr
unsigned int len
__data_loc char[] name
比如sys_enter_read是对系统调用read(2)的开始处插桩,man手册可以看到read(2)的帮助文档:
ssize_t read(int fd, void *buf, size_t count);
其有三个参数,那么对于sys_enter_read跟踪点来说,bpftrace中可以用args->fd, args->buf, args->count来访问这三个参数。
root@pc:test# bpftrace -lv tracepoint:syscalls:sys_enter_read
tracepoint:syscalls:sys_enter_read
int __syscall_nr
unsigned int fd
char * buf
size_t count
一个有趣的例子,对系统调用clone(2)的开始和推出进行插桩,test_sys_clone.bt代码
#!/usr/bin/bpftrace
tracepoint:syscalls:sys_enter_clone
{
printf("\n-> clone() by %s PID %d\n", comm, pid);
}
tracepoint:syscalls:sys_exit_clone
{
printf("<- clone() return %d, %s PID %d\n", args->ret, comm, pid);
}
执行,同时开启另一个terminal窗口
root@pc:test# ./sys_clone.bt
Attaching 2 probes...
-> clone() by gnome-terminal- PID 3287
<- clone() return 76637, gnome-terminal- PID 3287
<- clone() return 0, gnome-terminal- PID 76637
-> clone() by bash PID 76637
<- clone() return 76639, bash PID 76637
<- clone() return 0, bash PID 76639
-> clone() by lesspipe PID 76639
<- clone() return 76640, lesspipe PID 76639
<- clone() return 0, lesspipe PID 76640
-> clone() by lesspipe PID 76639
<- clone() return 76641, lesspipe PID 76639
<- clone() return 0, lesspipe PID 76641
-> clone() by lesspipe PID 76641
<- clone() return 76642, lesspipe PID 76641
<- clone() return 0, lesspipe PID 76642
-> clone() by bash PID 76637
<- clone() return 76643, bash PID 76637
<- clone() return 0, bash PID 76643
^C
有趣的是clone(2)总是进入一次,返回两次,一次是父进程中返回子进程PID,一次是子进程中返回0。
2. usdt
usdt探针是对用户态静态探针点进行插桩,格式为:
usdt:binary_path:probe_name
usdt:library_path:probe_name
usdt:binary_path:probe_namespace:probe_name
usdt:library_path:probe_namespace:probe_name
usdt可以对完整路径的二进制文件或者共享库进行插桩。probe_name为USDT的探针名字。
USDT探针的任意参数,都可以用bpftrace内置变量args访问。
3. kprobe和kretprobe
内核态动态插桩,格式为
kprobe:function_name
kretprobe:function_name
kprobe的参数"arg0, arg1, … argN"是函数入参,都为64位无符号整型。
kretprobe的参数:内置的retval是函数的返回值。retval永远是64无符号整型,如果和函数返回值不一致,需要强制转化。
4. uprobe和uretprobe
用户态动态插桩,格式为
uprobe:binary_path:function_name
uprobe:library_path:function_name
uretprobe:binary_path:function_name
uretprobe:library_path:function_name
uprobe的参数:arg0,arg1,…,argN 是进入函数时的入参,类型均为64位无符号整型,如果是执行C结构体的指针,可以强制类型转换为对应结构体,目前还不支持BTF,将来可能会支持,那就可以像内核一样自己描述自己的结构体类型。
uretprobe的参数:内置变量retval是函数的返回值,retval恒为64位无符号整型,如果和函数返回值不一样,需要强制转换。
举例,一段简单的C代码,debugtest.c
root@pc:bpf-test# cat debugtest.c
#include <stdio.h>
void test_func1(int num)
{
printf("recv number %d\n", num);
}
int main(int argc, char *argv[])
{
test_func1(2);
return 0;
}
将debugtest.c编译,带debug编译
gcc -g debugtest.c -o debugtest
然后使用bpftrace跟踪test_func1()被调用时的输入参数num
bpftrace -e 'uprobe:./debugtest:test_func1 { printf("num is %d\n", arg0); }'
执行效果如下,在一个终端中跟踪,另一个终端中执行./debugtest
当然,bpftrace也可以直接跟踪地址,只要带"–unsafe"参数即可
root@pc:bpf-test# nm debugtest
000000000000038c r __abi_tag
0000000000004010 B __bss_start
0000000000004010 b completed.0
w __cxa_finalize@GLIBC_2.2.5
0000000000004000 D __data_start
0000000000004000 W data_start
0000000000001090 t deregister_tm_clones
0000000000001100 t __do_global_dtors_aux
0000000000003dc0 d __do_global_dtors_aux_fini_array_entry
0000000000004008 D __dso_handle
0000000000003dc8 d _DYNAMIC
0000000000004010 D _edata
0000000000004018 B _end
0000000000001198 T _fini
0000000000001140 t frame_dummy
0000000000003db8 d __frame_dummy_init_array_entry
0000000000002118 r __FRAME_END__
0000000000003fb8 d _GLOBAL_OFFSET_TABLE_
w __gmon_start__
0000000000002014 r __GNU_EH_FRAME_HDR
0000000000001000 T _init
0000000000002000 R _IO_stdin_used
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
U __libc_start_main@GLIBC_2.34
0000000000001174 T main
U printf@GLIBC_2.2.5
00000000000010c0 t register_tm_clones
0000000000001060 T _start
0000000000001149 T test_func1
0000000000004010 D __TMC_END__
这里看到,debugtest中test_func1()函数地址在0x1149位置,同样一个终端跟踪0x1149这个地址,另一个终端执行./debugtest,
甚至,在strip debugtest
删除debugsymbol之后,还可以使用0x1149这个地址来追踪test_func1()函数。
控制流
bpftrace中支持3中类型的测试:过滤器filter、ternary运算符和if语句
1. 过滤器
probe /filter/ { actions }
2. 三元操作符
test ? true_statement : false_statement
3. if语句
if (test) { true_statement }
if (test) { true_statement } else { false_statement }
目前还不支持else if语句。
运算符
bpftrace支持布尔运算,以及下面一些运算符
运算符 | 含义 |
---|---|
= | 赋值 |
+、-、*、/ | 加减乘除 |
++、– | 加1、减1 |
&、|、^ | 按位与、按位或、按位与或 |
! | 逻辑非 |
<<、>> | 向左位移、向右位移 |
+=、-=、*=、/=、%=、&=、^=、<<=、>>= | 复合运算符 |
变量
1. 内置变量
bpftrace中内置变量一般用于对信息的只读访问
内置变量 | 类型 | 含义 |
---|---|---|
pid | int | 进程ID |
tid | int | 线程ID |
uid | int | 用户ID |
username | string | 用户名 |
nesecs | int | 时间戳,纳秒级 |
elapsed | int | 时间戳,纳秒,自bpftrace启动开始计时 |
cpu | int | CPU ID |
comm | string | 进程名 |
kstack | string | 内核调用栈 |
ustack | string | 用户态调用栈 |
arg0, …, argN | int | 某些探针类型的参数 |
args | struct | 某些探针类型的参数 |
retval | int | 某些探针类型的返回值 |
func | string | 被跟踪函数名 |
probe | string | 当前探针全名 |
curtask | int | 内核task_struct的地址,类型是64位无符号整型 |
cgroup | int | cgroup ID |
$1, …, $N | int、chat* | bpftrace程序的位置参数 |
所有的int型目前都是64位无符号整型。
举例:
-
使用pid、comm、uid来跟踪谁在调用setuid()这个系统调用
bpftrace -e 't:syscalls:sys_enter_setuid { printf("setuid by PID %d (%s), UID %d\n", pid, comm, uid); }' bpftrace -e 't:syscalls:sys_enter_setuid { printf("setuid by %s returned %d\n", comm, args->ret); }'
-
使用pid来跟踪指定pid的进程在哪个CPU上被进程调度切换掉给哪个进程
//比如这里pid 79095是我运行的htop进程,查看其在哪个CPU上调度切换给哪个进程 bpftrace -e 't:sched:sched_switch /pid == 79095/ { printf("cpu %d switch from %d(%s) to %d(%s)\n", cpu, args->prev_pid, args->prev_comm, args->next_pid, args->next_comm); }'
-
使用kstack打印内核态调用栈,比如打印htop(pid为79182)的进程被切换时内核调用栈
bpftrace -e 't:sched:sched_switch /pid == 79182/ { printf("sched switch call stack %s\n", kstack); }'
2. 临时变量
格式如下:
$name
这些变量可以在一个动作中进行临时计算。类型取决于第一次被赋值,可以是整型、字符串、结构体的指针或者结构体。
3. 映射表变量
格式如下:
@name
@name[key]
@name[key1, key2[, ...]]
这些变量使用BPF映射表作为存储,BPF映射表是一种哈希表(关联数组),可以用于不同的存储类型。值可以用一个或多个键值来存储。映射表使用的键/值类型必须前后保持一致。与临时变量一样,映射表的类型也取决于第一次赋值。
举例:
@start = nsecs; //@start是整型,因为被赋值了内置变量纳秒时间戳nsecs
@last[tid] = nsecs; //@last是整型,因为被赋值纳秒时间戳nsecs,同时要求键类型为整型,因为这里用了整数键tid
@bytes = hist(retval); //@bytes是一个特殊类型:以2的幂为区间的直方图,会管理并打印直方图
@who[pid, comm] = count(); //@who映射表中有两个键,整数pid和字符串comm,它的值是一个统计函数count()
函数
bpftrace中提供了针对各种任务的函数,这里列出最重要的一些
函数 | 描述 |
---|---|
printf(char *fmt [, …]) | 打印 |
time(char *fmt) | 格式化打印时间 |
join(char *arr[ ]) | 打印字符串数组,以空格分隔 |
str(char *s [, int len]) | 从指针s返回字符串,长度参数可选 |
kstack(int limit) | 返回一个深度最大为limit的内核态调用栈 |
ustack(int limit) | 返回一个深度最大为limit的用户态调用栈 |
ksym(void *p) | 分析内核地址,并返回字符串形式的符号 |
usym(void *p) | 分析用户空间地址,并返回字符串形式的符号 |
kaddr(char *name) | 将内核符号名字翻译为地址 |
uaddr(char) | 将用户空间符号翻译为地址 |
reg(char *name) | 将返回值存储到指定寄存器中 |
ntop([int af, ] int addr) | 返回一个字符串表示的IP地址 |
system(char *fmt [, …]) | 执行一个shell命令 |
cat(char *filename) | 打印文件内容 |
exit() | 退出bpftrace |
异步处理,上面一些函数是异步处理的:内核将事件加入队列,然后有用户态程序后面处理。
printf()、time()、cat()、join()、system()是异步处理
kstack()、ustack()、ksym()、usym()会同步记录地址,但符号转义是异步处理。
映射表函数
bpftrace中一些重要的映射表函数:
函数 | 描述 |
---|---|
count() | 对出现次数进行统计计数 |
sum(int n) | 求和 |
avg(int n) | 求平均 |
min(int n) | 记录最小值 |
max(int n) | 记录最大值 |
stats(int n) | 返回事件次数、平均值和总和 |
hist(int n) | 打印2的幂次方直方图 |
lhist(int n, int min, int max, int step) | 打印线性直方图 |
delete(@m[key]) | 删除映射表中的键/值对 |
print(@m [, top [, div] ]) | 删除映射表,可带参数limit和除数 |
clear(@m) | 删除映射表中全部的键 |
zero(@m) | 将映射表中的所有值设置为0 |
异步处理的函数有:print()、clear()、zero(),所以这几个函数执行会有延迟。