개발 도구

개발 도구 (Development Tools)

이 문서는 BPF 주변의 사용자 공간 도구, 인트로스펙션 시설, 커널 제어 노브를 다뤄요. 개발 환경 설정, 커널/iproute2/bpftool 컴파일, LLVM을 통한 BPF 프로그램 작성, iproute2로 오브젝트 파일 로드 방법을 소개합니다.

출처: Development Tools

본문

이 섹션에서는 BPF 주변의 현재 사용자 공간 도구, 인트로스펙션 시설, 커널 제어 노브를 논의해요.

참고

BPF 주변의 도구와 인프라는 여전히 빠르게 진화하므로 사용 가능한 모든 도구의 완전한 그림을 제공하지 못할 수 있어요.

개발 환경 (Development Environment)

BPF 개발 환경 설정을 위한 단계별 가이드는 아래에서 Fedora와 Ubuntu 모두에 대해 찾을 수 있어요. 이는 개발 커널을 빌드·설치·테스트하고 iproute2를 빌드·설치하는 과정을 안내해요.

iproute2와 리눅스 커널을 수동으로 빌드하는 단계는 주요 배포판이 기본적으로 이미 충분히 최신 커널을 제공하므로 보통 필요하지 않지만, 최첨단(bleeding edge) 버전을 테스트하거나 각각 iproute2와 리눅스 커널에 BPF 패치를 기여하려면 필요해요. 마찬가지로 디버깅과 인트로스펙션 목적으로 bpftool 빌드는 선택사항이지만 권장돼요.

Fedora / Ubuntu / openSUSE Tumbleweed

다음은 Fedora 25 이상에 적용돼요:

$ sudo dnf install -y git gcc ncurses-devel elfutils-libelf-devel bc \
  openssl-devel libcap-devel clang llvm graphviz bison flex glibc-static

참고

다른 Fedora 파생판을 실행하고 dnf가 없으면 yum을 사용해 보세요.

다음은 Ubuntu 17.04 이상에 적용돼요:

$ sudo apt-get install -y make gcc libssl-dev bc libelf-dev libcap-dev \
  clang gcc-multilib llvm libncurses5-dev git pkg-config libmnl-dev bison flex \
  graphviz

다음은 openSUSE Tumbleweed와 openSUSE Leap 15.0 이상에 적용돼요:

$ sudo zypper install -y git gcc ncurses-devel libelf-devel bc libopenssl-devel \
libcap-devel clang llvm graphviz bison flex glibc-devel-static

커널 컴파일 (Compiling the Kernel)

리눅스 커널용 새 BPF 기능 개발은 net-next git 트리에서 일어나고, 최신 BPF 수정은 net 트리에 있어요. 다음 명령은 net-next 트리용 커널 소스를 git으로 가져와요:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git

git 커밋 히스토리가 관심 없으면 --depth 1로 git 히스토리를 가장 최근 커밋으로만 잘라내 트리를 훨씬 빠르게 클론할 수 있어요.

net 트리에 관심이 있으면 다음 URL에서 클론할 수 있어요:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/netdev/net.git

리눅스 커널을 빌드하는 방법에 대한 인터넷 튜토리얼이 수십 개 있으며, 좋은 리소스 중 하나는 Kernel Newbies 웹사이트(https://kernelnewbies.org/KernelBuild)로 위에서 언급한 두 git 트리 중 하나로 따라갈 수 있어요.

생성된 .config 파일이 BPF 실행을 위해 다음 CONFIG_* 항목을 포함하는지 확인하세요. 이 항목은 Cilium에도 필요해요.

CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_NET_SCH_INGRESS=m
CONFIG_NET_CLS_BPF=m
CONFIG_NET_CLS_ACT=y
CONFIG_BPF_JIT=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_TEST_BPF=m

일부 항목은 make menuconfig로 조정할 수 없어요. 예를 들어 CONFIG_HAVE_EBPF_JIT는 주어진 아키텍처가 eBPF JIT와 함께 제공되면 자동으로 선택돼요. 이 특정 경우 CONFIG_HAVE_EBPF_JIT는 선택사항이지만 매우 권장돼요. eBPF JIT 컴파일러가 없는 아키텍처는 BPF 명령어 실행이 덜 효율적인 비용으로 커널 내 인터프리터에 의존해야 해요.

설정 검증 (Verifying the Setup)

새로 컴파일된 커널로 부팅한 후 BPF 기능을 테스트하기 위해 BPF selftest 제품군으로 이동하세요(현재 작업 디렉터리는 클론된 git 트리의 루트를 가리킵니다):

$ cd tools/testing/selftests/bpf/
$ make
$ sudo ./test_verifier

Verifier 테스트는 수행되는 모든 현재 검사를 출력해요. 모든 테스트 실행이 끝날 때의 요약은 테스트 성공·실패 정보를 덤프해요:

Summary: 847 PASSED, 0 SKIPPED, 0 FAILED

참고

커널 릴리스 4.16+의 경우 BPF selftest는 더 이상 인라인될 필요가 없는 BPF 함수 호출로 인해 LLVM 6.0+에 대한 의존성이 있어요. 자세한 내용은 BPF to BPF Calls 섹션 또는 커널 패치의 커버 레터 메일(https://lwn.net/Articles/741773/)을 참고하세요. 모든 BPF 프로그램이 이 새 기능을 사용하지 않으면 LLVM 6.0+ 의존성이 있는 것은 아니에요. 배포판이 LLVM 6.0+를 제공하지 않으면 LLVM 섹션의 지침을 따라 컴파일할 수 있어요.

모든 BPF selftest를 실행하려면 다음 명령이 필요해요:

$ sudo make run_tests

실패가 보이면 전체 테스트 출력과 함께 Cilium Slack으로 연락해 주세요.

iproute2 컴파일 (Compiling iproute2)

(fixes 전용) net와 (새 기능) net-next 커널 트리와 유사하게 iproute2는 iproute와 iproute2-next라는 두 개의 별도 트리로 나뉘어요. iproute2 저장소는 net 트리를 기반으로 하고 iproute2-next 저장소는 net-next 커널 트리를 기반으로 해요. 이는 헤더 파일의 변경이 iproute2 트리에서 동기화될 수 있도록 필요해요.

안정 iproute2 저장소를 클론하려면:

$ git clone https://git.kernel.org/pub/scm/network/iproute2/iproute2.git

마찬가지로 언급한 개발 iproute2-next 트리를 클론하려면:

$ git clone https://git.kernel.org/pub/scm/network/iproute2/iproute2-next.git

그 후 빌드와 설치를 진행하세요:

$ cd iproute2/
$ ./configure --prefix=/usr
TC schedulers
 ATM    no

libc has setns: yes
SELinux support: yes
ELF support: yes
libmnl support: no
Berkeley DB: no

docs: latex: no
 WARNING: no docs can be built from LaTeX files
 sgml2html: no
 WARNING: no HTML docs can be built from SGML
$ make
[...]
$ sudo make install

configure 스크립트가 ELF support: yes를 보여주는지 확인하세요. 그래야 iproute2가 LLVM의 BPF 백엔드에서 ELF 파일을 처리할 수 있어요. libelf는 앞서 Fedora와 Ubuntu의 의존성 설치 지침에 나열됐어요.

bpftool 컴파일 (Compiling bpftool)

bpftool은 BPF 프로그램과 맵의 디버깅·인트로스펙션 주변의 필수 도구예요. 커널 트리의 일부이며 tools/bpf/bpftool/ 아래에서 사용할 수 있어요.

앞서 설명한 대로 net 또는 net-next 커널 트리를 클론했는지 확인하세요. bpftool을 빌드·설치하려면 다음 단계가 필요해요:

$ cd <kernel-tree>/tools/bpf/bpftool/
$ make
Auto-detecting system features:
...                        libbfd: [ on  ]
...        disassembler-four-args: [ OFF ]

  CC       xlated_dumper.o
  CC       prog.o
  CC       common.o
  CC       cgroup.o
  CC       main.o
  CC       json_writer.o
  CC       cfg.o
  CC       map.o
  CC       jit_disasm.o
  CC       disasm.o
make[1]: Entering directory '/home/foo/trees/net/tools/lib/bpf'

Auto-detecting system features:
...                        libelf: [ on  ]
...                           bpf: [ on  ]

  CC       libbpf.o
  CC       bpf.o
  CC       nlattr.o
  LD       libbpf-in.o
  LINK     libbpf.a
make[1]: Leaving directory '/home/foo/trees/bpf/tools/lib/bpf'
  LINK     bpftool
$ sudo make install

LLVM

LLVM은 현재 BPF 백엔드를 제공하는 유일한 컴파일러 제품군이에요. gcc는 현재 BPF를 지원하지 않아요.

BPF 백엔드는 LLVM의 3.7 릴리스에 병합됐어요. 주요 배포판은 LLVM을 패키징할 때 기본적으로 BPF 백엔드를 활성화하므로, 대부분의 최신 배포판에서 clang과 llvm을 설치하면 C를 BPF 오브젝트 파일로 컴파일하기에 충분해요.

일반적인 워크플로는 BPF 프로그램을 C로 작성하고, LLVM이 이를 오브젝트/ELF 파일로 컴파일하며, 사용자 공간 BPF ELF 로더(iproute2 등)가 이를 파싱해 BPF 시스템 콜을 통해 커널로 푸시하는 것이에요. 커널은 BPF 명령어를 검증하고 JIT해 프로그램에 대한 새 파일 디스크립터를 반환하며, 이를 서브시스템(예: 네트워킹)에 부착할 수 있어요. 지원되면 서브시스템이 BPF 프로그램을 하드웨어(예: NIC)로 더 오프로드할 수도 있어요.

LLVM의 경우 BPF 대상 지원은 예를 들어 다음으로 확인할 수 있어요:

$ llc --version
LLVM (http://llvm.org/):
LLVM version 3.8.1
Optimized build.
Default target: x86_64-unknown-linux-gnu
Host CPU: skylake

Registered Targets:
  [...]
  bpf        - BPF (host endian)
  bpfeb      - BPF (big endian)
  bpfel      - BPF (little endian)
  [...]

기본적으로 bpf 대상은 컴파일하는 CPU의 엔디언을 사용하는데, 즉 CPU가 리틀 엔디언이면 프로그램도 리틀 엔디언 형식으로 표현되고, CPU가 빅 엔디언이면 빅 엔디언으로 표현돼요. 이는 어떤 형식으로도 아키텍처를 불리하게 하지 않기 위해 실행되는 CPU의 엔디언을 사용하는 범용적인 BPF의 런타임 동작과도 일치해요.

크로스 컴파일을 위해 bpfeb와 bpfel 두 타깃이 도입됐으며, 덕분에 BPF 프로그램을 한 엔디언(예: x86의 리틀 엔디언)으로 실행되는 노드에서 컴파일하고 다른 엔디언 형식(예: arm의 빅 엔디언)의 노드에서 실행할 수 있어요. 프런트 엔드(clang)도 대상 엔디언으로 실행해야 한다는 점을 주의하세요.

엔디언 혼합이 적용되지 않는 상황에서는 bpf 타깃 사용이 선호돼요. 예를 들어 x86_64에서의 컴파일은 리틀 엔디언이므로 bpf와 bpfel 타깃에 대해 같은 출력이 나오며, 따라서 컴파일을 트리거하는 스크립트도 엔디언을 인지할 필요가 없어요.

최소한의 독립형 XDP 드롭 프로그램은 다음 예시(xdp-example.c)와 같을 수 있어요:

#include <linux/bpf.h>

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
    return XDP_DROP;
}

char __license[] __section("license") = "GPL";

그런 다음 다음과 같이 컴파일하고 커널에 로드할 수 있어요:

$ clang -O2 -Wall --target=bpf -c xdp-example.c -o xdp-example.o
# ip link set dev em1 xdp obj xdp-example.o

참고

위처럼 XDP BPF 프로그램을 네트워크 장치에 부착하려면 XDP를 지원하는 장치가 있는 Linux 4.11 또는 Linux 4.12 이상이 필요해요.

생성된 오브젝트 파일에 대해 LLVM(>= 3.9)은 공식 BPF 머신 값, 즉 EM_BPF(10진수 247 / 16진수 0xf7)를 사용해요. 이 예시에서 프로그램은 x86_64 아래 bpf 타깃으로 컴파일됐으므로 엔디언과 관련해 MSB(반대)가 아닌 LSB가 표시돼요:

$ file xdp-example.o
xdp-example.o: ELF 64-bit LSB relocatable, *unknown arch 0xf7* version 1 (SYSV), not stripped

readelf -a xdp-example.o는 ELF 파일에 대한 추가 정보를 덤프하며, 생성된 섹션 헤더, 재배치 항목, 심볼 테이블을 인트로스펙션하는 데 유용할 수 있어요.

clang과 LLVM을 처음부터 컴파일해야 하는 드문 경우 다음 명령을 사용할 수 있어요:

$ git clone https://github.com/llvm/llvm-project.git
$ cd llvm-project
$ mkdir build
$ cd build
$ cmake -DLLVM_ENABLE_PROJECTS=clang -DLLVM_TARGETS_TO_BUILD="BPF;X86" -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release -DLLVM_BUILD_RUNTIME=OFF  -G "Unix Makefiles" ../llvm
$ make -j $(getconf _NPROCESSORS_ONLN)
$ ./bin/llc --version
LLVM (http://llvm.org/):
LLVM version x.y.zsvn
Optimized build.
Default target: x86_64-unknown-linux-gnu
Host CPU: skylake

Registered Targets:
  bpf    - BPF (host endian)
  bpfeb  - BPF (big endian)
  bpfel  - BPF (little endian)
  x86    - 32-bit X86: Pentium-Pro and above
  x86-64 - 64-bit X86: EM64T and AMD64

$ export PATH=$PWD/bin:$PATH   # add to ~/.bashrc

--version이 Optimized build.를 언급하는지 확인하세요. 그렇지 않으면 LLVM이 디버깅 모드일 때 프로그램 컴파일 시간이 크게 증가해요(예: 10배 이상).

디버깅을 위해 clang은 다음과 같이 어셈블러 출력을 생성할 수 있어요:

$ clang -O2 -S -Wall --target=bpf -c xdp-example.c -o xdp-example.S
$ cat xdp-example.S
    .text
    .section    prog,"ax",@progbits
    .globl      xdp_drop
    .p2align    3
xdp_drop:                             # @xdp_drop
# BB#0:
    r0 = 1
    exit

    .section    license,"aw",@progbits
    .globl    __license               # @__license
__license:
    .asciz    "GPL"

LLVM 릴리스 6.0부터 어셈블러 파서 지원도 있어요. BPF 어셈블러로 직접 프로그래밍한 다음 llvm-mc로 오브젝트 파일로 조립할 수 있어요. 예를 들어 위에 나열된 xdp-example.S를 다음과 같이 오브젝트 파일로 다시 조립할 수 있어요:

$ llvm-mc -triple bpf -filetype=obj -o xdp-example.o xdp-example.S

또한 더 최근의 LLVM 버전(>= 4.0)은 dwarf 형식의 디버깅 정보를 오브젝트 파일에 저장할 수도 있어요. 이는 컴파일에 -g를 추가하는 일반적인 워크플로로 할 수 있어요.

$ clang -O2 -g -Wall --target=bpf -c xdp-example.c -o xdp-example.o
$ llvm-objdump -S --no-show-raw-insn xdp-example.o

xdp-example.o:        file format ELF64-BPF

Disassembly of section prog:
xdp_drop:
; {
    0:        r0 = 1
; return XDP_DROP;
    1:        exit

그러면 llvm-objdump 도구가 컴파일에 사용된 원래 C 코드로 어셈블러 출력을 주석 처리할 수 있어요. 이 경우의 사소한 예는 C 코드가 많지 않지만, 0:과 1:으로 표시된 줄 번호는 커널의 verifier 로그에 직접 대응해요.

이것은 BPF 프로그램이 verifier에 거부되면 llvm-objdump가 명령어를 원래 C 코드와 연관시키는 데 도움이 될 수 있음을 의미하며, 분석에 매우 유용해요.

# ip link set dev em1 xdp obj xdp-example.o verb

Prog section 'prog' loaded (5)!
 - Type:         6
 - Instructions: 2 (0 over limit)
 - License:      GPL

Verifier analysis:

0: (b7) r0 = 1
1: (95) exit
processed 2 insns

verifier 분석에서 볼 수 있듯이 llvm-objdump 출력은 커널과 같은 BPF 어셈블러 코드를 덤프해요.

--no-show-raw-insn 옵션을 빼면 어셈블리 앞에 원시 struct bpf_insn을 hex로 덤프해요:

$ llvm-objdump -S xdp-example.o

xdp-example.o:        file format ELF64-BPF

Disassembly of section prog:
xdp_drop:
; {
   0:       b7 00 00 00 01 00 00 00     r0 = 1
; return foo();
   1:       95 00 00 00 00 00 00 00     exit

LLVM IR 디버깅을 위해 BPF 컴파일 과정을 두 단계로 나눌 수 있어요. 나중에 llc에 전달할 수 있는 바이너리 LLVM IR 중간 파일 xdp-example.bc를 생성하는 거예요:

$ clang -O2 -Wall --target=bpf -emit-llvm -c xdp-example.c -o xdp-example.bc
$ llc xdp-example.bc -march=bpf -filetype=obj -o xdp-example.o

생성된 LLVM IR은 인간이 읽을 수 있는 형식으로도 덤프할 수 있어요:

$ clang -O2 -Wall -emit-llvm -S -c xdp-example.c -o -

LLVM은 프로그램에서 사용된 데이터 타입 설명 같은 디버그 정보를 생성된 BPF 오브젝트 파일에 부착할 수 있어요. 기본적으로 이는 DWARF 형식이에요.

BPF에서 사용하는 매우 단순화된 버전을 BTF(BPFT Type Format)라고 해요. 결과 DWARF는 BTF로 변환될 수 있고 나중에 BPF 오브젝트 로더를 통해 커널에 로드돼요. 커널은 BTF 데이터가 올바른지 검증하고 BTF 데이터가 포함한 데이터 타입을 추적해요.

그러면 BTF 데이터에서 키·값 유형으로 BPF 맵에 주석을 달 수 있어, 이후 맵 덤프가 관련 타입 정보와 함께 맵 데이터를 내보내요. 이는 더 나은 인트로스펙션, 디버깅, 값 예쁜 출력을 허용해요. BTF 데이터는 범용 디버깅 데이터 형식이므로 DWARF에서 BTF로 변환된 어떤 데이터든 로드할 수 있어요(예: 커널의 vmlinux DWARF 데이터를 BTF로 변환해 로드). 후자는 특히 향후 BPF 트레이싱에 유용해요.

DWARF 디버깅 정보에서 BTF를 생성하려면 elfutils(>= 0.173)가 필요해요. 그게 없으면 컴파일 중 llc 명령에 -mattr=dwarfris 옵션을 추가해야 해요:

$ llc -march=bpf -mattr=help |& grep dwarfris
  dwarfris - Disable MCAsmInfo DwarfUsesRelocationsAcrossSections.
  [...]

-mattr=dwarfris를 사용하는 이유는 dwarfris(dwarf relocation in section) 플래그가 DWARF와 ELF의 심볼 테이블 사이의 DWARF 크로스 섹션 재배치를 비활성화하기 때문이에요. libdw에 적절한 BPF 재배치 지원이 없어서 pahole 같은 도구가 그렇지 않으면 오브젝트에서 구조체를 제대로 덤프하지 못할 수 있기 때문이에요.

elfutils(>= 0.173)는 적절한 BPF 재배치 지원을 구현하므로 -mattr=dwarfris 옵션 없이도 같은 것을 이룰 수 있어요. 오브젝트 파일에서 구조체 덤프는 DWARF 또는 BTF 정보 어느 쪽에서든 할 수 있어요. pahole은 이 시점에 LLVM이 내보낸 DWARF 정보를 사용하지만, 향후 pahole 버전은 BTF가 있으면 이에 의존할 수 있어요.

DWARF를 BTF로 변환하려면 최근 pahole 버전(>= 1.12)이 필요해요. 최근 pahole 버전은 배포판 패키지에서 사용할 수 없으면 공식 git 저장소에서도 얻을 수 있어요:

$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git

pahole는 옵션 -J와 함께 제공되어 오브젝트 파일에서 DWARF를 BTF로 변환해요. pahole의 BTF 지원은 다음과 같이 프로브할 수 있어요(pahole에도 llvm-objcopy 도구가 필요하므로 그 존재도 확인하세요):

$ pahole --help | grep BTF
-J, --btf_encode           Encode as BTF

디버깅 정보를 생성하려면 프런트 엔드가 clang 명령줄에 -g를 전달해 소스 수준 디버그 정보도 생성해야 해요. -g는 llc의 dwarfris 옵션 사용 여부와 관계없이 필요하다는 점을 주의하세요. 오브젝트 파일 생성을 위한 전체 예시:

$ clang -O2 -g -Wall --target=bpf -emit-llvm -c xdp-example.c -o xdp-example.bc
$ llc xdp-example.bc -march=bpf -mattr=dwarfris -filetype=obj -o xdp-example.o

또는 clang만 사용해 디버깅 정보가 있는 BPF 프로그램을 빌드하려면(다시, 적절한 elfutils 버전이 있으면 dwarfris 플래그를 생략할 수 있음):

$ clang --target=bpf -O2 -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o

성공적으로 컴파일한 후 pahole을 사용해 DWARF 정보에 기반해 BPF 프로그램의 구조체를 제대로 덤프할 수 있어요:

$ pahole xdp-example.o
struct xdp_md {
        __u32                      data;                 /*     0     4 */
        __u32                      data_end;             /*     4     4 */
        __u32                      data_meta;            /*     8     4 */

        /* size: 12, cachelines: 1, members: 3 */
        /* last cacheline: 12 bytes */
};

-J 옵션을 통해 pahole은 결국 DWARF에서 BTF를 생성할 수 있어요. 오브젝트 파일에서 DWARF 데이터는 새로 추가된 BTF 데이터와 함께 유지돼요. clang과 pahole을 결합한 전체 예시:

$ clang --target=bpf -O2 -Wall -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o
$ pahole -J xdp-example.o

.BTF 섹션의 존재는 readelf 도구로 볼 수 있어요:

$ readelf -a xdp-example.o
[...]
  [18] .BTF              PROGBITS         0000000000000000  00000671
[...]

iproute2 같은 BPF 로더는 BTF 섹션을 감지·로드하므로 BPF 맵에 타입 정보를 주석으로 달 수 있어요.

LLVM은 기본적으로 생성된 오브젝트 파일이 장기 안정 커널(예: 4.9+) 같은 이전 커널로도 로드될 수 있도록 BPF 기본 명령어 집합을 사용해 코드를 생성해요.

그러나 LLVM은 BPF 백엔드에 -mcpu 선택기가 있어 BPF 명령어 집합의 다른 버전을 선택하는데, 즉 BPF 기본 명령어 집합 위에 명령어 집합 확장을 선택해 더 효율적이고 더 작은 코드를 생성해요.

사용 가능한 -mcpu 옵션은 다음으로 조회할 수 있어요:

$ llc -march bpf -mcpu=help
Available CPUs for this target:

  generic - Select the generic processor.
  probe   - Select the probe processor.
  v1      - Select the v1 processor.
  v2      - Select the v2 processor.
[...]

generic 프로세서는 기본 프로세서이며 BPF의 기본 명령어 집합 v1이기도 해요. v1과 v2 옵션은 일반적으로 BPF 프로그램이 크로스 컴파일되고 프로그램이 로드되는 대상 호스트가 컴파일된 곳과 다른 환경에서 유용해요(따라서 사용 가능한 BPF 커널 기능도 다를 수 있음).

권장되는 -mcpu 옵션은 Cilium 내부에서도 사용하는 -mcpu=probe예요! 여기서 LLVM BPF 백엔드는 BPF 명령어 집합 확장의 사용 가능성을 커널에 조회하고, 발견되면 LLVM이 적절할 때마다 BPF 프로그램 컴파일에 그 확장을 사용해요.

llc의 -mcpu=probe를 사용한 전체 명령줄 예시:

$ clang -O2 -Wall --target=bpf -emit-llvm -c xdp-example.c -o xdp-example.bc
$ llc xdp-example.bc -march=bpf -mcpu=probe -filetype=obj -o xdp-example.o

일반적으로 LLVM IR 생성은 아키텍처 독립적이에요. 그러나 clang --target=bpf를 사용하는 것과 --target=bpf를 빼고 clang의 기본 타깃(기본 아키텍처에 따라 x86_64, arm64 등일 수 있음)을 사용하는 것 사이에는 몇 가지 차이가 있어요.

커널의 Documentation/bpf/bpf_devel_QA.txt에서 인용:

  • BPF 프로그램은 파일 범위 인라인 어셈블리 코드가 있는 헤더 파일을 재귀적으로 포함할 수 있어요. 기본 타깃은 이를 잘 처리할 수 있는 반면, BPF 백엔드 어셈블러가 이러한 어셈블리 코드를 이해하지 못하면(대부분이 그렇다) bpf 타깃이 실패할 수 있어요.
  • -g 없이 컴파일하면 .eh_frame과 .rela.eh_frame 같은 추가 ELF 섹션이 기본 타깃에서는 오브젝트 파일에 있을 수 있지만 bpf 타깃에서는 없어요.
  • 기본 타깃은 C switch 문을 switch 테이블 조회·점프 연산으로 바꿀 수 있어요. switch 테이블이 전역 읽기 전용 섹션에 배치되므로 bpf 프로그램 로드가 실패할 수 있어요. bpf 타깃은 switch 테이블 최적화를 지원하지 않아요. clang 옵션 -fno-jump-tables로 switch 테이블 생성을 비활성화할 수 있어요.
  • clang --target=bpf의 경우 기본 clang 바이너리나 기본 타깃(또는 커널)이 32비트인지와 무관하게 포인터나 long/unsigned long 타입이 항상 64비트 폭을 가지는 것이 보장돼요. 그러나 네이티브 clang 타깃을 사용하면 이러한 타입을 기본 아키텍처의 규약에 따라 컴파일하는데, 즉 32비트 아키텍처에서는 BPF LLVM 백엔드가 여전히 64비트로 작동하는 동안 BPF 컨텍스트 구조체의 포인터나 long/unsigned long 타입이 32비트 폭을 가질 수 있어요. 네이티브 타깃은 CPU 레지스터를 매핑하는 커널의 struct pt_regs나 CPU 레지스터 폭이 중요한 다른 커널 구조체를 순회하는 경우 트레이싱에서 대부분 필요해요. 네트워킹 같은 다른 모든 경우에는 clang --target=bpf 사용이 선호돼요.

또한 LLVM은 릴리스 7.0부터 32비트 서브레지스터와 BPF ALU32 명령어를 지원하기 시작했어요. 새 코드 생성 속성 alu32가 추가됐어요. 활성화되면 LLVM은 가능할 때마다, 보통 32비트 타입에 대한 연산이 있을 때 32비트 서브레지스터를 사용하려 해요. 32비트 서브레지스터와 관련된 ALU 명령어는 ALU32 명령어가 돼요. 예를 들어 다음 샘플 코드에 대해:

$ cat 32-bit-example.c
    void cal(unsigned int *a, unsigned int *b, unsigned int *c)
    {
      unsigned int sum = *a + *b;
      *c = sum;
    }

기본 코드 생성에서는 어셈블러가 이렇게 보이는데:

$ clang --target=bpf -emit-llvm -S 32-bit-example.c
$ llc -march=bpf 32-bit-example.ll
$ cat 32-bit-example.s
    cal:
      r1 = *(u32 *)(r1 + 0)
      r2 = *(u32 *)(r2 + 0)
      r2 += r1
      *(u32 *)(r3 + 0) = r2
      exit

64비트 레지스터가 사용되므로 덧셈이 64비트 덧셈을 의미해요. 이제 -mattr=+alu32를 지정해 새 32비트 서브레지스터 지원을 활성화하면 어셈블러가 이렇게 보이는데:

$ llc -march=bpf -mattr=+alu32 32-bit-example.ll
$ cat 32-bit-example.s
    cal:
      w1 = *(u32 *)(r1 + 0)
      w2 = *(u32 *)(r2 + 0)
      w2 += w1
      *(u32 *)(r3 + 0) = w2
      exit

64비트 r 레지스터 대신 32비트 서브레지스터를 의미하는 w 레지스터가 사용돼요.

32비트 서브레지스터를 활성화하면 타입 확장 명령어 시퀀스 줄이는 데 도움이 될 수 있어요. 또한 레지스터 쌍이 64비트 eBPF 레지스터를 모델링하고 상위 32비트를 조작하는 데 추가 명령어가 필요한 32비트 아키텍처용 커널 eBPF JIT 컴파일러에도 도움이 될 수 있어요. 32비트 서브레지스터에서 읽는 것이 낮은 32비트만 읽는 것으로 보장되지만 쓰기는 여전히 상위 32비트를 지워야 하므로, JIT 컴파일러가 한 레지스터의 정의가 서브레지스터 읽기만 가진다는 것을 알고 있으면 대상의 상위 32비트를 설정하는 명령어를 제거할 수 있어요.

일반적인 C 애플리케이션 개발과 비교해 BPF용 C 프로그램을 작성할 때 알고 있어야 할 몇 가지 함정이 있어요. 다음 항목은 BPF 모델에 대한 몇 가지 차이를 설명해요:

  1. 모든 것이 인라인되어야 한다. (구버전 LLVM에서) 함수 호출이나 공유 라이브러리 호출이 없다. 공유 라이브러리 등은 BPF와 함께 사용할 수 없어요. 그러나 BPF 프로그램에서 사용하는 공통 라이브러리 코드는 헤더 파일에 넣고 메인 프로그램에 포함할 수 있어요. 예를 들어 Cilium은 이를 많이 사용해요(bpf/lib/ 참고). 하지만 이는 여전히 커널이나 다른 라이브러리의 헤더 파일을 포함하고 그들의 static inline 함수나 매크로/정의를 재사용하는 것을 허용해요. BPF to BPF 함수 호출이 지원되는 최신 커널(4.16+)과 LLVM(6.0+)을 사용하지 않는다면, LLVM은 주어진 프로그램 섹션에 대해 전체 코드를 평평한 BPF 명령어 시퀀스로 컴파일·인라인해야 해요. 그런 경우 베스트 프랙티스는 아래처럼 모든 라이브러리 함수에 __inline 같은 주석을 쓰는 것이에요. always_inline 사용이 권장되는데, 컴파일러가 inline으로만 주석된 큰 함수를 여전히 인라인하지 않기로 결정할 수 있기 때문이에요. 후자가 발생하면 LLVM이 ELF 파일에 재배치 항목을 생성하는데, iproute2 같은 BPF ELF 로더가 해석할 수 없어 로더가 처리할 수 있는 유효한 재배치 항목은 BPF 맵뿐이므로 오류가 발생해요.
#include <linux/bpf.h>

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline                         \
   inline __attribute__((always_inline))
#endif

static __inline int foo(void)
{
    return XDP_DROP;
}

__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
    return foo();
}

char __license[] __section("license") = "GPL";
  1. 단일 C 파일의 서로 다른 섹션에 여러 프로그램이 들어갈 수 있다. BPF용 C 프로그램은 섹션 주석을 많이 사용해요. C 파일은 보통 3개 이상의 섹션으로 구조화돼요. BPF ELF 로더는 이러한 이름을 사용해 관련 정보를 추출·준비해 bpf 시스템 콜을 통해 프로그램과 맵을 로드해요. 예를 들어 iproute2는 maps와 license를 기본 섹션 이름으로 사용해 각각 맵 생성에 필요한 메타데이터와 BPF 프로그램의 라이선스를 찾아요. 프로그램 생성 시점에 후자도 커널로 푸시되며, 프로그램이 GPL 호환 라이선스도 보유한 경우에만 GPL 전용으로 노출되는 일부 헬퍼 함수(bpf_ktime_get_ns(), bpf_probe_read() 등)를 활성화해요. 나머지 섹션 이름은 BPF 프로그램 코드에 특정한데, 예를 들어 아래 코드는 ingress와 egress 두 프로그램 섹션을 포함하도록 수정됐어요. 장난감 예시 코드는 둘 다 맵과 account_data() 함수 같은 공통 static inline 헬퍼를 공유할 수 있음을 보여줘요. xdp-example.c 예시는 tc로 로드하고 netdevice의 ingress·egress 훅에 부착할 수 있는 tc-example.c 예시로 수정됐어요. 전송된 바이트를 acc_map이라는 맵에 계정 기록하는데, 이 맵에는 ingress 훅에서 계정된 트래픽과 egress 훅에서 계정된 트래픽 각각에 대한 두 개의 맵 슬롯이 있어요.
#include <linux/bpf.h>
#include <linux/pkt_cls.h>
#include <stdint.h>
#include <iproute2/bpf_elf.h>

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __inline
# define __inline                         \
   inline __attribute__((always_inline))
#endif

#ifndef lock_xadd
# define lock_xadd(ptr, val)              \
   ((void)__sync_fetch_and_add(ptr, val))
#endif

#ifndef BPF_FUNC
# define BPF_FUNC(NAME, ...)              \
   (*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME
#endif

static void *BPF_FUNC(map_lookup_elem, void *map, const void *key);

struct bpf_elf_map acc_map __section("maps") = {
    .type           = BPF_MAP_TYPE_ARRAY,
    .size_key       = sizeof(uint32_t),
    .size_value     = sizeof(uint32_t),
    .pinning        = PIN_GLOBAL_NS,
    .max_elem       = 2,
};

static __inline int account_data(struct __sk_buff *skb, uint32_t dir)
{
    uint32_t *bytes;

    bytes = map_lookup_elem(&acc_map, &dir);
    if (bytes)
            lock_xadd(bytes, skb->len);

    return TC_ACT_OK;
}

__section("ingress")
int tc_ingress(struct __sk_buff *skb)
{
    return account_data(skb, 0);
}

__section("egress")
int tc_egress(struct __sk_buff *skb)
{
    return account_data(skb, 1);
}

char __license[] __section("license") = "GPL";

이 예시는 프로그램을 개발할 때 알아두면 유용한 다른 몇 가지 것도 보여줘요. 코드는 커널 헤더, 표준 C 헤더, struct bpf_elf_map 정의를 포함하는 iproute2 특정 헤더를 포함해요. iproute2는 공통 BPF ELF 로더를 가지므로 struct bpf_elf_map의 정의는 XDP와 tc 형식 프로그램 모두에 대해 동일해요.

struct bpf_elf_map 항목은 프로그램에서 맵을 정의하고 두 BPF 프로그램이 사용하는 맵을 생성하는 데 필요한 모든 관련 정보(키/값 크기 등)를 포함해요. 로더가 찾을 수 있도록 구조체를 maps 섹션에 배치해야 해요. 서로 다른 변수 이름으로 이 유형의 맵 선언이 여러 개 있을 수 있지만, 모두 __section("maps")로 주석을 달아야 해요.

struct bpf_elf_map은 iproute2에 특정해요. 다른 BPF ELF 로더는 다른 형식을 가질 수 있어요. 예를 들어 주로 perf가 사용하는 커널 소스 트리의 libbpf는 다른 사양을 가져요. iproute2는 struct bpf_elf_map에 대한 하위 호환성을 보장해요. Cilium은 iproute2 모델을 따릅니다.

예시는 또한 BPF 헬퍼 함수가 C 코드에 어떻게 매핑되고 사용되는지 보여줘요. 여기서 map_lookup_elem()은 이 함수를 uapi/linux/bpf.h에서 헬퍼로 노출된 BPF_FUNC_map_lookup_elem enum 값에 매핑해 정의돼요. 나중에 프로그램이 커널에 로드되면 verifier가 전달된 인자가 예상 유형인지 확인하고 헬퍼 호출을 실제 함수 호출로 다시 가리켜요. 또한 map_lookup_elem()은 맵이 BPF 헬퍼 함수로 어떻게 전달될 수 있는지도 보여줘요. 여기서 maps 섹션의 &acc_map이 map_lookup_elem()의 첫 번째 인자로 전달돼요.

정의된 배열 맵은 전역이므로 계정 기록은 lock_xadd()로 정의된 원자 연산을 사용해야 해요. LLVM은 __sync_fetch_and_add()를 내장 함수로 BPF 원자 add 명령어, 즉 워드 크기용 BPF_STX | BPF_XADD | BPF_W에 매핑해요.

마지막으로 struct bpf_elf_map은 맵이 PIN_GLOBAL_NS로 고정됨을 알려줘요. 이는 tc가 맵을 노드로 BPF 의사 파일 시스템에 고정한다는 뜻이에요. 기본적으로 주어진 예시의 경우 /sys/fs/bpf/tc/globals/acc_map에 고정돼요. PIN_GLOBAL_NS 덕분에 맵은 /sys/fs/bpf/tc/globals/ 아래에 배치돼요. globals는 오브젝트 파일에 걸쳐 있는 전역 네임스페이스로 작동해요. 예시가 PIN_OBJECT_NS를 사용했다면 tc가 오브젝트 파일에 로컬인 디렉터리를 만들 거예요. 예를 들어 BPF 코드가 있는 서로 다른 C 파일이 위와 같은 acc_map 정의를 PIN_GLOBAL_NS 핀으로 가질 수 있어요. 그 경우 맵은 다양한 오브젝트 파일에서 기원한 BPF 프로그램들 사이에서 공유돼요. PIN_NONE은 맵이 BPF 파일 시스템에 노드로 배치되지 않음을 의미하며, 결과적으로 tc가 종료된 후 사용자 공간에서 접근할 수 없어요. 또한 tc가 각 프로그램에 대해 두 개의 별도 맵 인스턴스를 만든다는 뜻이 되는데, 해당 이름으로 이전에 고정된 맵을 가져올 수 없기 때문이에요. 언급된 경로의 acc_map 부분은 소스 코드에 지정된 맵 이름이에요.

따라서 ingress 프로그램을 로드할 때 tc는 BPF 파일 시스템에 그런 맵이 존재하지 않음을 확인하고 새로 생성해요. 성공 시 맵도 고정되므로, egress 프로그램을 tc로 로드할 때 BPF 파일 시스템에 그런 맵이 이미 존재함을 발견하고 egress 프로그램에 그것을 재사용해요. 로더는 또한 같은 이름의 맵이 존재하면 그 속성(키/값 크기 등)도 일치하는지 확인해요.

tc가 같은 맵을 가져올 수 있는 것처럼 서드파티 애플리케이션도 bpf 시스템 콜의 BPF_OBJ_GET 명령을 사용해 같은 맵 인스턴스를 가리키는 새 파일 디스크립터를 만들 수 있으며, 이를 맵 요소 lookup/update/delete에 사용할 수 있어요.

코드는 iproute2로 다음과 같이 컴파일·로드할 수 있어요:

$ clang -O2 -Wall --target=bpf -c tc-example.c -o tc-example.o

# tc qdisc add dev em1 clsact
# tc filter add dev em1 ingress bpf da obj tc-example.o sec ingress
# tc filter add dev em1 egress bpf da obj tc-example.o sec egress

# tc filter show dev em1 ingress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[ingress] direct-action id 1 tag c5f7825e5dac396f

# tc filter show dev em1 egress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 tc-example.o:[egress] direct-action id 2 tag b2fd5adc0f262714

# mount | grep bpf
sysfs on /sys/fs/bpf type sysfs (rw,nosuid,nodev,noexec,relatime,seclabel)
bpf on /sys/fs/bpf type bpf (rw,relatime,mode=0700)

# tree /sys/fs/bpf/
/sys/fs/bpf/
+-- ip -> /sys/fs/bpf/tc/
+-- tc
|   +-- globals
|       +-- acc_map
+-- xdp -> /sys/fs/bpf/tc/

4 directories, 1 file

패킷이 em1 장치를 통과하는 즉시 BPF 맵의 카운터가 증가돼요.

  1. 전역 변수는 허용되지 않는다. 1번에서 이미 언급한 이유로 BPF는 일반 C 프로그램에서 자주 사용되는 것 같은 전역 변수를 가질 수 없어요. 그러나 프로그램이 임의의 값 크기의 단일 슬롯만 있는 BPF_MAP_TYPE_PERCPU_ARRAY 유형의 BPF 맵을 사용해 해결할 수 있다는 점에서 우회책이 있어요. 이는 실행 중 BPF 프로그램이 커널에 의해 선점되지 않음이 보장되므로 단일 맵 항목을 임시 데이터용 스크래치 버퍼로 사용할 수 있기 때문이에요(예: 스택 제한을 넘어서기 위해). 이는 선점에 관해 같은 보장을 가지므로 tail call을 가로질러서도 작동해요. 그 외에 여러 BPF 프로그램 실행에 걸쳐 상태를 보유하려면 일반 BPF 맵을 사용할 수 있어요.

  2. const 문자열이나 배열은 허용되지 않는다. BPF C 프로그램에서 const 문자열이나 다른 배열을 정의하는 것은 1번과 3번에서 지적한 것과 같은 이유로 작동하지 않는데, 즉 ELF 파일에 재배치 항목이 생성되고 로더에 대한 ABI의 일부가 아니므로 로더가 거부하기 때문이에요(로더는 이미 컴파일된 BPF 시퀀스의 큰 재작성이 필요하므로 그러한 항목을 수정할 수도 없음). 미래에는 LLVM이 이러한 발생을 감지해 사용자에게 일찍 오류를 던질 수도 있어요. trace_printk() 같은 헬퍼 함수는 다음과 같이 해결할 수 있어요:

static void BPF_FUNC(trace_printk, const char *fmt, int fmt_size, ...);

#ifndef printk
# define printk(fmt, ...)                                      \
    ({                                                         \
        char ____fmt[] = fmt;                                  \
        trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \
    })
#endif

그러면 프로그램이 printk("skb len:%u\n", skb->len);처럼 매크로를 자연스럽게 사용할 수 있어요. 출력은 추적 파이프에 기록돼요. tc exec bpf dbg를 사용해 거기서 메시지를 가져올 수 있어요. trace_printk() 헬퍼 함수 사용에는 몇 가지 단점이 있으므로 프로덕션 사용에는 권장되지 않아요. "skb len:%u\n" 같은 상수 문자열은 헬퍼 함수가 호출될 때마다 BPF 스택에 로드되어야 하지만, BPF 헬퍼 함수는 최대 5개의 인자로 제한돼요. 이는 덤프용으로 전달할 수 있는 추가 변수 3개만 남겨둬요. 따라서 빠른 디버깅에 유용함에도 불구하고 (네트워킹 프로그램의 경우) 각각 skb_event_output() 또는 xdp_event_output() 헬퍼를 사용하는 것이 권장돼요. 이는 BPF 프로그램에서 perf 이벤트 링 버퍼로 커스텀 struct를 선택적 패킷 샘플과 함께 전달할 수 있게 해줘요. 예를 들어 Cilium의 monitor는 디버깅 프레임워크, 네트워크 정책 위반 알림 등을 구현하기 위해 이러한 헬퍼를 사용해요. 이러한 헬퍼는 잠금 없는 메모리 매핑 per-CPU perf 링 버퍼를 통해 데이터를 전달하므로 trace_printk()보다 훨씬 빠르다.

  1. memset()/memcpy()/memmove()/memcmp()용 LLVM 내장 함수 사용. BPF 프로그램은 BPF 헬퍼에 대한 호출 외에 다른 함수 호출을 수행할 수 없으므로 공통 라이브러리 코드를 인라인 함수로 구현해야 해요. 또한 LLVM은 상수 크기(여기서: n)에 대해 사용할 수 있는 몇 가지 내장 함수를 제공하며, 이는 항상 인라인돼요:
#ifndef memset
# define memset(dest, chr, n)   __builtin_memset((dest), (chr), (n))
#endif

#ifndef memcpy
# define memcpy(dest, src, n)   __builtin_memcpy((dest), (src), (n))
#endif

#ifndef memmove
# define memmove(dest, src, n)  __builtin_memmove((dest), (src), (n))
#endif

memcmp() 내장 함수는 백엔드의 LLVM 이슈로 인라인이 일어나지 않는 몇 가지 코너 케이스가 있었으므로, 이슈가 고쳐질 때까지는 사용하지 않는 것이 좋아요.

  1. (아직) 사용 가능한 루프가 없다. 커널의 BPF verifier는 다른 제어 흐름 그래프 검증 외에 모든 가능한 프로그램 경로의 깊이 우선 검색을 수행해 BPF 프로그램에 루프가 포함되지 않음을 확인해요. 목적은 프로그램이 항상 종료됨을 보장하기 위해서예요. 상수 상한 루프 바운드에 대해 #pragma unroll 지시문을 사용해 매우 제한된 형태의 반복이 가능해요. BPF로 컴파일되는 예시 코드:
#pragma unroll
    for (i = 0; i < IPV6_MAX_HEADERS; i++) {
        switch (nh) {
        case NEXTHDR_NONE:
            return DROP_INVALID_EXTHDR;
        case NEXTHDR_FRAGMENT:
            return DROP_FRAG_NOSUPPORT;
        case NEXTHDR_HOP:
        case NEXTHDR_ROUTING:
        case NEXTHDR_AUTH:
        case NEXTHDR_DEST:
            if (skb_load_bytes(skb, l3_off + len, &opthdr, sizeof(opthdr)) < 0)
                return DROP_INVALID;

            nh = opthdr.nexthdr;
            if (nh == NEXTHDR_AUTH)
                len += ipv6_authlen(&opthdr);
            else
                len += ipv6_optlen(&opthdr);
            break;
        default:
            *nexthdr = nh;
            return len;
        }
    }

또 다른 가능성은 같은 프로그램으로 다시 호출하는 tail call을 사용하고 로컬 스크래치 공간을 위한 BPF_MAP_TYPE_PERCPU_ARRAY 맵을 사용하는 것이에요. 동적이지만 이 형태의 루핑은 최대 34회 반복(초기 프로그램 + tail call의 33회 반복)으로 제한돼요. 미래에는 BPF가 루프를 구현하는 네이티브하지만 제한된 형태를 가질 수도 있어요.

  1. tail call로 프로그램 분할. Tail call은 한 BPF 프로그램에서 다른 프로그램으로 점프해 런타임 중 프로그램 동작을 원자적으로 변경하는 유연성을 제공해요. 다음 프로그램을 선택하기 위해 tail call은 프로그램 배열 맵(BPF_MAP_TYPE_PROG_ARRAY)을 사용하며, 맵과 점프할 다음 프로그램의 인덱스를 전달해요. 점프가 수행된 후 이전 프로그램으로의 반환은 없으며, 주어진 맵 인덱스에 프로그램이 없으면 원래 프로그램에서 실행이 계속돼요. 예를 들어 파서의 다양한 단계를 구현하는 데 사용할 수 있으며, 그러한 단계를 런타임 중 새 파싱 기능으로 업데이트할 수 있어요. 또 다른 사용 사례는 이벤트 알림인데, 예를 들어 tail call된 프로그램 안에 skb_event_output() 호출이 있는 경우 Cilium이 런타임 중 패킷 드롭 알림에 선택할 수 있어요. 따라서 정상 작동 중에는 관련 맵 인덱스에 프로그램이 추가되지 않는 한 fall-through 경로가 항상 실행되며, 그 프로그램이 메타데이터를 준비하고 사용자 공간 데몬으로 이벤트 알림을 트리거해요. 프로그램 배열 맵은 상당히 유연해 각 맵 인덱스에 있는 프로그램에 개별 액션을 구현할 수도 있게 해줘요. 예를 들어 XDP나 tc에 부착된 루트 프로그램이 프로그램 배열 맵의 인덱스 0으로 초기 tail call을 수행해 트래픽 샘플링을 하고, 프로그램 배열 맵의 인덱스 1로 점프해 방화벽 정책을 적용하고 패킷을 드롭하거나 프로그램 배열 맵의 인덱스 2에서 추가 처리해 변조하고 다시 인터페이스 밖으로 보낼 수 있어요. 프로그램 배열 맵에서의 점프는 물론 임의로 할 수 있어요. 커널은 최대 tail call 제한에 도달하면 결국 fall-through 경로를 실행해요. tail call 사용의 최소 예시 발췌:
[...]

#ifndef __stringify
# define __stringify(X)   #X
#endif

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

#ifndef __section_tail
# define __section_tail(ID, KEY)          \
   __section(__stringify(ID) "/" __stringify(KEY))
#endif

#ifndef BPF_FUNC
# define BPF_FUNC(NAME, ...)              \
   (*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME
#endif

#define BPF_JMP_MAP_ID   1

static void BPF_FUNC(tail_call, struct __sk_buff *skb, void *map,
                     uint32_t index);

struct bpf_elf_map jmp_map __section("maps") = {
    .type           = BPF_MAP_TYPE_PROG_ARRAY,
    .id             = BPF_JMP_MAP_ID,
    .size_key       = sizeof(uint32_t),
    .size_value     = sizeof(uint32_t),
    .pinning        = PIN_GLOBAL_NS,
    .max_elem       = 1,
};

__section_tail(BPF_JMP_MAP_ID, 0)
int looper(struct __sk_buff *skb)
{
    printk("skb cb: %u\n", skb->cb[0]++);
    tail_call(skb, &jmp_map, 0);
    return TC_ACT_OK;
}

__section("prog")
int entry(struct __sk_buff *skb)
{
    skb->cb[0] = 0;
    tail_call(skb, &jmp_map, 0);
    return TC_ACT_OK;
}

char __license[] __section("license") = "GPL";

이 장난감 프로그램을 로드할 때 tc는 프로그램 배열을 생성하고 jmp_map으로 전역 네임스페이스의 BPF 파일 시스템에 고정해요. 또한 iproute2의 BPF ELF 로더는 __section_tail()로 표시된 섹션도 인식해요. struct bpf_elf_map에 제공된 id가 __section_tail()의 id 마커, 즉 JMP_MAP_ID와 일치하며, 프로그램은 따라서 사용자가 지정한 프로그램 배열 맵 인덱스(이 예시에서 0)에 로드돼요. 결과적으로 제공된 모든 tail call 섹션이 iproute2 로더에 의해 해당 맵에 채워져요. 이 메커니즘은 tc에 특정하지 않으며 iproute2가 지원하는 다른 BPF 프로그램 유형(예: XDP, lwt)에도 적용할 수 있어요. 생성된 ELF에는 맵 id와 그 맵 내 항목을 설명하는 섹션 헤더가 포함돼요:

$ llvm-objdump -S --no-show-raw-insn prog_array.o | less
prog_array.o:   file format ELF64-BPF

Disassembly of section 1/0:
looper:
       0:       r6 = r1
       1:       r2 = *(u32 *)(r6 + 48)
       2:       r1 = r2
       3:       r1 += 1
       4:       *(u32 *)(r6 + 48) = r1
       5:       r1 = 0 ll
       7:       call -1
       8:       r1 = r6
       9:       r2 = 0 ll
      11:       r3 = 0
      12:       call 12
      13:       r0 = 0
      14:       exit
Disassembly of section prog:
entry:
       0:       r2 = 0
       1:       *(u32 *)(r1 + 48) = r2
       2:       r2 = 0 ll
       4:       r3 = 0
       5:       call 12
       6:       r0 = 0
       7:       exit

이 경우 section 1/0은 looper() 함수가 맵 id 1의 위치 0에 있음을 나타내요. 고정된 맵은 사용자 공간 애플리케이션(예: Cilium 데몬)뿐만 아니라 tc 자체에서도 새 프로그램으로 맵을 업데이트하기 위해 가져올 수 있어요. 업데이트는 원자적으로 일어나며, 다양한 서브시스템에서 먼저 트리거되는 초기 항목 프로그램도 원자적으로 업데이트돼요. tc가 tail call 맵 업데이트를 수행하는 예:

# tc exec bpf graft m:globals/jmp_map key 0 obj new.o sec foo

iproute2가 고정된 프로그램 배열을 업데이트해야 하는 경우 graft 명령을 사용할 수 있어요. globals/jmp_map을 가리키면 tc가 인덱스/키 0에서 맵을 foo 섹션의 new.o 오브젝트 파일에 있는 새 프로그램으로 업데이트해요.

  1. 최대 512바이트의 제한된 스택 공간. BPF 프로그램의 스택 공간은 512바이트로 제한되며, C로 BPF 프로그램을 구현할 때 신중히 고려해야 해요. 그러나 앞서 3번에서 언급했듯 단일 항목의 BPF_MAP_TYPE_PERCPU_ARRAY 맵을 사용해 스크래치 버퍼 공간을 늘릴 수 있어요.

  2. BPF 인라인 어셈블리 사용 가능. LLVM 6.0 이상은 필요할 수 있는 드문 경우를 위해 BPF 인라인 어셈블리 사용을 허용해요. 다음(말도 안 되는) 장난감 예시는 64비트 원자 add를 보여줘요. 문서 부족으로 인해 lib/Target/BPF/BPFInstrInfo.td의 LLVM 소스 코드와 test/CodeGen/BPF/가 추가 예시를 제공하는 데 도움이 될 수 있어요. 테스트 코드:

#include <linux/bpf.h>

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

__section("prog")
int xdp_test(struct xdp_md *ctx)
{
    __u64 a = 2, b = 3, *c = &a;
    /* just a toy xadd example to show the syntax */
    asm volatile("lock *(u64 *)(%0+0) += %1" : "=r"(c) : "r"(b), "0"(c));
    return a;
}

char __license[] __section("license") = "GPL";

위 프로그램은 다음 BPF 명령어 시퀀스로 컴파일돼요:

Verifier analysis:

0: (b7) r1 = 2
1: (7b) *(u64 *)(r10 -8) = r1
2: (b7) r1 = 3
3: (bf) r2 = r10
4: (07) r2 += -8
5: (db) lock *(u64 *)(r2 +0) += r1
6: (79) r0 = *(u64 *)(r10 -8)
7: (95) exit
processed 8 insns (limit 131072), stack depth 8
  1. #pragma pack으로 멤버를 정렬해 struct 패딩 제거. 현대 컴파일러에서 데이터 구조체는 메모리에 효율적으로 접근하기 위해 기본적으로 정렬돼요. 구조체 멤버는 메모리 주소로 패킹되고 프로세서 워드 크기(예: 64비트 프로세서는 8바이트, 32비트 프로세서는 4바이트)와의 적절한 정렬을 위해 패딩이 추가돼요. 이 때문에 struct 크기가 예상보다 자주 커질 수 있어요.
struct called_info {
    u64 start;  // 8-byte
    u64 end;    // 8-byte
    u32 sector; // 4-byte
}; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info
// 0x0(0)                   0x8(8)
//  ↓________________________↓
//  |        start (8)       |
//  |________________________|
//  |         end  (8)       |
//  |________________________|
//  |  sector(4) |  PADDING  | <= address aligned to 8
//  |____________|___________|     with 4-byte PADDING.

커널의 BPF verifier는 BPF 프로그램이 경계 밖이나 초기화되지 않은 스택 영역에 접근하지 않는지 스택 경계를 확인해요. 패딩이 있는 struct를 맵 값으로 사용하면 bpf_prog_load()에서 invalid indirect read from stack 실패가 발생해요. 예시 코드:

struct called_info {
    u64 start;
    u64 end;
    u32 sector;
};

struct bpf_map_def SEC("maps") called_info_map = {
    .type = BPF_MAP_TYPE_HASH,
    .key_size = sizeof(long),
    .value_size = sizeof(struct called_info),
    .max_entries = 4096,
};

SEC("kprobe/submit_bio")
int submit_bio_entry(struct pt_regs *ctx)
{
    char fmt[] = "submit_bio(bio=0x%lx) called: %llu\n";
    u64 start_time = bpf_ktime_get_ns();
    long bio_ptr = PT_REGS_PARM1(ctx);
    struct called_info called_info = {
            .start = start_time,
            .end = 0,
            .sector = 0
    };

    bpf_map_update_elem(&called_info_map, &bio_ptr, &called_info, BPF_ANY);
    bpf_trace_printk(fmt, sizeof(fmt), bio_ptr, start_time);
    return 0;
}

bpf_load_program()의 해당 출력:

bpf_load_program() err=13
0: (bf) r6 = r1
...
19: (b7) r1 = 0
20: (7b) *(u64 *)(r10 -72) = r1
21: (7b) *(u64 *)(r10 -80) = r7
22: (63) *(u32 *)(r10 -64) = r1
...
30: (85) call bpf_map_update_elem#2
invalid indirect read from stack off -80+20 size 24

bpf_prog_load()에서 eBPF verifier bpf_check()가 호출되고 check_func_arg() -> check_stack_boundary()를 호출해 스택 경계를 확인해요. 위 오류에서 struct called_info가 24바이트 크기로 컴파일되고 +20에서 데이터를 읽는 것이 잘못된 간접 읽기라고 메시지가 말해요. 그리고 앞서 논의했듯 주소 0x14(20)는 PADDING이 있는 곳이에요.

// Actual compiled composition of struct called_info
// 0x10(16)    0x14(20)    0x18(24)
//  ↓____________↓___________↓
//  |  sector(4) |  PADDING  | <= address aligned to 8
//  |____________|___________|     with 4-byte PADDING.

check_stack_boundary()는 시작 포인터에서부터 access_size(24) 바이트를 전부 루프하며 스택 경계 안에 있고 모든 스택 요소가 초기화되었는지 확인해요. 패딩은 사용되지 않을 예정이므로 'invalid indirect read from stack' 실패를 받아요. 이런 종류의 실패를 피하려면 struct에서 패딩을 제거하는 것이 필요해요. #pragma pack(n) 지시문을 사용해 패딩을 제거:

#pragma pack(4)
struct called_info {
    u64 start;  // 8-byte
    u64 end;    // 8-byte
    u32 sector; // 4-byte
}; // size of 20-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 20-byte

// Actual compiled composition of packed struct called_info
// 0x0(0)                   0x8(8)
//  ↓________________________↓
//  |        start (8)       |
//  |________________________|
//  |         end  (8)       |
//  |________________________|
//  |  sector(4) |             <= address aligned to 4
//  |____________|                 with no PADDING.

struct called_info 앞에 #pragma pack(4)를 배치하면 컴파일러가 struct 멤버를 4바이트와 그 자연 정렬 중 작은 값으로 정렬해요. 보시다시피 struct called_info의 크기는 20바이트로 줄었고 패딩이 더 이상 존재하지 않아요. 하지만 패딩 제거에는 단점도 있어요. 예를 들어 컴파일러가 덜 최적화된 코드를 생성해요. 패딩을 제거했으므로 프로세서가 구조체에 대해 정렬되지 않은 접근을 수행하며 이는 성능 저하로 이어질 수 있어요. 그리고 정렬되지 않은 접근은 일부 아키텍처에서 verifier가 거부할 수도 있어요. 그러나 packed 구조체의 단점을 피하는 방법이 있어요. 끝에 명시적 패딩 u32 pad 멤버를 간단히 추가하면 구조체를 packing하지 않고 같은 문제를 해결해요.

struct called_info {
    u64 start;  // 8-byte
    u64 end;    // 8-byte
    u32 sector; // 4-byte
    u32 pad;    // 4-byte
}; // size of 24-byte ?

printf("size of %d-byte\n", sizeof(struct called_info)); // size of 24-byte

// Actual compiled composition of struct called_info with explicit padding
// 0x0(0)                   0x8(8)
//  ↓________________________↓
//  |        start (8)       |
//  |________________________|
//  |         end  (8)       |
//  |________________________|
//  |  sector(4) |  pad (4)  | <= address aligned to 8
//  |____________|___________|     with explicit PADDING.
  1. 무효화된 참조를 통한 패킷 데이터 접근. bpf_skb_store_bytes 같은 일부 네트워킹 BPF 헬퍼 함수는 패킷 데이터 크기를 바꿀 수 있어요. verifier는 그러한 변경을 추적할 수 없으므로 데이터에 대한 사전 참조는 verifier가 무효화해요. 따라서 verifier가 프로그램을 거부하는 것을 피하려면 데이터에 접근하기 전에 참조를 갱신해야 해요. 이를 설명하기 위해 다음 스니펫을 고려하세요:
struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);

if (ip4->protocol == IPPROTO_TCP) {
    // do something
}

verifier는 무효화된 ip4->protocol의 역참조 때문에 스니펫을 거부해요:

R1=pkt_end(id=0,off=0,imm=0) R2=pkt(id=0,off=34,r=34,imm=0) R3=inv0
R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=4294967295,var_off=(0x0; 0xffffffff))
R8=inv4294967162 R9=pkt(id=0,off=0,r=34,imm=0) R10=fp0,call_-1
...
18: (85) call bpf_skb_store_bytes#9
19: (7b) *(u64 *)(r10 -56) = r7
R0=inv(id=0) R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=2,var_off=(0x0; 0x3))
R8=inv4294967162 R9=inv(id=0) R10=fp0,call_-1 fp-48=mmmm???? fp-56=mmmmmmmm
21: (61) r1 = *(u32 *)(r9 +23)
R9 invalid mem access 'inv'

이를 고치려면 ip4에 대한 참조를 갱신해야 해요:

struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;

skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);

ip4 = (struct iphdr *) skb->data + ETH_HLEN;

if (ip4->protocol == IPPROTO_TCP) {
    // do something
}

iproute2

BPF 프로그램을 커널로 로드하는 bcc, perf, iproute2 등 다양한 프런트 엔드가 있어요. 리눅스 커널 소스 트리도 tools/lib/bpf/ 아래에 사용자 공간 라이브러리를 제공하며, 주로 perf가 BPF 트레이싱 프로그램을 커널로 로드하는 데 사용·추진해요. 그러나 라이브러리 자체는 범용이며 perf에만 제한되지 않아요. bcc는 BPF C 코드를 내장한 Python 인터페이스를 통해 임시로 로드되는 주로 트레이싱용 유용한 BPF 프로그램을 많이 제공하는 툴킷이에요. 일반적으로 프런트 엔드마다 BPF 프로그램 구현의 구문·의미가 약간 다르지만요. 또한 커널 소스 트리(samples/bpf/)에 생성된 오브젝트 파일을 파싱하고 코드를 시스템 콜 인터페이스로 직접 로드하는 BPF 샘플도 있어요.

이 섹션과 이전 섹션은 주로 XDP, tc 또는 lwt 유형의 네트워킹 프로그램을 로드하는 iproute2 제품군의 BPF 프런트 엔드에 초점을 맞춰요. Cilium의 프로그램이 이 BPF 로더에 대해 구현되기 때문이에요. 향후 Cilium은 네이티브 BPF 로더를 갖추겠지만, 개발·디버깅을 용이하게 하기 위해 프로그램은 여전히 iproute2 제품군을 통해 로드 가능해야 해요.

iproute2가 지원하는 모든 BPF 프로그램 유형은 공통 로더 백엔드가 라이브러리로 구현되어 있으므로(iproute2 소스 트리의 lib/bpf.c) 같은 BPF 로더 로직을 공유해요.

LLVM에 대한 이전 섹션도 BPF C 프로그램 작성과 관련된 일부 iproute2 부분을 다뤘고, 이 문서의 이후 섹션은 프로그램 작성 시 tc와 XDP 특정 측면과 관련돼 있어요. 따라서 이 섹션은 오히려 iproute2로 오브젝트 파일을 로드하는 사용 예시와 로더의 일반 메커니즘에 초점을 맞춰요. 모든 세부 사항의 완전한 커버리지를 제공하려 하지 않고 시작하기에 충분한 내용을 제공해요.

1. XDP BPF 오브젝트 파일 로드.

XDP용으로 컴파일된 BPF 오브젝트 파일 prog.o가 있다면 XDP를 지원하는 netdevice em1에 다음 명령으로 ip을 통해 로드할 수 있어요:

# ip link set dev em1 xdp obj prog.o

위 명령은 프로그램 코드가 XDP 경우 prog라고 불리는 기본 섹션에 있다고 가정해요. 그렇지 않고 섹션이 foobar처럼 다르게 명명됐다면 프로그램을 이렇게 로드해야 해요:

# ip link set dev em1 xdp obj prog.o sec foobar

.text 섹션 밖에서 프로그램을 로드하는 것도 가능하다는 점을 주의하세요. xdp_drop 엔트리 포인트에서 __section() 주석을 제거해 최소 독립형 XDP 드롭 프로그램을 변경하면 다음과 같아요:

#include <linux/bpf.h>

#ifndef __section
# define __section(NAME)                  \
   __attribute__((section(NAME), used))
#endif

int xdp_drop(struct xdp_md *ctx)
{
    return XDP_DROP;
}

char __license[] __section("license") = "GPL";

그리고 다음과 같이 로드할 수 있어요:

# ip link set dev em1 xdp obj prog.o sec .text

기본적으로 ip는 실수로 덮어쓰는 것을 방지하기 위해 네트워킹 인터페이스에 XDP 프로그램이 이미 부착된 경우 오류를 던져요. 현재 실행 중인 XDP 프로그램을 새 것으로 교체하려면 -force 옵션을 사용해야 해요:

# ip -force link set dev em1 xdp obj prog.o

오늘날 대부분의 XDP 지원 드라이버는 트래픽 중단 없이 기존 프로그램을 새 것으로 원자적 교체를 지원해요. 성능상의 이유로 XDP 지원 드라이버에는 항상 단일 프로그램만 부착되므로 프로그램 체인은 지원되지 않아요. 그러나 이전 섹션에서 설명했듯 tail call을 통한 프로그램 분할로 필요할 때 비슷한 사용 사례를 달성할 수 있어요.

ip link 명령은 인터페이스에 XDP 프로그램이 부착되어 있으면 xdp 플래그를 표시해요. 따라서 ip link | grep xdp로 XDP가 실행 중인 모든 인터페이스를 찾을 수 있어요. ip -d link의 상세 보기로 추가 인트로스펙션 시설이 제공되며, ip link 덤프에서 표시된 BPF 프로그램 ID를 기반으로 부착된 프로그램에 대한 정보를 bpftool로 가져올 수 있어요.

인터페이스에서 기존 XDP 프로그램을 제거하려면 다음 명령을 실행해야 해요:

# ip link set dev em1 xdp off

드라이버의 운영 모드를 비-XDP에서 네이티브 XDP로 또는 그 반대로 전환하는 경우 보통 드라이버가 BPF가 읽고 쓸 수 있도록 수신 패킷이 단일 페이지 내에 선형으로 설정되도록 수신(및 전송) 링을 재구성해야 해요. 그러나 완료되면 대부분의 드라이버는 BPF 프로그램이 교체 요청될 때 프로그램 자체만 원자적으로 교체하면 돼요.

종합적으로 XDP는 iproute2도 구현하는 세 가지 운영 모드를 지원해요: xdpdrv, xdpoffload, xdpgeneric.

xdpdrv는 네이티브 XDP를 뜻하며, 즉 BPF 프로그램이 소프트웨어에서 가능한 가장 이른 시점에 드라이버의 수신 경로에서 직접 실행돼요. 이는 정상/관례적 XDP 모드이며 드라이버가 XDP 지원을 구현해야 하는데, 상류 리눅스 커널의 모든 주요 10G/40G+ 네트워킹 드라이버가 이미 제공해요.

xdpgeneric는 제네릭 XDP를 뜻하며 아직 네이티브 XDP를 지원하지 않는 드라이버를 위한 실험적 테스트 베드로 의도됐어요. ingress 경로의 제네릭 XDP 훅은 패킷이 이미 skb로 스택의 주요 수신 경로에 들어갈 때 훨씬 나중에 오므로, 성능이 xdpdrv 모드에서의 처리보다 훨씬 낮아요. 따라서 xdpgeneric은 대부분 실험에만 흥미롭고 프로덕션 환경에는 덜해요.

마지막으로 xdpoffload 모드는 Netronome의 nfp 드라이버가 지원하는 것 같은 SmartNIC가 구현하며 전체 BPF/XDP 프로그램을 하드웨어로 오프로드해 각 패킷 수신 시 카드에서 직접 프로그램을 실행해요. 이는 네이티브 XDP에서 실행하는 것보다 더 높은 성능을 제공하지만 네이티브 XDP와 비교해 모든 BPF 맵 유형이나 BPF 헬퍼 함수를 사용할 수 있는 것은 아니에요. 그러한 경우 BPF verifier가 프로그램을 거부하고 지원되지 않는 것이 무엇인지 사용자에게 보고해요. 지원되는 BPF 기능과 헬퍼 함수 영역에 머무는 것 외에 BPF C 프로그램 작성 시 특별한 예방 조치를 취할 필요는 없어요.

ip link set dev em1 xdp obj [...] 같은 명령을 사용하면 커널이 프로그램을 먼저 네이티브 XDP로 로드하려 시도하고, 드라이버가 네이티브 XDP를 지원하지 않으면 자동으로 제네릭 XDP로 폴백해요. 따라서 예를 들어 xdp 대신 명시적으로 xdpdrv를 사용하면 커널이 네이티브 XDP로만 프로그램을 로드하려 시도하고 드라이버가 지원하지 않으면 실패하여 제네릭 XDP를 완전히 피하는 것을 보장해요.

BPF/XDP 프로그램을 네이티브 XDP 모드로 로드하고, 링크 세부 정보를 덤프하고, 프로그램을 다시 언로드하는 것을 강제하는 예:

# ip -force link set dev em1 xdpdrv obj prog.o
# ip link show
[...]
6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc mq state UP mode DORMANT group default qlen 1000
    link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff
    prog/xdp id 1 tag 57cd311f2e27366b
[...]
# ip link set dev em1 xdpdrv off

드라이버가 네이티브 XDP를 지원하더라도 제네릭 XDP를 강제하는 같은 예시, 그리고 부착된 더미 프로그램의 BPF 명령어를 bpftool로 추가 덤프:

# ip -force link set dev em1 xdpgeneric obj prog.o
# ip link show
[...]
6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric qdisc mq state UP mode DORMANT group default qlen 1000
    link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff
    prog/xdp id 4 tag 57cd311f2e27366b                <-- BPF program ID 4
[...]
# bpftool prog dump xlated id 4                       <-- Dump of instructions running on em1
0: (b7) r0 = 1
1: (95) exit
# ip link set dev em1 xdpgeneric off

그리고 마지막으로 오프로드 XDP. 여기서는 일반 메타데이터를 가져오기 위해 bpftool로 프로그램 정보를 추가로 덤프해요:

# ip -force link set dev em1 xdpoffload obj prog.o
# ip link show
[...]
6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpoffload qdisc mq state UP mode DORMANT group default qlen 1000
    link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff
    prog/xdp id 8 tag 57cd311f2e27366b
[...]
# bpftool prog show id 8
8: xdp  tag 57cd311f2e27366b dev em1                  <-- Also indicates a BPF program offloaded to em1
    loaded_at Apr 11/20:38  uid 0
    xlated 16B  not jited  memlock 4096B
# ip link set dev em1 xdpoffload off

xdpdrv와 xdpgeneric 또는 다른 모드를 동시에 사용할 수 없는 것에 유의하세요. 즉 XDP 운영 모드 중 하나만 선택해야 해요.

다른 XDP 모드 간의 전환(예: 제네릭에서 네이티브로 또는 그 반대로)은 원자적으로 가능하지 않아요. 특정 운영 모드 내에서만 프로그램 전환이 가능해요:

# ip -force link set dev em1 xdpgeneric obj prog.o
# ip -force link set dev em1 xdpoffload obj prog.o
RTNETLINK answers: File exists
# ip -force link set dev em1 xdpdrv obj prog.o
RTNETLINK answers: File exists
# ip -force link set dev em1 xdpgeneric obj prog.o    <-- Succeeds due to xdpgeneric
#

모드 전환은 새 모드에 들어가기 전에 먼저 현재 운영 모드를 벗어나야 해요:

# ip -force link set dev em1 xdpgeneric obj prog.o
# ip -force link set dev em1 xdpgeneric off
# ip -force link set dev em1 xdpoffload obj prog.o
# ip l
[...]
6: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpoffload qdisc mq state UP mode DORMANT group default qlen 1000
    link/ether be:08:4d:b6:85:65 brd ff:ff:ff:ff:ff:ff
    prog/xdp id 17 tag 57cd311f2e27366b
[...]
# ip -force link set dev em1 xdpoffload off

2. tc BPF 오브젝트 파일 로드.

tc용으로 컴파일된 BPF 오브젝트 파일 prog.o가 있다면 tc 명령으로 netdevice에 로드할 수 있어요. XDP와 달리 장치에 BPF 프로그램 부착을 지원하기 위한 드라이버 의존성이 없어요. 여기서 netdevice는 em1이라 하고, 다음 명령으로 프로그램을 em1의 네트워킹 ingress 경로에 부착할 수 있어요:

# tc qdisc add dev em1 clsact
# tc filter add dev em1 ingress bpf da obj prog.o

첫 단계는 clsact qdisc(리눅스 큐잉 규율)를 설정하는 것이에요. clsact는 분류기와 액션만 보유할 수 있고 실제 큐잉을 수행하지 않는 ingress qdisc와 유사한 더미 qdisc예요. bpf 분류기를 부착하려면 필요해요. clsact qdisc는 ingress와 egress라는 두 개의 특수 훅을 제공하며, 분류기를 부착할 수 있어요. ingress와 egress 훅 둘 다 장치의 모든 패킷이 통과하는 네트워킹 데이터패스의 중앙 수신·전송 위치에 있어요. ingress 훅은 커널의 __netif_receive_skb_core() -> sch_handle_ingress()에서 호출되고 egress 훅은 __dev_queue_xmit() -> sch_handle_egress()에서 호출돼요.

프로그램을 egress 훅에 부착하는 동등한 방식은 다음과 같아요:

# tc filter add dev em1 egress bpf da obj prog.o

clsact qdisc는 ingress와 egress 방향에서 잠금 없이 처리되며 컨테이너를 연결하는 veth 장치 같은 가상의 무큐(queue-less) 장치에도 부착할 수 있어요.

훅 다음으로 tc filter 명령은 da(direct-action) 모드에서 사용될 bpf를 선택해요. da 모드는 권장되며 항상 지정해야 해요. 이는 기본적으로 bpf 분류기가 외부 tc 액션 모듈을 호출할 필요가 없다는 뜻인데, 모든 패킷 변조, 포워딩 또는 다른 종류의 액션이 이미 단일 BPF 프로그램 내에서 수행될 수 있으므로 bpf에는 어차피 필요 없고, 따라서 상당히 빠르기 때문이에요.

이 시점에 프로그램이 부착되었고 패킷이 장치를 통과하면 실행돼요. XDP처럼 기본 섹션 이름을 사용하지 않아야 한다면 로드 중에 지정할 수 있어요. 예를 들어 foobar 섹션의 경우:

# tc filter add dev em1 egress bpf da obj prog.o sec foobar

iproute2의 BPF 로더는 프로그램 유형 전반에 걸쳐 같은 명령줄 구문을 사용하므로, obj prog.o sec foobar는 앞서 언급한 XDP와 같은 구문이에요.

부착된 프로그램은 다음 명령으로 나열할 수 있어요:

# tc filter show dev em1 ingress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 prog.o:[ingress] direct-action id 1 tag c5f7825e5dac396f

# tc filter show dev em1 egress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle 0x1 prog.o:[egress] direct-action id 2 tag b2fd5adc0f262714

prog.o:[ingress] 출력은 ingress 프로그램 섹션이 prog.o 파일에서 로드됐고 bpf가 direct-action 모드로 작동함을 알려줘요. 각 경우에 프로그램 id와 tag가 추가되는데, 후자는 스택 트레이스 등으로 오브젝트 파일이나 perf 보고서와 연관될 수 있는 명령어 스트림에 대한 해시를 나타내요. 마지막으로 id는 부착된 BPF 프로그램을 bpftool로 더 검사하거나 덤프하는 데 사용할 수 있는 시스템 전체 고유 BPF 프로그램 식별자를 나타내요.

tc는 단일 BPF 프로그램보다 더 많이 부착할 수 있고, 함께 연결할 수 있는 다른 분류기를 다양하게 제공해요. 그러나 da(direct-action) 모드 덕분에 모든 패킷 연산이 프로그램 자체에 포함될 수 있으므로 단일 BPF 프로그램 부착이 완전히 충분해요. 즉 BPF 프로그램 자체가 TC_ACT_OK, TC_ACT_SHOT 등의 tc 액션 판결을 이미 반환할 테니까요. 최적 성능·유연성을 위해 이것이 권장 사용방식이에요.

위 show 명령에서 tc는 BPF 관련 출력 옆에 pref 49152와 handle 0x1도 표시해요. 둘 다 명령줄로 명시적으로 제공되지 않으면 자동 생성돼요. pref는 우선순위 번호를 뜻하며, 여러 분류기가 부착되면 오름차순 우선순위로 실행됨을 의미하고, handle은 같은 pref 아래에 같은 분류기의 여러 인스턴스가 로드된 경우의 식별자를 나타내요. BPF의 경우 단일 프로그램이 완전히 충분하므로 pref와 handle은 보통 무시할 수 있어요.

부착된 BPF 프로그램을 원자적으로 교체할 계획인 경우에만 최초 로드 시 pref와 handle을 사전에 명시적으로 지정하는 것이 권장되어, 나중에 replace 연산을 위해 그 둘을 조회할 필요가 없게 해요. 따라서 생성은 다음과 같이 돼요:

# tc filter add dev em1 ingress pref 1 handle 1 bpf da obj prog.o sec foobar

# tc filter show dev em1 ingress
filter protocol all pref 1 bpf
filter protocol all pref 1 bpf handle 0x1 prog.o:[foobar] direct-action id 1 tag c5f7825e5dac396f

그리고 원자적 교체를 위해 다음을 발행해 ingress 훅의 기존 프로그램을 prog.o 파일의 foobar 섹션에서 새 BPF 프로그램으로 업데이트할 수 있어요:

# tc filter replace dev em1 ingress pref 1 handle 1 bpf da obj prog.o sec foobar

마지막으로 각각 ingress/egress 훅에서 모든 부착 프로그램을 제거하려면 다음을 사용할 수 있어요:

# tc filter del dev em1 ingress
# tc filter del dev em1 egress

netdevice에서 전체 clsact qdisc를 제거하려면(이는 ingress와 egress 훅에서 모든 부착 프로그램을 암묵적으로 제거함) 아래 명령이 제공돼요:

# tc qdisc del dev em1 clsact

tc BPF 프로그램도 NIC와 드라이버가 지원하면 XDP BPF 프로그램처럼 오프로드할 수 있어요. Netronome의 nfp 지원 NIC가 두 유형의 BPF 오프로드를 모두 제공해요.

# tc qdisc add dev em1 clsact
# tc filter replace dev em1 ingress pref 1 handle 1 bpf skip_sw da obj prog.o
Error: TC offload is disabled on net device.
We have an error talking to the kernel

위 오류가 표시되면 먼저 ethtool의 hw-tc-offload 설정으로 장치에 대해 tc 하드웨어 오프로드를 활성화해야 해요:

# ethtool -K em1 hw-tc-offload on
# tc qdisc add dev em1 clsact
# tc filter replace dev em1 ingress pref 1 handle 1 bpf skip_sw da obj prog.o
# tc filter show dev em1 ingress
filter protocol all pref 1 bpf
filter protocol all pref 1 bpf handle 0x1 prog.o:[classifier] direct-action skip_sw in_hw id 19 tag 57cd311f2e27366b

in_hw 플래그는 프로그램이 NIC에 오프로드됐음을 확인해요.

tc와 XDP 모두의 BPF 오프로드는 동시에 로드될 수 없다는 점을 주의하세요. tc 또는 XDP 오프로드 옵션 중 하나를 선택해야 해요.

3. netdevsim 드라이버로 BPF 오프로드 인터페이스 테스트.

리눅스 커널의 일부인 netdevsim 드라이버는 XDP BPF와 tc BPF 프로그램용 오프로드 인터페이스를 구현하는 더미 드라이버를 제공하고, 커널의 UAPI에 대해 직접 제어 플레인을 구현하는 커널 변경이나 저수준 사용자 공간 프로그램 테스트를 용이하게 해요.

netdevsim 장치는 다음과 같이 생성할 수 있어요:

# modprobe netdevsim
// [ID] [PORT_COUNT]
# echo "1 1" > /sys/bus/netdevsim/new_device
# devlink dev
netdevsim/netdevsim1
# devlink port
netdevsim/netdevsim1/0: type eth netdev eth0 flavour physical
# ip l
[...]
4: eth0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether 2a:d5:cd:08:d1:3f brd ff:ff:ff:ff:ff:ff

그 후 앞선 다양한 예시에서 보여준 것처럼 XDP BPF 또는 tc BPF 프로그램을 테스트 로드할 수 있어요:

# ip -force link set dev eth0 xdpoffload obj prog.o
# ip l
[...]
4: eth0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 xdpoffload qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether 2a:d5:cd:08:d1:3f brd ff:ff:ff:ff:ff:ff
    prog/xdp id 16 tag a04f5eef06a7f555

이 두 워크플로는 iproute2로 XDP BPF와 tc BPF 프로그램을 각각 로드하는 기본 연산이에요.

XDP와 tc 모두에 적용되는 BPF 로더용 고급 옵션도 다양하며, 그중 일부가 여기에 나열돼요. 단순화를 위해 예시에서는 XDP만 제시해요.

1. 성공 시에도 상세 로그 출력.

오류가 없는 경우에도 verifier 로그를 덤프하기 위해 verb 옵션을 프로그램 로드에 추가할 수 있어요:

# ip link set dev em1 xdp obj xdp-example.o verb

Prog section 'prog' loaded (5)!
 - Type:         6
 - Instructions: 2 (0 over limit)
 - License:      GPL

Verifier analysis:

0: (b7) r0 = 1
1: (95) exit
processed 2 insns

2. BPF 파일 시스템에 이미 고정된 프로그램 로드.

오브젝트 파일에서 프로그램을 로드하는 대신, 어떤 외부 엔티티가 고정해 둔 경우 iproute2가 BPF 파일 시스템에서 프로그램을 가져와 장치에 부착할 수도 있어요:

# ip link set dev em1 xdp pinned /sys/fs/bpf/prog

iproute2는 감지된 BPF 파일 시스템 마운트 지점에 상대적인 짧은 형태도 사용할 수 있어요:

# ip link set dev em1 xdp pinned m:prog

BPF 프로그램을 로드할 때 iproute2는 노드 고정을 수행하기 위해 마운트된 파일 시스템 인스턴스를 자동 감지해요. 마운트된 BPF 파일 시스템 인스턴스가 발견되지 않으면 tc가 기본 위치인 /sys/fs/bpf/에 자동 마운트해요.

인스턴스를 이미 발견했으면 그것을 사용하고 추가 마운트를 수행하지 않아요:

# mkdir /var/run/bpf
# mount --bind /var/run/bpf /var/run/bpf
# mount -t bpf bpf /var/run/bpf
# tc filter add dev em1 ingress bpf da obj tc-example.o sec prog
# tree /var/run/bpf
/var/run/bpf
+-- ip -> /run/bpf/tc/
+-- tc
|   +-- globals
|       +-- jmp_map
+-- xdp -> /run/bpf/tc/

4 directories, 1 file

기본적으로 tc는 위와 같은 초기 디렉터리 구조를 만들며, 모든 서브시스템 사용자가 globals 네임스페이스에 대한 심볼릭 링크를 통해 같은 위치를 가리키므로 iproute2에서 다양한 BPF 프로그램 유형 사이에 고정된 BPF 맵을 재사용할 수 있어요. 파일 시스템 인스턴스가 이미 마운트됐고 기존 구조가 이미 존재하면 tc는 그것을 덮어쓰지 않아요. 이는 모든 사람이 globals를 공유하지 않도록 lwt, tc, xdp 맵을 분리하는 경우일 수 있어요.

이전 LLVM 섹션에서 잠깐 다뤘듯 iproute2는 설치 시 BPF 프로그램이 표준 include 경로로 포함할 수 있는 헤더 파일을 설치해요:

#include <iproute2/bpf_elf.h>

이 헤더 파일의 목적은 프로그램이 사용하는 맵과 기본 섹션 이름에 대한 API를 제공하는 것이에요. iproute2와 BPF 프로그램 사이의 안정적인 계약이에요.

iproute2용 맵 정의는 struct bpf_elf_map이에요. 그 멤버는 이 문서의 LLVM 섹션에서 이미 다뤘어요.

BPF 오브젝트 파일을 파싱할 때 iproute2 로더는 모든 ELF 섹션을 순회해요. 처음에는 maps와 license 같은 보조 섹션을 가져와요. maps의 경우 struct bpf_elf_map 배열이 유효성을 검사되고 필요할 때마다 호환성 우회책이 수행돼요. 이후 모든 맵이 사용자 제공 정보로 생성되는데, 고정된 오브젝트로 가져오거나 새로 생성한 뒤 BPF 파일 시스템에 고정돼요. 다음으로 로더는 맵에 대한 ELF 재배치 항목이 포함된 모든 프로그램 섹션을 처리하는데, 즉 맵 파일 디스크립터를 레지스터로 로드하는 BPF 명령어가 재작성되어 해당 맵 파일 디스크립터가 명령어의 즉시 값에 인코딩되어 커널이 나중에 이를 맵 커널 포인터로 변환할 수 있게 해요. 그 후 모든 프로그램 자체가 BPF 시스템 콜을 통해 생성되고, tail call되는 맵이 있으면 프로그램의 파일 디스크립터로 업데이트돼요.

더 알아보기 (Learn more)