Skip to content

메모리 로딩

  • 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 변수가 속해있는 섹션에서의 오프셋을 집어넣는다.

    Terminal window
    // 예시
    /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 코드는 아래와 같다.

Terminal window
$ 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에 접근하기 위한 재배치를 수행한다.