-
BPF는 일반적으로 하나의 함수(main 함수)가 하나의 섹션이 되고, 하나의 섹션이 하나의 프로그램이 된다. 그리고 커널에서 해당 프로그램을 실행할 때는 프로그램의 시작 위치가 메인함수의 시작 위치이기 때문에 간단히 처음부터 실행하면 된다.
-
하지만 아래와 같이 메인함수에서 공통함수를 호출하는 경우처럼 두 개 이상의 함수가 필요한 경우에는 프로그램의 시작 위치를 어떻게 보장할까? BPF는 이러한 상황에서 시작 위치 보장을 위해 서브프로그램이라는 기능을 제공한다.
-
아래 코드는 bcc의 filetop 예제코드이다. 해당 예제는
vfs_read
와vfs_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
)에서-1
에1
을 더한 위치를 의미한다. -
즉,
(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 컴파일한 결과물이 저장된 메모리 위치까지의 거리(오프셋)를 이용하여 호출하는 것을 볼 수 있다.
참고