- BPF는 일반적인 프로그램과 유사한 방식으로 개발하기 때문에 유사한 실행파일 및 메모리 구조를 가지고 있지만, 커널 안에서 제한된 환경으로 실행되기 때문에 로딩(loading) 또한 다른 방식으로 실행된다
데이터 유형
-
일반적인 실행파일에서 가장 중요한 두 가지는 코드와 데이터이다. 코드는 말그대로 상위언어를 컴파일한 머신코드를 의미하고, 데이터는 실행시 코드가 참조하는 메모리를 의미한다.
-
데이터는 스택과 힙같이 실행시 메모리가 할당/해제되는 동적 데이터와 전역변수처럼 코드에서 선언되는 정적 데이터로 나뉘는데, 정적 데이터는 실행파일을 로딩할 때 메모리가 할당되고 해당 메모리를 참조하는 코드도 재배치(relocation)된다.
-
그리고 정적 데이터는 크게 읽기전용 변수, 초기화된 전역변수 그리고 초기화되지 않은 전역변수로 구분된다.
-
읽기전용 변수는
rodata0
처럼 const로 선언된 전역변수를 의미하고, 해당 메모리에 대한 쓰기 작업을 금지하기 위해 읽기전용의 페이지를 할당받아 사용한다. -
그리고
data0
과data1
같이 초기값을 가지고 있는 전역변수는 초기화된 전역변수로 분류되고, bss0과 같이 초기값을 가지고 있지 않은 전역변수는 초기화되지 않은 전역변수로 분류된다.
-
-
아래는 위의 예제코드를 컴파일한 후 objdump를 이용해서 섹션 테이블과 심볼 테이블 ELF 형식으로 출력한 것이다.
-
심볼 테이블을 보면 읽기전용 변수인
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 변수를 참조하는 부분의 코드를 살펴보자.
- BPF 맵은 사용자 코드와 BPF 코드가 데이터를 공유하는 가장 보편적인 방식으로,
-
위의 (
11:
) 명령어는 data1 에 있는 값을 r2 레지스터로 복사하는 부분인데, opcode와 레지스터 정보만 있고 다른 모든 값은 0으로 채워져있다. 이 부분은 커널에 BPF 코드를 넘기기 전에 재배치 정보를 참조하여 필요한 다른 값으로 채워진다. -
위의 재배치 목록의 첫 번째 항목은
tp_btf/sched_switch
섹션(handle__sched_switch
함수)의 오프셋이0x58
인, (11:
) 명령어에서 data1 변수를 참조하고 있다는 의미이다. -
이 명령어는 보이는 것처럼 8 바이트씩 개의 명령어로 구성되어 있는데, 재배치 과정에서 첫 번째 명령어에는 data1 변수가 속해있는 섹션(
.data
)으로 만들어진 BPF 맵의 파일디스크립터를 집어넣고, 두 번째 명령어에는 data1 변수가 속해있는 섹션에서의 오프셋을 집어넣는다. -
해당 프로세스의 파일 디스크립터 목록 중 6번이
.data
섹션에 해당하는 BPF 맵이기 때문에 (11:
) 명령어의 첫 번째 명령어에는 6이라는 값이 채워지고, 심볼 테이블을 보면 data1 변수는.data
섹션에서 오프셋이 4이기 때문에 (11:
) 명령어의 두 번째 명령어에는 4 라는 값이 채워진다. -
이러한 재배치 과정을 통해 나온 BPF 코드는 아래와 같다.
-
현재 사용 중인 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) 컴파일한 결과물은 아래와 같다.
- 위의 (
4b:
) 명령어를 보면 x86 CPU 에서r2
레지스터에 해당하는 rsi 레지스터가 할당되어 있는 것과 재배치 작업이 끝난data1
의 메모리 주소가 들어가 있는 것을 볼 수 있다. 이러한 과정을 통해 BPF 코드에서 전역변수로 선언된data1
에 접근하기 위한 재배치를 수행한다.