Skip to content

Latest commit

 

History

History
210 lines (179 loc) · 11.3 KB

서브프로그램.md

File metadata and controls

210 lines (179 loc) · 11.3 KB
  • 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);
    }
  • 이를 컴파일한 결과는 아래와 같다.

    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를 기준으로 알아보자. 아래 재배치 목록을 살펴보자.

    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 코드를 작성하는 과정은 다음과 같다.

    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 코드)을 덤프한 것이다.

    $ 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) 컴파일한 결과물은 아래와 같다.

    $ 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 컴파일한 결과물이 저장된 메모리 위치까지의 거리(오프셋)를 이용하여 호출하는 것을 볼 수 있다.


참고