7.2.1-内核bpf的实现原理

基本格式 (在终端)

    bpftrace -e 
    '
    BEGIN //事件 {
        printf("start\n"); //行为
    }
    '
    tracepoint:syscalls:sys_enter_accept //事件
    /comm == "sshd"/ 条件
    {
        printf("accept\n"); //行为
    }
    END //事件 {
        printf("end\n"); //行为
    }
    '
  • BEGINEND 是特殊的挂载点,它们不是由内核事件(如系统调用、函数执行)触发的,而是由 bpftrace 工具本身的启动动作 触发的, 且只执行一次。
  • 常见的挂载类型有 tp (tracepoint的缩写),kprobe,uprobe,kretprobe

tcp_connect.bt

#include <net/sock.h>
#include <linux/socket.h>

BEGIN
{
    printf("Enter tcp_connect Case \n");   
    printf("%8s %6s %16s" , "TIME" , "PID" , "COMM"); 
    printf("%19s:%5s  %16s:%5s\n","SADDR","SPORT","DADDR","DPORT");
}

kprobe:tcp_connect
{
    $sk = (struct sock*)arg0;
    
    $inet_family = $sk->__sk_common.skc_family;

    if ($inet_family == AF_INET) { //IPV4

        $daddr = ntop($sk->__sk_common.skc_daddr);
        $saddr = ntop($sk->__sk_common.skc_rcv_saddr);
    
    }

    $dport = $sk->__sk_common.skc_dport;
    $lport = $sk->__sk_common.skc_num;

    $dport = bswap($dport);

    time("%H:%M:%S ");
    printf("%6d %16s",pid,comm);
    printf("%19s:%5d  %16s:%5d\n",$saddr,$lport,$daddr,$dport);
}
END
{
    printf("Exit tcp_connect Case\n");
}
  • #include <linux/socket.h> 常用到,用于获取 struct sock(套接字结构体)的定义以及 AF_INET 等常量的定义。

  • kprobe

    • 优先使用 Tracepoint 再去使用 Kprobe,因为 Kprobe 的开销比较大。

    • 当挂载 Kprobe 时,内核会将目标函数入口的第一条指令替换为 int3 (断点指令) , 再跳转到 Kprobe 对应的 bpftrace 的代码。

    • Tracepoint 是 "预埋点",当开启时,内核会修改内存中的那条指令,使其跳转到追踪函数, 少了中断的代价。

    • Kprobe 能覆盖大部分函数,能不能用,指令查就好,比如这个例子,肯定要去查 tcp 相关的函数

      • bpftrace -l 'tracepoint:tcp*'

      • bpftrace -l 'kprobe:tcp*'

  • $sk = (struct sock*)arg0;

    • $标志起来定义与引用变量,例如:$idx = 0;

    • 思考一个问题:既然代码是在 kprobe 的处理程序里运行的,怎么能拿原本属于 tcp_connect 的参数呢?

      • 虽然 kprobe 代码是挂载的,但在挂载触发的那一瞬间,原本用来传递参数的硬件状态(寄存器)还没有被改变,你完全可以读取它。
    • 怎么知道这些参数的含义? 直接查内核代码 (https://elixir.bootlin.com/linux/v6.8/source)

    alt text

    alt text

    alt text

  • ntop($sk->__sk_common.skc_daddr)

    • 内核里存 IP 地址是一个 32 位的整数(比如 0x0100007f),人看不懂。ntop 会自动把它转换成字符串形式(比如 127.0.0.1)。
  • 端口的字节序

    • skc_dport (目的端口):通常存储为 网络字节序 (Big Endian)。读取后必须用 ntohs()bpf_ntohs() 转换。

    • skc_num (本地端口):Linux 内核为了优化查找速度,通常存储为 主机字节序 (Host Endian)。注意!它通常不需要转字节序,或者处理方式不同。(这是一个非常反直觉的知识点,也是面试常考题)。

    • $dport = bswap($dport); ,字节翻转。

  • 结果

    • 操作在本机上 curl www.baidu.com,通过 ping 检查发现是对的, 80http 的端口。

    alt text

    alt text


tcp_accept.bt

#include <net/sock.h>
#include <linux/socket.h>

BEGIN
{
    printf("BEGIN\n");
    
    printf("Enter tcp_connect Case \n");   
    printf("%8s %6s %16s" , "TIME" , "PID" , "COMM"); 
    printf("%19s:%5s  %16s:%5s\n","SADDR","SPORT","DADDR","DPORT");
}

kretprobe:inet_csk_accept
{
    $sk = (struct sock*)retval;
    
    $inet_family = $sk->__sk_common.skc_family;

    if ($inet_family == AF_INET) {

        $daddr = ntop($sk->__sk_common.skc_daddr);
        $saddr = ntop($sk->__sk_common.skc_rcv_saddr);
    
    }

    $dport = $sk->__sk_common.skc_dport;
    $lport = $sk->__sk_common.skc_num;

    $dport = bswap($dport);

    time("%H:%M:%S ");
    printf("%6d %16s",pid,comm);
    printf("%19s:%5d  %16s:%5d\n",$saddr,$lport,$daddr,$dport);
}

END
{
    printf("END\n");
}
  • kretprobe

    • 主要和 kprobe 对比,之前说过了,在函数的入口出软断,再跳转到 kprobe 代码中,这个时候能拿到的数据就是函数参数 (arg0, arg1...)

    • kretprobe 是在函数执行完毕准备返回时,再软中断,能拿到返回值 (retval)。

  • 看一下挂载点

    alt text

  • retvalbpftrace 的内置关键字,代表返回值。

  • 结果

alt text

alt text


tcp_life.bt

#include <net/sock.h>
#include <linux/socket.h>

BEGIN
{
    printf("%8s %6s %16s" , "TIME" , "PID" , "COMM"); 
    printf("%19s:%5s  %16s:%5s %s\n","SADDR","SPORT","DADDR","DPORT","DURms");
}

kprobe:tcp_set_state
{
    $sk = (struct sock*)arg0;
    $newstate = arg1;

    if ($newstate <= TCP_SYN_RECV) { //begin
        @birth[$sk] = nsecs;
    }
    if ($newstate == TCP_CLOSE) { //end
        
        $dur = (nsecs - @birth[$sk]) / 1e6;
    
        $inet_family = $sk->__sk_common.skc_family;

        if ($inet_family == AF_INET) {

            $daddr = ntop($sk->__sk_common.skc_daddr);
            $saddr = ntop($sk->__sk_common.skc_rcv_saddr);
        
        }

        $dport = $sk->__sk_common.skc_dport;
        $lport = $sk->__sk_common.skc_num;

        $dport = bswap($dport);
        time("%H:%M:%S ");
        printf("%6d %16s",pid,comm);
        printf("%19s:%5d  %16s:%5d %d\n",$saddr,$lport,$daddr,$dport,$dur);
        
    }

    printf("tcp_set_state: %d\n",$newstate);
}

END
{
    printf("END\n");
}
  • 看一下挂载点

    alt text

    alt text

  • nsecs 是内置变量,代表当前的时间戳,单位是 ns

  • $newstate <= TCP_SYN_RECV

    • newstate 的几种状态

      alt text

    • 分析情况 <= 3 的状态包括:

      1. (ESTABLISHED):瞬间建立完成。

      2. (SYN_SENT):客户端刚发起。

      3. (SYN_RECV):服务端刚收到。

      • 逻辑:只要连接进入了这三个“开局”状态中的任何一个,就视为生命周期的开始,记录时间戳。

      • 当时的实验是令 $newstate == SYN_SENT 是不行的。这样, map[sock] 的值是零,用当前的 nsecs - 0 就是一个很大的值。

  • 讨论一种情况(这里为什么要用 @

    伪代码

    //begin
    if (state == SYN_SENT) {
        $start = nsecs;
    }
    //end 
    if (state == CLOSED) {
        $dur = nsecs - $start
    }
    

    问题:当有多个进程进行 tcp 连接的时候,公共变量 $start 会被反复刷新

  • 这个时候引用内置函数 map

//begin
if (state == SYN_SENT) {
    @start[sk] = nsecs;
}
//end 
if (state == CLOSED) {
    @dur[sk] = nsecs - @start[sk];
}
  • 注意 @x@ + 单变量), x 这是为全局变量。

  • 结果

    alt text

    • 这里的 comm 为什么是 irq/xxx-iwlwifi , 不是 networkio

      alt text

    • bpftrace 里的 commpid 变量,并不是指“这个 Socket 属于谁”,而是指 “此时此刻,是谁在 CPU 上执行 tcp_set_state 这行代码”


nginx.bt

#include <net/sock.h>
#include <linux/socket.h>

BEGIN
{
    printf("NGINX\n");
}

uprobe:/usr/sbin/nginx:ngx_close_connection
{
    printf("ngx_close_connection\n");
}

END
{
    printf("Exit\n");
}
  • 这个问题实际上是在探讨应用层(Nginx)与内核层(Kernel)在连接关闭行为上的因果关系。是 Nginx 觉得自己处理完了(或者等烦了)主动挂的电话?还是 Nginx 发现对面已经挂了(或者网断了),被迫挂的电话?

  • uprobe 是检测用户态的,注意它和其他的挂载点不一样的,它是要用程序绝对地址:函数

  • nginx80 端口

  • nginx 主动调用了 ngx_close_connection 断开服务(netassist 没有发消息)

    alt text


redis.bt

#include <net/sock.h>
#include <linux/socket.h>

BEGIN
{
    printf("Redis\n");
}

uprobe:/usr/bin/redis-server:call
{
    printf("redis --> call\n");
}

END
{
    printf("Exit\n");
}
  • 结果

alt text

alt text


mysqld.bt

BEGIN
{
    printf("Tracing MySQL Server\n");
    printf("%10s %6s %s\n","TIME(ns)","PID","SQL");
}

uprobe:/usr/sbin/mysqld:*dispatch_command*
{
    @start = nsecs;
}
uprobe:/usr/sbin/mysqld:*dispatch_command*
/@start/ 
{
    $dur = nsecs - @start;
    printf("%10u %6d %s\n",$dur,pid,str(*arg1));
}

END
{
    printf("Exit\n");
}
  • 结果

alt text

alt text


malloc.bt

BEGIN
{
    printf("%10s %16s %6s\n","TYPE","NAME","PID");
}

uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc
/comm == "King"/
{
    $size = arg0;
    printf("%10s %16s %6d\n","malloc",comm,$size);
}
uprobe:/lib/x86_64-linux-gnu/libc.so.6:free
/comm == "King"/
{   
    $ptr = arg0;
    printf("%10s %16s %6p\n","free",comm,$ptr);
}

END
{
    printf("Exit\n");
}
  • 普及: 动态库和静态库的区别:

    • .so --> 动态库,进程运行时加载进来的(如果 .exe 用到了 .so , .so 文件必须存在)。
      .a --> 静态库,编译的时候,一起编入到可执行程序

    • 总结:动态库挂载到.so文件上;静态库挂载到可执行文件上

  • mallocfree 内存管理函数都依托于 libc.so.6 这个动态库

  • 结果

    alt text

    alt text

  • malloc 是申请大小,free 是回收的地址


ntyco.bt

BEGIN
{
    printf("Tracing NtyCo\n");
}

uprobe:/home/king/share/2404/7.2.1-bpf/NtyCo/nty_server:nty_coroutine_create
{
    printf("name: %s, pid: %d",comm,pid);
}

END
{
    printf("Exit\n");
}
  • 静态库追踪不追踪.a,反而追踪可执行文件?

    • 看到的 nty_server 这个可执行文件,已经把静态库.a “吃” 进肚子里了。

    • 所谓的“静态库追踪”,指的不是去追踪那个躺在硬盘上的 .a 文件,而是追踪被编译进可执行文件里的那个库的函数。


posted @ 2025-11-18 22:11  xqy2003  阅读(7)  评论(0)    收藏  举报