This page teaches the macOS assemble–link–run–debug loop end to end: what each tool actually does, what a Mach-O binary is, why Apple’s platform imposes the handful of rules it does, and enough lldb to interrogate anything the later modules produce. After this module the toolchain is furniture — every later lesson assumes it without comment.
1 · The pipeline: what happens between hello.s and a running process
The assembler translates mnemonics to machine encodings. Anything it can’t resolve — the address of _puts, which lives in libc — becomes a relocation: a note saying “patch this field later.”
The linker merges objects, resolves symbols against libraries (libSystem on macOS — libc, libm and the rest in one), lays out segments, and on Apple Silicon ad-hoc code-signs the result automatically — unsigned arm64 binaries won’t launch, but the toolchain handles it invisibly.
dyld maps the executable and its libraries at load time, applying an ASLR slide — which is why addresses differ run to run, and why position-independent addressing (§4) is mandatory.
Two ways to drive it:
# Driver route — one step, clang orchestrates everything:clang hello.s -o hello# Explicit route — see the seams:clang-c hello.s -o hello.o # assemble onlynm hello.o # symbol table: _main defined, _puts undefined 'U'clang hello.o -o hello # link (driver fronts for ld)
The driver route is the daily loop; the explicit route is Exercise 0.3, and it’s worth doing once because the seam between assembler and linker is where half of all build errors live: “undefined symbol” is a linker complaint, “unknown instruction” is an assembler one, and knowing which tool is talking tells you where to look.
2 · Mach-O in one sitting
macOS’s object format is Mach-O, not Linux’s ELF. For this course the differences that matter fit in a table:
Concept
Linux / ELF (Smith’s world)
macOS / Mach-O (yours)
C-visible symbol names
main, puts
Leading underscore: _main, _puts
Entry declaration
.global main
.global _main
Sections
.text, .data, .bss
Same directives work under clang’s integrated assembler; internally they map to segment,section pairs (__TEXT,__text, __DATA,__data)
Symbol metadata
.type, .size directives
Don’t exist — omit them
Low/high address halves
:lo12:label
label@PAGEOFF (with adrp label@PAGE)
Disassembler
objdump -d
objdump -dandotool -tv — both installed
Debugger
gdb
lldb
Static binaries
routine
effectively not a thing — everything links libSystem dynamically
The underscore rule alone explains the single most common Module 0 failure: .global main assembles fine, then the link fails with “undefined symbol: _main” — the C runtime’s startup code (crt) calls _main, and nothing by that name exists. Exercise 0.6 makes you trip this on purpose so the signature is burned in.
Alignment directives: prefer .p2align n (align to 2ⁿ) — it’s unambiguous. On Mach-O, .align is also power-of-two, which differs from some ELF assemblers where it means bytes; using .p2align sidesteps the trap entirely. Data that later exercises load as words should sit on 4- or 8-byte boundaries.
3 · PIE, ASLR, and why absolute addresses don’t exist
Every macOS executable is position-independent (PIE): the OS maps it at a randomized base each run. Consequences:
Code can never embed “the” address of a symbol; it must compute addresses relative to the PC.
The number lldb shows for _main includes the run’s ASLR slide; otool shows the unslid file offset. They differ by a constant — worth verifying once (Exercise 0.4/0.5) so neither number ever confuses you again.
4 · Addressing: the ADRP + @PAGEOFF idiom
A64 instructions are fixed 32-bit, so no instruction can hold a 64-bit address. The A64 solution is a two-instruction pattern, and on Apple platforms it wears Mach-O syntax:
adrp x0, msg@PAGE ; x0 = 4 KB page containing msg (PC-relative, ±4 GB)add x0, x0, msg@PAGEOFF ; += offset of msg within that page
ADRP computes the target page address (low 12 bits zeroed) relative to the PC — that’s what makes it position-independent — and the ADD supplies the low 12 bits. Together they materialize any address within ±4 GB in two instructions. This pair is the standard way to reach your .data strings and arrays for the entire course; the pattern appears in literally every exercise from 0.2 on.
(When the symbol lives in another library — _printf’s own data, say — the compiler goes through the Global Offset Table with @GOTPAGE/@GOTPAGEOFF instead. Recognize it in disassembly; you rarely write it by hand.)
5 · Reading binaries: the inspection toolkit
Tool
What it answers
objdump -d prog
Disassembly, LLVM-flavored
otool -tv prog
Disassembly, Apple-flavored (-t = text section, -v = symbolic)
nm prog
Symbol table: T defined in text, U undefined (to be found at link/load), D data
size prog
Section sizes — your first flash-budget instinct, portable to firmware
file prog
“Mach-O 64-bit executable arm64” — confirms what you built
otool -L prog
Which dynamic libraries it links (expect libSystem.B.dylib)
Reading disassembly is a course-long skill, so start the habit now (Exercise 0.4): for each instruction, name why it’s there — “materialize the string address,” “save the frame,” “the call,” “the return value.” An instruction you can’t explain is a question worth answering before moving on.
6 · lldb essentials
lldb is the course debugger; the Smith/Plantz gdb workflows map almost one-to-one:
Task
Command (abbreviations in parentheses)
Start
lldb ./prog
Breakpoint on symbol
breakpoint set -n _main (b _main)
Run / continue
run (r) / continue (c)
Step one instruction
stepi (si) — into calls; nexti (ni) — over calls
Registers
register read (all) / register read x0 x29 x30 sp
Flags
register read cpsr — N, Z, C, V live in the top bits
The Module 0 habit that pays compound interest: single-step your program once even when it works. Watching x0 fill with the string page, then the offset, then seeing x30 capture the return address at bl — that’s the mental model the whole of Part II builds on, acquired in five minutes of si.
Two mechanics worth knowing early: w registers read/write the low 32 bits of the x register and zero the top half on write (Module 3 §1 makes this precise); and register read prints cpsr whose bit 31–28 nibble is NZCV — Module 1’s flag exercises read it constantly.
7 · The Makefile that every module copies
make’s one idea: files depend on files; rebuild what’s older than its inputs. The course pattern (Exercise 0.6’s wrap-up):
-g keeps debug info so lldb can map addresses to lines. Recipes are tab-indented (the classic trap). Later modules extend this with C harness objects (clang harness.c routine.s -o test — the driver happily mixes the two), which is all Module 4 needs for mixed-language builds.
8 · Failure signatures — learn them cold
The three deliberate breakages of Exercise 0.6, and what they teach:
Sabotage
Signature
Lesson
.global main (no underscore)
Link error: undefined _main
Mach-O symbol naming; linker-stage error
Bare adr/absolute reference to a far/other-page symbol
Assembler/link relocation error, or an address that’s wrong at run time
Fixed-width instructions can’t hold addresses; PIE forbids absolutes — ADRP exists for a reason
SP misaligned at an external call
Crash (EXC_BAD_ACCESS) inside the callee, often somewhere confusing in libc
Apple enforces 16-byte SP alignment at calls in hardware; the crash site is far from the bug — recognize the pattern
That last row generalizes: ABI violations crash where the rule is relied on, not where you broke it. Half of Module 4’s discipline is this sentence.
Reference shelf for this module: Apple, Writing ARM64 code for Apple platforms (the ABI page cited all course); Smith 1, 3 and Plantz 1, 10 cover the same ground Linux-flavored — optional now that this page exists.