Raspberry Pi 5 Assembly: BCM2712, Cortex-A76, and AArch64 Linux
The Raspberry Pi 5 is an inviting native machine for learning AArch64 assembly: its BCM2712 system-on-chip contains a quad-core Arm Cortex-A76 application processor, and current Raspberry Pi OS can provide a conventional 64-bit Linux development environment. This is a study of Linux user-space assembly, not a promise that every board behavior is a CPU-core behavior. Boot firmware, the kernel, memory controller, RP1 I/O controller, GPU, drivers, power management, cooling, storage, and other peripherals all contribute to what a Pi 5 does. Start with a small function called by C, measure it honestly, and leave hardware access to documented kernel interfaces.
Establish the machine and operating-system facts
“Arm”, “ARM64”, and “aarch64” are often used loosely. Before choosing assembler options, ask the running system rather than relying on the board label. These commands report complementary facts: the kernel machine name, the C library’s configured architecture, and CPU information as the kernel exposes it.
$ uname -m
aarch64
$ getconf LONG_BIT
64
$ lscpu
# Look for Architecture: aarch64, CPU(s), and the model information supplied by Linux
uname -m says what architecture the running kernel reports; it does not by itself prove every installed executable is 64-bit. getconf LONG_BIT reports the width of C long in the current environment; it is not evidence of pointer width or of every installed library’s ABI. lscpu is a readable summary, but its fields come from the kernel and can vary by kernel version or virtualization. For an individual binary, use file program or readelf -h program; an ELF header makes its class and machine explicit. When compatibility matters, check each executable, its ELF interpreter with readelf -l program, and the relevant shared libraries rather than inferring them from one process-level command. Record these outputs beside benchmark notes.
| Question | Useful check | What it establishes |
|---|---|---|
| Running kernel architecture | uname -m | Usually aarch64 on a 64-bit Pi OS kernel |
Current C long width | getconf LONG_BIT | Whether this environment reports a 64-bit C long, not pointer or library ABI width |
| CPU summary | lscpu | Kernel-reported architecture, online CPUs, caches, and model fields |
| Object format and target | readelf -h file.o | ELF class and Machine: AArch64 for an AArch64 object |
Use a 64-bit operating system and 64-bit userland for the AArch64 source in this article. A Pi 5 can run a 64-bit kernel with a 32-bit userland in some configurations; that is not the same programming environment. A 32-bit process uses the AArch32 ABI, 32-bit pointers, different register naming, and a different syscall ABI. It cannot simply assemble this AArch64 source and link it against 32-bit libc. Conversely, a 64-bit executable needs an AArch64 dynamic loader and matching 64-bit libraries. Check the executable and libraries instead of inferring compatibility from the CPU.
The BCM2712’s Cortex-A76 cores implement the AArch64 instruction set used here, including the baseline Advanced SIMD/NEON facility. That tells us about instruction execution, not direct ownership of board peripherals. In particular, Pi 5 GPIO is mediated by the RP1 I/O controller and Linux drivers. A user program should use a maintained GPIO library or kernel character-device interface appropriate to the installed OS; it must not map guessed or hardcoded physical addresses from an assembly example.
Tooling already on a working OS
This lesson assumes Raspberry Pi OS or another supported Linux installation is already booted and updated; it does not duplicate operating-system installation. On a Debian-family userland, install development tools through the distribution package manager, then identify the commands actually installed. A compact native tool set is:
$ sudo apt install build-essential binutils gdb clang lld llvm
$ gcc --version
$ clang --version
$ as --version
$ gdb --version
build-essential normally supplies GCC, libc headers, and make; binutils supplies GNU as, ld, readelf, and objdump. LLVM provides Clang and tools such as llvm-objdump and llvm-readelf. Package names can differ across distributions, so ask the package manager rather than inventing substitutes. Native gcc, clang, and GNU as on a 64-bit Pi normally target AArch64 Linux; confirm with gcc -dumpmachine or clang -print-target-triple.
Keep the assembler, linker, compiler driver, and debugger conceptually separate. GNU as translates GNU-syntax assembly to an ELF relocatable object. Clang can also assemble a .s file when passed -c. A compiler driver such as gcc or clang should normally perform the final hosted link because it selects the C runtime startup objects, the dynamic loader setting, libc, and other target details. Calling ld directly is valuable only after those dependencies are understood; an apparently simple direct invocation commonly omits startup code or libraries.
The AAPCS64 contract in daily use
AArch64 registers have both architectural names and ABI roles. The base AAPCS64 procedure-call standard makes separately built C and assembly interoperable. Integer and pointer arguments arrive in x0 through x7; an ordinary integer or pointer result returns in x0. The lower 32-bit views are w0 through w30: writing a w register zero-extends into its corresponding x register. Floating-point and vector arguments use v0 through v7. Difficult types—variadic arguments, structures, homogeneous aggregates, and large results—need the full specification, not a guessed register layout.
| Registers | Working ABI rule | Practical implication |
|---|---|---|
x0–x7 | Argument/result and caller-clobbered | Save a needed value before making a call. |
x9–x17 | Temporary; some have linker/veneer uses | Do not expect them to survive an external call. |
x19–x28 | Callee-saved | A function that changes one must restore its incoming value. |
x29, x30 | Conventional frame pointer and link register | bl writes the return address to x30. |
sp | Stack pointer, 16-byte aligned | Keep it aligned at public interfaces and whenever used for memory access. |
bl target branches to a function and records the return address in link register x30. ret normally returns through that register. A leaf function that makes no calls can often just compute a result and ret. A non-leaf function must preserve its incoming LR before it executes another bl, and must restore any callee-saved registers it changes. A conventional frame starts with stp x29, x30, [sp, #-16]!, establishes x29, and reverses this with ldp x29, x30, [sp], #16. Do not allocate eight bytes and then use the misaligned sp for a save.
A safe callable function and C harness
Use a function with no I/O, no allocation, and no privilege requirement. The source below returns the sum of four signed 64-bit integers. Its arithmetic wraps modulo 264 in registers; the C harness uses values whose mathematical sum is representable, so it does not rely on signed-overflow behavior. It touches only caller-clobbered registers, needs no stack frame, and is easy to inspect.
// sum4.s — GNU/ELF AArch64 syntax
// long sum4(long a, long b, long c, long d);
// x0=a, x1=b, x2=c, x3=d; return x0. Leaf function.
.text
.global sum4
.type sum4, %function
sum4:
add x0, x0, x1 // a + b
add x0, x0, x2 // + c
add x0, x0, x3 // + d
ret
.size sum4, .-sum4
.section .note.GNU-stack,"",%progbits
/* test_sum4.c */
#include <stdio.h>
extern long sum4(long, long, long, long);
int main(void) {
long got = sum4(10, -3, 20, 5);
if (got != 32) {
fprintf(stderr, "sum4 returned %ld, expected 32\n", got);
return 1;
}
puts("sum4 test passed");
return 0;
}
Build through the C driver, which makes this a normal libc-hosted executable:
$ gcc -g -Wall -Wextra -c sum4.s -o sum4.o
$ gcc -g -Wall -Wextra -c test_sum4.c -o test_sum4.o
$ gcc sum4.o test_sum4.o -o test_sum4
$ ./test_sum4
sum4 test passed
The final .note.GNU-stack directive declares that this GNU/ELF assembly object has no executable-stack requirement. Keep it in copyable GNU/ELF source; it lets the linker make an informed stack-permission decision. A link option such as -Wl,-z,noexecstack can be retained as defense in depth, but is not a substitute for correct object metadata. The declaration and symbol spelling are part of the interface. The C compiler knows the argument types, but the linker mostly matches the external name sum4; it cannot prove that hand-written assembly honors the declaration. Keep C prototype, register comment, and test together. For an unsigned modular API, declare unsigned long consistently in both places. Avoid using this example as a template for a function that calls printf: such a function needs to preserve LR and observe the complete ABI.
ELF, libc calls, and raw syscalls are different layers
sum4.o is an ELF relocatable object. It contains sections, symbols, machine code, and possibly relocations; it is not an independently runnable program. The final link combines it with the C object and, through the compiler driver, startup objects and libc. The harness calls puts and fprintf as libc functions. Their calling convention is AAPCS64, while the dynamic linker may arrange a PLT/GOT indirection for an external symbol. That is ordinary hosted Linux programming.
A raw Linux syscall is a separate interface between a process and the Linux kernel. On Linux AArch64, the documented convention uses x8 for a syscall number, x0–x5 for up to six arguments, and svc #0 to enter the kernel. Syscall numbers and error conventions are Linux-kernel ABI details, not AAPCS64 function calls and not portable to other operating systems. This article intentionally does not turn them into a “hello world” startup recipe. Prefer libc or a documented library API in a normal program; if systems work truly requires a syscall, obtain the number and argument structure from the installed target’s Linux UAPI headers and current kernel documentation, handle errors, and keep it separate from C-library assumptions.
Position-independent addressing
Modern Linux executables are commonly built as position-independent executables (PIE), and shared libraries must tolerate loading at a runtime-selected address. Do not materialize an assumed absolute address for your own static data. For a nearby symbol in the same ELF object, the usual AArch64 sequence computes a page-relative address with adrp, then adds the low twelve bits:
.section .rodata
message:
.asciz "local data"
.text
.global message_ptr
.type message_ptr, %function
message_ptr: // const char *message_ptr(void)
adrp x0, message
add x0, x0, :lo12:message
ret
.section .note.GNU-stack,"",%progbits
The assembler and linker record and resolve the appropriate relocations. A symbol in another object or shared library can require GOT-based addressing, and compiler-generated code is often the safest way to learn the exact sequence for a build mode. Do not substitute a numeric address copied from objdump: ASLR, PIE, link layout, and future rebuilds can change it. Compile C with -fPIE or build a shared library with -fPIC when the project’s build rules require it; inspect the emitted relocations rather than assuming every reference uses the same form.
Inspect first: readelf, objdump, and GDB
Object inspection makes assembly concrete without requiring privileged access. Run the following after assembling the small example:
$ readelf -h sum4.o # class, machine, type
$ readelf -S sum4.o # sections such as .text
$ readelf -s sum4.o # symbol table; find sum4
$ readelf -r sum4.o # relocations, if this object has any
$ objdump -dr sum4.o # disassembly plus relocations
$ llvm-objdump -dr sum4.o # LLVM alternative
Expect sum4.o to report ELF64 and AArch64. A four-add leaf function has no external address to relocate, while the message_ptr example should demonstrate relocations before the final link. The -d option disassembles code; -r displays relocation annotations next to instructions. Compare operands and symbol names, not just hexadecimal bytes. A relocation is evidence that an address is intentionally deferred to linking, not an error.
GDB is most useful after a known harness reports an unexpected result. Build with -g, then use your own local executable:
$ gdb ./test_sum4
(gdb) break sum4
(gdb) run
(gdb) info registers x0 x1 x2 x3 x30 sp
(gdb) disassemble /m sum4
(gdb) stepi
(gdb) continue
At the breakpoint, x0–x3 hold the four inputs and x30 holds the caller return address. After each add, observe only expected arithmetic in x0. Debug only programs you built or are authorized to analyze. GDB does not make an unfamiliar executable safe, and a debugger session should never become a way to poke device memory.
Where NEON fits
NEON is the AArch64 Advanced SIMD register and instruction facility. The same physical v0–v31 registers can be viewed as 128-bit q registers or as lanes such as v0.4s (four 32-bit elements). It can make a fixed-size operation concise:
// void add4u(const unsigned *a, const unsigned *b, unsigned *out)
// Caller supplies three valid, non-overlapping arrays of four elements.
ldr q0, [x0]
ldr q1, [x1]
add v0.4s, v0.4s, v1.4s
str q0, [x2]
ret
.section .note.GNU-stack,"",%progbits
This is not a general array loop: each vector load needs a complete readable 16-byte region, and the interface explicitly avoids overlap ambiguity. Real loops need a length check, a scalar tail or another safe strategy, and tests for zero, short, and boundary lengths. Integer lane addition here wraps; use unsigned C types when that is the intended contract. A compiler can often generate NEON from a clear scalar loop or intrinsics, preserving portability and allowing target-aware scheduling. Read the companion NEON lesson before hand-vectorizing.
Performance: a measurement, not an instruction count
Cortex-A76 is an out-of-order, superscalar core. Its front end and execution resources can overlap independent work, while dependencies, branch misses, cache misses, and memory bandwidth can dominate. Therefore “fewer instructions” or “uses NEON” does not establish a speedup. A hand-unrolled sequence may help one data size and hurt another through instruction-cache pressure, register pressure, or lost compiler transformations. Begin with a correct scalar reference and compare equal work, equal optimization levels, and identical output checks.
| Source of variation | Why it misleads | Better practice |
|---|---|---|
| Warm versus cold caches | First-touch data includes misses and page effects. | State whether measurements are warmed and test representative sizes. |
| DVFS and thermals | Frequency can change with load, power, and temperature. | Use adequate power/cooling; record throttling and temperature conditions. |
| Background activity | Scheduling and I/O perturb short timings. | Repeat trials, report distribution, and avoid claiming false precision. |
| Dead-code elimination | A compiler can remove unused results. | Consume or verify results in the harness. |
Use a monotonic clock in a C harness or a suitable profiler, run enough iterations for useful resolution, include a warm-up policy, and report median plus spread rather than one best time. Pinning a process or changing governor settings is a system-administration choice with tradeoffs; document it if you do it and restore policy afterward. The Pi 5’s cooling, supply, case, kernel load, memory allocation, and firmware state matter to sustained results. Do not generalize one board’s benchmark to all BCM2712 devices or all software versions.
The user-space boundary
Linux user-space assembly executes with ordinary process permissions. It may calculate, access memory the process owns, call libraries, and request kernel services through documented APIs. It is not bare-metal code and cannot configure exception levels, bootstrap the board, or directly operate kernel-owned hardware. Kernel modules and kernel code follow different build, calling, synchronization, memory-mapping, and safety rules. Bare-metal tutorials have still another startup and linker model. Do not mix their linker scripts, exception vectors, physical addresses, or device-register examples into a Raspberry Pi OS process.
For GPIO, I2C, SPI, serial, PCIe, or storage, use the relevant Linux driver and a maintained user-facing API. On Pi 5 specifically, never access GPIO through hardcoded BCM physical addresses: it is unsafe, bypasses ownership and permissions, and does not reflect the RP1-based I/O path. If an application needs a capability denied by Linux, redesign around supported interfaces or study kernel development separately on controlled hardware.
Exercises and references
- Record
uname -m,getconf LONG_BIT,lscpu, andreadelf -h sum4.o. Explain the different claim each makes. - Change
sum4to add five arguments. Consult AAPCS64 and identify the register for the fifth integer argument before testing. - Build
message_ptras an object and usereadelf -rplusobjdump -drto locate its address relocations. - Write a scalar unsigned four-element reference for
add4u; test zero, maximum, and mixed values before inspecting vector instructions. - Benchmark a correct scalar loop and a candidate optimized version over small and cache-sized buffers. Record cooling, repetitions, and result validation.
Continue with the existing AAPCS64 branches and calling-convention lesson, AArch64 NEON lesson, and AArch64 build/link lesson. For Pi 5 preparation, see the site’s Raspberry Pi 5 Debian setup article.
- Raspberry Pi: Raspberry Pi 5 documentation
- Raspberry Pi 5 product page and BCM2712 specifications
- Arm AAPCS64 procedure call standard
- Arm Cortex-A76 Software Optimization Guide
- Arm A-profile architecture reference manual
- GNU binutils documentation
- GNU GDB manual
- LLVM command guides
- Clang user manual
- Linux arm64 documentation
dispelled