How DWARF, Mach-O, and dSYM Actually Turn a Crash Address Into a Line Number
Foreword
Symbolication is the process of translating a memory address into human-readable function names, source files, and code line numbers.
Before symbolication, addresses in a call stack:
0 APMDEMO 0x0000000104000958
1 APMDEMO 0x00000001040008a0
After symbolication:
0 APMDEMO add(_:_:) main.swift:11
1 APMDEMO main main.swift:15
Before starting, you need to understand some basic concepts:
- DWARF
- dSYM
- Mach-O
DWARF
DWARF is a "standardized debugging data format." It maps compiled machine addresses, registers, and memory locations back to functions, source files, line numbers, variables, and types. It is typically generated by compilers, assemblers, or linkers and read by debuggers and symbolication tools. DWARF Official Standard
It is a data format that records debugging information, allowing you to find the corresponding source code through machine instructions:
Source Code
UserService.swift line 42
loadUser()
↓ compilation
Machine Instruction Address
0x10401234
↑ DWARF stores the correspondence
Its raw content is binary bytes, which can be decoded using dwarfdump/llvm-dwarfdump.
Assume the source code is:
int add(int a, int b) {
int sum = a + b;
return sum;
}
When the CPU executes, it cannot see things like add or sum; it can only see:
Address 0x00: some arm64 instruction
Address 0x0c: some arm64 instruction
Address 0x1c: some arm64 instruction
The compiler uses DWARF to record:
Function add corresponds to address range [0x00, 0x28)
Variable sum is of type int
Variable sum is located at the function frame base position + 4
Address 0x1c corresponds to source file line 3, column 12
Then, through symbolication tools, you can use the address to find:
0x1c
→ located in function add
→ corresponds to line 3, column 12
→ return sum
This is a mapping relationship pre-recorded by the compiler during the build.
Data Structure
Raw DWARF is binary bytes:
00000000000000c8 00000066 08010005 00000000 001d0001
00000000000000d8 08030201 00000000 04000000 00005000
After decoding with dwarfdump:
DW_TAG_subprogram
DW_AT_low_pc [DW_FORM_addrx] (0x0000000000000000)
DW_AT_high_pc [DW_FORM_data4] (0x00000028)
DW_AT_frame_base[DW_FORM_exprloc](DW_OP_reg31 WSP)
DW_AT_name [DW_FORM_strx1] ("add")
DW_AT_decl_line [DW_FORM_data1] (1)
DW_AT_type [DW_FORM_ref4] (0x00000065 "int")
DW_TAG_variable
DW_AT_location [DW_FORM_exprloc](DW_OP_fbreg +4)
DW_AT_name [DW_FORM_strx1] ("sum")
DW_AT_decl_line [DW_FORM_data1] (2)
DW_AT_type [DW_FORM_ref4] (0x00000065 "int")
...
Meanings of several common prefixes:
| Name | Identity and Role |
|---|---|
| DIE | Debugging Information Entry, a record describing an object like a function, variable, or type |
DW_TAG_* |
The kind of record, e.g., function, variable, or type |
DW_AT_* |
Attributes of the record, e.g., name, address, line number, and type |
DW_FORM_* |
The encoding form used for the attribute in binary |
DW_OP_* |
Small expression instructions for computing a variable's location or value |
| CU | Compilation Unit, a set of debugging information corresponding to one compilation input |
For example:
DW_TAG_variable
DW_AT_name ("sum")
DW_AT_type (points to int type)
DW_AT_location (DW_OP_fbreg +4)
The overall meaning is:
This is a variable record, the variable name is
sum, the type isint, and it can be found at the current function frame base position plus 4.
Main Sections
DWARF does not cram all information into one table but splits it into multiple mutually referencing Sections, typically placed in the __DWARF segment in Mach-O.
Apple Mach-O uses __debug_* naming, while the DWARF standard documentation usually writes .debug_*.
| Standard DWARF Name | Name in Mach-O | Role |
|---|---|---|
.debug_info |
__DWARF,__debug_info |
Main DIE tree, describing functions, variables, parameters, types, and scopes |
.debug_abbrev |
__DWARF,__debug_abbrev |
Format dictionary for .debug_info |
.debug_line |
__DWARF,__debug_line |
Mapping of machine addresses to source files, line numbers, and column numbers |
.debug_line_str |
__DWARF,__debug_line_str |
File name and directory strings used by the line number table |
.debug_str |
__DWARF,__debug_str |
Common string pool, holding function names, variable names, type names, etc. |
.debug_str_offsets |
__DWARF,__debug_str_offs |
Maps string numbers to .debug_str offsets |
.debug_addr |
__DWARF,__debug_addr |
Address table that other records can reference by index |
.debug_names |
__DWARF,__debug_names |
Fast lookup index from name to DIE |
.debug_loclists |
__DWARF,__debug_loclists |
Where a variable is located across different instruction ranges |
.debug_rnglists |
__DWARF,__debug_rnglists |
Non-contiguous code ranges corresponding to functions or scopes |
.debug_frame |
__DWARF,__debug_frame |
Call frame information for restoring caller registers and supporting stack unwinding |
.debug_aranges |
__DWARF,__debug_aranges |
Optional index for quickly locating a compilation unit from an address |
.debug_macro |
__DWARF,__debug_macro |
Macro definitions, undefinitions, and file relationships |
They roughly collaborate like this:
.debug_abbrev
Format dictionary
↓
.debug_names ───────→ .debug_info ───────→ .debug_line
Name index Program entity body Address—source location
│ │
│ └→ .debug_line_str
│
├→ .debug_str_offsets → .debug_str
├→ .debug_addr
├→ .debug_loclists
└→ .debug_rnglists
.debug_frame: Relatively independently provides stack frame restoration rules
__debug_info
Stores:
- Functions
- Parameters
- Local variables
- Types
- Struct members and offsets
- Namespaces
- Lexical scopes
- Inlined functions
- Function address ranges
- Declaration locations
- Variable location rules
It is the true program description and the most core Section of DWARF.
__debug_abbrev
Encoding templates.
If every DIE repeated:
This is a function
Has child nodes
Attribute one is name
Attribute two is low_pc
Attribute three is high_pc
……
The data would be very large.
Therefore, __debug_abbrev first defines templates:
Template 2:
Type = DW_TAG_subprogram
Has child nodes
Attributes = name, low_pc, high_pc, type……
A DIE in __debug_info only needs to record "I use template 2" and the specific attribute values.
So:
__debug_abbrevdetermines how to decode__debug_info; the two must be read together.
__debug_str
Shared strings.
Function names, type names, compiler names, and paths are heavily repeated.
__debug_info can avoid storing the full string directly and instead save only an index or offset:
DW_AT_name → item N in the string table
Then, through __debug_str_offsets, find the actual content in __debug_str.
__debug_ranges / __debug_rnglists
After optimization, the machine instructions corresponding to a function or scope may not be contiguous.
For example, a function's hot path and cold path might be placed separately:
Function foo:
0x1000~0x1050
0x3000~0x3020
In this case, a single pair of low_pc/high_pc is insufficient; an address range list is needed.
DWARF 5 uses .debug_rnglists to replace the previous version's .debug_ranges.
__debug_loclists
It describes:
Within a certain machine address range, where the debugger should go to find this variable.
For example, after optimization, the variable count might experience:
0x1000~0x1010: in register x0
0x1010~0x1040: on the stack at SP + 32
0x1040~0x1050: unrecoverable, has been optimized away
DWARF stores this kind of lookup rule, not the actual value of count at the time of a crash.
__debug_names
Name index.
For example, when a debugger searches for add, it can first find the corresponding DIE in __debug_names without scanning the entire __debug_info.
It mainly solves:
Function name -> DIE
It is an acceleration structure, not the entirety of the debugging information itself.
__debug_line
Line number program.
It does not simply store:
Address A → line 7
Address B → line 8
It stores a small instruction sequence defined by DWARF, called a Line Number Program.
When executing these instructions, a set of state is maintained, such as:
- Current machine address
- Current file
- Current source line
- Current column
- Whether it is suitable as a breakpoint
- Whether it is a function prologue or epilogue
For example:
Address Line Column
0x100000378 7 0
0x100000380 8 24
0x100000384 8 34
0x100000388 8 26
0x10000038c 8 9
0x100000390 9 12
Therefore, querying 0x100000388 yields:
Function: add
File: <stdin>
Line: 8
Column: 26
The Difference Between .debug_info and .debug_line
This is the most easily confused point.
.debug_info might contain:
DW_AT_name ("add")
DW_AT_decl_line (1)
Here, line 1 means:
The function
addis declared at line 1.
.debug_line might contain:
Address 0x1c → Line 3, Column 12
Here, line 3 means:
When the CPU executes to address
0x1c, it corresponds to source code line 3.
Therefore:
DW_AT_decl_line: Where is this function or variable declared?
.debug_line: Which line of source code is currently executing at this machine address?
Recovering the source line from a crash address mainly relies on .debug_line.
Mach-O
Mach-O is a binary file format used on Apple/Darwin platforms.
The problem it solves is: after compilation, a string of machine instructions is not enough; the system must also know:
- Whether these instructions belong to
arm64orx86_64 - Whether the file is an executable program, dynamic library, or object file
- Which bytes should be mapped to which memory locations?
- Which regions are readable, writable, or executable
- Which dynamic libraries the program depends on
- Where the program starts execution
- Which addresses need adjustment at load time
- Where the symbol table, code signature, and debugging information are located
Mach-O is the "binary container format" that organizes this information.
Objective-C / Swift / C
↓ compilation
Machine instructions
↓ organized in Mach-O format
Mach-O file
↓ parsed and loaded by the system
Executable image in memory
Overall Structure
A single-architecture Mach-O, also called a thin Mach-O, can be simplified as:
In a real file, the specific positions of each block are determined by offsets and sizes in the Load Commands.
Also, the file range of __TEXT usually starts from offset 0, so the Mach Header and Load Commands often fall within the mapping range of __TEXT as well.
| Part | Role |
|---|---|
| Mach Header | States what architecture and type the file is |
| Load Commands | Describes what content exists, where it is located, and how it should be loaded |
| Segment | States how a section of file data maps to memory |
| Section | States what a small block of data within a Segment is |
__TEXT |
Machine code, read-only constants, stack unwinding info |
__DATA |
Writable global data |
__DWARF |
Debugging information, including address-to-source-line mappings |
__LINKEDIT |
Auxiliary data like dynamic linking, symbols, string tables, code signature |
Mach Header
64-bit Mach-O uses mach_header_64, which is 32 bytes in size, with fields including:
mach_header_64
├── magic Whether it is a 64-bit Mach-O
├── cputype CPU architecture
├── cpusubtype CPU subtype
├── filetype File type
├── ncmds Number of Load Commands
├── sizeofcmds Total size of all Load Commands
└── flags File characteristics
Common filetype:
| External Form | Mach-O Type | Role |
|---|---|---|
Compiler-generated .o |
MH_OBJECT |
Object file not yet fully linked |
| Main program inside App | MH_EXECUTE |
Main executable that can be launched |
.dylib |
MH_DYLIB |
Dynamic library |
| Loadable plugin | MH_BUNDLE |
Code module loaded at runtime |
| DWARF file inside dSYM | MH_DSYM |
Only stores companion debugging info |
Mach-O does not only represent executable programs; many different files can be Mach-O.
Here are a few easily confused points:
.appis a directory bundle, not a Mach-O file; the main program inside it is the Mach-O.frameworkis also a directory structure; the main binary inside a dynamic Framework is usuallyMH_DYLIB.ais an archive container, usually holding multiple Mach-O.ofiles.dSYMis a directory bundle; the file inside that actually holds DWARF is still a Mach-O, of typeMH_DSYMMH_BUNDLEis not equal to.app bundle; here it refers to a dynamically loadable Mach-O type
Load Commands
Load Commands are the "table of contents and loading instructions" in the file.
For example:
LC_SEGMENT_64:__DWARF
├── Position and size of __debug_info
├── Position and size of __debug_line
└── Position and size of __debug_str
Their relationship with the actual data is:
Section descriptions in Load Commands
│
│ offset + size
▼
Actual Section data in the file
Segment and Section
Segment is the memory mapping unit, Section is the data classification unit.
The full name is usually written as:
Segment name, Section name
For example:
__TEXTis a Segment, containing code and read-only data__textis the Section within__TEXTthat holds machine instructions__DWARFis an optional Segment__debug_lineis the Section within it that holds the address-to-source-line mapping
Universal / Fat Mach-O
A single file can also contain multiple CPU architectures simultaneously:
┌─────────────────────────────────┐
│ Fat Header │
│ Records the position and size of each architecture │
├─────────────────────────────────┤
│ arm64 Mach-O slice │
│ ├── Mach Header │
│ ├── Load Commands │
│ └── Segment Data │
├─────────────────────────────────┤
│ x86_64 Mach-O slice │
│ ├── Mach Header │
│ ├── Load Commands │
│ └── Segment Data │
└─────────────────────────────────┘
Each slice is an independent, complete Mach-O; the outer Fat Header is only responsible for packaging them into one file.
How It Is Produced and Run?
Source code
↓ Compiler, Assembler
Mach-O .o (MH_OBJECT)
↓ Linker ld
Final Mach-O (MH_EXECUTE / MH_DYLIB)
↓ Launcher
Kernel identifies Mach-O
↓
dyld reads Load Commands
↓
Maps Segments, loads dynamic libraries, fixes addresses
↓
Enters program entry point
- The compiler converts source code into machine instructions
.ois already a Mach-O, but cannot yet run as a complete program- The linker merges multiple
.ofiles to produce the final Mach-O dyldis the dynamic loader provided by Apple, used to read Mach-Odyldloads dependent libraries and fixes runtime addresses according to Load Commands
dSYM
dSYM is an external debugging information package for a binary file generated from a specific build.
It does not participate in the App's execution, nor is it responsible for capturing crashes. Its role is to stay on the development side, for use by debuggers or symbolication services, to restore hexadecimal addresses in a crash back to:
- Function names
- Source files
- Source line numbers
- Inlined call relationships
Overall Structure
Although often referred to as a "dSYM file," it is actually a directory bundle:
MyApp.app.dSYM
└── Contents
├── Info.plist
└── Resources
└── DWARF
└── MyApp
| Part | Role |
|---|---|
| Myapp.app.dSYM | Outermost directory bundle |
| Info.plist | Basic metadata for the dSYM bundle |
| Resources/DWARF/MyApp | The binary file that truly holds the debugging information |
The MyApp inside is a Mach-O of type MH_DSYM:
MyApp.app.dSYM
└── Contents/Resources/DWARF/MyApp
┌──────────────────────────────┐
│ Mach Header │
│ filetype = MH_DSYM │
├──────────────────────────────┤
│ Load Commands │
│ LC_UUID │
│ LC_SEGMENT_64:__DWARF │
├──────────────────────────────┤
│ __DWARF │
│ ├── __debug_info │
│ ├── __debug_line │
│ ├── __debug_str │
│ ├── __debug_abbrev │
│ └── ... │
└──────────────────────────────┘
Apple's Mach-O definition describes MH_DSYM as a companion file containing only debug sections.
So:
dSYM is a directory bundle, and the bundle uses a Mach-O file to carry the debugging data.
How It Is Generated
The basic generation flow is:
Source files
↓ compilation
Multiple .o object files
Each .o carries debugging information fragments
↓ linking
Final executable file
Also retains information on "which .o corresponds to the final address"
↓ dsymutil
Collects debugging info from each .o
Merges and adjusts according to the final linked addresses
↓
MyApp.app.dSYM
dsymutil is a command-line tool provided by the LLVM/Xcode toolchain.
It is only responsible for collecting and linking debugging information after linking is complete; it does not compile source code, run programs, or capture crashes.
dsymutilwill, based on the debug map information in the final executable, find the DWARF in each object file and link them into a default.dSYMbundle. LLVM: dsymutil
It can be understood like this:
Foo.o
├── Debug info for foo()
└── Temporary address of foo() in Foo.o
Final linked result
└── foo() was placed at final address 0x100003400
dsymutil
└── Generates the final debug map:
0x100003400
→ foo()
→ Foo.m
→ line 42
So the address information in the dSYM corresponds to the final linked result, not the temporary, unlinked positions in some .o.
When Does Xcode Generate It?
Search for Debug Information Format in Build Settings; the default setting under Release is:
DWARF with dSYM File
The directory structure after Archive:
MyApp.xcarchive
├── Products
│ └── Applications
│ └── MyApp.app
└── dSYMs
└── MyApp.app.dSYM
- Debug builds can keep debug symbols inside the build product by default
- Release builds typically put debug symbols into a companion dSYM to reduce the size of the distributed App
- When archiving, Xcode saves the binary and the corresponding dSYM together into the
.xcarchive
How dSYM Matches a Build
The most important identity information for a dSYM is the build UUID.
If the App binary has:
UUID: A12B3456-7890-1234-5678-ABCDEF123456 (arm64)
Then the corresponding dSYM must be:
UUID: A12B3456-7890-1234-5678-ABCDEF123456 (arm64)
Only when the UUIDs of the two match can they be considered paired.
Even if rebuilt from the same source code, if the toolchain or build settings change, the generated binary and UUID may differ, and the old dSYM cannot be used for the new binary.
One App, Multiple dSYMs
An App bundle may have multiple dSYMs:
Main App Mach-O → Main App's dSYM
Dynamic Framework → Framework's own dSYM
App Extension → Extension's own dSYM
Other dynamic libraries → Their respective dSYMs
Each binary — main program, Framework, App Extension — has its own dSYM.
For multi-architecture binaries, a single dSYM may also contain multiple architecture slices, each with its own UUID.
The Relationship Among the Three
| Concept | Essence | Problem Solved |
|---|---|---|
| DWARF | Data format for debugging info | How to represent functions, variables, source files, line numbers, and address mappings |
| Mach-O | Binary container format for Apple platforms | How to organize code, data, loading info, and optional debugging info |
| dSYM | External debugging info package for a specific binary | How to independently save, archive, and upload the debugging info for that binary |
In terms of structural relationship:
The main program in an App:
MyApp.app
└── MyApp ← Mach-O executable
├── Mach Header
├── Load Commands
│ ├── LC_UUID
│ ├── LC_SEGMENT_64:__TEXT
│ ├── LC_SEGMENT_64:__DATA
│ └── ...
├── __TEXT ← Machine code
├── __DATA ← Runtime data
├── __LINKEDIT ← Linking, symbol, signature info
└── __DWARF ← Optional
The corresponding dSYM:
MyApp.app.dSYM ← dSYM directory bundle
└── Contents
└── Resources
└── DWARF
└── MyApp ← Mach-O of type MH_DSYM
├── Mach Header
├── Load Commands
│ ├── LC_UUID
│ └── LC_SEGMENT_64:__DWARF
└── __DWARF
├── __debug_info
├── __debug_line
├── __debug_str
├── __debug_abbrev
└── ...
So it is actually two companion Mach-Os:
Runtime Mach-O Debugging Mach-O
MyApp dSYM/.../DWARF/MyApp
├── LC_UUID = A ├── LC_UUID = A
├── __TEXT └── __DWARF
├── __DATA ├── __debug_info
└── __LINKEDIT ├── __debug_line
└── ...
They correspond via UUID.
So:
The outer layer of a dSYM is a directory bundle, and inside it is still a Mach-O; this inner Mach-O primarily carries DWARF.
In a Release build:
Source code
│
│ compilation
▼
Multiple .o files
Each .o is itself a Mach-O
├── Machine code fragments
└── DWARF debug info fragments
│
│ linking
▼
Final App Mach-O
├── Final machine code
├── Final address layout
└── Debug map info
│
│ dsymutil collects and links DWARF from each .o
▼
MyApp.app.dSYM
└── Internal MH_DSYM Mach-O
└── DWARF corresponding to final addresses
So, three stages:
- The compiler produces DWARF
- The linker produces the final Mach-O address layout
dsymutilproduces the dSYM corresponding to that address layout
Symbolication
Assume your crash report contains:
Runtime address: 0x104001234
Image load address: 0x104000000
Image UUID: A12B...
The link base address of the corresponding App Mach-O is:
0x100000000
First, calculate the ASLR slide, because each run has a random address offset, which can be calculated from the runtime load address and the base address:
ASLR slide
= 0x104000000 - 0x100000000
= 0x04000000
Then you can get the link-time address:
0x104001234 - 0x04000000
= 0x100001234
Then, based on the UUID (which is in the Mach-O), find the corresponding dSYM. Once found:
__debug_info
└── 0x100001234 belongs to function sendRequest()
__debug_line
└── 0x100001234 corresponds to FRNetworkManager.m:128
Finally, you get:
0x104001234
→ -[FRNetworkManager sendRequest:]
→ FRNetworkManager.m:128
Thus, the division of labor among the three is:
Mach-O
└── Confirms which image this is, and completes the runtime address conversion
dSYM
└── Provides independent debugging data for this specific build
DWARF
└── Interprets the link address as a function, file, and line number
Summary
The essence of symbolication is using the debug mapping generated in advance during the build phase to reverse-translate runtime machine addresses back into function names, source files, and code line numbers.
Three core concepts:
| Concept | Core Role |
|---|---|
| DWARF | Specifies how debugging info like functions, types, variables, addresses, and source line numbers is encoded |
| Mach-O | Organizes machine code, runtime data, loading info, and optional debugging data for Apple platforms |
| dSYM | Independently saves the DWARF debugging info corresponding to a specific Mach-O build |
They form the following relationship during the build phase:
Source code
↓ compilation
Mach-O .o
├── Machine code fragments
└── DWARF debug info fragments
↓ linking
Final App Mach-O
├── Final machine code
├── Final address layout
└── Build UUID
↓ dsymutil collects and links DWARF
dSYM directory bundle
└── Mach-O of type MH_DSYM
├── Same build UUID
└── DWARF corresponding to final addresses
After the App runs, the Mach-O is loaded as an image in the process. Due to ASLR, the call stack records runtime addresses, while DWARF describes link-time addresses, so symbolication requires the following data:
- Runtime addresses in the call stack
- Runtime load address of the image
- Link base address of the Mach-O
- CPU architecture and UUID of the image
- dSYM matching the UUID and architecture
The complete symbolication process is:
Runtime address
↓ Determine the owning image based on address range
Read image load address, link base address, and UUID
↓
ASLR slide = runtime load address - link base address
↓
Link address = runtime address - ASLR slide
↓ Select the correct dSYM based on UUID and architecture
Read the DWARF within it
↓
__debug_info determines function, scope, and inlining relationships
__debug_line determines source file, line number, and column number
↓
Function name + source file + code line number
Thus, the final division of labor among the three:
Mach-O
└── Describes the binary image, address layout, architecture, and UUID
dSYM
└── Saves the external debugging data corresponding to this specific build
DWARF
└── Interprets the link address as function, file, line number, and variable information
Points to note:
- Mach-O may not contain complete DWARF
- dSYM is not the DWARF format itself, but a directory bundle carrying DWARF
- dSYM is not responsible for capturing crashes, nor does it participate in the normal operation of the App
- Matching file names, version numbers, and source code does not prove a dSYM match
- The binary and dSYM must have the same architecture and build UUID
- The declaration line number in
__debug_infois not equal to the crash execution line number; mapping runtime addresses to source lines mainly relies on__debug_line - Optimization may cause function inlining, code reordering, or variable elimination, so even with a correct dSYM, restoring all source-level state is not guaranteed