- BPF는 일반적인 프로그램과 유사한 방식으로 개발하기 때문에 유사한 실행파일 및 메모리 구조를 가지고 있지만, 커널 안에서 제한된 환경으로 실행되기 때문에 로딩(loading) 또한 다른 방식으로 실행된다
-
일반적인 실행파일에서 가장 중요한 두 가지는 코드와 데이터이다. 코드는 말그대로 상위언어를 컴파일한 머신코드를 의미하고, 데이터는 실행시 코드가 참조하는 메모리를 의미한다.
-
데이터는 스택과 힙같이 실행시 메모리가 할당/해제되는 동적 데이터와 전역변수처럼 코드에서 선언되는 정적 데이터로 나뉘는데, 정적 데이터는 실행파일을 로딩할 때 메모리가 할당되고 해당 메모리를 참조하는 코드도 재배치(relocation)된다.
-
그리고 정적 데이터는 크게 읽기전용 변수, 초기화된 전역변수 그리고 초기화되지 않은 전역변수로 구분된다.
-
읽기전용 변수는
rodata0
처럼 const로 선언된 전역변수를 의미하고, 해당 메모리에 대한 쓰기 작업을 금지하기 위해 읽기전용의 페이지를 할당받아 사용한다. -
그리고
data0
과data1
같이 초기값을 가지고 있는 전역변수는 초기화된 전역변수로 분류되고, 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
에 속해있고, 초기화된 전역변수인data0
과data1
은.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> ...
- BPF 맵은 사용자 코드와 BPF 코드가 데이터를 공유하는 가장 보편적인 방식으로,
-
위의 (
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
에 접근하기 위한 재배치를 수행한다.