- 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-bpfarchitecture: bpfelstart address: 0x0000000000000000Program Header:Dynamic Section:Sections:Idx Name Size VMA Type0 00000000 00000000000000001 .text 00000000 0000000000000000 TEXT2 tp_btf/sched_wakeup 000000f8 0000000000000000 TEXT3 tp_btf/sched_wakeup_new 000000f8 0000000000000000 TEXT4 tp_btf/sched_switch 00000318 0000000000000000 TEXT5 .rodata 00000015 0000000000000000 DATA6 .data 00000008 0000000000000000 DATA7 .maps 00000038 0000000000000000 DATA8 license 00000004 0000000000000000 DATA9 .bss 00000004 0000000000000000 BSS10 .BTF 00005e0c 000000000000000011 .BTF.ext 0000068c 000000000000000012 .symtab 000002a0 000000000000000013 .reltp_btf/sched_wakeup 00000030 000000000000000014 .reltp_btf/sched_wakeup_new 00000030 000000000000000015 .reltp_btf/sched_switch 00000080 000000000000000016 .rel.BTF 000000a0 000000000000000017 .rel.BTF.ext 00000630 000000000000000018 .llvm_addrsig 00000009 000000000000000019 .strtab 0000017c 0000000000000000SYMBOL TABLE:0000000000000000 g O .bss 0000000000000004 bss00000000000000000 g O .data 0000000000000004 data00000000000000004 g O .data 0000000000000004 data10000000000000000 g F tp_btf/sched_switch 0000000000000318 handle__sched_switch0000000000000010 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 = r11: 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 = 04: 7b 1a e8 ff 00 00 00 00 *(u64 *)(r10 - 24) = r15: 7b 1a e0 ff 00 00 00 00 *(u64 *)(r10 - 32) = r16: 7b 1a d8 ff 00 00 00 00 *(u64 *)(r10 - 40) = r17: 7b 1a d0 ff 00 00 00 00 *(u64 *)(r10 - 48) = r18: 7b 1a c8 ff 00 00 00 00 *(u64 *)(r10 - 56) = r19: 7b 1a c0 ff 00 00 00 00 *(u64 *)(r10 - 64) = r110: 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 ll13: 61 22 00 00 00 00 00 00 r2 = *(u32 *)(r2 + 0)14: 67 02 00 00 20 00 00 00 r2 <<= 3215: c7 02 00 00 20 00 00 00 r2 s>>= 3216: 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 VALUE0000000000000058 R_BPF_64_64 data100000000000000a8 R_BPF_64_64 targ_tgid00000000000000e8 R_BPF_64_64 targ_pid0000000000000148 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 -lhlrwx------ 1 root root 64 Sep 16 05:13 0 -> /dev/tty1lrwx------ 1 root root 64 Sep 16 05:13 1 -> /dev/tty1lrwx------ 1 root root 64 Sep 16 05:56 10 -> anon_inode:bpf-proglrwx------ 1 root root 64 Sep 16 05:56 11 -> anon_inode:bpf-proglr-x------ 1 root root 64 Sep 16 05:56 12 -> anon_inode:bpf_linklr-x------ 1 root root 64 Sep 16 05:56 13 -> anon_inode:bpf_linklr-x------ 1 root root 64 Sep 16 05:56 14 -> anon_inode:bpf_linklrwx------ 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/tty1lr-x------ 1 root root 64 Sep 16 05:13 3 -> anon_inode:btflrwx------ 1 root root 64 Sep 16 05:56 4 -> anon_inode:bpf-maplrwx------ 1 root root 64 Sep 16 05:56 5 -> anon_inode:bpf-maplrwx------ 1 root root 64 Sep 16 05:13 6 -> anon_inode:bpf-maplrwx------ 1 root root 64 Sep 16 05:13 7 -> anon_inode:bpf-maplrwx------ 1 root root 64 Sep 16 05:56 8 -> anon_inode:bpf-maplrwx------ 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 map8105: array name runqslow.data flags 0x400 key 4B value 8B max_entries 1 memlock 4096B btf_id 9448106: array name runqslow.rodata flags 0x480 key 4B value 21B max_entries 1 memlock 4096B btf_id 944 frozen8107: array name runqslow.bss flags 0x400 key 4B value 4B max_entries 1 memlock 4096B btf_id 944
$ bpftool prog dump xlated id 62928int 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 62928int 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
에 접근하기 위한 재배치를 수행한다.