Skip to content

서브프로그램

  • BPF는 일반적으로 하나의 함수(main 함수)가 하나의 섹션이 되고, 하나의 섹션이 하나의 프로그램이 된다. 그리고 커널에서 해당 프로그램을 실행할 때는 프로그램의 시작 위치가 메인함수의 시작 위치이기 때문에 간단히 처음부터 실행하면 된다.

  • 하지만 아래와 같이 메인함수에서 공통함수를 호출하는 경우처럼 두 개 이상의 함수가 필요한 경우에는 프로그램의 시작 위치를 어떻게 보장할까? BPF는 이러한 상황에서 시작 위치 보장을 위해 서브프로그램이라는 기능을 제공한다.

  • 아래 코드는 bcc의 filetop 예제코드이다. 해당 예제는 vfs_readvfs_write 커널함수에서 각각 사용할 두 개의 BPF 메인함수와 두 개의 함수에서 사용하는 공통함수(probe_entry)로 구성되어 있다.

    static int probe_entry(struct pt_regs *ctx, struct file *file, size_t count, enum op op)
    {
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u32 pid = pid_tgid >> 32;
    __u32 tid = (__u32)pid_tgid;
    ...
    return 0;
    }
    SEC("kprobe/vfs_read")
    int BPF_KPROBE(vfs_read_entry, struct file *file, char *buf, size_t count, loff_t *pos)
    {
    return probe_entry(ctx, file, count, READ);
    }
    SEC("kprobe/vfs_write")
    int BPF_KPROBE(vfs_write_entry, struct file *file, const char *buf, size_t count, loff_t *pos)
    {
    return probe_entry(ctx, file, count, WRITE);
    }
  • 이를 컴파일한 결과는 아래와 같다.

    Terminal window
    Disassembly of section .text:
    0000000000000000 <probe_entry>:
    0: 7b 3a b8 ff 00 00 00 00 *(u64 *)(r10 - 72) = r3
    1: 7b 2a c0 ff 00 00 00 00 *(u64 *)(r10 - 64) = r2
    2: 7b 1a c8 ff 00 00 00 00 *(u64 *)(r10 - 56) = r1
    3: 85 00 00 00 0e 00 00 00 call 14
    4: bf 08 00 00 00 00 00 00 r8 = r0
    5: b7 01 00 00 00 00 00 00 r1 = 0
    6: 7b 1a e0 ff 00 00 00 00 *(u64 *)(r10 - 32) = r1
    7: 7b 1a d8 ff 00 00 00 00 *(u64 *)(r10 - 40) = r1
    8: 7b 1a d0 ff 00 00 00 00 *(u64 *)(r10 - 48) = r1
    9: bf 89 00 00 00 00 00 00 r9 = r8
    10: 77 09 00 00 20 00 00 00 r9 >>= 32
    11: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
    13: 61 12 00 00 00 00 00 00 r2 = *(u32 *)(r1 + 0)
    14: 15 02 02 00 00 00 00 00 if r2 == 0 goto +2 <LBB2_2>
    15: 61 11 00 00 00 00 00 00 r1 = *(u32 *)(r1 + 0)
    16: 5d 91 7c 00 00 00 00 00 if r1 != r9 goto +124 <LBB2_14>
    ...
    Disassembly of section kprobe/vfs_read:
    0000000000000000 <vfs_read_entry>:
    0: 79 12 60 00 00 00 00 00 r2 = *(u64 *)(r1 + 96)
    1: 79 11 70 00 00 00 00 00 r1 = *(u64 *)(r1 + 112)
    2: b7 03 00 00 00 00 00 00 r3 = 0
    3: 85 10 00 00 ff ff ff ff call -1
    4: b7 00 00 00 00 00 00 00 r0 = 0
    5: 95 00 00 00 00 00 00 00 exit
    Disassembly of section kprobe/vfs_write:
    0000000000000000 <vfs_write_entry>:
    0: 79 12 60 00 00 00 00 00 r2 = *(u64 *)(r1 + 96)
    1: 79 11 70 00 00 00 00 00 r1 = *(u64 *)(r1 + 112)
    2: b7 03 00 00 01 00 00 00 r3 = 1
    3: 85 10 00 00 ff ff ff ff call -1
    4: b7 00 00 00 00 00 00 00 r0 = 0
    5: 95 00 00 00 00 00 00 00 exit
  • 위의 오브젝트를 보면 메인함수는 각각의 섹션에 위치해있지만 공통함수는 .text 섹션에 위치해있는 것을 볼 수 있다. (함수 선언 앞에 섹션을 지정하지 않으면 해당 함수는 기본적으로 .text 섹션에 위치하게 된다.)

  • 일반적으로 BPF 프로그램을 로딩할 때는 하나의 특정 섹션을 지정해서 사용하는데, 위와 같이 메인함수에서 호출하는 함수가 다른 섹션에 존재할 때는 어떻게 동작하는 것일까?

  • 이 질문에 대한 해답은 libbpf를 기준으로 알아보자. 아래 재배치 목록을 살펴보자.

    Terminal window
    RELOCATION RECORDS FOR [kprobe/vfs_read]:
    OFFSET TYPE VALUE
    0000000000000018 R_BPF_64_32 .text
    RELOCATION RECORDS FOR [kprobe/vfs_write]:
    OFFSET TYPE VALUE
    0000000000000018 R_BPF_64_32 .text
  • 위의 재배치 목록 중 두 번째 항목은 kprobe/vfs_write 섹션의 0x18 오프셋에 해당하는 (3:) 명령어에서 .text 섹션을 참조한다는 의미이다.

  • 그리고 kprobe/vfs_write 섹션의 (3:) 명령어의 인자를 보면 -1(0xffffffff)인 값인데, 이는 해당 섹션(.text)에서 -11을 더한 위치를 의미한다.

  • 즉, (3:) 명령어는 .text 섹션의 0x0 오프셋을 호출(call)하라는 뜻이다. 이러한 재배치 정보를 이용하여 실제 커널에 전달할 BPF 코드를 작성하는 과정은 다음과 같다.

    Terminal window
    Symbol table '.symtab' contains 20 entries:
    Num: Value Size Type Bind Vis Ndx Name
    0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
    1: 00000000000003c8 0 NOTYPE LOCAL DEFAULT 1 LBB2_10
    2: 00000000000003d0 0 NOTYPE LOCAL DEFAULT 1 LBB2_11
    3: 0000000000000430 0 NOTYPE LOCAL DEFAULT 1 LBB2_13
    4: 0000000000000468 0 NOTYPE LOCAL DEFAULT 1 LBB2_14
    5: 0000000000000088 0 NOTYPE LOCAL DEFAULT 1 LBB2_2
    6: 0000000000000138 0 NOTYPE LOCAL DEFAULT 1 LBB2_4
    7: 00000000000003b0 0 NOTYPE LOCAL DEFAULT 1 LBB2_8
    8: 0000000000000000 1136 FUNC LOCAL DEFAULT 1 probe_entry
    9: 0000000000000000 4160 OBJECT LOCAL DEFAULT 7 zero_value
    10: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
    11: 0000000000000000 0 SECTION LOCAL DEFAULT 2 kprobe/vfs_read
    12: 0000000000000000 0 SECTION LOCAL DEFAULT 3 kprobe/vfs_write
    13: 0000000000000000 0 SECTION LOCAL DEFAULT 7 .bss
    14: 0000000000000000 13 OBJECT GLOBAL DEFAULT 5 LICENSE
    15: 0000000000000000 32 OBJECT GLOBAL DEFAULT 6 entries
    16: 0000000000000004 1 OBJECT GLOBAL DEFAULT 4 regular_file_only
    17: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 target_pid
    18: 0000000000000000 48 FUNC GLOBAL DEFAULT 2 vfs_read_entry
    19: 0000000000000000 48 FUNC GLOBAL DEFAULT 3 vfs_write_entry
  • 일단 심볼 테이블을 이용하여 코드를 포함하고 있는 섹션에 있는 함수들을 모두 프로그램으로 등록한다. 위의 심볼 테이블을 보면, kprobe/vfs_read 섹션에 있는 vfs_read_entry 함수, kprobe/vfs_write 섹션에 있는 vfs_write_entry 함수, 그리고 .text 섹션에 있는 probe_entry 함수를 각각 프로그램으로 등록한다.

  • 이때 .text 섹션에 있는 함수들은 모두 서브프로그램으로 등록이 되는데, 이는 커널에 직접 로딩되는 프로그램이 아니고, 다른 프로그램에서 호출해서 사용하는 프로그램이라는 의미이다.

  • 그리고 나머지 커널에 직접 로딩되는 프로그램들은 앞의 재배치 정보(.text 섹션의 0x0 오프셋)와 프로그램 목록(.text 섹션의 0x0 오프셋에 해당하는 probe_entry 프로그램)을 이용하여 메인함수에서 호출하는 함수를 해당 프로그램의 뒤쪽에 추가하고, 해당 함수를 호출하는 명령어의 인자를 적절한 값으로 수정한다.

  • 이 과정은 메인함수에서 호출한 함수에서도 다른 함수를 호출할 수 있기 때문에 재귀적으로 일어난다. 아래는 커널에 로딩된 프로그램(BPF 코드)을 덤프한 것이다.

    Terminal window
    $ bpftool prog dump xlated id 17
    int vfs_write_entry(struct pt_regs * ctx):
    ; int BPF_KPROBE(vfs_write_entry, struct file *file, const char *buf, size_t count, loff_t *pos)
    0: (79) r2 = *(u64 *)(r1 +96)
    1: (79) r1 = *(u64 *)(r1 +112)
    ; return probe_entry(ctx, file, count, WRITE);
    2: (b7) r3 = 1
    3: (85) call pc+2#bpf_prog_14ee69a88d05505b_F
    ; int BPF_KPROBE(vfs_write_entry, struct file *file, const char *buf, size_t count, loff_t *pos)
    4: (b7) r0 = 0
    5: (95) exit
    int probe_entry(struct pt_regs * ctx, struct file * file, size_t count, enum op op):
    ; static int probe_entry(struct pt_regs *ctx, struct file *file, size_t count, enum op op)
    6: (7b) *(u64 *)(r10 -72) = r3
    7: (7b) *(u64 *)(r10 -64) = r2
    8: (7b) *(u64 *)(r10 -56) = r1
    ; __u64 pid_tgid = bpf_get_current_pid_tgid();
    9: (85) call bpf_get_current_pid_tgid#133744
    10: (bf) r8 = r0
    11: (b7) r1 = 0
    ; struct file_id key = {};
    12: (7b) *(u64 *)(r10 -32) = r1
    13: (7b) *(u64 *)(r10 -40) = r1
    14: (7b) *(u64 *)(r10 -48) = r1
    ; __u32 pid = pid_tgid >> 32;
    15: (bf) r9 = r8
    16: (77) r9 >>= 32
  • 위의 코드를 보면, 맨 앞 부분에 메인함수가 위치해있고, 바로 이어서 공통함수(probe_entry)가 위치해있는 것을 볼 수 있다.

  • 그리고 공통함수를 호출하는 (3:) 명령어를 보면, 공통함수의 시작 위치가 (6:) 명령어이기 때문에 다음 명령어(4:)의 주소값(Program Counter)을 기준으로 2 를 더한 위치를 호출하는 것을 볼 수 있다.

  • 마지막으로 BPF 코드를 실제 동작 가능한 머신코드(x86)로 JIT(Just-In-Time) 컴파일한 결과물은 아래와 같다.

    Terminal window
    $ bpftool prog dump jited id 17
    int vfs_write_entry(struct pt_regs * ctx):
    bpf_prog_f3dfb13428230191_F:
    ; int BPF_KPROBE(vfs_write_entry, struct file *file, const char *buf, size_t count, loff_t *pos)
    0: nopl 0x0(%rax,%rax,1)
    5: xchg %ax,%ax
    7: push %rbp
    8: mov %rsp,%rbp
    b: mov 0x60(%rdi),%rsi
    f: mov 0x70(%rdi),%rdi
    ; return probe_entry(ctx, file, count, WRITE);
    13: mov $0x1,%edx
    18: callq 0x00000000000020c8
    ; int BPF_KPROBE(vfs_write_entry, struct file *file, const char *buf, size_t count, loff_t *pos)
    1d: xor %eax,%eax
    1f: leaveq
    20: retq
    int probe_entry(struct pt_regs * ctx, struct file * file, size_t count, enum op op):
    bpf_prog_41cced38f6644d9a_F:
    ; static int probe_entry(struct pt_regs *ctx, struct file *file, size_t count, enum op op)
    0: nopl 0x0(%rax,%rax,1)
    5: xchg %ax,%ax
    7: push %rbp
    8: mov %rsp,%rbp
    b: sub $0x48,%rsp
    12: push %rbx
    13: push %r13
    15: push %r14
    17: push %r15
    19: mov %rdx,-0x48(%rbp)
    1d: mov %rsi,-0x40(%rbp)
    21: mov %rdi,-0x38(%rbp)
    ; __u64 pid_tgid = bpf_get_current_pid_tgid();
    25: callq 0xffffffffd3e3693c
    2a: mov %rax,%r14
    2d: xor %edi,%edi
  • 리눅스 커널에서는 앞의 BPF 코드를 한번에 컴파일하지 않고, 메인함수와 공통함수를 서브프로그램으로 나눈 다음 각각 컴파일한다.

  • 그리고 메인함수(vfs_write_entry)에서 공통함수(probe_entry)를 호출하는 명령어(18:)를 보면, 다음 명령어(0x1d:)의 위치에서 공통함수를 JIT 컴파일한 결과물이 저장된 메모리 위치까지의 거리(오프셋)를 이용하여 호출하는 것을 볼 수 있다.


참고