Back to course

The Dynamic Symbol Table

15 minintermediateModule 4: SymbolsContinues from macho-symtab
15 min · Module 4: Symbols

Why This Matters

LC_SYMTAB gives you one flat array of nlist_64 entries. dyld cannot scan 2,005 symbols one by one every time it binds a reference — it needs to know, without searching, where the undefined symbols begin, where the exported definitions live, and which symbol each stub slot or GOT entry points at. LC_DYSYMTAB is that index into the same array: no new symbol storage, just ranges and pointers.

This is the Mach-O answer to ELF's split between .symtab and .dynsym — except here both views share one array, and the "dyn" information is this load command's partitions plus the indirect table.

A Simple Model

One 80-byte command (cmd 0xb) holding eighteen u32 fields in five groups:

  1. Three index/count pairs — locals, defined externals, undefined externals. loader.h: the symbol table is "organized into three groups of symbols: local symbols, defined external symbols, undefined external symbols." The three ranges are contiguous and together cover every entry.
  2. toc, modtab, extrefsym — for dylibs only: which module (object file) defines each exported symbol, per-module ranges, per-module references. loader.h: executables and objects are single modules, so these tables are omitted and the sorted extdef range itself serves as the table of contents.
  3. Indirect symbol table — an array of u32 symbol indexes consumed by stub and pointer sections.
  4. extrel / locrel pools — relocation arrays for dylib files; in object and executable files relocations hang off the section headers instead (loader.h says exactly this).

The model's missing piece: an indirect table entry is not a symbol — it is an index into LC_SYMTAB's array (or one of two specials, INDIRECT_SYMBOL_LOCAL = 0x80000000 and INDIRECT_SYMBOL_ABS = 0x40000000). The stub at address X and the nlist_64 at that index are two views of the same call target.

The Actual Detail

dysymtab_command's fields, in file order after cmd/cmdsize: ilocalsym, nlocalsym, iextdefsym, nextdefsym, iundefsym, nundefsym (the three partitions), then tocoff/ntoc, modtaboff/nmodtab, extrefsymoff/nextrefsyms, indirectsymoff/nindirectsyms, extreloff/nextrel, locreloff/nlocrel — eighteen u32s, 8 + 72 = 80 bytes total.

The real command from libasyncProfiler.dylib (x86_64 slice):

            cmd LC_DYSYMTAB
        cmdsize 80
      ilocalsym 0
      nlocalsym 1752
     iextdefsym 1752
     nextdefsym 45
      iundefsym 1797
      nundefsym 218
         tocoff 0
           ntoc 0
      modtaboff 0
        nmodtab 0
   extrefsymoff 0
    nextrefsyms 0
 indirectsymoff 617880
  nindirectsyms 385
      extreloff 0
        nextrel 0
      locreloff 0
        nlocrel 0

Read the partitions as half-open ranges: locals [0, 1752), defined externals [1752, 1797), undefined [1797, 2015). And they must tile the whole table — 1752 + 45 + 218 = 2015 = nsyms from LC_SYMTAB. If that sum ever disagrees with nsyms, the file is corrupt or you are looking at the wrong architecture slice.

Common mistake. Expecting extrel/locrel to be non-zero. Those pools exist for classic dylib relocations, but this modern dylib has nextrel = nlocrel = 0 — 64-bit toolchains rely on rebase/bind info instead, and object-file relocations live in section headers (reloff/nreloc). tocoff/ntoc/modtaboff are 0 too: loader.h documents them as dylib-only, and even here they can be absent when the newer binding paths take over.

A Real Example

The indirect table's 385 entries are exactly the slots three sections need. Each section's reserved1 field is its starting index (loader.h documents this), and entry counts fall out of size divided by slot size:

Section (flags type)reserved1EntriesSlot
__stubs (0x8 = S_SYMBOL_STUBS)0172 (size 0x408 ÷ 6, reserved2 = 6)6-byte jump stub
__got (0x6 = S_NON_LAZY_SYMBOL_POINTERS)17241 (size 0x148 ÷ 8)8-byte pointer
__la_symbol_ptr (0x7 = S_LAZY_SYMBOL_POINTERS)213172 (size 0x560 ÷ 8)8-byte pointer

The arithmetic closes twice: 172 + 41 + 172 = 385 = nindirectsyms, and 172 + 41 = 213 = __la_symbol_ptr's starting index. The ranges also line up with the partitions — otool's first stub entries hold indexes 1797, 1798, 1799... exactly where the undefined partition starts, because lazy stubs jump to symbols this image does not define.

A small file shows the same structure: prog64.macho's LC_DYSYMTAB reads 0/1, 1/5, 6/0 — one local (_helper), five defined globals, nothing undefined. Even a fully resolved static-style executable still carries the command; dyld just has less to do.

Try It

$ otool -arch x86_64 -Iv libasyncProfiler.dylib | head -8
libasyncProfiler.dylib:
Indirect symbols for (__TEXT,__stubs) 172 entries
address            index
0x000000000006cb62  1797
0x000000000006cb68  1798
0x000000000006cb6e  1799
0x000000000006cb74  1800
0x000000000006cb7a  1801

What to look for: the address column walks 6 bytes at a time — one stub per entry, matching reserved2 = 6; and every index it prints lands at or past iundefsym (1797). Dump the command itself with otool -arch x86_64 -l libasyncProfiler.dylib | sed -n '/LC_DYSYMTAB/,/nlocrel/p'.

Check Your Understanding

Without looking back: iundefsym = 1797 and nundefsym = 218. Which index range holds the undefined symbols?

Apply It

__stubs occupies indirect indexes [0, 172) and __got occupies [172, 213). __la_symbol_ptr comes next. What must its reserved1 be, and what is nindirectsyms?

The pattern: the indirect table is one shared, gap-free pool; reserved1 is a running offset, not a count. That is why section order in the file must match the order the linker allocated entries.

Connect

Everything here indexes the array from The Symbol Table — same symoff, same nsyms, just ranged and cross-referenced instead of flat.

Next: Relocation Entries — how the linker learns what to patch while the file is still an object, before any of these dynamic tables matter.