이전 챕터에서는 BCC로 간단한 eBPF “Hello World”를 만들어봤다. 이번에는 같은 프로그램을 순수 C로 작성해서, BCC가 뒤에서 대신 해주던 작업이 무엇인지 직접 이해해보자.
eBPF 프로그램은 작성부터 실행까지 몇 단계를 거친다.

즉, 개발자는 고수준 레벨의 컴퓨터 언어를 통해 코드를 작성하고, eBPF 바이트코드로 컴파일해서 커널 내부의 eBPF 가상 머신에서 실행되는 구조다.
eBPF 가상 머신은 말 그대로 소프트웨어로 구현된 컴퓨터다. eBPF 바이트코드 형태의 프로그램을 입력받아, 이를 CPU에서 실행 가능한 머신 코드로 변환한다.
초기 eBPF에서는 이 바이트코드를 커널 내부에서 인터프리팅 방식으로 실행했다. 즉, 프로그램이 실행될 때마다 커널이 명령어를 하나씩 해석해 머신 코드로 바꿔 실행했다. 하지만 성능 문제와 Spectre 관련 취약점을 피하기 위해, 현재는 대부분 JIT 컴파일 방식을 사용한다. 이 방식은 프로그램이 커널에 로드될 때 한 번만 머신 코드로 변환한다.
eBPF 바이트코드는 여러 명령어로 이루어지며, 이 명령어들은 eBPF 레지스터를 대상으로 동작한다. 이 구조는 일반적인 CPU 아키텍처와 잘 맞도록 설계되어 있어, 바이트코드에서 머신 코드로 변환하는 과정이 비교적 단순하다.
eBPF 가상 머신은 0번부터 9번까지 총 10개의 범용 레지스터를 사용한다. 추가로 10번 레지스터는 스택 프레임 포인터로 사용되며 읽기만 가능하다. 프로그램이 실행되는 동안 상태 값들은 이 레지스터에 저장된다.
이 레지스터들은 실제 하드웨어 레지스터가 아니라 소프트웨어로 구현된 것이다. 리눅스 커널의 include/uapi/linux/bpf.h 파일에서 BPF_REG_0부터 BPF_REG_10까지 정의되어 있다.
eBPF 프로그램이 실행되기 전에 context 인자는 레지스터 1에 들어간다. 그리고 함수의 반환 값은 레지스터 0에 저장된다.
또한 eBPF 코드에서 함수를 호출할 때는, 인자를 레지스터 1번부터 5번까지 순서대로 넣는다. 인자가 5개보다 적으면 일부 레지스터는 사용되지 않는다.
함수 인자는 최대 5개까지만 받을 수 있다!
ref: https://docs.ebpf.io/linux/concepts/functions/ 의 BPF to BPF functions (sub-programs) 참고
linux/bpf.h에는 eBPF 명령어를 표현하는 bpf_insn 구조체가 정의되어 있다.
struct bpf_insn {
__u8 code; /* opcode */
__u8 dst_reg:4; /* dest register */
__u8 src_reg:4; /* source register */
__s16 off; /* signed offset */
__s32 imm; /* signed immediate constant */
};
각 명령어는 opcode를 통해 어떤 동작을 수행할지 결정한다. 예를 들어 레지스터에 값을 더하거나, 특정 조건에서 다른 명령어로 점프하는 작업을 수행할 수 있다. 연산에 따라 최대 두 개의 레지스터가 사용될 수 있으며, 경우에 따라 오프셋이나 즉시값이 함께 사용된다.
이 구조체 하나는 64비트(8바이트) 크기다. 하지만 64비트 값을 레지스터에 넣는 경우처럼 더 많은 정보가 필요하면, 16바이트 크기의 확장된 명령어 형식을 사용한다.
eBPF 프로그램이 커널에 로드되면, 이 bpf_insn 구조체들의 배열 형태로 표현된다. 이후 verifier가 여러 검사를 수행해 해당 코드가 안전하게 실행될 수 있는지 확인한다.
대부분의 eBPF 명령어는 다음과 같은 유형으로 나뉜다.
eBPF에서 패킷 처리는 매우 흔한 활용 사례다. 네트워크 인터페이스로 들어오는 모든 패킷마다 eBPF 프로그램이 실행되고, 이 프로그램은 패킷 내용을 확인하거나 수정할 수 있다. 그리고 해당 패킷을 어떻게 처리할지 결정한다. 예를 들어 그대로 처리하거나, 드롭하거나, 다른 곳으로 전달하도록 할 수 있다.
이번 챕터에서는 단순히 패킷이 도착할 때마다 "Hello World"와 카운터 값을 trace로 출력하는 예제를 살펴보자.
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
int counter = 0;
SEC("xdp")
int hello(void *ctx) {
bpf_printk("Hello World %d", counter);
counter++;
return XDP_PASS;
}
char LICENSE[] SEC("license") = "Dual BSD/GPL";
SEC("xdp") 매크로는 이 함수가 속한 섹션을 정의한다. 여기서는 이 프로그램이 XDP(eXpress Data Path) 타입의 eBPF 프로그램이라고 알려주는 역할을 한다.hello 함수에서는 Hello World 와 함께 카운터를 출력하고, 카운터 증가시킨 뒤, XDP_PASS 를 반환한다. XDP_PASS 는 패킷을 정상적으로 계속 처리하라는 의미의 반환값이다.매크로란?
전처리 지시자를 사용하여 특정 코드 조각을 이름(식별자)으로 정의하고, 컴파일 전 단계에서 문자열을 단순 치환하는 기능
#include <stdio.h> #define MAX(a, b) ((a) > (b) ? (a) : (b)) // 매크로 정의 int main() { int x = 10, y = 20; printf("Max: %d\n", MAX(x, y)); // 20으로 치환됨 return 0; }
작성한 eBPF C 코드는 바로 실행되지 않는다. 먼저 eBPF 가상 머신이 이해할 수 있는 eBPF 바이트코드로 컴파일해야 한다.
이때 LLVM 프로젝트의 Clang 컴파일러를 사용하며, -target bpf 옵션을 지정하면 된다.
hello.bpf.o: %.o: %.c
clang \
-target bpf \
-I/usr/include/$(shell uname -m)-linux-gnu \
-g \
-O2 -c $< -o $@
이 설정은 hello.bpf.c 소스 파일을 컴파일해서 hello.bpf.o라는 오브젝트 파일을 생성한다.
file 명령어를 사용하여 오브젝트 파일을 살펴보면,
$ file hello.bpf.o
hello.bpf.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), with debug_info, not stripped
hello.bpf.o는 ELF 형식의 파일이며, eBPF 코드를 담고 있다는 것을 알 수 있다. 64비트 플랫폼용이고, 컴파일할 때 -g 옵션을 사용했기에 디버그 정보도 포함된다.
eBPF 명령어를 더 자세히 보려면 llvm-objdump를 사용할 수 있다.
$ llvm-objdump -S hello.bpf.o
hello.bpf.o: file format elf64-bpf
Disassembly of section xdp:
0000000000000000 <hello>:
; bpf_printk("Hello World %d", counter");
0: 18 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r6 = 0 ll
2: 61 63 00 00 00 00 00 00 r3 = *(u32 *)(r6 + 0)
3: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
5: b7 02 00 00 0f 00 00 00 r2 = 15
6: 85 00 00 00 06 00 00 00 call 6
; counter++;
7: 61 61 00 00 00 00 00 00 r1 = *(u32 *)(r6 + 0)
8: 07 01 00 00 01 00 00 00 r1 += 1
9: 63 16 00 00 00 00 00 00 *(u32 *)(r6 + 0) = r1
; return XDP_PASS;
10: b7 00 00 00 02 00 00 00 r0 = 2
11: 95 00 00 00 00 00 00 00 exit
각 줄 왼쪽의 숫자는 해당 명령어의 오프셋이다. eBPF 명령어는 보통 8바이트 길이이기 때문에 보통 한 줄마다 오프셋이 1씩 증가한다. 다만 이 예제의 첫 번째와 세 번째 명령어는 16바이트가 필요한 와이드 명령어(ex: 레지스터 6을 64비트 값 0으로 설정), 그 다음 명령어의 오프셋이 +1 이 아닌 +2 의 값이다.
각 명령어의 첫 바이트는 opcode이며, 커널이 어떤 동작을 수행해야 하는지를 나타낸다. 오른쪽에는 사람이 읽기 쉬운 형태로 해석된 결과가 함께 표시된다.
예를 들어 오프셋 5의 명령어는 다음과 같다.
5: b7 02 00 00 0f 00 00 00 r2 = 15
오프셋 10의 명령어도 같은 방식이다.
10: b7 00 00 00 02 00 00 00 r0 = 2
=> 요건 레지스터 0에 2를 넣겠다는 의미이다. 10번째 명령어이므로 이는 XDP_PASS의 값은 2다 를 의미한다. 즉, 이 결과는 소스 코드의 return XDP_PASS;와 그대로 대응된다.
이제 eBPF 오브젝트 파일을 커널에 로드한다. 이 예제에서는 bpftool을 사용한다.
$ bpftool prog load hello.bpf.o /sys/fs/bpf/hello
이 명령은 hello.bpf.o에 들어 있는 eBPF 프로그램을 커널에 로드하고, /sys/fs/bpf/hello 위치에 "pins" 한다. 명령 실행 시 아무 출력이 없으면 정상적으로 로드된 것이다.
이후 ls 명령어를 통해 다음과 같이 확인할 수 있다.
$ ls /sys/fs/bpf
hello
여기서 “핀(pin)한다”는 표현은 eBPF 객체를 파일 시스템에 고정해서 계속 참조할 수 있게 만든다는 의미다. 즉, 커널 안에 올라간 eBPF 프로그램에 이름(경로)을 붙여서 꺼내 쓸 수 있게 만드는 것이다.
위 예제에서 왜 필요할까?
eBPF 프로그램을 그냥 로드만 하면, 그 프로그램은 파일 디스크립터(fd)로만 접근할 수 있다.
문제는 이 fd는:
• 프로세스가 종료되면 사라진다
• 다른 프로그램에서 접근하기 어렵다
그래서 /sys/fs/bpf라는 특수 파일 시스템에 프로그램을 파일처럼 고정(pinning) 시킨다.
bpftool을 사용하면 현재 커널에 올라간 eBPF 프로그램 목록을 확인할 수 있다.
$ bpftool prog list
...
540: xdp name hello tag d35b94b4c0c10efb gpl
loaded_at 2022-08-02T17:39:47+0000 uid 0
xlated 96B jited 148B memlock 4096B map_ids 165,166
btf_id 254
여기서 540은 이 프로그램에 할당된 ID다. eBPF 프로그램은 로드될 때마다 고유한 ID를 가진다.
이 ID를 이용하면 더 자세한 정보를 확인할 수 있다.
$ bpftool prog show id 540 --pretty
{
"id": 540,
"type": "xdp",
"name": "hello",
"tag": "d35b94b4c0c10efb",
"gpl_compatible": true,
"loaded_at": 1659461987,
"uid": 0,
"bytes_xlated": 96,
"jited": true,
"bytes_jited": 148,
"bytes_memlock": 4096,
"map_ids": [165, 166],
"btf_id": 254
}
tag는 eBPF 프로그램의 명령어들을 기반으로 만든 SHA 해시값이다. 프로그램을 식별하는 또 다른 방법이다.
중요한 점은:
bpftool은 여러 방식으로 같은 프로그램을 조회할 수 있다.
bpftool prog show id 540
bpftool prog show name hello
bpftool prog show tag d35b94b4c0c10efb
bpftool prog show pinned /sys/fs/bpf/hello
프로그램을 띄울 때 동일한 이름, 태그 등을 가질 수 있으나 id 는 고유하다.
bytes_xlated는 verifier를 통과한 뒤의 eBPF 바이트코드 크기를 의미한다. 이 과정에서 커널이 일부 코드를 변경할 수도 있다.
이 변환된 코드는 bpftool로 확인할 수 있다.
$ bpftool prog dump xlated name hello
int hello(struct xdp_md * ctx):
; bpf_printk("Hello World %d", counter);
0: (18) r6 = map[id:165][0]+0
2: (61) r3 = *(u32 *)(r6 +0)
3: (18) r1 = map[id:166][0]+0
5: (b7) r2 = 15
6: (85) call bpf_trace_printk#-78032
; counter++;
7: (61) r1 = *(u32 *)(r6 +0)
8: (07) r1 += 1
9: (63) *(u32 *)(r6 +0) = r1
; return XDP_PASS;
10: (b7) r0 = 2
11: (95) exit
이 출력은 앞에서 llvm-objdump로 본 디스어셈블된 코드와 매우 비슷하다. 명령어의 오프셋도 같고, 예를 들어 offset 5에서 r2 = 15 같은 동일한 동작을 확인할 수 있다.
eBPF 바이트코드는 꽤 저수준이지만, 아직 CPU가 직접 실행하는 머신 코드는 아니다. eBPF는 JIT 컴파일을 통해 이 바이트코드를 실제 CPU에서 실행되는 머신 코드로 변환한다.
bytes_jited 필드는 이 변환된 코드의 크기를 보여준다. 즉, JIT 컴파일 이후의 최종 실행 코드 크기다.
일반적으로 eBPF 프로그램은 성능을 위해 JIT 컴파일된다. 바이트코드를 그대로 해석(interpreter)하는 방식도 가능하지만, 컴파일된 코드가 더 빠르다. eBPF는 이 과정을 쉽게 만들기 위해 CPU 구조와 비슷한 명령어와 레지스터 모델을 사용한다.
아래는 bpftool 을 사용해 이 코드는 CPU가 실제로 실행하는 어셈블리 코드를 확인하는 코드이다.
$ bpftool prog dump jited name hello
int hello(struct xdp_md * ctx):
bpf_prog_d35b94b4c0c10efb_hello:
; bpf_printk("Hello World %d", counter);
0: hint #34
4: stp x29, x30, [sp, #-16]!
8: mov x29, sp
...
58: blr x10
; counter++;
60: mov x10, #0
64: ldr w0, [x19, x10]
68: add x0, x0, #1
70: str w0, [x19, x10]
; return XDP_PASS;
74: mov x7, #2
...
90: ret
eBPF 프로그램은 로드만 한다고 실행되지 않는다. 반드시 특정 이벤트에 연결(attach) 해야 실행된다.
이때 프로그램 타입과 이벤트 타입이 반드시 맞아야 한다.
이 예제에서는 XDP 프로그램이므로, 네트워크 인터페이스의 XDP 이벤트에 연결한다.
$ bpftool net attach xdp id 540 dev eth0 # 1
$ bpftool net list # 2
xdp:
eth0(2) driver id 540
tc:
flow_dissector:
$ ip link # 3
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp ...
...
prog/xdp id 540 tag 9d0e949f89f1a82c jited
bpftool net attach 명령어를 통해 ID 540 인 eBPF 프로그램을 eth0 네트워크에 붙이는 코드이다.bpftool net list 명령어를 통해 ID 540 프로그램이 XDP 로 연결된 것을 확인할 수 있다.ip link 명령어를 통해 eth0 이 xdp 프로그램에 붙어있고, 해당 프로그램의 ID 540, tag 9d0e949f89f1a82c 인 것을 확인할 수 있다.이제 패킷이 들어올 때마다 프로그램이 실행되어 trace_pipe 에 찍히는 것을 확인할 수 있다.
$ cat /sys/kernel/debug/tracing/trace_pipe
<idle>-0 [003] ...: bpf_trace_printk: Hello World 4531
<idle>-0 [003] ...: bpf_trace_printk: Hello World 4532
<idle>-0 [003] ...: bpf_trace_printk: Hello World 4533
이전 챕터 2에서는 사용자가 명령어를 실행시키면서 syscall 이 발생하는 것을 감지했다면(kprobe) 챕터 3에서는 네트워크 패킷 도착할 때마다 감지한다는(XDP) 점이 다르다.
eBPF에서 전역 변수는 내부적으로 map을 이용해 구현된다. map은 eBPF 프로그램과 사용자 공간 모두에서 접근할 수 있는 데이터 구조다. 또한 프로그램이 여러 번 실행되어도 같은 map을 계속 사용하기 때문에, 이전 실행의 상태를 유지하는 데 사용할 수 있다. 이런 특성 덕분에 map은 전역 변수처럼 활용된다.
TODO: 챕터 2 Exercise 에서 같은 map 을 사용하는걸 확인했었는데 그때 결과랑 비교하기
위 예제에서 사용하는 map 리스트를 보면 두 개의 map 을 사용하는 것을 확인할 수 있다.
$ bpftool map list
165: array name hello.bss flags 0x400
key 4B value 4B max_entries 1 memlock 4096B
btf_id 254
166: array name hello.rodata flags 0x80
key 4B value 15B max_entries 1 memlock 4096B
btf_id 254 frozen
$ bpftool map dump name hello.bss
[{
"value": {
".bss": [{
"counter": 11127
}]
}
}]
hello.bss 를 보면 전역 변수 counter 를 볼 수 있다. 이는 패킷이 들어올 때마다 계속 증가한다. 만약 위에서 컴파일 할 때 -g 옵션이 없다면 아래와 같이 출력되어 변수명을 추론하기가 어렵다.
$ bpftool map dump name hello.bss
key: 00 00 00 00 value: 19 01 00 00
Found 1 element
이번에 hello.rodata 를 살펴보면 아래와 같이 문자열이 들어간 것을 확인할 수 있다.
$ bpftool map dump name hello.rodata
[{
"value": {
".rodata": [{
}],
"hello.____fmt": "Hello World %d"
}
}]
이제 프로그램을 이벤트로부터 분리하는 예제를 살펴보자.
# 1. 프로그램을 이벤트로부터 분리
$ bpftool net detach xdp dev eth0 # 출력 없으면 성공
# 2. xdp 목록 확인
$ bpftool net list
xdp:
tc:
flow_dissector:
# 3. 프로그램 확인
$ bpftool prog show name hello
395: xdp name hello tag 9d0e949f89f1a82c gpl
...
# 4. 프로그램 언로드
$ rm /sys/fs/bpf/hello
$ bpftool prog show name hello # 아무것도 출력안됨
bpftool net detach 명령어를 통해 프로그램을 분리한다. 이제 eBPF 내부에서 함수 호출을 사용하는 예제를 본다.
다음 함수는 tracepoint에서 syscall opcode를 가져온다.
static __attribute((noinline)) int get_opcode(struct bpf_raw_tracepoint_args *ctx) {
return ctx->args[1];
}
noinline은 컴파일러가 이 함수를 인라인하지 못하도록 강제한다.
이 함수를 호출하는 eBPF 프로그램은 다음과 같다.
SEC("raw_tp")
int hello(struct bpf_raw_tracepoint_args *ctx) {
int opcode = get_opcode(ctx);
bpf_printk("Syscall: %d", opcode);
return 0;
}
이제 프로그램을 로드하고 결과를 확인해보자.
$ bpftool prog load hello-func.bpf.o /sys/fs/bpf/hello
$ bpftool prog list name hello
893: raw_tracepoint name hello ...
$ bpftool prog dump xlated name hello
int hello(struct bpf_raw_tracepoint_args * ctx):
; int opcode = get_opcode(ctx);
0: (85) call pc+7#bpf_prog_cbacc90865b1b9a5_get_opcode
; bpf_printk("Syscall: %d", opcode);
1: (18) r1 = map[id:193][0]+0
3: (b7) r2 = 12
4: (bf) r3 = r0
5: (85) call bpf_trace_printk#-73584
; return 0;
6: (b7) r0 = 0
7: (95) exit
int get_opcode(struct bpf_raw_tracepoint_args * ctx):
; return ctx->args[1];
8: (79) r0 = *(u64 *)(r1 +8)
; return ctx->args[1];
9: (95) exit
offset 0의 (85) 명령은 함수 호출이다. 다음 명령으로 가지 않고, offset 8로 점프해서 get_opcode()를 실행한다.
함수 호출 명령어는 호출된 함수가 종료될 때 호출 함수에서 실행을 계속할 수 있도록
현재 상태를 eBPF 가상 머신의 스택에 저장해야 한다. 스택 크기가 512바이트로 제한되어 있기 때문에,
BPF 간 호출은 너무 많이 쌓일 수 없다.