Standalone Mach-O linker
afs-ld
Source-backed guide to the bespoke ARM64 Mach-O linker: current workflows, dump modes, parity surfaces, and the complete option parser as it exists today.
Open repositoryOverview
afs-ld is the linker half of the armfortas toolchain story: a bespoke ARM64 Mach-O linker that reads objects, archives, dylibs, and TBD stubs, then emits executables or dylibs without leaning on LLVM’s Mach-O stack.
The repo README still describes the project as early scaffolding, but the current source tree is already much richer than that summary. The codebase now includes a full CLI parser, Mach-O and TBD readers, symbol resolution, atomization, layout, relocation application, synthetic link-edit section generation, a writer, and code-signing support. For current status, the source and tests are fresher than the short README status line.
In the parent compiler, armfortas still defaults to the system linker and only opts into afs-ld when AFS_LD_PATH is set. That is the right mental model today: afs-ld is real, substantial, and test-backed, but it is not yet the default production path in the driver.
Build, inputs, and workflows
cargo build -p afs-ld
cargo test -p afs-ld
cargo clippy -p afs-ld --all-targets -- -D warningsThe linker is Mach-O and Apple Silicon specific. In practice that means a working Xcode CLI toolchain and an SDK path you can point at when you want system stubs such as libSystem.tbd.
Link an executable directly
SDK=$(xcrun --show-sdk-path)
target/debug/afs-ld -arch arm64 -e _main -syslibroot "$SDK" -lSystem hello.o target/debug/libarmfortas_rt.a -o helloUse it through the compiler driver
AFS_LD_PATH=$PWD/target/debug/afs-ld target/debug/armfortas hello.o -o helloThat driver-override path is intentionally narrower than the system-ldpath. Shared-library links, -L, -l, -rpath, and -static are still rejected when the driver delegates to afs-ld.
Inspect inputs instead of linking
target/debug/afs-ld --dump hello.o
target/debug/afs-ld --dump-archive libarmfortas_rt.a
target/debug/afs-ld --dump-dylib "$SDK/usr/lib/libSystem.B.dylib"
target/debug/afs-ld --dump-tbd "$SDK/usr/lib/libSystem.tbd"CLI reference
The parser in src/args.rs is hand-rolled and intentionally explicit. It does not use clap. Accepted flags are normalized into a single LinkOptionsstructure before the linker runs.
Core output and target flags
| Flag | Behavior | Notes |
|---|---|---|
<inputs...> | Provide object files, archives, dylibs, or TBD stubs. | No inputs is a usage error; the linker also accepts pure library/framework-driven invocations. |
-o <path> | Write the linked output to a specific path. | If omitted, the internal default is the linker’s own fallback output path. |
-dylib | Switch the output kind from executable to dynamic library. | This is tracked directly in `LinkOptions.kind` and is not a compatibility alias. |
-e <symbol> | Choose the entry symbol. | The armfortas driver currently passes `_main` when it opts into afs-ld. |
-arch arm64 | Select the ARM64 target. | Any other architecture is rejected explicitly. |
-syslibroot <path> | Prefix SDK search roots. | Useful when resolving system stubs and frameworks from an Xcode SDK. |
-platform_version macos <min> <sdk> | Populate LC_BUILD_VERSION for macOS targets. | Only `macos` is accepted today. |
Search, library, and framework flags
| Flag | Behavior | Notes |
|---|---|---|
-l<name> / -l <name> | Resolve and load a named library. | Works with the search-path and syslibroot logic in the linker. |
-L <dir> | Add a library search path. | Paths are stored in `LinkOptions.search_paths`. |
-framework <name> | Link a framework normally. | Framework resolution is a first-class input path, not a driver-only alias. |
-weak_framework <name> | Link a framework as weak. | Recorded as a weak `FrameworkSpec` and carried into dylib load metadata. |
-all_load | Force-load every member from every archive input. | Mirrors Apple `ld` archive-loading behavior for whole-archive style links. |
-force_load <archive> | Force-load every member from one archive. | Useful when only one archive needs whole-archive treatment. |
-ObjC | Accept Objective-C archive loading mode. | Currently only emits a warning; Objective-C archive metadata scanning is not implemented yet. |
-undefined <mode> | Choose unresolved-symbol treatment. | Accepted modes: `error`, `warning`, `suppress`, `dynamic_lookup`. |
-rpath <path> | Add an LC_RPATH entry. | Stored in `LinkOptions.rpaths` and emitted by the writer when relevant. |
-Wl,<arg,arg,...> | Normalize driver-style comma-separated linker flags. | The parser expands this form before regular argument handling. |
Export-surface and dylib identity flags
| Flag | Behavior | Notes |
|---|---|---|
-install_name <path> | Override the dylib install name. | Relevant for dylib output and dylib metadata shaping. |
-current_version <v> | Set the dylib current version. | Parsed as `<major>[.<minor>[.<patch>]]`. |
-compatibility_version <v> | Set the dylib compatibility version. | Uses the same packed version parser as `-current_version`. |
-exported_symbols_list <file> | Export only symbols that match patterns from a file. | Multiple files may be supplied. |
-unexported_symbols_list <file> | Hide symbols that match patterns from a file. | Multiple files may be supplied. |
-exported_symbol <sym> | Export a single symbol or pattern. | Pattern handling follows the linker’s export filter logic. |
-unexported_symbol <sym> | Hide a single symbol or pattern. | Pairs with the exported-symbol controls above. |
Layout, stripping, and optimization-control flags
| Flag | Behavior | Notes |
|---|---|---|
-map <path> | Emit a text link map. | Intended as part of the practical debugging surface, not an afterthought. |
-why_live <symbol> | Print a reachability chain for a symbol. | Useful when dead-strip or retention behavior is surprising. |
-x | Strip local symbols. | Recorded as `strip_locals` in the main `LinkOptions` structure. |
-S | Request debug-symbol stripping. | Currently produces a warning because afs-ld does not emit debug symbols yet. |
-no_uuid | Omit LC_UUID for deterministic output. | Important for byte-for-byte reproducibility and parity work. |
-no_loh | Accept the LOH-disabling compatibility flag. | Currently warns that final-output LOH is already omitted, so the flag has no effect. |
-thunks=none|safe|all | Configure branch-thunk planning. | The linker stores this directly as a `ThunkMode`. |
-dead_strip | Enable dead stripping of unreferenced code and data. | Integrated into the main link pipeline. |
-icf=none|safe|all | Configure identical code folding. | `safe` and `none` are accepted; `all` currently errors explicitly. |
-fixup_chains / -no_fixup_chains | Toggle chained-fixup output mode. | `-fixup_chains` currently errors as unsupported; `-no_fixup_chains` clears the flag. |
-r | Request relocatable output. | Currently rejected as unsupported. |
-bundle | Request bundle output. | Currently rejected as unsupported. |
Dump and tracing flags
| Flag | Behavior | Notes |
|---|---|---|
--dump <path> | Print a Mach-O file summary instead of linking. | Useful for object and final-image inspection. |
--dump-archive <path> | Dump a static archive summary. | Exercises the archive reader directly. |
--dump-dylib <path> | Dump a dylib summary and exports. | Targets MH_DYLIB inputs. |
--dump-tbd <path> | Dump a TAPI TBD summary. | Good for validating system stub parsing. |
-t, -trace | Print input paths as they are loaded. | Useful when archive pulls or framework resolution are opaque. |
--help, -h | Print the usage block and exit. | The usage string in `src/main.rs` is the authoritative CLI summary. |
--version, -v | Print `afs-ld <version>` and exit. | Matches the binary’s top-level package version. |
Architecture pipeline
The architectural summary in .docs/overview.md is concise and still accurate:
args -> inputs -> resolve -> atomize -> layout -> apply relocs -> synth sections -> write -> signConcretely, the repo splits that flow across dedicated modules. args.rsparses the CLI, input.rs and the Mach-O readers classify files, resolve.rs builds the symbol-resolution loop, atom.rs and layout.rsshape the linked image, reloc/ applies ARM64 relocation math, synth/ creates metadata sections, and macho/writer.rs emits the final file.
Two practical details matter for users. First, arm64 is the only supported architecture. Second, executable output is signed ad hoc so the resulting binary can run on modern macOS without a separate manual signing step.
Debugging and parity workflow
The debugging surface is intentionally strong because linker bugs are otherwise miserable to chase. The most important switches are -trace, -map, -why_live, and the various dump modes. For reproducible-output work, -no_uuid is essential.
The tests are built around parity, not just local smoke coverage. The repo includes reader corpus tests, archive and dylib integration tests, parity harnesses, matrix tests, and performance baselines. The project expects afs-ld to be explainable against Apple ld, not just “close enough”.
There is also one environment toggle worth knowing: AFS_LD_VALIDATE_UNWIND_INFO. It enables extra unwind validation during synthesis and is useful when debugging low-level unwind metadata.
Status and gaps
The project is substantial, but the source still marks a few options as intentionally unsupported. -r, -bundle, -fixup_chains, and -icf=all all error explicitly. -S, -ObjC, and -no_loh are accepted but currently warn that they are no-ops or partial compatibility shims.
Non-goals are also narrow on purpose: Mach-O only, arm64 only, and no LTO or foreign object formats. That keeps the scope aligned with the actual armfortas use case on Apple Silicon instead of turning the linker into a generic all-platform project.
The right way to evaluate afs-ld today is by reading the option parser, the main linker entry, and the parity tests together. Those three surfaces show both how far it has come and where it is still gated.