Rust 컴파일러의 디버깅 지원
이 문서는 Rust 컴파일러(rustc)의 디버깅 도구 지원 현황을 설명합니다. GDB, LLDB, WinDbg/CDB에 대한 개요와 함께 Rust 코드를 디버깅하기 위한 Rust 컴파일러 주변 인프라를 다룹니다. Rust 컴파일러 자체를 디버깅하는 방법을 알고 싶다면 컴파일러 디버깅하기를 참조하십시오.
이 자료는 Tom Tromey가 rustc의 디버깅 지원에 대해 논의하는 영상에서 수집한 것입니다.
사전 지식
디버거
위키백과에 따르면
디버거 또는 디버깅 도구는 다른 프로그램(“대상” 프로그램)을 테스트하고 디버깅하는 데 사용되는 컴퓨터 프로그램입니다.
한 언어를 위한 디버거를 처음부터 작성하려면 많은 작업이 필요하며, 특히 디버거가 다양한 플랫폼에서 지원되어야 한다면 더욱 그렇습니다. 그러나 GDB와 LLDB는 특정 언어의 디버깅을 지원하도록 확장할 수 있습니다. 이것이 Rust가 선택한 경로입니다. 이 문서의 주요 목표는 Rust 컴파일러에서 앞서 언급한 디버거들을 지원하는 방식을 문서화하는 것입니다.
DWARF
DWARF 표준 웹사이트에 따르면
DWARF는 소스 수준 디버깅을 지원하기 위해 많은 컴파일러와 디버거에서 사용하는 디버깅 파일 형식입니다. C, C++, Fortran과 같은 여러 절차적 언어의 요구 사항을 충족하며, 다른 언어로도 확장할 수 있도록 설계되었습니다. DWARF는 아키텍처에 독립적이며 어떤 프로세서나 운영 체제에도 적용할 수 있습니다. DWARF는 Unix, Linux 및 기타 운영 체제뿐만 아니라 독립 실행형 환경에서도 널리 사용됩니다.
DWARF 리더는 DWARF 형식을 읽어 디버거와 호환되는 출력을 생성하는 프로그램입니다. 이 프로그램은 컴파일러 자체에 포함될 수도 있습니다. DWARF는 디버깅 정보 항목(Debugging Information Entry, DIE)이라는 자료 구조를 사용합니다. DIE는 함수, 변수 등을 나타내는 “태그“로 정보를 저장하며, 예를 들면 DW_TAG_variable, DW_TAG_pointer_type, DW_TAG_subprogram 등이 있습니다. 직접 태그와 속성을 새로 만들 수도 있습니다.
CodeView/PDB
PDB(Program Database)는 Microsoft가 만든 파일 형식으로, 디버그 정보를 담고 있습니다. WinDbg/CDB와 같은 디버거 및 기타 도구는 PDB를 읽어 디버그 정보를 표시할 수 있습니다. PDB에는 특정 바이너리에 대한 디버그 정보를 기술하는 여러 스트림이 들어 있으며, 여기에는 해당 바이너리를 컴파일하는 데 사용된 타입, 심볼, 소스 파일 등이 포함됩니다. CodeView는 PDB 스트림 안에 나타나는 심볼 레코드와 타입 레코드의 구조를 정의하는 또 다른 형식입니다.
지원되는 디버거
GDB
Rust 표현식 파서
디버그 출력을 표시하려면 표현식 파서가 필요합니다. 이 (GDB) 표현식 파서는 Bison으로 작성되었으며, Rust 표현식의 일부만 파싱할 수 있습니다. GDB 파서는 처음부터 새로 작성되었으며, rustc의 파서를 포함한 다른 어떤 파서와도 관련이 없습니다.
GDB는 Rust와 유사한 형태로 값과 타입을 출력합니다. GDB는 값과 타입을 Rust 구문처럼 보이는 방식으로 출력할 수 있습니다. 또한 GDB에서 ptype으로 타입을 출력하면 그 결과도 Rust 소스 코드처럼 보입니다. GDB/Rust 매뉴얼의 문서를 확인하십시오.
파서 확장
표현식 파서에는 Rust로는 할 수 없는 기능을 지원하기 위한 몇 가지 확장이 포함되어 있습니다. 일부 제약 사항은 GDB/Rust 매뉴얼에 나열되어 있습니다. GDB의 DWARF 리더에는 이러한 확장을 지원하기 위한 특수한 코드가 일부 있습니다.
필요한 DWARF 리더 지원의 몇 가지 예는 다음과 같습니다.
-
열거형: 열거형 타입을 지원하는 데 필요합니다. Rust 컴파일러는 열거형에 대한 정보를 DWARF에 기록하고, GDB는 DWARF를 읽어 태그 필드가 어디에 있는지, 태그 필드가 존재하는지, 태그 슬롯이 non-zero 최적화와 공유되는지 등을 파악합니다.
-
트레이트 객체 분석: DWARF 안의 트레이트 객체 설명이 해당 vtable의 스텁 설명도 가리키고, 이 스텁 설명이 다시 해당 트레이트 객체가 존재하는 구체 타입을 가리키도록 하는 DWARF 확장입니다. 즉, 해당 트레이트 객체에 대해
print *object를 실행할 수 있으며, GDB는 트레이트 객체 안에 있는 페이로드의 올바른 타입을 찾는 방법을 파악합니다.
TODO: 중복이 없도록 다음 내용을 이 가이드 페이지 대신 GDB-Rust 문서에서 언급해야 하는지 확인하십시오. 이는 다음 코멘트들과 관련된 것입니다.
gdb의 Rust 확장과 제약 사항은 gdb 매뉴얼에 문서화되어 있습니다: https://sourceware.org/gdb/onlinedocs/gdb/Rust.html – 다만 이 매뉴얼은 gdb 편의 변수와 레지스터가 gdb의 $ 규칙을 따른다는 점과, Rust 파서가 gdb의 @ 확장을 구현한다는 점을 언급하지 않습니다.
@tromey 중복 등을 피하기 위해 이 부분을 이 문서 대신 GDB-Rust 문서에서 언급해야 한다고 생각하십니까?
LLDB
Rust 표현식 파서
이 표현식 파서는 C++로 작성되었습니다. 이 파서는 재귀 하향 파서의 일종입니다. 이 파서는 GDB보다 Rust 언어를 약간 덜 구현합니다. LLDB는 Rust와 유사한 형태로 값과 타입을 출력합니다.
개발자 노트
- LLDB는 플러그인 아키텍처를 가지고 있지만, 이 아키텍처는 언어 지원에는 사용할 수 없습니다.
- 일반적으로 Linux에서는 GDB가 더 잘 동작합니다.
WinDbg/CDB
Microsoft는 Windows 디버거(WinDbg)와 콘솔 디버거(CDB) 같은 Windows 디버깅 도구를 제공하며, 두 디버거 모두 Rust로 작성된 프로그램의 디버깅을 지원합니다. 이 디버거들은 PDB가 있으면 거기에서 바이너리의 디버그 정보를 파싱하여 디버거에 표시할 시각화를 구성합니다.
Natvis
WinDbg와 CDB는 모두 Natvis 프레임워크를 사용하여 디버거 안에서 임의의 타입에 대한 사용자 정의 시각화를 정의하고 볼 수 있도록 지원합니다. Rust 컴파일러는 std, core, alloc 등 표준 라이브러리에 있는 일부 타입에 대한 사용자 정의 시각화를 정의하는 Natvis 파일 모음을 정의합니다. 이 Natvis 파일들은 *-pc-windows-msvc 타깃 트리플로 생성된 PDBs에 내장되어, 디버깅할 때 이러한 사용자 정의 시각화가 자동으로 활성화되도록 합니다. 이 기본 동작은 strip rustc 플래그를 debuginfo 또는 symbols로 설정하여 재정의할 수 있습니다.
Rust는 #[debugger_visualizer] 속성을 사용하여 표준 라이브러리 외부의 크레이트에 대한 Natvis 파일을 내장하는 기능을 지원합니다. 디버거 시각화 도구를 내장하는 방법에 대한 자세한 내용은 debugger_visualizer 속성 절을 참고하십시오.
DWARF와 rustc
DWARF는 디버거가 읽는 디버깅 정보를 컴파일러가 생성하는 표준 방식입니다. macOS와 Linux에서는 바로 이것이 디버깅 형식입니다. DWARF는 여러 언어를 지원하는 확장 가능한 형식이며, Rust의 용도에도 대체로 충분합니다. 따라서 현재 구현은 DWARF의 개념을 재사용합니다. DWARF의 일부 개념이 의미상 Rust와 맞지 않더라도 이는 마찬가지인데, 일반적으로 둘 사이에 어떤 형태로든 대응 관계를 만들 수 있기 때문입니다.
Rust 컴파일러가 내보내고 디버거가 이해하지만 DWARF 표준에는 없는 DWARF 확장이 몇 가지 있습니다.
-
Rust 컴파일러는 가상 테이블에 대한 DWARF를 내보내며, 이
vtable객체는 실제 타입을 가리키는DW_AT_containing_type을 가집니다. 이를 통해 디버거는 트레이트 객체 포인터를 분해하여 페이로드를 올바르게 찾을 수 있습니다. 다음은 gdb 저장소의 테스트 케이스에서 가져온 그러한 DIE의 예시입니다.<1><1a9>: Abbrev Number: 3 (DW_TAG_structure_type) <1aa> DW_AT_containing_type: <0x1b4> <1ae> DW_AT_name : (indirect string, offset: 0x23d): vtable <1b2> DW_AT_byte_size : 0 <1b3> DW_AT_alignment : 8 -
또 다른 확장은 Rust 컴파일러가 태그 없는 판별 공용체(tagless discriminated union)를 내보낼 수 있다는 것입니다. 이 항목에 대해서는 DWARF 기능 요청을 참고하십시오.
DWARF의 현재 한계
- 트레이트 - DWARF에서 트레이트를 표현하는 방법과 관련하여 DWARF에 평소보다 큰 변경이 필요합니다.
- DWARF는 구조체와 튜플을 구별할 방법을 제공하지 않습니다. 이 한계를 극복하기 위해 Rust 컴파일러는
__0과 같은 이름으로 필드를 내보내고, 디버거는 그러한 이름이 연속으로 나타나는지 찾습니다. 예를 들어 이 경우 디버거는x.0대신x.__0을 통해 필드를 조회합니다. 이 문제는 디버거 안의 Rust 파서를 통해 해결되었으므로 이제x.0을 사용할 수 있습니다.
DWARF는 디버거가 플랫폼 ABI에 관한 일부 정보를 알고 있다고 전제합니다. Rust는 항상 그렇게 하지는 않습니다.
개발자 노트
이 절은 개발의 특정 측면에 관한 발표 내용에서 가져왔습니다.
부족한 부분
macOS에서 LLDB 디버그 서버의 코드 서명
위키백과에 따르면 System Integrity Protection은 다음과 같습니다.
System Integrity Protection(SIP, rootless라고도 함)은 OS X El Capitan에서 도입된 Apple macOS 운영 체제의 보안 기능입니다. 이 기능은 커널이 강제하는 여러 메커니즘으로 구성됩니다. 핵심은 특정 “권한(entitlement)“이 없는 프로세스가 시스템 소유의 파일과 디렉터리를 수정하지 못하도록 보호하는 것이며, 이는 root 사용자나 root 권한을 가진 사용자(sudo)가 실행하는 경우에도 마찬가지입니다.
이 기능은 프로세스가 ptrace 시스템 호출을 사용하지 못하게 합니다. 프로세스가 ptrace를 사용하려면 코드 서명이 되어 있어야 합니다. 서명에 사용한 인증서는 여러분의 컴퓨터에서 신뢰할 수 있는 것이어야 합니다.
System Integrity Protection에 관한 Apple 개발자 문서를 참고하십시오.
이 서명을 하려면 Apple에 등록하고 키를 발급받아야 할 수도 있습니다. Tom은 Mozilla가 서명할 수 있는 키의 최대 개수에 이미 도달해서 이 작업을 할 수 없는지 알아보았습니다. Tom은 Mozilla가 키를 더 받을 수 있는지는 알지 못합니다.
다른 방법으로, Tom은 Apple을 통해 키를 받으려면 Rust 법인이 필요할 수도 있다고 제안합니다. 이 문제는 본질적으로 기술적인 문제가 아닙니다. 그러한 키가 있다면 GDB에도 서명하여 함께 배포할 수 있을 것입니다.
DWARF와 트레이트
Rust 트레이트는 DWARF에 전혀 출력되지 않습니다. 그 결과 x.method()와 같은 메서드 호출이 그대로는 동작하지 않습니다. 그 이유는 해당 메서드가 타입이 아니라 트레이트에 의해 구현되기 때문입니다. 그 정보가 없으므로 트레이트 메서드를 찾을 수 없습니다.
DWARF에는 인터페이스 타입이라는 개념이 있습니다(아마 Java를 위해 추가된 것으로 보입니다). Tom의 아이디어는 이 인터페이스 타입을 트레이트로 사용하는 것이었습니다.
DWARF는 참조 타입이 아닌 구체적인 이름만 다룹니다. 따라서 어떤 타입에 대한 트레이트의 특정 구현은 이러한 인터페이스(DW_tag_interface 타입) 중 하나가 됩니다. 또한 트레이트가 구현된 타입은 해당 타입이 구현하는 모든 인터페이스를 기술하게 됩니다. 이를 위해서는 DWARF 확장이 필요합니다.
GitHub 이슈: https://github.com/rust-lang/rust/issues/33014
디버그 정보 변경의 일반적인 절차(LLVM)
LLVM에는 디버그 정보(Debug Info, DI) 빌더가 있습니다. 이것이 Rust가 호출하는 주된 대상입니다. DWARF가 직접 방출되는 것이 아니라 이것이 먼저 방출되기 때문에 LLVM을 먼저 변경해야 합니다. 이것은 직접 구성하여 LLVM에 넘겨주는 일종의 메타데이터입니다. Rustc에서 LLVM으로 넘겨줄 때에는 타입의 표현을 구성하기 위해 몇몇 LLVM DI 빌더 메서드가 호출됩니다.
이 과정의 단계는 다음과 같습니다.
-
LLVM을 변경해야 합니다.
LLVM은 Interface 타입을 전혀 방출하지 않으므로, 이를 먼저 LLVM에 구현해야 합니다.
이것이 좋은 아이디어라는 데 대해 LLVM 메인테이너들의 승인을 받으십시오.
-
DWARF 확장을 변경하십시오.
-
디버거를 업데이트하십시오.
DWARF 리더와 표현식 평가기를 업데이트하십시오.
-
Rust 컴파일러를 업데이트하십시오.
이 새로운 정보를 방출하도록 변경하십시오.
절차적 매크로 스테핑
매우 근본적인 질문은 절차적 매크로를 실제로 어떻게 디버깅하느냐는 것입니다. 매크로 확장에 대해 어떤 위치를 방출해야 합니까? 다음과 같은 몇 가지 경우를 생각해 보십시오.
- 매크로 호출 위치를 방출할 수 있습니다.
- 매크로 정의 위치를 방출할 수 있습니다.
- 매크로 내용의 위치들을 방출할 수 있습니다.
RFC: https://github.com/rust-lang/rfcs/pull/2117
핵심은 무엇을 할지 매크로가 결정하도록 하는 것입니다. 이는 라인 마커가 어디에 있어야 하는지 매크로가 컴파일러에 알려 줄 수 있게 하는 일종의 속성(attribute)을 두어 달성할 수 있습니다. 이는 중단점을 설정하는 위치와 스텝 실행 시 일어나는 일에 영향을 줍니다.
디버그 정보의 소스 파일 체크섬
DWARF와 CodeView(PDB)는 모두 해당 바이너리를 만드는 데 사용된 각 소스 파일의 암호학적 해시를 포함하는 기능을 지원합니다.
디버거는 암호학적 해시를 사용하여 소스 파일이 실행 파일과 일치하는지 확인할 수 있습니다. 소스 파일이 일치하지 않으면 디버거는 사용자에게 경고를 표시할 수 있습니다.
해시는 특정 소스 파일이 실행 파일을 컴파일하는 데 사용된 이후로 수정되지 않았음을 증명하는 데에도 사용할 수 있습니다. MD5와 SHA1은 모두 취약점이 입증되었으므로, 이 용도에는 SHA256을 사용하는 것이 권장됩니다.
Rust 컴파일러는 각 소스 파일의 해시를 SourceMap 안의 해당 SourceFile에 저장합니다. 외부 크레이트의 입력 파일 해시는 rlib 메타데이터에 저장됩니다.
기본 해싱 알고리즘은 타깃 명세에서 설정됩니다. 모든 타깃이 모든 해시 알고리즘을 지원하지는 않으므로, 이를 통해 각 타깃은 사용 가능한 가장 좋은 해시를 지정할 수 있습니다.
타깃의 해싱 알고리즘은 -Z source-file-checksum= 명령줄 옵션으로 재정의할 수도 있습니다.
DWARF 5
DWARF 버전 5는 사용 중인 소스 파일 버전을 검증할 수 있도록 MD5 해시를 포함하는 기능을 지원합니다. DWARF 5 - 6.2.4.1절 opcode DW_LNCT_MD5
LLVM
LLVM IR은 DIFile 노드에서 MD5 및 SHA1 소스 파일 체크섬을 지원합니다(LLVM 11 이상에서는 SHA256도 지원합니다).
Microsoft Visual C++ 컴파일러 /ZH 옵션
MSVC 컴파일러는 /ZH 컴파일러 옵션을 사용하여 PDB에 MD5, SHA1 또는 SHA256 해시를 포함하는 기능을 지원합니다.
Clang
문서에는 나와 있지 않지만, Clang은 항상 MD5 체크섬을 포함합니다.
향후 작업
이름 맹글링 변경
libiberty(gcc 소스 트리)의 새 디맹글러입니다.- LLVM 또는 LLDB의 새 디맹글러입니다.
TODO: 디맹글러 소스의 위치를 확인하십시오. #1157
표현식 평가에 Rust 컴파일러 재사용
디버거는 대체로 타입 추론을 구현하려 하지 않기 때문에 이는 중요한 아이디어입니다. 디버거에 입력할 때는 실제 소스 코드를 작성할 때보다 훨씬 더 명시적으로 작성해야 합니다. 따라서 소스 코드의 표현식을 디버거에 그대로 복사해 붙여 넣고 같은 결과를 기대할 수는 없지만, 그렇게 할 수 있다면 좋을 것입니다. 컴파일러를 사용하면 이 문제를 개선할 수 있습니다.
충분히 가능한 일이지만, 규모가 큰 프로젝트입니다. 메모리에 접근할 수 있는 것은 디버거뿐이므로 디버거와 연결하는 계층이 반드시 필요합니다. GDB(gcc)와 LLDB(clang) 모두 이 기능을 갖추고 있습니다. LLDB는 Clang을 사용해 코드를 JIT로 컴파일하며, GDB도 GCC로 같은 작업을 할 수 있습니다.
두 디버거의 표현식 평가는 모두 Rust의 상위 집합이면서 동시에 하위 집합을 구현합니다. 이들은 표현식 언어만 구현하지만, GDB의 편의 변수(convenience variable)와 같은 몇 가지 확장 기능도 추가합니다. 따라서 이 방식을 택한다면 이러한 연결 작업을 해야 할 뿐만 아니라, 컴파일러가 일부 확장 기능을 이해할 수 있도록 별도의 모드를 추가해야 할 수도 있습니다.