跪拜 Guibai
← Back to the summary

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

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 is int, 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:

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_abbrev determines 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:

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 add is 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:

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:

mach_o.png

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:

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:

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

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:

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.

dsymutil will, based on the debug map information in the final executable, find the DWARF in each object file and link them into a default .dSYM bundle. 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

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:

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:

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: