AArch64 Assembly: Build, Link, and Run a Small Program

Assembly source becomes an object file, then a linker combines objects and libraries into an executable or library. Keep these stages distinct. They explain why a source file can assemble successfully yet fail to link, and why a linked program can still be unsuitable for a different operating system. Build and inspect programs you authored or are authorized to analyze; never execute an unknown binary merely to “see what it does.”

A small, portable-at-the-C-ABI function

A good first artifact is a function without startup code, I/O, or syscalls. On common AAPCS64 targets this returns a + 7 when called from C with a 64-bit integer argument. Its public calling convention comes from the ABI, while the file format and symbol decoration come from the target toolchain. The following source is explicitly ELF/GNU assembler syntax; it is not directly portable to Mach-O.

.text
.global add_seven
.type add_seven, %function       // ELF/GNU syntax
add_seven:
    add x0, x0, #7              // argument and result use x0
    ret

For Darwin/Mach-O on Apple platforms, use the corresponding Mach-O spelling below. It deliberately omits ELF-only .type. The C declaration remains add_seven; Mach-O assembly names that external C symbol with the leading underscore _add_seven.

.text
.globl _add_seven
_add_seven:
    add x0, x0, #7
    ret

Compile a known C harness such as extern long add_seven(long); int main(void) { return add_seven(5) != 12; }. Returning a status lets a test framework report success without teaching direct kernel interfaces. On an Apple Arm64 system, an appropriate native build is clang -c add_macho.s -o add_macho.o && clang add_macho.o test.c -o test. Verify the target triple and output format before reusing directives: .type ... %function is familiar in ELF but not a universal assembly language feature.

GNU and LLVM workflows

StageGNU-oriented exampleLLVM-oriented example
Assemble onlyaarch64-linux-gnu-as -o add.o add.sclang --target=aarch64-linux-gnu -c add.s -o add.o
Compile harnessaarch64-linux-gnu-gcc -c test.c -o test.oclang --target=aarch64-linux-gnu -c test.c -o test.o
Link through driveraarch64-linux-gnu-gcc add.o test.o -o testclang --target=aarch64-linux-gnu add.o test.o -o test
Inspect objectaarch64-linux-gnu-objdump -dr add.ollvm-objdump -dr add.o

Using the compiler driver for the final link selects runtime objects, libraries, and the target linker configuration. Calling ld directly is educational only after you understand those dependencies. An object-only build (-c) is safe for syntax, relocation, and disassembly study on any host; it does not need the result to run. Cross-compilation produces an AArch64 object or executable on another host, but does not make it runnable there. A native run is optional and only appropriate on an authorized, compatible AArch64 operating system with the expected runtime loader and libraries.

Targets and object formats

aarch64-linux-gnu conventionally selects Linux/ELF assumptions. An Apple target such as arm64-apple-macos produces Mach-O with Apple SDK, deployment-target, linker, and ABI requirements. The spelling arm64 in an Apple target and aarch64 in GNU tooling normally refer to the same 64-bit architecture family, but they do not imply binary compatibility. Link an object only with matching target objects and libraries. Check facts with file add.o, readelf -h add.o for ELF, or the platform’s Mach-O inspection tools rather than guessing from a filename.

Source-level labels can be local or global. A global symbol lets another object refer to the function; a relocation records a reference whose final address is not known while assembling. Static and dynamic links may add startup objects, PLT/GOT machinery, stubs, code signatures, or platform metadata. Seeing a relocation or stub in disassembly is normal evidence of linking, not proof that source did something suspicious.

Read artifacts before running them

Object inspection is a valuable low-risk checkpoint. Disassemble with relocations shown, list the symbol table, and confirm that the machine field says AArch64 before attempting a final link. In an unlinked object, a branch to another object may display a relocation rather than its finished address. That is expected: the linker chooses final layout. Compare generated assembly or disassembly against the small source routine, but remember that directives, alignment padding, and compiler-added sections are also valid output.

Reproducible commands help distinguish source mistakes from environment mistakes. Record the compiler version, target triple, relevant optimization/debug flags, and whether a sysroot was supplied. A cross compiler needs target headers, libraries, and a linker configuration to build a hosted C executable; assembling one self-contained object needs far less. Do not “fix” missing cross libraries by mixing host x86_64 objects with AArch64 objects. The linker diagnostic is warning about a real binary-format mismatch.

On an authorized native target, a normal test cycle is compile, inspect, run a known harness, then inspect a failure under a debugger if needed. The harness should use deterministic inputs and check a defined result. Avoid a tutorial pattern that downloads, pipes, chmods, or executes opaque content. Assembly expertise includes recognizing that a successful link says nothing about an executable’s trustworthiness.

Options worth understanding

-c stops after compilation/assembly and is the right default for learning object structure. -o names the output. -g requests debug information, while optimization choices can radically change the listing without changing the required C-level result. Pass the target explicitly during cross-compilation; relying on a host default can silently create an object for the wrong machine. An architecture feature option should be selected from the deployment baseline, not merely the developer’s newest CPU.

Keep assembly source, C declarations, and build command in the same review. A mismatch such as declaring an int function in C while returning a 64-bit result in assembly can pass superficial tests and fail later. Compilers can warn about some declaration inconsistencies across a single translation unit, but the linker generally sees symbol names, not full type signatures. A tiny harness with boundary and representative values is cheap insurance.

Static linking versus dynamic linking changes deployment and inspection, but neither removes the ABI contract. A dynamic executable asks a loader to resolve shared-library references at program start or lazily; a static executable incorporates more code at link time. Both need correct target libraries and licensing awareness. Let build systems and package tooling manage dependencies; do not copy loader paths or library files from unrelated systems.

For Apple platforms, use Apple-supported Xcode/Clang SDK workflows and consult deployment-target documentation. For Linux distributions, use the distribution’s cross packages or a deliberately configured sysroot. “AArch64 assembly” is portable at the instruction level only where the selected extension set overlaps; executable startup, linking, signing, and operating-system services are platform-specific.

Clean builds prevent stale objects from masking a source change. When a result surprises you, delete only generated artifacts, rebuild from the recorded commands, and inspect the new object timestamps and headers. Never substitute a downloaded prebuilt object for a source artifact you intend to understand.

Preserve the original source beside any disassembly notes. Addresses change after linking and optimization, while symbol names, relocation records, and the source-to-object relationship provide a durable explanation of the intended program.

Review link commands as carefully as source. Their library search paths, target options, and output format determine what code and metadata become part of the final artifact.

Linux-only syscall note

On Linux AArch64 only, a raw system call convention commonly places a syscall number in x8, arguments in x0x5, and executes svc #0. Numbers are kernel-ABI specific and may differ by architecture. This is deliberately not a cross-platform “hello world” recipe: it does not apply to Darwin/Apple, it bypasses useful C-library behavior, and copying an unchecked number is a poor learning method. Prefer a C harness or an established library API; consult the Linux kernel’s UAPI headers for the exact target when system programming is genuinely required.

Diagnose by stage

SymptomLikely stageConstructive next check
Unknown mnemonic or operandAssemblerConfirm architecture mode and assembler syntax.
Undefined referenceLinkerCheck global symbol spelling and include its object/library.
Wrong ELF class/machineLink or loaderCompare target triples and object headers.
Runs but returns wrong valueProgram/ABIInspect argument width, preserved registers, and disassembly.

Add debug information to a controlled build with compiler options such as -g, then inspect symbols and source interleaving. Debuggers are for your own process and known test inputs. Disassembly is an aid, not a permission mechanism or a guarantee that an unfamiliar program is safe.

Exercises

  1. Assemble add_seven to an object only and locate its symbol and one instruction in disassembly.
  2. Change the function to return a - 3; predict whether the assembler emits an add/sub immediate before inspecting.
  3. Build the C harness with an AArch64 cross target and explain why inspection can succeed even if execution cannot.
  4. Compare the object header of an ELF build with a platform-native Arm64 build; list format differences without linking them together.

For a board-oriented companion, use the existing ARMv8 Khadas/Vim3 lesson; its native commands are not a replacement for checking your own target.

For a native Pi 5 Linux workflow, see Raspberry Pi 5 Assembly: BCM2712, Cortex-A76, and AArch64 Linux.

References