Skip to content

Latest commit

 

History

History
241 lines (204 loc) · 12.8 KB

메모리 로딩.md

File metadata and controls

241 lines (204 loc) · 12.8 KB
  • BPF는 일반적인 프로그램과 유사한 방식으로 개발하기 때문에 유사한 실행파일 및 메모리 구조를 가지고 있지만, 커널 안에서 제한된 환경으로 실행되기 때문에 로딩(loading) 또한 다른 방식으로 실행된다

데이터 유형

  • 일반적인 실행파일에서 가장 중요한 두 가지는 코드와 데이터이다. 코드는 말그대로 상위언어를 컴파일한 머신코드를 의미하고, 데이터는 실행시 코드가 참조하는 메모리를 의미한다.

  • 데이터는 스택과 힙같이 실행시 메모리가 할당/해제되는 동적 데이터와 전역변수처럼 코드에서 선언되는 정적 데이터로 나뉘는데, 정적 데이터는 실행파일을 로딩할 때 메모리가 할당되고 해당 메모리를 참조하는 코드도 재배치(relocation)된다.

  • 그리고 정적 데이터는 크게 읽기전용 변수, 초기화된 전역변수 그리고 초기화되지 않은 전역변수로 구분된다.

    • 읽기전용 변수는 rodata0 처럼 const로 선언된 전역변수를 의미하고, 해당 메모리에 대한 쓰기 작업을 금지하기 위해 읽기전용의 페이지를 할당받아 사용한다.

    • 그리고 data0data1 같이 초기값을 가지고 있는 전역변수는 초기화된 전역변수로 분류되고, bss0과 같이 초기값을 가지고 있지 않은 전역변수는 초기화되지 않은 전역변수로 분류된다.

      ...
      int data0 = 1;
      int data1 = 1;
      int bss0;
      const char rodata0[] = "ebpf";
      
      SEC("tp_btf/sched_switch")
      int handle__sched_switch(u64 *ctx)
      {
      /* TP_PROTO(bool preempt, struct task_struct *prev,
      *      struct task_struct *next)
      */
      struct task_struct *prev = (struct task_struct *)ctx[1];
      struct task_struct *next = (struct task_struct *)ctx[2];
      struct event event = {};
      u64 *tsp, delta_us;
      long state;
      u32 pid;
      
      /* ivcsw: treat like an enqueue event and store timestamp */
      if (prev->state == data1)
          trace_enqueue(prev->tgid, prev->pid);
      
      pid = next->pid;
      ...
      }
      ...
  • 아래는 위의 예제코드를 컴파일한 후 objdump를 이용해서 섹션 테이블과 심볼 테이블 ELF 형식으로 출력한 것이다.

    .output/runqslower.bpf.o:       file format elf64-bpf
    
    architecture: bpfel
    start address: 0x0000000000000000
    
    Program Header:
    
    Dynamic Section:
    Sections:
    Idx Name                        Size     VMA              Type
      0                             00000000 0000000000000000
      1 .text                       00000000 0000000000000000 TEXT
      2 tp_btf/sched_wakeup         000000f8 0000000000000000 TEXT
      3 tp_btf/sched_wakeup_new     000000f8 0000000000000000 TEXT
      4 tp_btf/sched_switch         00000318 0000000000000000 TEXT
      5 .rodata                     00000015 0000000000000000 DATA
      6 .data                       00000008 0000000000000000 DATA
      7 .maps                       00000038 0000000000000000 DATA
      8 license                     00000004 0000000000000000 DATA
      9 .bss                        00000004 0000000000000000 BSS
    10 .BTF                        00005e0c 0000000000000000
    11 .BTF.ext                    0000068c 0000000000000000
    12 .symtab                     000002a0 0000000000000000
    13 .reltp_btf/sched_wakeup     00000030 0000000000000000
    14 .reltp_btf/sched_wakeup_new 00000030 0000000000000000
    15 .reltp_btf/sched_switch     00000080 0000000000000000
    16 .rel.BTF                    000000a0 0000000000000000
    17 .rel.BTF.ext                00000630 0000000000000000
    18 .llvm_addrsig               00000009 0000000000000000
    19 .strtab                     0000017c 0000000000000000
    
    SYMBOL TABLE:
    0000000000000000 g     O .bss   0000000000000004 bss0
    0000000000000000 g     O .data  0000000000000004 data0
    0000000000000004 g     O .data  0000000000000004 data1
    0000000000000000 g     F tp_btf/sched_switch    0000000000000318 handle__sched_switch
    0000000000000010 g     O .rodata        0000000000000005 rodata0
    ...
  • 심볼 테이블을 보면 읽기전용 변수인 rodata0 은 읽기전용 데이터 섹션인 .rodata 에 속해있고, 초기화된 전역변수인 data0data1.data 섹션에, 그리고 초기화되지 않은 전역변수인 bss0.bss 섹션에 포함되어 있는 것을 볼 수 있다.

  • .data 섹션은 실제 초기값들을 실행파일 안에 포함하고 있지만, .bss 섹션은 초기값이 없기 때문에 실행파일 안은 비어있고 로딩 시에 메모리를 할당받은 후 초기화된다는 것이 차이이다.

로딩 과정

  • 일반적인 프로그램을 실행할 때는 .data, .rodata, 그리고 .bss 섹션까지 프로세스의 가상 주소 공간에 필요한 메모리를 할당받아 실행파일로부터 필요한 데이터를 복사한 후 로딩 작업을 마무리하지만, BPF는 커널 주소 공간에서 실행되기 때문에 강도 높은 안정성과 보안성을 위해 다른 방식으로 로딩 작업을 진행한다.

  • 우선, BPF 실행파일의 .data, .rodata, 그리고 .bss 섹션을 각각 BPF 맵(map)으로 만든다.

    • BPF 맵은 사용자 코드와 BPF 코드가 데이터를 공유하는 가장 보편적인 방식으로, .data.rodata 섹션은 BPF 맵을 만든 다음 실행파일의 각 섹션에 있는 데이터를 복사하고, .bss 섹션은 0 으로 초기화되어 있는 BPF 맵을 만든다. 그리고 각 섹션에 있는 변수를 참조하는 코드를 재배치해야 하는데, 우선 앞의 예제에서 data1 변수를 참조하는 부분의 코드를 살펴보자.
    0000000000000000 <handle__sched_switch>:
          0:       bf 16 00 00 00 00 00 00 r6 = r1
          1:       79 68 10 00 00 00 00 00 r8 = *(u64 *)(r6 + 16)
          2:       79 67 08 00 00 00 00 00 r7 = *(u64 *)(r6 + 8)
          3:       b7 01 00 00 00 00 00 00 r1 = 0
          4:       7b 1a e8 ff 00 00 00 00 *(u64 *)(r10 - 24) = r1
          5:       7b 1a e0 ff 00 00 00 00 *(u64 *)(r10 - 32) = r1
          6:       7b 1a d8 ff 00 00 00 00 *(u64 *)(r10 - 40) = r1
          7:       7b 1a d0 ff 00 00 00 00 *(u64 *)(r10 - 48) = r1
          8:       7b 1a c8 ff 00 00 00 00 *(u64 *)(r10 - 56) = r1
          9:       7b 1a c0 ff 00 00 00 00 *(u64 *)(r10 - 64) = r1
          10:       79 71 10 00 00 00 00 00 r1 = *(u64 *)(r7 + 16)
          11:       18 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r2 = 0 ll
          13:       61 22 00 00 00 00 00 00 r2 = *(u32 *)(r2 + 0)
          14:       67 02 00 00 20 00 00 00 r2 <<= 32
          15:       c7 02 00 00 20 00 00 00 r2 s>>= 32
          16:       5d 21 1c 00 00 00 00 00 if r1 != r2 goto +28 <LBB2_7>
          ...
  • 위의 (11:) 명령어는 data1 에 있는 값을 r2 레지스터로 복사하는 부분인데, opcode와 레지스터 정보만 있고 다른 모든 값은 0으로 채워져있다. 이 부분은 커널에 BPF 코드를 넘기기 전에 재배치 정보를 참조하여 필요한 다른 값으로 채워진다.

    RELOCATION RECORDS FOR [tp_btf/sched_switch]:
    OFFSET           TYPE                     VALUE
    0000000000000058 R_BPF_64_64              data1
    00000000000000a8 R_BPF_64_64              targ_tgid
    00000000000000e8 R_BPF_64_64              targ_pid
    0000000000000148 R_BPF_64_64              start
    ...
  • 위의 재배치 목록의 첫 번째 항목은 tp_btf/sched_switch 섹션(handle__sched_switch 함수)의 오프셋이 0x58인, (11:) 명령어에서 data1 변수를 참조하고 있다는 의미이다.

  • 이 명령어는 보이는 것처럼 8 바이트씩 개의 명령어로 구성되어 있는데, 재배치 과정에서 첫 번째 명령어에는 data1 변수가 속해있는 섹션(.data)으로 만들어진 BPF 맵의 파일디스크립터를 집어넣고, 두 번째 명령어에는 data1 변수가 속해있는 섹션에서의 오프셋을 집어넣는다.

    // 예시
    /proc/4812/fd# ls -lh
    lrwx------ 1 root root 64 Sep 16 05:13 0 -> /dev/tty1
    lrwx------ 1 root root 64 Sep 16 05:13 1 -> /dev/tty1
    lrwx------ 1 root root 64 Sep 16 05:56 10 -> anon_inode:bpf-prog
    lrwx------ 1 root root 64 Sep 16 05:56 11 -> anon_inode:bpf-prog
    lr-x------ 1 root root 64 Sep 16 05:56 12 -> anon_inode:bpf_link
    lr-x------ 1 root root 64 Sep 16 05:56 13 -> anon_inode:bpf_link
    lr-x------ 1 root root 64 Sep 16 05:56 14 -> anon_inode:bpf_link
    lrwx------ 1 root root 64 Sep 16 05:56 15 -> 'anon_inode:[eventpoll]'
    lrwx------ 1 root root 64 Sep 16 05:56 16 -> 'anon_inode:[perf_event]'
    lrwx------ 1 root root 64 Sep 16 05:56 17 -> 'anon_inode:[perf_event]'
    lrwx------ 1 root root 64 Sep 16 05:56 18 -> 'anon_inode:[perf_event]'
    lrwx------ 1 root root 64 Sep 16 05:13 2 -> /dev/tty1
    lr-x------ 1 root root 64 Sep 16 05:13 3 -> anon_inode:btf
    lrwx------ 1 root root 64 Sep 16 05:56 4 -> anon_inode:bpf-map
    lrwx------ 1 root root 64 Sep 16 05:56 5 -> anon_inode:bpf-map
    lrwx------ 1 root root 64 Sep 16 05:13 6 -> anon_inode:bpf-map
    lrwx------ 1 root root 64 Sep 16 05:13 7 -> anon_inode:bpf-map
    lrwx------ 1 root root 64 Sep 16 05:56 8 -> anon_inode:bpf-map
    lrwx------ 1 root root 64 Sep 16 05:56 9 -> anon_inode:bpf-prog
  • 해당 프로세스의 파일 디스크립터 목록 중 6번이 .data 섹션에 해당하는 BPF 맵이기 때문에 (11:) 명령어의 첫 번째 명령어에는 6이라는 값이 채워지고, 심볼 테이블을 보면 data1 변수는 .data 섹션에서 오프셋이 4이기 때문에 (11:) 명령어의 두 번째 명령어에는 4 라는 값이 채워진다.

  • 이러한 재배치 과정을 통해 나온 BPF 코드는 아래와 같다.

$ bpftool map
8105: array  name runqslow.data  flags 0x400
 key 4B  value 8B  max_entries 1  memlock 4096B
 btf_id 944
8106: array  name runqslow.rodata  flags 0x480
 key 4B  value 21B  max_entries 1  memlock 4096B
 btf_id 944  frozen
8107: array  name runqslow.bss  flags 0x400
 key 4B  value 4B  max_entries 1  memlock 4096B
 btf_id 944

$ bpftool prog dump xlated id 62928
int handle__sched_switch(u64 * ctx):
; int handle__sched_switch(u64 *ctx)
   0: (bf) r6 = r1
; struct task_struct *next = (struct task_struct *)ctx[2];
   1: (79) r8 = *(u64 *)(r6 +16)
; struct task_struct *prev = (struct task_struct *)ctx[1];
   2: (79) r7 = *(u64 *)(r6 +8)
   3: (b7) r1 = 0
; if (prev->state == data1)
  10: (79) r1 = *(u64 *)(r7 +24)
; if (prev->state == data1)
  11: (18) r2 = map[id:8105][0]+4
  13: (61) r2 = *(u32 *)(r2 +0)
  14: (67) r2 <<= 32
  15: (c7) r2 s>>= 32
; if (prev->state == data1)
  16: (5d) if r1 != r2 goto pc+20
  ...
  • 현재 사용 중인 BPF 맵 목록을 보면 각 섹션(.data, .rodata, .bss)에 해당하는 BPF 맵에 대한 정보를 볼 수 있다. 각각의 BPF 맵은 1개의 요소만을 가지는 arraymap 형태로 만들어지고, 배열 요소의 크기는 각 섹션의 크기와 동일하다.

  • 위의 커널에 로딩된 BPF 코드를 살펴보면, (11:) 명령어에서 파일디스크립터(6)에 해당하는 BPF 맵의 ID(8105)와 첫 번째 배열 요소를 나타내는 인덱스(0), 그리고 해당 배열 요소에서의 오프셋(4)을 볼 수 있다.

  • 이러한 재배치 작업이 끝난 후, 커널에서는 파일 디스크립터와 오프셋을 이용하여 실제 메모리 주소를 구한 다음, (11:) 명령어의 첫 번째 명령어에 해당 메모리 주소의 하위 32bit 주소를 저장하고, 두 번째 명령어에 상위 32bit 주소를 저장한다.

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

$ bpftool prog dump jited id 62928
int handle__sched_switch(u64 * ctx):
bpf_prog_474ea3c284cc8478_handle__sched_switch:
; int handle__sched_switch(u64 *ctx)
   0: nopl   0x0(%rax,%rax,1)
   5: xchg   %ax,%ax
   7: push   %rbp
   8: mov    %rsp,%rbp
   b: sub    $0x40,%rsp
  12: push   %rbx
  13: push   %r13
  15: push   %r14
  17: push   %r15
  19: mov    %rdi,%rbx
; struct task_struct *next = (struct task_struct *)ctx[2];
  1c: mov    0x10(%rbx),%r14
; struct task_struct *prev = (struct task_struct *)ctx[1];
  20: mov    0x8(%rbx),%r13
  24: xor    %edi,%edi
; if (prev->state == data1)
  3e: test   %r13,%r13
  41: jne    0x0000000000000047
  43: xor    %edi,%edi
  45: jmp    0x000000000000004b
  47: mov    0x18(%r13),%rdi
; if (prev->state == data1)
  4b: movabs $0xffffba9640e6a004,%rsi
  55: mov    0x0(%rsi),%esi
  58: shl    $0x20,%rsi
  5c: sar    $0x20,%rsi
; if (prev->state == data1)
  60: cmp    %rsi,%rdi
  63: jne    0x00000000000000cf
  • 위의 (4b:) 명령어를 보면 x86 CPU 에서 r2 레지스터에 해당하는 rsi 레지스터가 할당되어 있는 것과 재배치 작업이 끝난 data1 의 메모리 주소가 들어가 있는 것을 볼 수 있다. 이러한 과정을 통해 BPF 코드에서 전역변수로 선언된 data1에 접근하기 위한 재배치를 수행한다.