LLDB 내부 구조
LLDB의 디버그 정보 처리는 주로 lldb/src/Plugins에 정의된 확장 가능한 인터페이스 집합에 의존합니다. 이 인터페이스들은 서드파티 컴파일러 개발자가 LLDB가 런타임에 로드하는 언어 지원을 추가할 수 있도록 하기 위한 것이지만, 이 글을 작성하는 시점(2025년 11월)에는 공개 API가 아직 확정되지 않았으므로 플러그인은 LLDB 자체에 포함되어 있거나 LLDB의 독립 포크에 존재합니다.
일반적으로 언어 지원은 다음과 같은 플러그인 파이프라인으로 작성됩니다: *ASTParser -> TypeSystem -> ExpressionParser/Language.
다음은 LLDB 플러그인 API의 기존 구현 사례입니다.
- Swift를 지원하는 Apple의 포크
- Rust를 지원하던 CodeLLDB의 이전 포크
- 현재 진행 중인 Rust 지원 재구현
- Rust 표현식 파서 플러그인입니다. 이 플러그인은
TypeSystemAPI가 만들어지기 전에 작성되었습니다. 표현식 파싱은 형식이 자유롭기 때문에, 그 밑에 있는 렉싱, 파싱, 함수 호출 등은 여전히 유용한 통찰을 줄 것입니다.
Rust 지원과 TypeSystemClang
디버그 정보 개요에서 언급했듯이 LLDB는 Rust를 부분적으로 지원합니다. 좀 더 명확히 말하자면, Rust는 C/C++용으로 만들어진 플러그인 파이프라인을 사용하며(Rust 열거형 타입을 위한 몇 가지 헬퍼가 포함되어 있기는 합니다), 이 파이프라인은 clang 컴파일러의 타입 표현에 직접 의존합니다. 이 때문에 LLDB의 출력이 우리가 원하는 것과 일치하지 않을 때 변경할 수 있는 범위가 크게 제한됩니다. 몇 가지 우회 방법이 도움이 될 수는 있지만, 결국 Rust의 요구 사항은 C와 C++의 컴파일 및 디버깅이 올바르게 동작하도록 하는 것에 비해 부차적입니다.
LLDB는 TypeSystemRust를 추가하는 것에 열려 있지만, 이는 매우 방대한 작업입니다. 이 절은 현재 우리가 TypeSystemClang과 상호 작용하는 방식을 문서화할 뿐만 아니라, 향후 TypeSystemRust를 구현하기 위한 간단한 지침 역할도 합니다.
TypeSystem이 대상 언어의 컴파일러와 직접 상호 작용하는 것이 의도된 방식이지만, 필수 요건은 아니라는 점에 유의할 필요가 있습니다. 필요한 모든 지원 타입을 플러그인 구현 내부에서 직접 만들 수도 있습니다.
참고: 소스 코드의 주석을 포함한 LLDB의 문서는 상당히 부족합니다.
TypeSystemClang의 구현을 읽으며 언어 지원이 어떻게 동작하는지 이해하려면clang컴파일러의 내부 구조까지 이해해야 하므로 다소 어렵습니다. 위에 나열된 2개의TypeSystemRust구현은 컴파일러의 타입 표현을 활용하지 않고 “처음부터” 작성되었으므로, 이를 살펴보는 것을 권장합니다. 이 구현들은 언어 지원을 구현하는 데 필요한 최소한의 수준에 비교적 가깝습니다.
DWARF와 PDB 비교
LLDB는 DWARF와 PDB 디버그 정보를 모두 처리할 수 있다는 점에서 독특합니다. 다만 이로 인해 복잡성이 다소 늘어납니다. 상황을 더 복잡하게 만드는 것은, PDB 지원이 Visual Studio와 함께 배포되는 msdia140.dll 라이브러리에 의존하는 dia와, PDB 형식에 관해 공개된 정보를 바탕으로 처음부터 작성된 native로 나뉘어 있다는 점입니다.
참고:
dia는 LLDB 버전 21까지 기본값이었습니다. LLDB 22 릴리스부터는native가 새로운 기본값입니다.dia기반 플러그인은 지원 중단 후 완전히 제거될 예정입니다. 따라서 아래에서는native파싱만 다룹니다. 진행 상황은 이 discourse 스레드와 관련 추적 이슈를 참고하십시오.
native는 LLDB 22에서 추가된plugin.symbol-file.pdb.reader설정이나 환경 변수LLDB_USE_NATIVE_PDB_READER=0/1을 사용하여 전환할 수 있습니다
디버그 노드 파싱
첫 번째 단계는 원시 디버그 노드를 사용할 수 있는 형태로 가공하는 것입니다. 이 작업은 주로 DWARFASTParser 클래스와 PdbAstBuilder 클래스에서 이루어집니다. 이 클래스들에는 각각 SymbolFileDWARF와 SymbolFileNativePDB에서 생성된 디버그 정보가 역직렬화된 형태로 입력됩니다. SymbolFile 구현체들은 기반 디버그 정보를 파서에 전달하기 전에 거의 아무런 변환도 하지 않습니다. PDB와 DWARF 모두 LLVM의 디버그 정보 핸들러를 사용하여 디버그 정보를 읽습니다.
파서는 노드를 LLDB의 목적에 더 편리한 형식으로 변환합니다. clang의 경우 이러한 형식은 clang::QualType, clang::Decl, clang::DeclContext이며, 이는 clang이 C와 C++를 컴파일할 때 내부적으로 사용하는 타입입니다. 다시 말하지만 컴파일러의 타입 표현을 반드시 사용해야 하는 것은 아니지만, 플러그인 시스템은 그러한 가능성을 염두에 두고 만들어졌습니다.
참고:
TypeSystemClang의 구체적인 구현 세부 사항이 중요하지 않은 경우, 위의 타입들을 언어에 구애받지 않는 이름인LangType,Decl,DeclContext로 부르겠습니다.
LangType은 타입을 나타냅니다. 여기에는 타입의 이름, 크기와 정렬, 분류(예: 구조체, 기본 타입, 포인터), 한정자(예: const, volatile), 템플릿 인자, 함수 인자 및 반환 타입 등의 정보가 포함됩니다. RustType의 모습을 보여 주는 예시는 여기에 있습니다.
Decl은 모든 종류의 선언을 나타냅니다. 이는 타입, 변수, 구조체의 정적 필드, static이나 const를 초기화하는 값 등일 수 있습니다.
DeclContext는 대략 스코프를 나타냅니다. DeclContext는 일반적으로 Decl과 다른 DeclContexts를 포함하지만, 그 관계가 그렇게 단순하지는 않습니다. 예를 들어 함수는 Decl(함수 시그니처는 타입이므로)이면서 동시에 DeclContext(함수는 변수 선언, 중첩 함수 선언 등을 포함하므로)일 수 있습니다.
변환 과정은 상당히 장황할 수 있지만, 대개는 간단합니다. 여기서 하는 작업의 상당 부분은 LangType, Decl, DeclContext를 채우는 데 필요한 정확한 정보에 따라 달라집니다.
노드가 변환되면 그 노드를 가리키는 포인터는 타입이 소거(void*)된 뒤 CompilerType, CompilerDecl 또는 CompilerDeclContext로 감싸집니다. 이 래퍼들은 해당 객체를 그것을 소유하는 TypeSystem과 연결합니다. 이 객체들의 메서드는 TypeSystem에 위임되며, TypeSystem은 void*를 적절한 LangType*/Decl*/DeclContext*로 다시 캐스팅하여 내부를 조작합니다. Rust 용어로 표현하면 그 관계는 대략 다음과 같습니다.
struct CompilerType {
inner_type: *mut c_void,
type_system: Arc<dyn TypeSystem>,
}
impl CompilerType {
pub fn get_byte_size(&self) -> usize {
self.type_system.get_byte_size(self.lang_type)
}
}
...
impl TypeSystem for TypeSystemLang {
pub fn get_byte_size(lang_type: *mut c_void) -> usize {
let lang_type = lang_type as *mut LangType;
// Operate on the internals of the LangType to
// determine its size
...
}
}
타입 시스템
TypeSystem 인터페이스에는 세 가지 주요 목적이 있습니다.
- 언어의 타입에 대한 “유일한 권한자” 역할을 합니다. 이를 통해 타입 시스템을 LLDB의 타입 시스템 “풀“에 추가할 수 있습니다. 실행 파일이 로드되면 대상 언어가 결정되고, 해당 언어를 처리할 수 있다고 주장하는
TypeSystem을 찾기 위해 풀에 쿼리합니다. 또한TypeSystem을 사용하여 기반이 되는SymbolFile을 가져오거나, 타입을 검색하거나, 디버그 정보에 존재하지 않을 수 있는 기본 타입(예: 원시 타입,T의 배열,T를 가리키는 포인터)을 합성할 수도 있습니다. LangType,Decl,DeclContext객체의 라이프타임을 관리합니다- 해당 타입들이 표시되는 방식과 상호 작용할 수 있는 방식의 “기본값“을 사용자 정의합니다.
처음 두 함수는 꽤 간단하므로 세 번째 함수에 집중하겠습니다.
시각화 스크립트를 다뤄 본 적이 있다면 TypeSystem 인터페이스의 많은 함수가 익숙해 보일 것입니다. 이 함수들은 이름이 같은 SBType과 SBValue 함수들의 기반이 됩니다. 예를 들어 TypeSystem::GetFormat은 사용자 정의 포매터가 적용되지 않은 경우 해당 타입의 기본 포맷을 반환합니다.
특히 주목할 만한 것은 GetIndexOfChildWithName과 GetNumChildren입니다. 이 함수들의 TypeSystem 버전은 SBValue 버전처럼 값에 대해 동작하는 것이 아니라 타입에 대해 동작합니다. TypeSystem 함수가 반환하는 값은 LLDB의 나머지 부분이 구조체의 어느 부분과 조금이라도 상호작용할 수 있는지를 결정합니다. 어떤 필드가 생략되면 LLDB에게 그 필드는 사실상 더 이상 존재하지 않습니다.
또한 이 함수들은 객체를 다루지 않으므로 검사하거나 해석할 기반 메모리가 없습니다. 본질적으로 이는 이 함수들이 대응하는 SyntheticProvider 함수들과 같은 목적을 갖지 않는다는 뜻입니다. Vec에 요소가 몇 개 있는지, 또는 그 요소들이 어느 주소에 있는지 알아낼 방법이 없습니다. 합 타입의 판별값을 알아내는 것도 불가능합니다.
이상적으로는 TypeSystem이 디버그 정보에 나타난 타입을 가능한 한 변경하지 않고 그대로 노출해야 합니다. 타입을 보기 좋게 만드는 일은 LLDB의 합성(synthetic) 기능과 프런트엔드가 처리할 수 있습니다. 어떤 정보가 쓸모없다면, 애초에 해당 디버그 정보를 출력하지 않도록 Rust 컴파일러를 수정해야 합니다.
표현식 파싱
TypeSystem은 일반적으로 표현식 파싱을 처리할 수 있는 짝을 갖도록 작성됩니다. 이를 위해서는 TypeSystem 인터페이스에서 몇 가지 추가 함수를 구현해야 합니다. 표현식 파싱 코드의 대부분은 lldb/source/Plugins/ExpressionParser에 있어야 합니다.
파서에 관해서는 특별히 언급할 내용이 많지 않습니다. (단순화되었을 수도 있는) Rust 구문을 처리할 수 있는 간단한 인터프리터를 구현해야 합니다. 이 인터프리터는 SBValue의 기반이 되는 객체인 lldb::ValueObject에 대해 동작합니다.
언어
Language 플러그인은 Python 시각화 스크립트에 해당하는 C++ 구현입니다. 이 플러그인들은 합성 자식(synthetic children) 생성과 보기 좋은 출력(pretty-printing)이라는 같은 목적을 위해 SBValue 객체에 대해 동작합니다. LibCxx 타입에 대한 CPlusPlusLanguage의 구현은 시각화를 어떻게 작성해야 하는지 배우기에 훌륭한 자료입니다.
이 플러그인들은 LLDB의 비공개 내부 구현(기반이 되는 TypeSystem 포함)에 접근할 수 있으므로, Language 플러그인으로 작성된 synthetic/summary 프로바이더는 Python으로 작성된 것보다 더 높은 품질의 출력을 제공할 수 있습니다.
디버그 노드 파싱, 타입 시스템, 표현식 파싱은 모두 서로 밀접하게 연결되어 있지만, Language 플러그인은 더 잘 캡슐화되어 있으므로 기존 타입 시스템이 지원하는 모든 언어에 대해 “독립적으로” 작성할 수 있습니다. 진입 장벽이 더 낮기 때문에 RustLanguage 플러그인은 LLDB에서 완전한 언어 지원으로 나아가는 좋은 디딤돌이 될 수 있습니다.
시각화
작업 중