Skip to content

eBPF实现某个内核函数调用次数的统计

工具选择

BCC tools

funccount

funccount可以实现函数调用次数统计功能,它是一个简洁的命令行工具

它同时支持内核态和用户态函数(它可以使用tracepoints、USDT、kprobes、uprobes)

注:但需要注意,对于高频事件它会引入较高的额外开销

例如malloc和free每秒会调用数百万次,其额外开销会高达30%

默认情况下,它会不断记录某内核函数的调用计数,直到用户按下Ctrl+C

bash
sudo funccount 'vfs_*'  # 通配符匹配到的所有内核函数都会被它跟踪

除了Ctrl+C的使用方式,它还可以定时输出该时段内的调用次数

bash
sudo funccount -i 1 c:pthread_mutex_lock    # 1秒一次输出,c:表示跟踪的是libc库的函数

可以指定使用哪一种事件源

bash
sudo funccount 't:syscalls:sys_enter_*'

stackcount

传入的eventname的格式与funccount一致,没有额外添加的内容

使用它就能实现发生某事件时调用栈的计数,最后输出时会打印同样的调用栈的函数签名和该调用栈的出现次数

bash
sudo stackcount ktime_get       # 不区分不同进程的相同函数调用栈
sudo stackcount -P ktime_get    # 这样可以区分不同进程的函数调用栈

注:有些函数调用栈长度非常长,很多函数调用栈又是罕见路径,导致整个函数调用栈的输出会异常长,对于这样的输出可以用火焰图来简化分析

trace

十分强大的多用途工具,针对多个数据源的事件进行跟踪,同时支持kprobes、uprobes、USDT、tracepoints

trace的语法如下:

trace [options] probe [probe ...]

bpftrace

使用bpftrace脚本语言上手,它的代码精炼简洁,且完全覆盖了BCC支持的所有事件源

程序结构

bpftrace
probes /filter/ { actions }

具体来说,由探针、过滤器和动作组成

探针(probe)

探针格式如下:

bpftrace
type:identifier1[:identifier2[...]]

type指探针类型,有如下类型:

类型缩写描述
tracepointt
usdtU
kprobek
kretprobekr
uprobeu
uretprobeur
softwares内核软件事件
hardwarehPMC事件插桩
profilep对全部CPU进行事件采样
intervali从一个CPU上周期性报告
BEGIN
END

探针支持通配符

有两个特殊的探针不需要额外的标识符:BEGIN和END,它们在bpftrace程序启动和退出的时候自动执行

过滤器(filter)

只有filter表达式为真时探针内的actions才会被执行

例如:

bpftrace
/pid == 123/

表示pid为123的进程才会触发该探针

bpftrace
/pid/

表示pid不为0的进程才会触发该探针

动作(actions)

动作可以由单条语句构成,也可由分号分隔多个语句

例如:

bpftrace
{ $x = 42; printf("$x is %d", $x); }

注:C语言常见的运算符、控制流在bpftrace中都可以使用,但唯独循环是受限的,因为BPF不能容忍死循环程序的运行

它使用unroll(count){statements}来替代循环,其中count是一个整数数字常量,最大值为20

变量类型
  • 内置变量
  • 临时变量:以“$”为前缀的变量,它们的名字和类型在首次赋值时被确定(也就是说这时强类型语言)
  • 映射表变量:映射表是BPF的一段存储对象,利用它们可以做到全局存储,在不同action间传递数据
bpftrace
probe1 { @start = nsecs; }
probe2 { $duration = nsecs - @start; }  # 用以计算两探针间的时间
内置变量
内置变量类型描述
pidinteger
tidintegerthread id
uidintegeruser id
usernamestring
nsecsinteger时间戳,单位纳秒
elapsedinteger时间戳,单位纳秒,从bpftrace启动开始计时
cpuintegerCPU ID
commstring进程名
kstackstring内核调用栈信息
ustackkstring用户态调用栈信息
arg0,...argNinteger探针的参数
argsstruct探针的参数
retvalinteger某些探针类型的返回值
funcstring被跟踪函数的名字
probestring当前探针全名
curtaskinteger内核task_struct的地址
cgroupintegercgroup ID
1,...Nint、char *bpftrace程序的传入参数

注:利用kstack可以实现内核调用栈次数统计,达到与stackcount一致的效果

bash
sudo bpftrace -e 't:block:block_rq_insert { @[kstack] = count(); }'
函数

主要有内置函数、映射表函数

内置函数
函数描述
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 *name)将用户空间符号名字翻译为地址
reg(char *name)将寄存器值返回
ntop([int af,] int addr)返回一个字符串表示的IP地址
system(char *fmt [, ...])执行shell命令
cat(char *filename)打印文件内容
exit()退出bpftrace

注1:可以看到主要是对字符串的处理函数

注2:kstack内置函数与kstack内置变量的区别主要在于前者可以限制栈的输出长度

用例:

bash
sudo bpftrace -e 't:block:block_rq_insert { @[kstack(3), comm] = count(); }'
映射表函数(map function)
函数描述
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

bpftrace进行函数执行时长分析实例

bpftrace
#!/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]);
}

bpftrace内核函数调用次数统计

下面的程序以同名进程为粒度输出了内核函数调用次数统计情况:

bpftrace
#!/usr/bin/bpftrace

kprobe:vfs_read
{
    @cnts[comm] = count();
}
bash
sudo bpftrace fcount.bt

以下是统计结果示例:

...
@cnts[ls]: 16
@cnts[gmain]: 20
@cnts[xsel]: 20
@cnts[xdg-desktop-por]: 21
@cnts[cat]: 27
@cnts[TTNet-AN-IO]: 28
@cnts[NetworkService]: 29
@cnts[kitty:gl0]: 30
@cnts[gsd-media-keys]: 33
@cnts[kitty:sh0]: 62
@cnts[Xwayland]: 65
@cnts[WebExtensions]: 66
@cnts[systemd-journal]: 79
@cnts[ThreadPoolServi]: 109
@cnts[threaded-ml]: 120
@cnts[DetectThread]: 131
@cnts[ThreadPoolForeg]: 148
@cnts[systemd]: 148
@cnts[feishu]: 170
...
@cnts[nvim]: 628
@cnts[ibus-daemon]: 848
@cnts[KMS thread]: 1120
@cnts[Isolated Web Co]: 1432
@cnts[fish]: 1501
@cnts[gnome-shell]: 1927
@cnts[gdbus]: 3423
@cnts[kitty]: 3597

答疑解惑

  • 如何理解@us = hist($duration_us)这样的语句?为什么它没有将@us传入作为hist的第一个参数?

    答:bpftrace是一个解释型语言,它对映射表函数有特殊的实现方式。这里的语法可以认为@us实际上就是hist的第一个参数,并且它作为修改后的结果返回。

    实际上,所有的映射表函数都是这样,返回的映射表变量同时也是传入这个函数的(隐式)第一个参数

总结

上述方案是使用BCC tools和bpftrace实现的内核函数调用次数统计方案,两者的使用复杂度都相当简单,且功能丰富强大

其他可能性

实际上还可以使用BCC提供的C语言环境实现,这样实现的好处是可以提供网络接口,在监控程序中直接使用网络,省去了额外的前端

但同时BCC提供的C语言环境编程接口复杂不少,会降低编写监控程序的代码效率