DWARF .debug_line 분석 — Source-to-PC 매핑 바이트코드 VM
addr2line 0x401234가 어떻게 main.cpp:42라고 답할까요. 답은 .debug_line 섹션입니다. PC 주소와 (파일, 줄, 컬럼)의 매핑을 바이트코드 가상 머신으로 압축한 형태.
이 장은 그 VM의 정체를 다룹니다. 상태 머신 레지스터, 표준 opcode, 특수 opcode의 인코딩 트릭, 파일 테이블 구조, 그리고 addr2line이 안쪽에서 이 VM을 어떻게 실행하는지.
#왜 VM인가
main.cpp 한 파일이 수천 PC↔줄 매핑을 생성합니다. 단순 표로 저장하면 수십 KB. 하지만 대부분의 연속 행은 작은 차분만 다릅니다 (PC가 1-2 명령어 진행, 줄이 1-2 줄 변화). 이 패턴을 작은 명령어로 표현하면 1바이트로 한 행 추가 가능 — 압축비 10배 이상.
VM 모델은 1990년대 ANSI C standard committee가 본 그대로 PostScript·PDF의 상태 머신 인코딩 영향을 받았습니다.
#상태 머신 레지스터
VM이 실행되면서 다음 레지스터를 갱신.
| 레지스터 | 초기값 | 의미 |
|---|---|---|
address | 0 | 현재 PC |
op_index | 0 | VLIW의 instruction slot (대부분 0) |
file | 1 | 현재 파일 인덱스 |
line | 1 | 현재 줄 |
column | 0 | 현재 컬럼 |
is_stmt | header.default_is_stmt | ”이 행이 문장 시작인가” |
basic_block | false | basic block 시작 |
end_sequence | false | 시퀀스 끝 |
prologue_end | false | 함수 프롤로그 끝 |
epilogue_begin | false | 함수 에필로그 시작 |
isa | 0 | ISA mode (ARM/Thumb 같은) |
discriminator | 0 | 같은 (file, line)의 구분자 |
DW_LNS_copy 명령이 현재 레지스터 상태를 한 행으로 emit. 그 후 basic_block, prologue_end, epilogue_begin, discriminator가 reset.
#헤더
각 컴파일 유닛에 한 .debug_line 프로그램. 헤더가 VM 동작 파라미터를 정의.
struct LineHeader { uint32_t unit_length; uint16_t version; uint8_t address_size; // DWARF 5 uint8_t segment_selector_size; // DWARF 5 uint32_t header_length; uint8_t minimum_instruction_length; uint8_t maximum_operations_per_instruction; // VLIW uint8_t default_is_stmt; int8_t line_base; uint8_t line_range; uint8_t opcode_base; uint8_t standard_opcode_lengths[opcode_base - 1];
// file/directory tables (DWARF 5는 형식 변경) DirectoryEntry directories[]; FileEntry files[];};핵심 파라미터.
minimum_instruction_length— 명령어 한 단위의 바이트. ARM=2 (Thumb), x86=1.maximum_operations_per_instruction— VLIW (IA-64) 같은 곳에서. 일반 RISC/CISC는 1.default_is_stmt—is_stmt초기값 (보통 1).line_base/line_range— 특수 opcode 인코딩에 사용 (아래).opcode_base— 표준 opcode 개수 + 1.
#File / Directory Table (DWARF 4)
directories[]:
- 0: “src”
- 1: “include”
- 2: “third_party”
files[]:
- 1: { name: “main.cpp”, dir: 0, mtime: 0, size: 0 }
- 2: { name: “utils.h”, dir: 1, mtime: 0, size: 0 }
- 3: { name: “math.cpp”, dir: 0, mtime: 0, size: 0 }
file 레지스터가 1부터 시작 (DWARF 4까지). 0은 무효.
#DWARF 5 변화
DWARF 5는 0번 인덱스도 유효 (CU 자체의 파일을 0번에 둠), 그리고 entry format이 유연. DW_LNCT_path, DW_LNCT_directory_index, DW_LNCT_timestamp, DW_LNCT_size, DW_LNCT_MD5 같은 content type을 정의해 임의 조합 가능.
directory_entry_format:
- DW_LNCT_path DW_FORM_line_strp
file_name_entry_format:
- DW_LNCT_path DW_FORM_line_strp
- DW_LNCT_directory_index DW_FORM_data1
- DW_LNCT_MD5 DW_FORM_data16
MD5 hash가 있어 디버거가 소스 파일의 변경을 검출 가능. list 시 현재 소스 파일이 빌드 시점과 다르다를 경고할 수 있게.
#Opcode 세 가지
#1. 표준 opcode (DW_LNS_*)
opcode_base 미만의 값. 각각이 0개 이상의 LEB128 인자를 가짐.
| Opcode | 인자 | 동작 |
|---|---|---|
DW_LNS_copy | - | 현재 레지스터로 한 행 emit, basic_block 등 reset |
DW_LNS_advance_pc | uleb128 | address += operand * min_inst_len |
DW_LNS_advance_line | sleb128 | line += operand |
DW_LNS_set_file | uleb128 | file = operand |
DW_LNS_set_column | uleb128 | column = operand |
DW_LNS_negate_stmt | - | is_stmt = !is_stmt |
DW_LNS_set_basic_block | - | basic_block = true |
DW_LNS_const_add_pc | - | (255 - opcode_base) / line_range만큼 address 증가 |
DW_LNS_fixed_advance_pc | uhalf | address += operand, op_index=0 |
DW_LNS_set_prologue_end | - | prologue_end = true |
DW_LNS_set_epilogue_begin | - | epilogue_begin = true |
DW_LNS_set_isa | uleb128 | ISA mode (ARM/Thumb) |
#2. 확장 opcode (DW_LNE_*)
0x00 + LEB128 길이 + opcode + 인자. 드물게 쓰이는 명령.
| Opcode | 인자 | 동작 |
|---|---|---|
DW_LNE_end_sequence | - | end_sequence=true, 한 행 emit, 머신 reset |
DW_LNE_set_address | address | address = operand, op_index=0 |
DW_LNE_set_discriminator | uleb128 | discriminator = operand |
DW_LNE_define_file | (DWARF 4 deprecated) | 동적 파일 추가 |
DW_LNE_set_address가 함수 시작 시 절대 주소를 설정하는 데 사용. 이후 DW_LNS_advance_pc로 상대 증분.
#3. 특수 opcode (Special opcode)
opcode_base 이상의 값. 한 바이트로 address 증분 + line 증분 + copy를 모두 수행. 압축의 핵심.
adjusted_opcode = opcode - opcode_baseoperation_advance = adjusted_opcode / line_rangeline_increment = line_base + (adjusted_opcode % line_range)
새 address = address + operation_advance * min_inst_len새 line = line + line_incrementcopy
그 후 basic_block / prologue_end / epilogue_begin / discriminator reset예: opcode_base=13, line_base=-5, line_range=14, min_inst_len=1인 헤더 — 한 바이트로 *(address 증분 0-17, line 증분 -5 ~ +8)*의 거의 모든 조합을 표현.
opcode 0x10 → adjusted = 3 operation_advance = 3 / 14 = 0 → address += 0 line_increment = -5 + (3 % 14) = -2 → line -= 2 copy
opcode 0x18 → adjusted = 11 operation_advance = 11 / 14 = 0 → address += 0 line_increment = -5 + 11 = 6 → line += 6 copy
opcode 0x35 → adjusted = 40 operation_advance = 40 / 14 = 2 → address += 2 line_increment = -5 + (40 % 14) = -5 + 12 = 7 → line += 7 copy1바이트가 세 연산. C 코드의 일반적 패턴 — 한 줄 코드가 평균 3-5 명령어, 다음 줄 — 에 정확히 맞춰 설계됨.
#실행 예 — 짧은 함수
C 소스:
// main.cpp:1int square(int x) { // 1 return x * x; // 2} // 3컴파일러 출력 (x86-64, -O0).
0x401120 push %rbp # prologue0x401121 mov %rsp, %rbp0x401124 mov %edi, -0x4(%rbp)0x401127 mov -0x4(%rbp), %eax0x40112a imul -0x4(%rbp), %eax0x40112e pop %rbp # epilogue0x40112f retDWARF line program (의사 코드).
DW_LNE_set_address 0x401120DW_LNS_advance_line 1 ; line = 2DW_LNS_set_prologue_endDW_LNS_copy ; emit (0x401120, line=2, prologue_end)[Special opcode] ; address += 7, line += 0, copy ; emit (0x401127, line=2)[Special opcode] ; address += 7, line += 1, copy ; emit (0x40112e, line=3)DW_LNE_end_sequence10개 매핑이 약 12바이트.
#addr2line의 안쪽
// 의사 코드int addr2line(elf *e, uint64_t target_pc) { for_each_cu(cu) { line_program *prog = get_line_program(cu); run_vm(prog, [](LineRow row) { if (row.address > target_pc) stop(); // 또는 sequence가 PC 이상 첫 번째 행 직전을 보고 }); }}VM을 실행하면서 각 emit마다 target_pc와 비교. target_pc 이상의 첫 번째 행 직전이 답.
PC file:line0x401120 main.cpp:20x401127 main.cpp:20x40112e main.cpp:3
target = 0x401125 → 0x401120 ≤ 0x401125 < 0x401127 → main.cpp:2target = 0x40112d → main.cpp:2target = 0x4011f0 → 다음 sequence.debug_aranges 인덱스가 있으면 첫 CU 검색이 빠릅니다. 없으면 전체 CU 순회.
$ llvm-dwarfdump --debug-aranges my_prog | head -10.debug_aranges contents:Address Range Header: length = 0x0000002c, format = DWARF32, version = 0x0002 cu_offset = 0x00000000, addr_size = 0x08, seg_size = 0x00[0x0000000000401120, 0x0000000000401200)각 CU의 주소 범위. addr2line이 그 범위만 line program 실행.
#인라인 — DWARF 5의 길
인라인된 함수의 줄 정보는 부모 함수의 line program에 같이 들어갑니다. PC 한 점이 여러 (file, line) 매핑을 가질 수 있어 보이지만 — 실제로는 가장 안쪽 인라인의 source만 line program이 표시.
그렇다면 (inlined by) foo at f.cpp:10 같은 정보는? 그건 .debug_info의 DW_TAG_inlined_subroutine에서 옵니다 (Ch 2).
$ addr2line -e my_prog -f -i -p 0x401134foo at f.cpp:10 (inlined by) bar at g.cpp:30 (inlined by) main at main.cpp:13-i(inline)은 line program + DW_TAG_inlined_subroutine 조합으로 호출 체인 재구성.
#is_stmt — BP 가능 위치
is_stmt = 1인 행만 문장 시작. GDB의 줄 단위 BP는 is_stmt만 후보로 봅니다.
int f() { return a + b; // 이 줄에 BP → 어느 명령에 걸리나?}컴파일러는 최적의 BP 위치를 is_stmt=1로 표시. 보통은 문장 시작 명령어. 인라인이 많은 코드에서는 is_stmt가 적게 표시돼 “BP가 그 줄에 못 걸린다”가 됨.
-Og는 더 많은 위치에 is_stmt=1을 표시 — 디버깅이 쉬워지는 이유.
#Discriminator — 같은 줄의 구분자
C++ 한 줄에 여러 호출이 있을 때:
int x = a() + b() + c(); // line 5세 호출 모두 line 5이지만 다른 명령어. 각 호출 사이트의 discriminator를 다른 값으로 두면 GDB가 구분 가능.
PC file:line:discriminator0x401120 main.cpp:5:0 ← line 시작0x401130 main.cpp:5:1 ← a() 호출 후0x401135 main.cpp:5:2 ← b() 호출 후0x40113a main.cpp:5:3 ← c() 호출 후info line main.cpp:5가 세 후보를 보여 줌. profiler/coverage 도구가 호출 site별 통계를 낼 수 있게 함.
#prologue_end / epilogue_begin
void f() { // prologue: // push rbp; mov rbp, rsp; sub rsp, 16 // ↑ 이 명령들은 함수 진입 setup, *사용자 코드 아님*
do_work(); // ← prologue_end가 *여기* 첫 명령에 marked
// epilogue: // mov rsp, rbp; pop rbp; ret // ↑ epilogue_begin이 *여기* 첫 명령에 marked}break f는 prologue 전이 아닌 prologue_end 첫 명령에 BP. 그래서 인자가 이미 스택에 있는 시점부터 디버깅 가능.
#.debug_line_str — DWARF 5
DWARF 4까지는 line program 안의 파일/디렉터리 이름이 인라인 NUL-terminated. DWARF 5는 .debug_line_str 풀로 분리해 중복 제거.
.debug_line_str:
- 0x00: “main.cpp\0”
- 0x09: “src\0”
- 0x0d: “include\0”
line program:
- directory[0] = strp(0x09) ← “src”
- file[1] = { path: strp(0x00), dir: 0 } ← “src/main.cpp”
큰 프로젝트에서 같은 디렉터리·파일 이름이 수백 CU에 반복되므로 효과 큼.
#직접 디코딩 — pyelftools
from elftools.elf.elffile import ELFFile
with open('my_prog', 'rb') as f: elf = ELFFile(f) dwarf = elf.get_dwarf_info() target = 0x401234
for cu in dwarf.iter_CUs(): lineprog = dwarf.line_program_for_CU(cu) prev = None for entry in lineprog.get_entries(): if entry.state is None: continue s = entry.state if prev and prev.address <= target < s.address: file_idx = prev.file file_entry = lineprog['file_entry'][file_idx - 1] print(f"{file_entry.name.decode()}:{prev.line}") exit() prev = s이 방식으로 자체 crash log 분석 도구를 작성하거나, coverage를 자체 측정.
#다른 도구
# 기본 도구$ addr2line -e my_prog 0x401234main.cpp:42
# 함수 이름 포함$ addr2line -e my_prog -f 0x401234process_datamain.cpp:42
# 인라인 체인까지$ addr2line -e my_prog -f -i -p 0x401234process_data at main.cpp:42 (inlined by) main at main.cpp:60
# 여러 주소 한 번에$ addr2line -e my_prog 0x401120 0x401200 0x401300
# stdin에서$ echo 0x401234 | addr2line -e my_prog
# raw line program 보기$ llvm-dwarfdump --debug-line my_prog | less$ readelf --debug-dump=decodedline my_prog | less$ readelf --debug-dump=line my_prog | less # raw opcode
# Python으로$ python3 -c "from elftools.elf.elffile import ELFFile; ..."#stack trace 후처리
crash log에 PC만 있고 심볼·줄 정보가 없을 때.
crash log:
Backtrace:
- #0 0x0000000000401234
- #1 0x000000000040122a
- #2 0x0000000000401180
$ for addr in 0x401234 0x40122a 0x401180; do echo -n "$addr → " addr2line -e my_prog -f -i -p $addrdone
0x401234 → process_data at main.cpp:42 (inlined by) main at main.cpp:600x40122a → start_handler at handler.cpp:120x401180 → main at main.cpp:20운영에서 stripped 바이너리 + build-id 매칭된 debug 파일로 같은 일을 합니다.
#호환성 — -g1 / line-tables-only
큰 프로젝트에서 전체 DWARF는 매우 큽니다. line program만 필요한 경우 (crash log 분석)에는.
$ clang -gline-tables-only -O2 ....debug_info, .debug_loc, .debug_ranges가 거의 비어 있고 .debug_line만. 크기는 1/10 이하. addr2line은 정상 동작.
print x는 못 하지만 bt의 줄 번호는 나옵니다. 운영 빌드의 표준 옵션.
#DWARF 5의 변화 요약
.debug_line_str추가 → 문자열 풀 분리.- File table 0번부터 유효.
DW_LNCT_*content type → 유연한 file entry format.DW_LNCT_MD5로 소스 무결성 검사.
#정리
.debug_line은 (PC, file, line, …) 행을 표현하는 바이트코드 VM.- 상태 머신 레지스터를 갱신하며
DW_LNS_copy로 한 행씩 emit. - 표준 opcode + 확장 opcode + 특수 opcode (한 바이트로 세 연산).
- 압축비가 매우 높음 — 수천 행이 수십~수백 바이트.
is_stmt가 BP 가능 위치 표시 —-Og로 늘림.discriminator로 같은 줄의 호출 site 구분.prologue_end/epilogue_begin으로 함수 진입/종료 정밀 표시.- addr2line이 VM을 실행하며 PC 검색.
- DWARF 5는
.debug_line_str+ MD5로 한 단계 더 효율.
#다음 장 예고
Ch 4 — .debug_loc / .debug_loclists의 DWARF expression VM. 변수가 어디 있는지를 표현하는 또 다른 바이트코드 머신.
#관련 항목
DWARF and ELF Internals · 3 of 6
- 1ELF 포맷 분해 — Section·Segment·Symbol Table 구조 추적
- 2DWARF 디버그 정보 분해 — DIE 트리와 .debug_abbrev
- 3DWARF .debug_line 분석 — Source-to-PC 매핑 바이트코드 VM
- 4DWARF .debug_loc 분석 — Variable Location Expression VM
- 5DWARF Call Frame Information — .debug_frame과 .eh_frame 분해
- 6DWARF 도구 생태계 — split-DWARF·dwz·debuginfod·pyelftools
관련 글
DWARF 도구 생태계 — split-DWARF·dwz·debuginfod·pyelftools
큰 디버그 정보 다루기. 분리·압축·네트워크 다운로드·자체 도구 작성.
DWARF Call Frame Information — .debug_frame과 .eh_frame 분해
콜스택 풀기의 정체. CIE/FDE, CFA, register rule, .eh_frame_hdr 이진 탐색.
DWARF .debug_loc 분석 — Variable Location Expression VM
변수가 어디 있는지의 표현. 스택 머신 opcode, location list, DW_OP_entry_value.