既然可以对 freplace 程序进行 trace,是否可以对 tailcall 程序进行 trace 呢?

TL;DR: NO!经过 demo 验证,发现是不行的。

trace tailcall 程序的 demo

demo 效果如下:

1
2
3
4
5
6
7
8
# ./fentry_fexit-tailcall
2023/07/19 21:44:34 Attached fentry(tailcall)
2023/07/19 21:44:34 Attached fexit(tailcall)
2023/07/19 21:44:34 Prepared tailcall(handle_new_connection)
2023/07/19 21:44:35 Attached kprobe(tcp_connect)
2023/07/19 21:44:35 Attached kprobe(inet_csk_complete_hashdance)
2023/07/19 21:44:35 Listening events...
2023/07/19 21:44:38 new tcp connection: 10.0.2.15:42990 -> 74.125.130.113:80 (kprobe)

其中使用的 trace 手段是 fentryfexit

demo 中使用的 fentry/fexit bpf 代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, __u32);
    __type(value, __u64);
    __uint(max_entries, 1);
} socks SEC(".maps");

SEC("fentry/tailcall")
int BPF_PROG(fentry_tailcall, struct pt_regs *regs)
{
    bpf_printk("tcpconn, fentry_tailcall\n");

    __u32 key = 0;
    struct sock **skp = bpf_map_lookup_elem(&socks, &key);
    if (!skp)
        return 0;

    struct sock *sk = *skp;
    __handle_new_connection(ctx, sk, PROBE_TYPE_FENTRY, 0);

    return 0;
}

SEC("fexit/tailcall")
int BPF_PROG(fexit_tailcall, struct pt_regs *regs, int retval)
{
    bpf_printk("tcpconn, fexit_tailcall\n");

    __u32 key = 0;
    struct sock **skp = bpf_map_lookup_and_delete(&socks, &key);
    if (!skp)
        return 0;

    struct sock *sk = *skp;
    __handle_new_connection(ctx, sk, PROBE_TYPE_FEXIT, retval);

    return 0;
}

demo 中使用的 tailcall bpf 代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
struct {
    __uint(type, BPF_MAP_TYPE_PROG_ARRAY);
    __uint(key_size, 4);
    __uint(value_size, 4);
    __uint(max_entries, 1);
} progs SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, __u32);
    __type(value, __u64);
    __uint(max_entries, 1);
} socks SEC(".maps");

SEC("kprobe/hanle_new_connection")
int handle_new_connection(struct pt_regs *ctx)
{
    __u32 key = 0;
    struct sock **skp = bpf_map_lookup_elem(&socks, &key);
    if (!skp)
        return 0;

    // bpf_map_delete_elem(&socks, &key);
    bpf_printk("tcpconn, handle_new_connection\n");

    struct sock *sk = *skp;
    __handle_new_connection(ctx, sk, PROBE_TYPE_DEFAULT, 0);

    return 0;
}

SEC("kprobe/tcp_connect")
int k_tcp_connect(struct pt_regs *ctx)
{
    struct sock *sk;
    sk = (typeof(sk))PT_REGS_PARM1(ctx);

    __u32 key = 0;
    bpf_map_update_elem(&socks, &key, &sk, BPF_ANY);

    bpf_tail_call_static(ctx, &progs, 0);

    return 0;
}

SEC("kprobe/inet_csk_complete_hashdance")
int k_icsk_complete_hashdance(struct pt_regs *ctx)
{
    struct sock *sk;
    sk = (typeof(sk))PT_REGS_PARM2(ctx);

    __u32 key = 0;
    bpf_map_update_elem(&socks, &key, &sk, BPF_ANY);

    bpf_tail_call_static(ctx, &progs, 0);

    return 0;
}

kprobe bpf 程序中通过 bpf_tail_call_static() 调用 tailcall bpf 程序 handle_new_connection()

而后,对 handle_new_connection() 进行 fentry/fexit

用户态的 Go 代码需要做的事情是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    spec, err := loadFentryFexit()
    if err != nil {
        log.Printf("Failed to load bpf obj: %v", err)
        return
    }

    tailcallFentry := spec.Programs["fentry_tailcall"]
    tailcallFentry.AttachTarget = obj.tcpconnPrograms.HandleNewConnection
    tailcallFentry.AttachTo = "handle_new_connection"
    tailcallFexit := spec.Programs["fexit_tailcall"]
    tailcallFexit.AttachTarget = obj.tcpconnPrograms.HandleNewConnection
    tailcallFexit.AttachTo = "handle_new_connection"
  1. 第一步,创建 kprobe 程序,其中包含了 tailcall 程序。
  2. 第二步,给 fentryfexit 程序指定 AttachTargetAttachTo
  3. 其中,AttachTargettailcall 程序,AttachTotailcall 程序中的函数名。
  4. 即,将 fentryfexit 程序 attach 到 tailcall 程序的 handle_new_connection 函数上。

P.S. demo 源代码:GitHub Asphaltt/learn-by-example/ebpf/fentry_fexit-tailcall

fentry/fexit 的函数参数

仔细对比上面 fentry/fexit 的函数定义和 tailcall 程序的函数定义:

1
2
3
4
5
6
7
8
SEC("fentry/tailcall")
int BPF_PROG(fentry_tailcall, struct pt_regs *regs);

SEC("fexit/tailcall")
int BPF_PROG(fexit_tailcall, struct pt_regs *regs, int retval);

SEC("kprobe/hanle_new_connection")
int handle_new_connection(struct pt_regs *ctx);

bpf 中规定 tailcall 程序的函数参数必须是 ctx;所以,此处 tailcall 程序的函数参数是 struct pt_regs *regs

tailcall 的更多详情,请参考:

理论上,使用 fentry/fexit 是能够对 tailcall 程序进行 trace 的。可是为什么就是不行呢?

下面从汇编的角度分析一下。

汇编分析

查看相关的函数入口地址:

1
2
3
4
5
6
7
# tail /proc/kallsyms
ffffffffc02e9114 t bpf_prog_62bef93723588309_handle_new_connection      [bpf]
ffffffffc02e926c t bpf_prog_3fb68e69f39b4dbf_k_icsk_complete_hashdance  [bpf]
ffffffffc02e9464 t bpf_prog_cd7cd930d5a4d05c_k_tcp_connect      [bpf]
ffffffffc02e9514 t bpf_prog_eef12c319a7cffba_fentry_tailcall    [bpf]
ffffffffc02e966c t bpf_prog_68da9df9fd9eec33_fexit_tailcall     [bpf]
ffffffffc05b0000 t bpf_trampoline_897648164865_1        [bpf]

kprobe tcp_connect() 的 x86 汇编指令如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# gdb -q -c /proc/kcore -ex 'x/150i 0xffffffffc02e9464' -ex 'quit'
   0xffffffffc02e9464:  nopl   0x0(%rax,%rax,1)
   0xffffffffc02e9469:  xor    %eax,%eax
   0xffffffffc02e946b:  push   %rbp
   0xffffffffc02e946c:  mov    %rsp,%rbp
   0xffffffffc02e946f:  sub    $0x10,%rsp
   // ...
   0xffffffffc02e94df:  add    $0x10,%rsp
   0xffffffffc02e94e6:  jmp    0xffffffffc02e911f   // tailcall
   0xffffffffc02e94eb:  xor    %eax,%eax
   0xffffffffc02e94ed:  pop    %r13
   0xffffffffc02e94ef:  pop    %rbx
   0xffffffffc02e94f0:  leave
   0xffffffffc02e94f1:  ret

tailcall handle_new_connection() 函数进行 trace 后的 x86 汇编指令如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# gdb -q -c /proc/kcore -ex 'x/150i 0xffffffffc02e9114' -ex 'quit'
   0xffffffffc02e9114:  call   0xffffffffc05b0000
   0xffffffffc02e9119:  xchg   %ax,%ax
   0xffffffffc02e911b:  push   %rbp
   0xffffffffc02e911c:  mov    %rsp,%rbp
   0xffffffffc02e911f:  sub    $0x18,%rsp
   0xffffffffc02e9126:  push   %rbx
   0xffffffffc02e9127:  push   %r13
   0xffffffffc02e9129:  push   %r14
   0xffffffffc02e912b:  push   %r15
   // ...
   0xffffffffc02e922d:  xor    %eax,%eax
   0xffffffffc02e922f:  pop    %r15
   0xffffffffc02e9231:  pop    %r14
   0xffffffffc02e9233:  pop    %r13
   0xffffffffc02e9235:  pop    %rbx
   0xffffffffc02e9236:  leave
   0xffffffffc02e9237:  ret

可以看到,第一条 nop 指令已被替换成 call 指令;而 call 指令的目标地址是 0xffffffffc05b0000,即 bpf_trampoline_897648164865_1,这是一个 bpf trampoline,里面会依次调用 fentry/fexit bpf 程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# gdb -q -c /proc/kcore -ex 'x/150i 0xffffffffc05b0000' -ex 'quit'
   0xffffffffc05b0000:  push   %rbp
   0xffffffffc05b0001:  mov    %rsp,%rbp
   0xffffffffc05b0004:  sub    $0x48,%rsp
   0xffffffffc05b0008:  push   %rbx
   // ...
   0xffffffffc05b005a:  call   0xffffffffc02e9514       // fentry tailcall
   0xffffffffc05b005f:  movabs $0xffffad57800e5000,%rdi
   // ...
   0xffffffffc05b00bc:  call   0xffffffffc02e966c       // fexit tailcall
   0xffffffffc05b00c1:  movabs $0xffffad5780101000,%rdi
   // ...
   0xffffffffc05b00ea:  pop    %rbx
   0xffffffffc05b00eb:  leave
   0xffffffffc05b00ec:  add    $0x8,%rsp
   0xffffffffc05b00f0:  ret

画图分析一下:

trace tailcall

可以看到,进行 tailcall 后,tailcall 所使用的 jmp 指令跳转的目标地址是 0xffffffffc02e911f;该地址并不是 tailcall 程序的入口地址,而是偏移入口地址 5 条指令的地址。

最终导致,jmp 后直接跳过第一条指令,即跳过了 fentry/fexit 程序的执行;换句话说,tailcall 程序无法直接使用 fentry/fexit 进行 trace。

tailcall 复用当前栈空间

此时,该想起:tailcall 的目标之一是复用调用者的栈空间。

如果 jmp 指令没有跳过前面几条指令,而是跳到 0xffffffffc02e9114

1
2
3
4
5
6
# gdb -q -c /proc/kcore -ex 'x/150i 0xffffffffc02e9114' -ex 'quit'
   0xffffffffc02e9114:  call   0xffffffffc05b0000
   0xffffffffc02e9119:  xchg   %ax,%ax
   0xffffffffc02e911b:  push   %rbp
   0xffffffffc02e911c:  mov    %rsp,%rbp
   0xffffffffc02e911f:  sub    $0x18,%rsp

就会通过 push $rbpmov %rsp,%rbp 这两条指令使用一段新的栈空间,而不是复用调用者的栈空间。

这便违背了 tailcall 复用调用者栈空间的初衷。

JIT 里对 tailcall 的处理

既然事实已如此,就回过头来看看 JIT 里对 tailcall 的处理。

在 x86 JIT 里,对 bpf_tail_call_static() 的处理如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// ${KERNEL}/arch/x86/net/bpf_jit_comp.c

static void emit_bpf_tail_call_direct(struct bpf_jit_poke_descriptor *poke,
                      u8 **pprog, u8 *ip,
                      bool *callee_regs_used, u32 stack_depth,
                      struct jit_context *ctx)
{
    int tcc_off = -4 - round_up(stack_depth, 8);
    u8 *prog = *pprog, *start = *pprog;
    int offset;

    /*
     * if (tail_call_cnt++ >= MAX_TAIL_CALL_CNT)
     *  goto out;
     */
    EMIT2_off32(0x8B, 0x85, tcc_off);             /* mov eax, dword ptr [rbp - tcc_off] */
    EMIT3(0x83, 0xF8, MAX_TAIL_CALL_CNT);         /* cmp eax, MAX_TAIL_CALL_CNT */

    offset = ctx->tail_call_direct_label - (prog + 2 - start);
    EMIT2(X86_JAE, offset);                       /* jae out */
    EMIT3(0x83, 0xC0, 0x01);                      /* add eax, 1 */
    EMIT2_off32(0x89, 0x85, tcc_off);             /* mov dword ptr [rbp - tcc_off], eax */

    poke->tailcall_bypass = ip + (prog - start);
    poke->adj_off = X86_TAIL_CALL_OFFSET;           /* 关键之处 */
    poke->tailcall_target = ip + ctx->tail_call_direct_label - X86_PATCH_SIZE;
    poke->bypass_addr = (u8 *)poke->tailcall_target + X86_PATCH_SIZE;

    emit_jump(&prog, (u8 *)poke->tailcall_target + X86_PATCH_SIZE,
          poke->tailcall_bypass);

    pop_callee_regs(&prog, callee_regs_used);
    EMIT1(0x58);                                  /* pop rax */
    if (stack_depth)
        EMIT3_off32(0x48, 0x81, 0xC4, round_up(stack_depth, 8));

    memcpy(prog, x86_nops[5], X86_PATCH_SIZE);
    prog += X86_PATCH_SIZE;

    /* out: */
    ctx->tail_call_direct_label = prog - start;

    *pprog = prog;
}

其中关键之处是:poke->adj_off = X86_TAIL_CALL_OFFSET;

这是在 tailcall 调用者侧对 bpf_tail_call_static() 的处理。

而后,在给 bpf prog ARRAY bpf map 更新 bpf prog 时,会更新某条指令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ${KERNEL}/arch/x86/net/bpf_jit_comp.c

static void bpf_tail_call_direct_fixup(struct bpf_prog *prog)
{
    struct bpf_jit_poke_descriptor *poke;
    struct bpf_array *array;
    struct bpf_prog *target;
    int i, ret;

    for (i = 0; i < prog->aux->size_poke_tab; i++) {
        poke = &prog->aux->poke_tab[i];
        if (poke->aux && poke->aux != prog->aux)
            continue;

        WARN_ON_ONCE(READ_ONCE(poke->tailcall_target_stable));

        if (poke->reason != BPF_POKE_REASON_TAIL_CALL)
            continue;

        array = container_of(poke->tail_call.map, struct bpf_array, map);
        mutex_lock(&array->aux->poke_mutex);
        target = array->ptrs[poke->tail_call.key];
        if (target) {
            ret = __bpf_arch_text_poke(poke->tailcall_target,   /* 关键之处 */
                           BPF_MOD_JUMP, NULL,
                           (u8 *)target->bpf_func +
                           poke->adj_off);
            BUG_ON(ret < 0);
            ret = __bpf_arch_text_poke(poke->tailcall_bypass,   /* 附带之处 */
                           BPF_MOD_JUMP,
                           (u8 *)poke->tailcall_target +
                           X86_PATCH_SIZE, NULL);
            BUG_ON(ret < 0);
        }
        WRITE_ONCE(poke->tailcall_target_stable, true);
        mutex_unlock(&array->aux->poke_mutex);
    }
}

其中需要关注的地方:

  1. __bpf_arch_text_poke(poke->tailcall_target, BPF_MOD_JUMP, NULL, (u8 *)target->bpf_func + poke->adj_off);
  2. __bpf_arch_text_poke(poke->tailcall_bypass, BPF_MOD_JUMP, (u8 *)poke->tailcall_target + X86_PATCH_SIZE, NULL); 将原先 jmp 指令替换成 nop 指令,为了不跳过后面的 tailcall 逻辑。

其中 poke->adj_off = X86_TAIL_CALL_OFFSET;,而 X86_TAIL_CALL_OFFSET 的值是 11。用上面的例子来计算:0xffffffffc02e911f - 0xffffffffc02e9114 = 11

小结

  • tailcall 程序无法直接使用 fentry/fexit 进行 trace。
  • tailcall 为了复用调用者的栈空间,会跳过前面几条指令,而这几条指令包含了 call fentry/fexit 程序的指令。

简而言之,tailcall 程序不支持 fentry/fexit 进行 trace。

所以,对于 tailcall 地狱,就无法使用 fentry/fexit 进行 trace,从而无法通过外部手段跟踪 tailcall 地狱中的调用链。