Back to course

The Symbol Table

18 minintermediateModule 4: SymbolsContinues from macho-sections
18 min · Module 4: Symbols

Why This Matters

The bytes on disk say e8 00 00 00 00 — a call to nowhere. The symbol table is what turns that into call _helper. nm prints from it, debuggers set breakpoints through it, crash reports symbolicate with it, and the linker itself resolves cross-file references against it. In ELF terms it is .symtab and .strtab; the difference is how explicitly Mach-O points at them.

Without LC_SYMTAB a file is anonymous machine code. Every "by name" story in this course — binding, exports, relocations, dyld lookups — ultimately indexes this one array.

A Simple Model

One 24-byte load command pointing at two blobs, both normally inside __LINKEDIT:

  1. symoff / nsyms — the symbol table: nsyms fixed 16-byte nlist_64 records, packed back to back.
  2. stroff / strsize — the string table: one NUL-separated pool of names, strsize bytes total.

Each nlist_64 answers: what is the name (n_strx — an index into the string table, not into the file), what kind of symbol is it (n_type), which section is it defined in (n_sect, 1-based, or 0 for none), any extra flags (n_desc), and what is its address (n_value).

The model's missing piece: n_type is not one field but four packed bitfields — nlist.h shows the layout: N_STAB:3, N_PEXT:1, N_TYPE:3, N_EXT:1. Stabs (debug entries) claim the top three bits; N_TYPE selects undefined/absolute/section/indirect; N_EXT is the global/local switch; N_PEXT marks private-external (hidden visibility).

The Actual Detail

loader.h's symtab_command (cmd 0x2, cmdsize 24): symoff, nsyms, stroff, strsize — four u32s after cmd and cmdsize. nlist.h's nlist_64 (16 bytes):

OffsetSizeFieldWhat it says
04n_strxName's index into the string table; 0 means the empty name
41n_typeN_STAB / N_PEXT / N_TYPE / N_EXT bits
51n_sect1-based section ordinal, or 0 (NO_SECT) for absolute/undefined
62n_descReference type, library ordinal (high byte), weak/no-dead-strip bits
88n_valueAddress of the symbol (or size, for commons)

The N_TYPE values from nlist.h: N_UNDF = 0x0 (undefined), N_ABS = 0x2 (absolute), N_INDR = 0xa (alias for another name), N_PBUD = 0xc (prebound), N_SECT = 0xe (defined in section n_sect). Combine with N_EXT = 0x01: 0x0f = N_SECT | N_EXT, the classic "global function/data" byte, and 0x0e is the local version. n_desc's documented bits: REFERENCE_TYPE = 0x7 (lazy vs non-lazy undefined reference), REFERENCED_DYNAMICALLY = 0x0010, N_NO_DEAD_STRIP = 0x0020, N_WEAK_REF = 0x0040, N_WEAK_DEF = 0x0080, and the high byte is GET_LIBRARY_ORDINAL — which library an undefined symbol belongs to (next-but-one concept uses this).

Common mistake. Treating n_strx as a file offset. It indexes the string table: the name's file position is stroff + n_strx. Second trap: a common symbol is n_type 0x01 (N_UNDF | N_EXT) with a non-zero n_value — there n_value holds the size in bytes, not an address (nlist.h's own wording).

A Real Example

prog64.macho's LC_SYMTAB: symoff 12448, nsyms 6, stroff 12544, strsize 72. The whole table is six entries — and nm reads exactly that:

$ nm prog64.macho
0000000100000000 T __mh_execute_header
0000000100000410 T _add
0000000100002004 S _bss_counter
0000000100002000 D _global_counter
0000000100000440 t _helper
0000000100000450 T _main

T/t = text (global/local), D = global data, S = defined outside the standard text/data/bss set — _bss_counter sits in __common. Now the raw bytes of _main's entry (symoff + 16):

000030b0: 0a00 0000 0f01 0000 5004 0000 0100 0000  ........P.......
  1. 0a 00 00 00 — n_strx = 10: name at string table offset 10 (file offset 12544 + 10).
  2. 0f — n_type 0x0f = N_SECT | N_EXT: defined, in a section, global. 01 — n_sect = 1, the first section_64 record (__text).
  3. 00 00 — n_desc = 0: no weak, no ordinal tricks.
  4. 50 04 00 00 01 00 00 00 — n_value = 0x0000000100000450, the address nm printed. Compare _helper: same layout but n_type 0x0e (local), which is why nm shows lowercase t.

Both tables live inside __LINKEDIT (fileoff 12288, filesize 328): strings end at 12544 + 72 = 12616 = exactly the end of the file.

Try It

Unpack any nlist_64 yourself — the _main entry is bytes 12464..12479:

$ python3 -c "import struct; d=open('prog64.macho','rb').read(); print(struct.unpack('<IBBhQ', d[12464:12480]))"
(10, 15, 1, 0, 4294968400)

The five numbers are n_strx, n_type, n_sect, n_desc, n_value — 15 is 0x0f, and 4294968400 is 0x100000450. Same order as the struct in nlist.h, all little-endian, one entry, one function. Cross-check with otool -l prog64.macho | sed -n '/LC_SYMTAB/,/strsize/p' for the four offsets.

Check Your Understanding

Without looking back: a nlist_64 has n_type = 0x0f. What kind of symbol is it?

Apply It

A symbol reports n_strx = 0x0000000a, and LC_SYMTAB says stroff = 12544. Where does its name start in the file?

The pattern: two bases (symoff, stroff), one index rule. That plus little-endian unpacking is the entire parse — nlist.h needs nothing else.

Connect

Each entry's n_sect field points straight back into the Sections you decoded — the section ordinal is nothing more than the position of the section_64 record in load-command order.

Next: The Dynamic Symbol Table — LC_DYSYMTAB slices this same array into locals, defined externals, and undefined symbols, and adds the indirect table that stubs and GOT slots read.