简单Linux系统环境下的内核探测
在笔者之前的文章中提到,基于内核eBPF探针的常用工具主要bpftrace、bcc,二者复杂的依赖库使得其在嵌入式Linux系统环境下常常是不可用的。截止目前,一些嵌入式SDK(例如buildroot及openwrt等)未提供这两个性能分析工具的自动化构建功能。一种可行的方案是参考Linux内核源码samples/bpf 下的示例编写基于eBPF 的C代码,并编译生成BTF 目柡文件和可执行应用,用于嵌入式设备上的性能分析。这种方案可行但实施的效率较低。幸运的是,同属于iovisor的开源软件PLY很好地填补了这一空缺,它可以使用eBPF 子系统对Linux内核进行监测,而且没有复杂的依赖库(仅依赖libc 库)。其用法接近bpftrace ,尽管功能较弱,但一定程度上能够满足要求。其最大的缺憾是缺少对局部变量和uprobe功能的支持。本文主要对ply 的内核探测做相关的演示说明。
监测文件的打开
bpftrace 及bcc 工具都提供了一个名为opensnoop的脚本工具,用于监测系统上所有打开的文件。笔者编写了ply 版本的opensnoop.ply ,其实现基于Linux内核的tracepoint探测,脚本内容如下:
#!/usr/sbin/ply -k
tracepoint:syscalls/sys_enter_open
{
opentab[kpid] = data->filename;
}
tracepoint:syscalls/sys_enter_openat
{
opentab[kpid] = data->filename;
}
tracepoint:syscalls/sys_exit_open /opentab[kpid] != 0/
{
printf("[%d.%06d] pid: %d, kpid: %d, comm: %s, open(%s): %d\n",
time / 1000000000, (time % 1000000000) / 1000000,
pid, kpid, comm, str(opentab[kpid]), data->ret);
delete opentab[kpid];
}
tracepoint:syscalls/sys_exit_openat /opentab[kpid] != 0/
{
printf("[%d.%06d] pid: %d, kpid: %d, comm: %s, open(%s): %d\n",
time / 1000000000, (time % 1000000000) / 1000000,
pid, kpid, comm, str(opentab[kpid]), data->ret);
delete opentab[kpid];
}
以上脚本中,使用到了ply 多个内置的变量和函数,如time 、str 等。data 变量仅针对tracepoint 有效,它类似于C语言中的结构体指针,其能指向的成员由内核确定。例如对于syscalls/sys_enter_open 这个跟踪点,data 能够指向的成员名称由内核文件/sys/kernel/tracing/events/syscalls/sys_enter_open/format 确定,可以打开该文件查看:
# cat /sys/kernel/tracing/events/syscalls/sys_enter_open/format
name: sys_enter_open
ID: 635
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:int __syscall_nr; offset:8; size:4; signed:1;
field:const char * filename; offset:16; size:8; signed:0;
field:int flags; offset:24; size:8; signed:0;
field:umode_t mode; offset:32; size:8; signed:0;
print fmt: "filename: 0x%08lx, flags: 0x%08lx, mode: 0x%08lx", ((unsigned long)(REC->filename)), ((unsigned long)(REC->flags)), ((unsigned long)(REC->mode))
pid 、kpid 、comm 等变量由ply 自动提供,分别对应进程的pid 、线程的pid 、及进程的名称。该脚本不建议在系统繁忙的系统中使用。在负载较低的系统环境下运行,可得到以下结果:
# ply -k trace-open.ply
[89137.000250] pid: 1, kpid: 1, comm: systemd, open(/proc/979/cgroup): 114
[89137.000251] pid: 1, kpid: 1, comm: systemd, open(/proc/912/cgroup): 114
[89138.000858] pid: 32010, kpid: 32276, comm: MemoryPoller, open(/proc/meminfo): 27
[89139.000240] pid: 14985, kpid: 33553, comm: ThreadPoolForeg, open(/etc/chromium-browser/policies/managed): -2
[89139.000240] pid: 14985, kpid: 33553, comm: ThreadPoolForeg, open(/etc/chromium-browser/policies/recommended): -2
[89140.000008] pid: 970, kpid: 970, comm: irqbalance, open(/proc/interrupts): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/stat): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/49/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/51/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/56/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/55/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/0/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/1/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/8/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/9/smp_affinity): 6
[89140.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/12/smp_affinity): 6
[89140.000941] pid: 2198, kpid: 2198, comm: gnome-shell, open(/proc/self/stat): 46
对打开文件进行统计
当系统频繁打开文件时,上面的脚本会造成系统负载增加。为避免给系统带来不必要的负荷,可以使用count() 特殊函数对打开的文件进行统计,并隔一段时间周期性地输出统计结果。这里使用到了interval 定时器,具体用法可参考官方文档。笔者编写的统计脚本open-count.ply 内容如下:
#!/usr/sbin/ply -k
kprobe:do_sys_open
{
@openfreq[str(arg1)] = count();
}
interval:10s
{
printf("--------------------------------------------------------\n");
printf("[%d.%06d] dumping opened files in the last 10 seconds:\n",
time / 1000000000, (time % 1000000000) / 1000000);
print(@openfreq);
clear(@openfreq);
}
上面的定时器每10秒执行一次,输出统计信息后会清空hash表openfreq 以重新计数。笔者的观测结果如下(部分):
# ply -k open-count.ply
--------------------------------------------------------
[90150.000678] dumping opened files in the last 10 seconds:
@openfreq:
{ /proc/interrupts }: 10
{ /proc/irq/0/smp_affinity }: 1
{ /proc/irq/1/smp_affinity }: 1
{ /proc/irq/12/smp_affinity }: 1
{ /proc/irq/50/smp_affinity }: 1
{ /proc/irq/51/smp_affinity }: 1
{ /proc/stat }: 1
{ /proc/meminfo }: 5
--------------------------------------------------------
[90160.000678] dumping opened files in the last 10 seconds:
@openfreq:
{ /proc/979/cgroup }: 1
{ /proc/interrupts }: 1
{ /proc/irq/0/smp_affinity }: 1
{ /proc/irq/1/smp_affinity }: 1
{ /proc/irq/12/smp_affinity }: 1
{ /proc/irq/51/smp_affinity }: 1
{ /proc/stat }: 1
{ /proc/meminfo }: 2
过滤以只读方式打开的文件
某些情况下,我们只想跟踪探测以可写方式打开的文件,忽略以只读方式打开的文件。这样可以极大地减少跟踪探测的输出结果,从而一定程度上降低ply 探测对系统负载的影响。当使用kprobe 探测一些函数的入口时,通过arg0 、arg1 等变量可以访问到函数的入参,这些入参的数据类型为整数,据此可以实现探测的过滤。笔者编写的probe-open.ply 内容如下:
#!/usr/sbin/ply -k
/* fs/open.c:
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
*/
kprobe:do_sys_open
{
if (arg1 != 0 && (arg2 & 0x3) != 0) {
opentab[kpid] = arg1;
openflags[kpid] = arg2;
}
}
kretprobe:do_sys_open /opentab[kpid] != 0/
{
printf("[%d.%06d] pid: %d, kpid: %d, comm: %s, open(%s): %d, trunc: %d\n",
time / 1000000000, (time % 1000000000) / 1000000,
pid, kpid, comm, str(opentab[kpid]), retval,
(openflags[kpid] & 0x200) >> 9);
delete opentab[kpid];
delete openflags[kpid];
}
arg2 对应函数do_sys_open 的第三个参数flags ,当其低2位比特不为0时,表明以O_WRONLY 或O_RDWR 可写方式打开了文件,据此就实现了探测结果的过滤。同样的,kretprobe 探针加入了opentab[kpid] != 0 的限定条件,它不会输出以只读方式打开文件的结果。特殊变量retval 仅对kretprobe 有效,它表示函数的返回值。笔者探测结果如下:
# ply -k probe-open.ply
[91220.000009] pid: 970, kpid: 970, comm: irqbalance, open(/proc/irq/49/smp_affinity): 6, trunc: 1
[91242.000803] pid: 22765, kpid: 22765, comm: bash, open(/dev/null): 3, trunc: 1
打开/dev/null 文件,是笔者在另一个终端上执行echo 'Hello World' > /dev/null 触发的结果。
内核调用栈的回溯
ply 提供了stack 变量,它是多行的字符串类型,可以得到探测点的内核函数的调用栈;该功能对于调试内核非常有帮助。以笔者上一篇博客为例,Linux内核为进程加载vdso 动态库之后,实际上并没有映射vvar 只读内存段,而是仅当进程实际去访问该内存了,才会触发实际的内存映射操作。通过ply 可以得到该映射函数的调用栈回溯。脚本func-backtrace.ply 内容如下:
#!/usr/sbin/ply -k
kprobe:vvar_fault
{
printf("PID: %d, TID: %d, comm: %s, accessing vdso memory:\n",
pid, kpid, comm);
print(stack);
}
跟踪探测结果如下:
# ply -k func-backtrace.ply
PID: 35486, TID: 35486, comm: clock_gettime, accessing vdso memory:
vvar_fault+1
__do_fault+62
do_fault+486
__handle_mm_fault+1561
handle_mm_fault+218
pgtable_bad+571
msr_save_cpuid_features+15669
_raw_write_lock_irqsave+2064046
访问内核数据
通过ply 加载的内核探针kprobe ,可以在函数后面加上一个偏移量,这样探针不会在函数入口处触发。不过并不是在函数的任意一个偏移量都可以成功加载内核探针的,eBPF 对探针所在的代码段有一定的要求。此时带有偏移量的kprobe 下的arg0 、arg1 很可能会失去意义,不过可以通过regs 变量访问探针处的寄存器。笔者编写了offset.ply 脚本(该偏量的计算仅限于内核版本:Linux ubuntu 5.13.0-39-generic #44~20.04.1-Ubuntu ),演示如何通过带偏移量的探针,确定一个脚本的解析器:
#!/usr/sbin/ply -k
/*
fs/binfmt_script.c
static int load_script(struct linux_binprm *bprm)
{
...
file = open_exec(i_name);
if (IS_ERR(file))
return PTR_ERR(file);
bprm->interpreter = file;
return 0;
}
(gdb) disassemble load_script
Dump of assembler code for function load_script:
0xffffffff813b8270 <+0>: callq 0xffffffff81077840 <__fentry__>
0xffffffff813b8275 <+5>: cmpw $0x2123,0xa0(%rdi)
......
0xffffffff813b83f9 <+393>: mov %r12,%rdi
0xffffffff813b83fc <+396>: callq 0xffffffff8132f1c0 <open_exec>
*/
tracepoint:syscalls/sys_enter_execve
{
newapp[kpid] = data->filename;
}
tracepoint:syscalls/sys_enter_execveat
{
newapp[kpid] = data->filename;
}
tracepoint:syscalls/sys_exit_execve
{
if (newapp[kpid] != 0) {
delete newapp[kpid];
}
}
tracepoint:syscalls/sys_exit_execveat
{
if (newapp[kpid] != 0) {
delete newapp[kpid];
}
}
kprobe:load_script+393 /newapp[kpid]/
{
if (regs->r12 != 0) {
printf("PID: %d, invoker: %s, file: %s, interpreter: %s\n",
pid, comm, str(newapp[kpid]), str(regs->r12));
print(stack);
}
}
笔者在load_script 的393字节偏移处加入内核探针,该处的寄存器r12 指向了脚本的解析器路径,通常为/bin/sh 等。ply 对内核数据的访问是有限的,不能像bpftrace 那样实现C语言层面的结构体解引用;以上脚本仅仅是将r12 寄存器转化为一个字符串并输出。笔者用ply 加载该脚本后,在另一个终端分别执行which -a perldoc 、perldoc perl ,可得到以下跟踪信息:
# ply -k offset.ply
PID: 35873, invoker: bash, file: /usr/bin/which, interpreter: /bin/sh
load_script+394
exec_binprm+314
bprm_execve+365
do_execveat_common.isra.0+393
__x64_sys_execve+55
msr_save_cpuid_features+425
_raw_write_lock_irqsave+2061404
PID: 35874, invoker: bash, file: /usr/bin/perldoc, interpreter: /usr/bin/perl
load_script+394
exec_binprm+314
bprm_execve+365
do_execveat_common.isra.0+393
__x64_sys_execve+55
msr_save_cpuid_features+425
_raw_write_lock_irqsave+2061404
可见在ubuntu 系统上,可执行文件/usr/bin/which 是一个shell 脚本,其解析器为/bin/sh ;而/usr/bin/perldoc 也是一个脚本,其解析器为/usr/bin/perl 。
|