Module 0 Lessons — Toolchain & First Program

Back to the Course 3 syllabus · Practice: Module 0 exercises

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

flowchart LR
    SRC["hello.s<br/>(A64 assembly text)"] -->|"assembler<br/>(clang -c / as)"| OBJ["hello.o<br/>Mach-O object:<br/>code + relocations +<br/>symbol table"]
    OBJ -->|"linker (ld,<br/>driven by clang)"| BIN["hello<br/>Mach-O executable:<br/>segments, PIE,<br/>ad-hoc signature"]
    BIN -->|"loader (dyld)"| PROC["running process<br/>libraries mapped,<br/>ASLR slide applied"]

flowchart LR
    SRC["hello.s<br/>(A64 assembly text)"] -->|"assembler<br/>(clang -c / as)"| OBJ["hello.o<br/>Mach-O object:<br/>code + relocations +<br/>symbol table"]
    OBJ -->|"linker (ld,<br/>driven by clang)"| BIN["hello<br/>Mach-O executable:<br/>segments, PIE,<br/>ad-hoc signature"]
    BIN -->|"loader (dyld)"| PROC["running process<br/>libraries mapped,<br/>ASLR slide applied"]

  • 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 only
nm 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 -d and otool -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
Memory memory read --size 1 --count 16 0xADDR (x/16bx GDB-style also works)
Disassemble around PC disassemble --pc (dis -p)
Where am I bt (backtrace), frame info
Address → symbol image lookup -a 0xADDR

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):

CC      := clang
CFLAGS  := -g

PROGS   := hello

all: $(PROGS)

%: %.s
    $(CC) $(CFLAGS) $< -o $@

clean:
    rm -f $(PROGS) *.o

-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.

Where the exercises hook in

Lesson section Exercise
§1 pipeline, driver vs. explicit 0.1 — toolchain inventory · 0.3 — explicit route
§2–4 Mach-O, PIE, ADRP 0.2 — hello, driver route
§5 inspection toolkit 0.4 — first disassembly
§6 lldb 0.5 — first lldb session
§8 failure signatures 0.6 — break it on purpose

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.