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.
One 24-byte load command pointing at two blobs, both normally inside __LINKEDIT:
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).
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):
| Offset | Size | Field | What it says |
|---|---|---|---|
| 0 | 4 | n_strx | Name's index into the string table; 0 means the empty name |
| 4 | 1 | n_type | N_STAB / N_PEXT / N_TYPE / N_EXT bits |
| 5 | 1 | n_sect | 1-based section ordinal, or 0 (NO_SECT) for absolute/undefined |
| 6 | 2 | n_desc | Reference type, library ordinal (high byte), weak/no-dead-strip bits |
| 8 | 8 | n_value | Address 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).
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 _mainT/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.......
0a 00 00 00 — n_strx = 10: name at string table offset 10 (file offset 12544 + 10).0f — n_type 0x0f = N_SECT | N_EXT: defined, in a section, global. 01 — n_sect = 1, the first section_64 record (__text).00 00 — n_desc = 0: no weak, no ordinal tricks.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.
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.
Without looking back: a nlist_64 has n_type = 0x0f. What kind of symbol is 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.
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.