-
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);} -
이를 컴파일한 결과는 아래와 같다.
Terminal window Disassembly of section .text:0000000000000000 <probe_entry>:0: 7b 3a b8 ff 00 00 00 00 *(u64 *)(r10 - 72) = r31: 7b 2a c0 ff 00 00 00 00 *(u64 *)(r10 - 64) = r22: 7b 1a c8 ff 00 00 00 00 *(u64 *)(r10 - 56) = r13: 85 00 00 00 0e 00 00 00 call 144: bf 08 00 00 00 00 00 00 r8 = r05: b7 01 00 00 00 00 00 00 r1 = 06: 7b 1a e0 ff 00 00 00 00 *(u64 *)(r10 - 32) = r17: 7b 1a d8 ff 00 00 00 00 *(u64 *)(r10 - 40) = r18: 7b 1a d0 ff 00 00 00 00 *(u64 *)(r10 - 48) = r19: bf 89 00 00 00 00 00 00 r9 = r810: 77 09 00 00 20 00 00 00 r9 >>= 3211: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll13: 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 = 03: 85 10 00 00 ff ff ff ff call -14: b7 00 00 00 00 00 00 00 r0 = 05: 95 00 00 00 00 00 00 00 exitDisassembly 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 = 13: 85 10 00 00 ff ff ff ff call -14: b7 00 00 00 00 00 00 00 r0 = 05: 95 00 00 00 00 00 00 00 exit -
위의 오브젝트를 보면 메인함수는 각각의 섹션에 위치해있지만 공통함수는
.text
섹션에 위치해있는 것을 볼 수 있다. (함수 선언 앞에 섹션을 지정하지 않으면 해당 함수는 기본적으로.text
섹션에 위치하게 된다.) -
일반적으로 BPF 프로그램을 로딩할 때는 하나의 특정 섹션을 지정해서 사용하는데, 위와 같이 메인함수에서 호출하는 함수가 다른 섹션에 존재할 때는 어떻게 동작하는 것일까?
-
이 질문에 대한 해답은 libbpf를 기준으로 알아보자. 아래 재배치 목록을 살펴보자.
Terminal window RELOCATION RECORDS FOR [kprobe/vfs_read]:OFFSET TYPE VALUE0000000000000018 R_BPF_64_32 .textRELOCATION RECORDS FOR [kprobe/vfs_write]:OFFSET TYPE VALUE0000000000000018 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 코드를 작성하는 과정은 다음과 같다.Terminal window Symbol table '.symtab' contains 20 entries:Num: Value Size Type Bind Vis Ndx Name0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND1: 00000000000003c8 0 NOTYPE LOCAL DEFAULT 1 LBB2_102: 00000000000003d0 0 NOTYPE LOCAL DEFAULT 1 LBB2_113: 0000000000000430 0 NOTYPE LOCAL DEFAULT 1 LBB2_134: 0000000000000468 0 NOTYPE LOCAL DEFAULT 1 LBB2_145: 0000000000000088 0 NOTYPE LOCAL DEFAULT 1 LBB2_26: 0000000000000138 0 NOTYPE LOCAL DEFAULT 1 LBB2_47: 00000000000003b0 0 NOTYPE LOCAL DEFAULT 1 LBB2_88: 0000000000000000 1136 FUNC LOCAL DEFAULT 1 probe_entry9: 0000000000000000 4160 OBJECT LOCAL DEFAULT 7 zero_value10: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text11: 0000000000000000 0 SECTION LOCAL DEFAULT 2 kprobe/vfs_read12: 0000000000000000 0 SECTION LOCAL DEFAULT 3 kprobe/vfs_write13: 0000000000000000 0 SECTION LOCAL DEFAULT 7 .bss14: 0000000000000000 13 OBJECT GLOBAL DEFAULT 5 LICENSE15: 0000000000000000 32 OBJECT GLOBAL DEFAULT 6 entries16: 0000000000000004 1 OBJECT GLOBAL DEFAULT 4 regular_file_only17: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 target_pid18: 0000000000000000 48 FUNC GLOBAL DEFAULT 2 vfs_read_entry19: 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 코드)을 덤프한 것이다.
Terminal window $ bpftool prog dump xlated id 17int 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 = 13: (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 = 05: (95) exitint 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) = r37: (7b) *(u64 *)(r10 -64) = r28: (7b) *(u64 *)(r10 -56) = r1; __u64 pid_tgid = bpf_get_current_pid_tgid();9: (85) call bpf_get_current_pid_tgid#13374410: (bf) r8 = r011: (b7) r1 = 0; struct file_id key = {};12: (7b) *(u64 *)(r10 -32) = r113: (7b) *(u64 *)(r10 -40) = r114: (7b) *(u64 *)(r10 -48) = r1; __u32 pid = pid_tgid >> 32;15: (bf) r9 = r816: (77) r9 >>= 32 -
위의 코드를 보면, 맨 앞 부분에 메인함수가 위치해있고, 바로 이어서 공통함수(probe_entry)가 위치해있는 것을 볼 수 있다.
-
그리고 공통함수를 호출하는 (
3:
) 명령어를 보면, 공통함수의 시작 위치가 (6:
) 명령어이기 때문에 다음 명령어(4:
)의 주소값(Program Counter)을 기준으로 2 를 더한 위치를 호출하는 것을 볼 수 있다. -
마지막으로 BPF 코드를 실제 동작 가능한 머신코드(x86)로 JIT(Just-In-Time) 컴파일한 결과물은 아래와 같다.
Terminal window $ bpftool prog dump jited id 17int 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,%ax7: push %rbp8: mov %rsp,%rbpb: mov 0x60(%rdi),%rsif: mov 0x70(%rdi),%rdi; return probe_entry(ctx, file, count, WRITE);13: mov $0x1,%edx18: callq 0x00000000000020c8; int BPF_KPROBE(vfs_write_entry, struct file *file, const char *buf, size_t count, loff_t *pos)1d: xor %eax,%eax1f: leaveq20: retqint 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,%ax7: push %rbp8: mov %rsp,%rbpb: sub $0x48,%rsp12: push %rbx13: push %r1315: push %r1417: push %r1519: mov %rdx,-0x48(%rbp)1d: mov %rsi,-0x40(%rbp)21: mov %rdi,-0x38(%rbp); __u64 pid_tgid = bpf_get_current_pid_tgid();25: callq 0xffffffffd3e3693c2a: mov %rax,%r142d: xor %edi,%edi -
리눅스 커널에서는 앞의 BPF 코드를 한번에 컴파일하지 않고, 메인함수와 공통함수를 서브프로그램으로 나눈 다음 각각 컴파일한다.
-
그리고 메인함수(
vfs_write_entry
)에서 공통함수(probe_entry
)를 호출하는 명령어(18:
)를 보면, 다음 명령어(0x1d:
)의 위치에서 공통함수를 JIT 컴파일한 결과물이 저장된 메모리 위치까지의 거리(오프셋)를 이용하여 호출하는 것을 볼 수 있다.
참고