GDB는 JIT 컴파일된 코드에 대한 디버깅 지원을 위해 두 가지 주요 인터페이스를 제공합니다. 과거에는 번거로운 방식을 요구했지만, 현재는 좀 더 유연한 접근법이 가능합니다.
기존 JIT 인터페이스: 인메모리 객체 파일 생성
GDB는 런타임이 __jit_debug_register_code 함수와 전역 변수 __jit_debug_descriptor를 노출할 것으로 기대합니다. GDB는 이 함수에 내부 브레이크포인트를 설정하고, JIT 컴파일러가 코드를 컴파일할 때 이 함수를 호출하여 새로운 코드의 메타데이터를 GDB에 전달합니다. 구체적인 과정은 다음과 같습니다.
-
JIT 컴파일러가 함수를 컴파일하여 함수 이름, 실행 가능한 코드 주소, 코드 크기 등을 확보합니다.
-
해당 함수에 대한 전체 ELF/Mach-O 객체(이름, 코드 영역, DWARF 메타데이터 등 포함)를 메모리 내에서 생성합니다.
-
이 객체를 가리키는
jit_code_entry연결 리스트 노드를 작성합니다. -
이를
__jit_debug_descriptor연결 리스트에 연결합니다. -
__jit_debug_register_code를 호출하여 GDB가 새로운 함수의 메타데이터를 가져갈 수 있도록 제어권을 넘깁니다. -
함수가 GC될 때, 연결 리스트를 수정하고
__jit_debug_register_code를 다시 호출하여 등록을 해제합니다.
이 방식은 V8과 같은 프로젝트에서 객체 파일을 만들기 위해 많은 코드를 포함해야 하는 번거로움이 있었습니다.
새로운 JIT 인터페이스: 커스텀 디버그 정보 리더
이러한 복잡성 때문에 GDB는 ELF/Mach-O+DWARF 객체 생성을 요구하지 않는 새로운 인터페이스를 도입했습니다. 이 방식은 개발자가 선택한 바이너리 포맷으로 디버그 정보를 작성하고, 해당 포맷을 읽는 리더를 구현해야 합니다. 이 리더는 GDB에서 공유 객체로 로드되며, 다음 인터페이스를 구현해야 합니다.
c
GDB_DECLARE_GPL_COMPATIBLE_READER;
extern struct gdb_reader_funcs *gdb_init_reader (void);
struct gdb_reader_funcs
{
int reader_version;
void *priv_data;
gdb_read_debug_info *read;
gdb_unwind_frame *unwind;
gdb_get_frame_id *get_frame_id;
gdb_destroy_reader *destroy;
};
이 인터페이스를 구현하는 런타임은 아직 소수에 불과하며, 리더는 GPL 호환성을 선언해야 합니다.
Linux perf 인터페이스 적용 가능성
Linux perf map 인터페이스를 JIT 디버깅에 활용하는 방안도 고려될 수 있습니다. /tmp/perf-… 파일에서 자동으로 심볼을 가져올 수 있다면, 기본적인 디버그 정보를 ‘무료’로 얻을 수 있습니다. 이는 커스텀 디버그 리더를 재사용 가능한 형태로 만들어 perf map 파일을 파싱하도록 구현하는 방식으로 가능할 수 있습니다. 비록 파일명과 코드 영역만 처리 가능하여 DWARF나 커스텀 리더만큼 유연하지는 않지만, 부분적인 해결책으로는 유용할 수 있습니다.
기술적 과제: N-제곱 문제와 가비지 컬렉션
V8 문서에 따르면, JIT 인터페이스가 연결 리스트 기반이고 헤드 포인터만 유지하므로 O(n^2) 성능 문제가 발생할 수 있습니다. 특히 함수뿐만 아니라 트램폴린, 캐시 스텁 등 다양한 코드 객체를 등록할 때 더욱 두드러집니다. 또한, GDB는 심볼 객체 파일 내의 코드 포인터가 이동하지 않을 것을 기대하므로, 안정적인 심볼 파일 포인터와 실행 가능한 코드 포인터를 유지해야 합니다. V8은 이를 위해 moving GC를 비활성화합니다. 컴파일된 함수가 가비지 컬렉션될 경우, 해당 함수를 등록 해제해야 하며, ART(Android Runtime)는 GDB JIT 연결 리스트를 약한 참조(weakref)로 처리하여 주기적으로 죽은 코드 엔트리를 제거하는 방식을 사용합니다.