<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged assembly</title>
<link>https://coderlegion.com/tag/assembly</link>
<description>Powered by Question2Answer</description>
<item>
<title>Assembly language</title>
<link>https://coderlegion.com/29562/assembly-language</link>
<description>&lt;p&gt;Yo everyone, this is my first post and I wanna talk about assembly.&lt;/p&gt;
&lt;p&gt;So back in 1962, the US launched Mariner 1 to Venus. A few mins after liftoff, the rocket started going off course, so they had to blow it up. Turns out it was a code bug: in an assembly program (or something close to it), they missed one little bar over a letter in a formula. Instead of smoothing the trajectory, the program started wobbling it. Cost of that mistake? Like 18.5 million bucks (in that era's money) and a dead mission. Since then, people bring it up as proof that even one symbol in low-level code can cost you a whole rocket.&lt;/p&gt;
&lt;p&gt;So yeah, I honestly don't get those &quot;mutants&quot; in programming who write in assembly. I got one buddy who codes in assembly, and we don't even talk anymore (maybe that's part of why lol). Anyway, I'm learning C myself and wanna be a systems programmer, and I got no clue how I'm gonna learn assembly — just so you know, it's right below machine code. Btw, assembly is a super hard language that eats up tons of time. But there are way more Python jobs (and neural nets will take those anyway) than assembly jobs. In the end, I just respect people who know it.&lt;/p&gt;
&lt;p&gt;Good luck everyone, catch you later&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29562/assembly-language</guid>
<pubDate>Fri, 02 Oct 2026 20:40:11 +0000</pubDate>
</item>
<item>
<title>AArch64 Register Conventions: What Survives a Function Call—and Why</title>
<link>https://coderlegion.com/26168/aarch64-register-conventions-what-survives-a-function-call-and-why</link>
<description>&lt;p&gt;What happens to a register when you call a function?&lt;/p&gt;
&lt;p&gt;On AArch64, the answer isn't &quot;the CPU saves it.&quot; When you call a function, the CPU doesn't look at a register table and decide what to preserve. &lt;code&gt;BL&lt;/code&gt; doesn't know about caller-saved or callee-saved registers. Those are ABI rules imposed on software, not behavior baked into the hardware.&lt;/p&gt;
&lt;p&gt;Once you understand that distinction, the rest of the calling convention becomes much easier to reason about: the ABI tells the caller and callee who is responsible for preserving what. That's why some registers are caller-saved, some are callee-saved, and why a register's ABI category says nothing about how long a particular value will live.&lt;/p&gt;
&lt;p&gt;When you write C++ code, the compiler decides whether a value lives in a register, on the stack, or nowhere at all after optimization. But under the hood, most scalar arithmetic and logical operations operate on register operands - and understanding the calling convention can make the difference between code that runs and code that &lt;em&gt;performs&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;If you've ever looked at compiler output for AArch64 and wondered why the compiler chose specific registers for specific values, this post is for you.&lt;/p&gt;
&lt;h3 id=&quot;aarch64-vs-arm64-what-s-in-a-name-&quot;&gt;AArch64 vs ARM64: what's in a name?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;AArch64&lt;/strong&gt; is Arm's official name for the 64-bit execution state and instruction set architecture introduced with Armv8-A. &lt;strong&gt;ARM64&lt;/strong&gt; is the colloquial term used in operating systems, toolchains, and developer communities (Linux refers to it as &lt;code&gt;aarch64&lt;/code&gt; in kernel sources, Apple calls it &lt;code&gt;arm64&lt;/code&gt;). They refer to the same thing. This post uses AArch64 to match the official architecture documentation, but you'll encounter both names in the wild.&lt;/p&gt;
&lt;h3 id=&quot;the-big-picture&quot;&gt;The big picture&lt;/h3&gt;
&lt;p&gt;AArch64 has 31 general-purpose registers (X0–X30), plus the special-purpose stack pointer SP and 32 128-bit SIMD/floating-point registers. That's a lot of registers - and beginners often see &quot;32&quot; in architecture documentation, so it's worth clarifying up front: there are 31 general-purpose registers, plus SP, which is a special-purpose register rather than an ordinary GPR (more on that later).&lt;/p&gt;
&lt;p&gt;But not all registers are created equal. The calling convention - the set of rules that govern how functions pass arguments and return values - assigns specific roles to specific registers. Understanding this convention requires a key distinction that underpins everything else.&lt;/p&gt;
&lt;h3 id=&quot;caller-saved-vs-callee-saved-the-conceptual-key&quot;&gt;Caller-saved vs callee-saved: the conceptual key&lt;/h3&gt;
&lt;p&gt;The ABI divides registers into two categories based on what happens across a function call:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Caller-saved&lt;/strong&gt; (also called &lt;em&gt;caller-clobbered&lt;/em&gt;): The caller cannot assume these registers survive a call. If the caller needs a value in one of these registers after a call, it must save the value before the call and restore it afterward. The called function is free to overwrite them.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Callee-saved&lt;/strong&gt;: If the callee modifies one of these registers, it must save the original value and restore it before returning. The caller can safely assume these registers are preserved across the call.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep these names in mind as obligations, not characteristics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;Caller-saved&quot; means the caller must protect a value if it needs it after a call.&lt;/li&gt;
&lt;li&gt;&quot;Callee-saved&quot; means the callee must restore the incoming value before returning if it modifies the register.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Caller-saved and callee-saved are ABI properties, not hardware properties.&lt;/strong&gt; The processor does not enforce these conventions; they are rules that separately compiled functions agree to follow. The CPU is perfectly happy to let any function overwrite X19 - it's the calling convention that says &quot;if you modify it, you must restore it before returning.&quot;&lt;/p&gt;
&lt;h4 id=&quot;a-common-misconception&quot;&gt;A common misconception&lt;/h4&gt;
&lt;p&gt;A callee-saved register isn't inherently a &quot;long-lived register.&quot; It is simply a register whose incoming value must survive a call if the callee modifies it. The compiler can put a short-lived value in X19, and it can put a long-lived value in X9 if that value doesn't cross a call. Register allocation is driven by liveness, register pressure, call boundaries, and cost models - not by which category a register falls into.&lt;/p&gt;
&lt;p&gt;To make this precise, keep three separate concepts in mind:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Register volatility&lt;/strong&gt;: what happens to the register across a call (caller-clobbered vs callee-saved).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Value liveness&lt;/strong&gt;: whether a particular value is needed again later in the code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Register allocation&lt;/strong&gt;: which physical register the compiler chooses for a value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are three different concepts. A register can be caller-clobbered while holding a long-lived value, provided no call occurs while that value is live. A callee-saved register can hold a short-lived value. &quot;Callee-saved&quot; describes the register's contract, not the lifetime of the value stored in it.&lt;/p&gt;
&lt;h3 id=&quot;scope-what-this-article-covers&quot;&gt;Scope: what this article covers&lt;/h3&gt;
&lt;p&gt;This article focuses on the core AArch64 GPR and SIMD/FP register conventions used in ordinary AAPCS64 code. The current AAPCS64 also defines calling conventions for SVE and SME state - the Z0–Z31 scalable vector registers, P0–P15 predicate registers, the FFR, and the SME ZA/ZT0/FPMR state. These are outside the scope of this introduction.&lt;/p&gt;
&lt;h3 id=&quot;the-calling-convention-register-map&quot;&gt;The calling-convention register map&lt;/h3&gt;
&lt;p&gt;Here's the calling-convention register map as a quick overview. We'll walk through each group in detail below - remember, the full reference table lives in the &lt;a rel=&quot;nofollow&quot; href=&quot;#practical-cheat-sheet&quot;&gt;cheat sheet&lt;/a&gt; at the end:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;X0–X7&lt;/strong&gt; — Caller-clobbered: integer/pointer arguments and results&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X8&lt;/strong&gt; — Caller-clobbered: indirect result location&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X9–X15&lt;/strong&gt; — Caller-clobbered: temporary/scratch&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X16–X17&lt;/strong&gt; — Caller-clobbered: IP0/IP1, linker/veneer scratch&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X18&lt;/strong&gt; — Platform-dependent: platform register or caller-clobbered&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X19–X28&lt;/strong&gt; — Callee-saved: general-purpose preserved state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X29&lt;/strong&gt; — Callee-saved: frame pointer / general-purpose register&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X30&lt;/strong&gt; — Special: link register&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SP&lt;/strong&gt; — Callee-saved / special: stack pointer&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;V0–V7&lt;/strong&gt; — Caller-clobbered: FP/SIMD args and results&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;V8–V15&lt;/strong&gt; — Callee-saved, low 64 bits only: preserved FP/SIMD state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;V16–V31&lt;/strong&gt; — Caller-clobbered: temporary FP/SIMD state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NZCV&lt;/strong&gt; — Undefined: condition flags&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;x0-x7-arguments-and-results&quot;&gt;X0–X7: arguments and results&lt;/h3&gt;
&lt;p&gt;X0–X7 are the primary general-purpose argument and result registers. For integer, pointer, and other appropriately classified arguments, the first eight argument-register slots use X0–X7 - note that &quot;eight registers&quot; does not necessarily mean &quot;eight source-level arguments,&quot; because the AAPCS64 argument classification rules can cause arguments to be packed or passed differently. Floating-point and vector arguments normally go in V0–V7 instead, and aggregates can have more complex treatment depending on the ABI and argument types.&lt;/p&gt;
&lt;p&gt;These registers are &lt;em&gt;caller-saved&lt;/em&gt; (caller-clobbered). If a function wants to preserve their values across a call, it must save them itself. They aren't just arguments - they can also hold intermediate values between calls.&lt;/p&gt;
&lt;p&gt;The following example is deliberately simplified to illustrate the calling convention; real compiler output will look different, but the register assignment principle is the same:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;// Simplified illustration of argument passing
MOV     X0, #42        // first integer argument
MOV     X1, #100       // second integer argument
BL      my_function    // call - X0-X7 may be modified
// X0 contains the return value
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This convention means that short, simple functions can be very efficient - no need to save and restore registers if you're just doing a quick computation.&lt;/p&gt;
&lt;h3 id=&quot;x8-indirect-results&quot;&gt;X8: indirect results&lt;/h3&gt;
&lt;p&gt;X8 is not an ordinary ninth argument register. It is specifically used by AAPCS64 to carry the address of caller-allocated storage when a function result is returned indirectly. For example, a result that cannot be returned in the normal result registers may use indirect result return — the caller allocates the result memory and passes its address in X8. There is no requirement for the callee to preserve X8, and X8 is not part of the normal argument sequence.&lt;/p&gt;
&lt;p&gt;The caller never passes X8 as an explicit argument in source code - the compiler handles it automatically when the return type requires it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;// Simplified illustration of indirect result
// Caller has allocated storage for the result.
// X8 points to that storage.
BL      get_large_struct
// The result has been written to the caller-provided storage.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;x9-x15-caller-clobbered-scratch-registers&quot;&gt;X9–X15: caller-clobbered scratch registers&lt;/h3&gt;
&lt;p&gt;These are caller-clobbered registers available for any intermediate use. The important nuance: &quot;caller-saved&quot; doesn't mean &quot;temporary&quot; in the sense that they can only hold short-lived values. It means the caller cannot &lt;em&gt;assume&lt;/em&gt; their values survive a call. A compiler may allocate a long-lived value to X9 if it can prove that no call occurs before the value is consumed - there's nothing in the ABI that prevents it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;// Simplified illustration of scratch register use
ADD     X9, X0, X1     // intermediate result
LSL     X10, X9, #3    // another intermediate
// X9 and X10 are caller-clobbered - a called function may overwrite them
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;x16-x17-ip0-ip1-and-linker-scratch&quot;&gt;X16–X17: IP0/IP1 and linker scratch&lt;/h3&gt;
&lt;p&gt;X16 and X17 are known as IP0 and IP1 - intra-procedure-call scratch registers. They have a special relationship with the linker and dynamic loader. The current AAPCS64 explicitly says X16/X17 can be used by call veneers and PLT code.&lt;/p&gt;
&lt;p&gt;The important consequence is that X16 and X17 are not safe places to keep values across calls. A linker-generated veneer or PLT sequence may use them even when the source-level callee doesn't. If you're writing hand-written assembly that calls external functions, treat these registers as clobbered.&lt;/p&gt;
&lt;h3 id=&quot;x18-platform-register&quot;&gt;X18: platform register&lt;/h3&gt;
&lt;p&gt;X18 is the platform register. Its preservation and usage rules are defined by the platform ABI rather than by the base AAPCS64.&lt;/p&gt;
&lt;p&gt;AAPCS64 says that a platform may reserve X18 for interprocedural state such as thread context. If the platform has no such requirement, X18 is an additional caller-saved register. Arm advises that platform-independent developers avoid X18 if possible.&lt;/p&gt;
&lt;p&gt;For example, Windows reserves X18 for the Thread Environment Block (TEB), and in user mode it points to the TEB. Android reserves X18 for the Shadow Call Stack (SCS) pointer in hardened binaries - a security mechanism that keeps return addresses on a separate, protected shadow stack. Apple platforms assign their own role to X18 as well. Other platforms have their own rules, so don't assume X18 is freely available without checking the target ABI.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Portable assembly rule&lt;/strong&gt;: don't use X18 unless you know the target platform ABI.&lt;/p&gt;
&lt;h3 id=&quot;x19-x28-callee-saved-registers&quot;&gt;X19–X28: callee-saved registers&lt;/h3&gt;
&lt;p&gt;These registers are &lt;em&gt;callee-saved&lt;/em&gt; - if a function modifies any of them, it must save their original values and restore them before returning. This is the opposite of the caller-saved convention.&lt;/p&gt;
&lt;p&gt;The nuance: &quot;callee-saved&quot; doesn't mean the callee &lt;em&gt;must&lt;/em&gt; use these registers. It means if it &lt;em&gt;does&lt;/em&gt; use them, it must preserve them. The compiler decides whether to allocate a value to a callee-saved register based on liveness analysis, register pressure, and cost models - not because the ABI mandates it.&lt;/p&gt;
&lt;p&gt;If a function keeps a value live across calls, the compiler may choose a callee-saved register. The called function is then required to preserve that register, so the caller doesn't need to save the value around every individual call. The trade-off is that the callee may need to save and restore it in its own prologue and epilogue:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;// One possible prologue/epilogue pattern; real compiler-generated
// prologues vary with frame layout, optimization level, unwind
// requirements, and security features.
my_function:
    STP     X29, X30, [SP, #-16]!    // save frame pointer and link register
    STP     X19, X20, [SP, #-16]!    // save callee-saved registers we'll use

    // Because this function modifies X19/X20,
    // it must restore their incoming values before returning.
    MOV     X19, X0                   // preserve first argument across calls
    MOV     X20, X1                   // preserve second argument across calls

    BL      some_other_function       // X19 and X20 survive this call
    // ...

    LDP     X19, X20, [SP], #16      // restore callee-saved registers
    LDP     X29, X30, [SP], #16      // restore frame pointer and link register
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To see why this matters, compare the two paths for a value you need again after a call:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Using caller-saved X9 means you must explicitly save the value before the call and restore it afterward.&lt;/li&gt;
&lt;li&gt;Using callee-saved X19 means the responsibility moves into the callee: if the current function itself needs to use X19, it saves it once in its prologue. But any function you call must also preserve X19, so your value survives.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That distinction is the heart of why the ABI has both classes. Values that remain live across calls are often good candidates for callee-saved registers, but the compiler may instead spill, recompute, or otherwise transform the value depending on its cost model.&lt;/p&gt;
&lt;h3 id=&quot;x29-and-x30-frame-pointer-and-link-register&quot;&gt;X29 and X30: frame pointer and link register&lt;/h3&gt;
&lt;h4 id=&quot;x29-frame-pointer&quot;&gt;X29: frame pointer&lt;/h4&gt;
&lt;p&gt;X29 is conventionally used as the frame pointer (FP) when a frame pointer is maintained. Optimized code may omit the frame pointer and use X29 as a general-purpose callee-saved register instead - this depends on compiler options (&lt;code&gt;-fno-omit-frame-pointer&lt;/code&gt;), debugging and unwinding requirements, and platform rules.&lt;/p&gt;
&lt;h4 id=&quot;x30-link-register&quot;&gt;X30: link register&lt;/h4&gt;
&lt;p&gt;X30 is the link register (LR), and it gets a special role rather than a simple caller/callee classification. When &lt;code&gt;BL&lt;/code&gt; (branch with link) executes, the CPU writes the return address into X30 before branching to the target. When the function returns with &lt;code&gt;RET&lt;/code&gt;, it jumps to the address in X30.&lt;/p&gt;
&lt;p&gt;The practical rule: a function that needs its incoming LR after making another call must preserve that LR. If a function makes another call, that call overwrites X30, so the function must save its incoming return address somewhere else - typically on the stack - if it still needs it to return.&lt;/p&gt;
&lt;p&gt;A leaf function that makes no calls can often leave X30 untouched and return directly with &lt;code&gt;RET&lt;/code&gt;, avoiding an LR save/restore.&lt;/p&gt;
&lt;p&gt;When a frame pointer is used, X29 and X30 are commonly saved together.&lt;/p&gt;
&lt;h4 id=&quot;pointer-authentication-and-x30-security&quot;&gt;Pointer Authentication and X30 security&lt;/h4&gt;
&lt;p&gt;In modern AArch64 environments (iOS, macOS, and hardened Linux builds), you will often see X30 handled with two additional instructions: &lt;code&gt;PACIASP&lt;/code&gt; and &lt;code&gt;AUTIASP&lt;/code&gt;. These are part of Armv8.3-A Pointer Authentication (PAC), a hardware security feature designed to prevent Return-Oriented Programming (ROP) and stack-smashing attacks.&lt;/p&gt;
&lt;p&gt;Because X30 holds the return address, an attacker who overwrites the saved X30 on the stack can hijack execution when the function returns. PAC solves this by signing the pointer in X30 using a secret key and the current stack pointer (SP) as context before pushing it to memory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;my_function:
    PACIASP                         // Sign X30 using A-key and SP
    STP     X29, X30, [SP, #-16]!   // Push frame pointer and signed X30

    // ... function body ...

    LDP     X29, X30, [SP], #16     // Restore signed X30 from stack
    AUTIASP                         // Authenticate X30 against SP
    RET                             // Return to verified address
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;PACIASP&lt;/code&gt; (prologue)&lt;/strong&gt;: Computes a cryptographic signature from the value in X30 and SP, inserting it into the normally-unused upper bits of X30.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AUTIASP&lt;/code&gt; (epilogue)&lt;/strong&gt;: Re-computes the signature using the current SP and validates X30. If the stack was corrupted and the saved X30 was modified, &lt;code&gt;AUTIASP&lt;/code&gt; replaces the signature with an invalid address pattern. When &lt;code&gt;RET&lt;/code&gt; then attempts to execute, the CPU triggers an immediate translation fault rather than jumping to malicious code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Crucially for backward compatibility, &lt;code&gt;PACIASP&lt;/code&gt; and &lt;code&gt;AUTIASP&lt;/code&gt; execute as NOP instructions on legacy Armv8.0 hardware, so binaries built with PAC enabled can still run safely on older AArch64 processors.&lt;/p&gt;
&lt;h3 id=&quot;sp-xzr-and-w-registers&quot;&gt;SP, XZR, and W registers&lt;/h3&gt;
&lt;h4 id=&quot;sp-stack-pointer&quot;&gt;SP: stack pointer&lt;/h4&gt;
&lt;p&gt;SP is a special architectural register rather than an ordinary GPR, and it is not interchangeable with X0–X30. The AAPCS64 requires the stack pointer to be 16-byte aligned at public interfaces, and also requires alignment whenever memory is accessed via SP. AArch64 imposes additional restrictions on how SP can be used by instructions.&lt;/p&gt;
&lt;p&gt;Importantly, this 16-byte SP alignment isn't only an ABI convention. The CPU itself enforces an architectural SP Alignment Check whenever SP is used as the base register in a load or store: if SP isn't 16-byte aligned at such an access, the hardware raises an alignment fault regardless of what the ABI says. The ABI rule and the architectural rule work together - the calling convention keeps SP aligned so the hardware check never trips on ordinary code.&lt;/p&gt;
&lt;p&gt;AAPCS64 groups SP with the callee-saved registers, but that doesn't mean the callee must restore the exact incoming SP value instruction-for-instruction. The useful rule is: a function must restore SP to the required value before returning, and SP must remain properly aligned.&lt;/p&gt;
&lt;h4 id=&quot;xzr-the-zero-register&quot;&gt;XZR: the zero register&lt;/h4&gt;
&lt;p&gt;XZR is the architectural zero register. Reads return zero; writes are discarded. Many instructions use register encoding 31 to mean either SP or XZR, depending on the instruction class: data-processing instructions treat encoding 31 as XZR, while memory instructions and branches treat it as SP.&lt;/p&gt;
&lt;p&gt;There is therefore no X31 register you can use like X0–X30; encoding 31 is interpreted as SP or XZR depending on the instruction. This eliminates a common beginner question - there is no architectural X31 general-purpose register.&lt;/p&gt;
&lt;h4 id=&quot;w-registers-the-32-bit-view&quot;&gt;W registers: the 32-bit view&lt;/h4&gt;
&lt;p&gt;Each X register also has a 32-bit W-register view. For example, W0 refers to the low 32 bits of X0.&lt;/p&gt;
&lt;p&gt;A key AArch64 rule is that writing a W register zeroes the upper 32 bits of the corresponding X register:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;MOV     W0, #42
// X0 is now 0x000000000000002A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is worth remembering when reading compiler output: W0 and X0 aren't separate physical registers. You'll encounter &lt;code&gt;w0&lt;/code&gt;, &lt;code&gt;w1&lt;/code&gt;, and so on constantly in disassembly.&lt;/p&gt;
&lt;h3 id=&quot;simd-and-floating-point-registers&quot;&gt;SIMD and floating-point registers&lt;/h3&gt;
&lt;p&gt;The V0-V31 registers are 128-bit SIMD/vector registers that also serve as floating-point registers. The calling convention divides them similarly to the general-purpose registers.&lt;/p&gt;
&lt;p&gt;Each V register has several aliases depending on the width you're accessing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;V0&lt;/strong&gt;: Full 128-bit register&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Q0&lt;/strong&gt;: Same 128 bits (quadword)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;D0&lt;/strong&gt;: Low 64 bits (doubleword)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;S0&lt;/strong&gt;: Low 32 bits (singleword)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;H0&lt;/strong&gt;: Low 16 bits (halfword)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;B0&lt;/strong&gt;: Low 8 bits (byte)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;v0-v7-argument-and-return-registers&quot;&gt;V0–V7: argument and return registers&lt;/h4&gt;
&lt;p&gt;These are the primary SIMD/floating-point argument and return registers. Like their general-purpose counterparts, they're &lt;em&gt;caller-clobbered&lt;/em&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;// Simplified illustration: assume S0 and S1 already contain the arguments
BL      my_float_func
// S0 (low 32 bits of V0) contains the return value
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;v8-v15-partially-preserved&quot;&gt;V8–V15: partially preserved&lt;/h4&gt;
&lt;p&gt;Only the low 64 bits of V8–V15 (D8–D15) are callee-saved. That means: if a function modifies the bottom 64 bits of one of these registers, it must restore them before returning; larger values are the caller's responsibility. If you need a full 128-bit value to survive a call, you can't simply put it in V8–V15 and rely on the ABI - you must preserve the upper 64 bits yourself.&lt;/p&gt;
&lt;h4 id=&quot;v16-v31-temporary-registers&quot;&gt;V16–V31: temporary registers&lt;/h4&gt;
&lt;p&gt;These are fully caller-clobbered, like the general-purpose scratch registers. Use them for intermediate SIMD computations that don't need to survive function calls.&lt;/p&gt;
&lt;h3 id=&quot;condition-flags-nzcv&quot;&gt;Condition flags: NZCV&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;NZCV&lt;/strong&gt; is the condition flags register. It holds four single-bit flags:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;N&lt;/strong&gt; (Negative): Set to 1 if the result of the last operation was negative&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Z&lt;/strong&gt; (Zero): Set to 1 if the result was zero&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C&lt;/strong&gt; (Carry): Set to 1 if the last operation produced a carry out&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;V&lt;/strong&gt; (Overflow): Set to 1 if the last operation produced a signed overflow&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;NZCV is modified by flag-setting instructions such as &lt;code&gt;ADDS&lt;/code&gt;, &lt;code&gt;SUBS&lt;/code&gt;, &lt;code&gt;ANDS&lt;/code&gt;, and by comparison instructions such as &lt;code&gt;CMP&lt;/code&gt;, &lt;code&gt;CMN&lt;/code&gt;, and &lt;code&gt;TST&lt;/code&gt;. Ordinary arithmetic instructions such as &lt;code&gt;ADD&lt;/code&gt; and &lt;code&gt;SUB&lt;/code&gt; do not modify NZCV - you must explicitly use the flag-setting variant.&lt;/p&gt;
&lt;p&gt;NZCV is not preserved across a function call; its values are undefined at a public interface. It's also ordinary scratch state within a function: any flag-setting instruction can replace the previous condition flags.&lt;/p&gt;
&lt;p&gt;This matters for inline assembly: if your inline assembly modifies condition flags in a way the compiler must not assume is preserved, declare the appropriate &lt;code&gt;&quot;cc&quot;&lt;/code&gt; clobber (in GCC/Clang extended inline assembly). Conversely, if your assembly reads condition flags, you need to ensure they've been set appropriately before your block.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;// Simplified illustration of NZCV in inline asm
CMP     X0, #0          // flag-setting comparison: sets NZCV
B.NE    is_nonzero       // conditional branch based on Z flag
// ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-this-looks-like-in-compiler-output&quot;&gt;What this looks like in compiler output&lt;/h3&gt;
&lt;p&gt;When you examine compiler output, these conventions explain the register allocation patterns you see. Compilers often use caller-clobbered registers for values that don't need to survive a call, because doing so avoids unnecessary save/restore work. Values that are live across call boundaries may end up in callee-saved registers, but the compiler may instead spill to the stack, recompute the value, or transform the code entirely - register allocation is driven by a cost model, not a simple rule.&lt;/p&gt;
&lt;p&gt;Consider a concrete example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;int bar(int);

int foo(int a, int b) {
    int x = a + b;
    return bar(x) + x;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;x&lt;/code&gt; is live across the call to &lt;code&gt;bar()&lt;/code&gt; - it's used again afterward. Therefore, the compiler needs some way to preserve its value across the call. It might use a callee-saved register, spill it to the stack, recompute it, or transform the expression. So conceptually:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt; → &lt;code&gt;x0&lt;/code&gt;, &lt;code&gt;x1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x&lt;/code&gt; → may move to &lt;code&gt;x19&lt;/code&gt;, because &lt;code&gt;x&lt;/code&gt; is live across the call to &lt;code&gt;bar()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A complete, valid implementation using a callee-saved register would also have to preserve the caller's incoming X19 in its prologue:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;foo:
    STP     X19, X30, [SP, #-16]!     // preserve caller's X19 and our LR

    ADD     W19, W0, W1               // x = a + b, kept in W19
    MOV     W0, W19                   // pass x as the argument
    BL      bar                       // call - x survives in W19
    ADD     W0, W0, W19               // result + x

    LDP     X19, X30, [SP], #16       // restore X19 and LR
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note what this makes explicit: using a callee-saved register doesn't make the register free. It transfers the preservation responsibility to the current function, which must save and restore X19 around its own body. That's the heart of the distinction between the two classes.&lt;/p&gt;
&lt;p&gt;Also notice how the stack is managed: &lt;code&gt;STP X19, X30, [SP, #-16]!&lt;/code&gt; pushes &lt;em&gt;two&lt;/em&gt; 64-bit registers at once (16 bytes total, hence the &lt;code&gt;#-16&lt;/code&gt; offset), and &lt;code&gt;LDP&lt;/code&gt; pops them back together. Storing registers in pairs of 64-bit registers is the standard AArch64 idiom - it's precisely how the code keeps SP moving in 16-byte units, maintaining the required alignment without extra padding. Because the pair takes exactly 16 bytes, the compiler never has to insert alignment holes.&lt;/p&gt;
&lt;p&gt;The compiler can also use caller-saved registers for long-lived values when it can prove no call intervenes. &quot;Caller-saved&quot; describes what happens across calls, not how long a value lives.&lt;/p&gt;
&lt;h3 id=&quot;writing-inline-assembly&quot;&gt;Writing inline assembly&lt;/h3&gt;
&lt;p&gt;With GCC/Clang-style extended inline assembly, ABI rules are only part of the contract. You must also tell the compiler which registers, memory, and condition flags your assembly modifies. An instruction sequence can obey the AArch64 ABI and still be invalid inline assembly if its clobbers and operands are incorrectly declared.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Important distinction&lt;/strong&gt;: in inline assembly the compiler - not your assembly code - is responsible for allocating registers around the asm block. This is fundamentally different from standalone assembly, where you own every register decision.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Specifically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Describe inputs, outputs, and clobbers using operands and constraints, allowing the compiler to allocate registers unless a specific register is genuinely required.&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;&quot;cc&quot;&lt;/code&gt; clobber if your assembly modifies condition flags.&lt;/li&gt;
&lt;li&gt;List any registers your assembly clobbers, or let the compiler allocate them via operand constraints.&lt;/li&gt;
&lt;li&gt;Declare memory effects if your assembly accesses memory the compiler doesn't know about.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Don't manually save a caller-saved register in inline asm just because it is caller-saved. Your job is to describe the inputs, outputs, and clobbers accurately. If you need a particular register preserved, model that requirement through operands/constraints rather than silently saving and restoring arbitrary registers.&lt;/p&gt;
&lt;p&gt;If your asm modifies a register that the compiler has allocated for something else and you don't tell the compiler, you can break the program even though you didn't violate the ABI. This is the key distinction between standalone assembly and inline assembly embedded in compiler-generated code.&lt;/p&gt;
&lt;h3 id=&quot;aapcs64-vs-platform-abi&quot;&gt;AAPCS64 vs platform ABI&lt;/h3&gt;
&lt;p&gt;The register conventions described above come from the &lt;strong&gt;Arm Procedure Call Standard for AArch64&lt;/strong&gt; (AAPCS64), which defines the core calling convention. But AAPCS64 is the base layer - not the whole story. The hierarchy looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AArch64 architecture
        ↓
    AAPCS64
        ↓
    platform ABI
        ↓
language/runtime/toolchain conventions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every platform builds on AAPCS64 with its own &lt;strong&gt;platform ABI&lt;/strong&gt; that adds rules specific to the operating system or runtime:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Linux / ELF&lt;/strong&gt;: AArch64 Linux user-space ABIs generally build on AAPCS64, with additional ELF and platform conventions. The exact ABI also depends on the toolchain and ABI variant being used.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Windows on AArch64&lt;/strong&gt;: Defines additional ABI rules, including reserving X18 for platform use and requiring specific unwind information.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Apple platforms&lt;/strong&gt;: Apple platforms build on AAPCS64 with additional platform and toolchain conventions. Apple platforms may also add security-related conventions such as pointer authentication, depending on the target and execution environment. If you're writing hand-written assembly, consult the ABI documentation for the specific Apple target rather than assuming that base AAPCS64 is the entire contract.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Beyond the platform ABI, C++ adds additional ABI requirements beyond plain C argument passing, and things like exception handling, TLS, unwind information, and toolchain-specific conventions can introduce further platform-specific rules.&lt;/p&gt;
&lt;p&gt;The practical consequence: when writing hand-written assembly or inline assembly, you need to know not just the AAPCS64 register rules, but also the platform-specific additions. Code that works on one platform may break on another if platform-specific register conventions are violated.&lt;/p&gt;
&lt;h3 id=&quot;practical-cheat-sheet&quot;&gt;Practical cheat sheet&lt;/h3&gt;
&lt;p&gt;Here's the compact cheat sheet - the part you'll want to bookmark:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Register &lt;/th&gt;&lt;th&gt;Across a call &lt;/th&gt;&lt;th&gt;Main purpose&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;X0–X7 &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;Arguments/results&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;X8 &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;Indirect result address&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;X9–X15 &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;Scratch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;X16–X17 &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;Linker/veneer scratch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;X18 &lt;/td&gt;&lt;td&gt;Platform-dependent &lt;/td&gt;&lt;td&gt;Platform register or caller-clobbered&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;X19–X28 &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;Callee-saved&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;X29 &lt;/td&gt;&lt;td&gt;✅ &lt;/td&gt;&lt;td&gt;Frame pointer / callee-saved GPR&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;X30 &lt;/td&gt;&lt;td&gt;Special &lt;/td&gt;&lt;td&gt;Link register&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SP &lt;/td&gt;&lt;td&gt;✅ (special) &lt;/td&gt;&lt;td&gt;Stack pointer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;V0–V7 &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;FP/SIMD args/results&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;V8–V15 &lt;/td&gt;&lt;td&gt;⚠️ &lt;/td&gt;&lt;td&gt;Low 64 bits callee-saved&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;V16–V31 &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;FP/SIMD scratch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;NZCV &lt;/td&gt;&lt;td&gt;❌ &lt;/td&gt;&lt;td&gt;Condition flags; values are undefined across public interfaces&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;So what survives a function call?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;X19–X29 survive&lt;/strong&gt;, because the callee must preserve them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only the low 64 bits of V8–V15 are guaranteed to survive.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X0–X17 and V0–V7/V16–V31 are generally caller-clobbered.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;X30 must be preserved by a non-leaf function&lt;/strong&gt; if it needs its incoming return address.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NZCV is not something you can carry across a public call&lt;/strong&gt; - its values are undefined there.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But the deeper lesson is that the ABI describes register obligations, not value lifetimes. The compiler decides where a live value actually goes, choosing between callee-saved registers, spilling, or recomputation based on a cost model. And none of this is enforced by the hardware - it's a contract between separately compiled functions.&lt;/p&gt;
&lt;p&gt;Next time you look at compiler output for AArch64, you'll know why certain registers are chosen and what happens to them across function boundaries.&lt;/p&gt;
&lt;h3 id=&quot;official-documentation-and-further-reading&quot;&gt;Official documentation and further reading&lt;/h3&gt;
&lt;p&gt;The ABI specification is a moving target - the current AAPCS64 is the 2025Q4 release, issued in January 2026. Link to the current Arm ABI repository rather than an old developer.arm.com PDF:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ARM-software/abi-aa&quot;&gt;Arm ABI repository — AAPCS64&lt;/a&gt; - The authoritative home of the current AAPCS64 and other Arm ABIs&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.arm.com/documentation/ddi0602/latest&quot;&gt;Arm Architecture Reference Manual for AArch64&lt;/a&gt; - The authoritative source for the instruction set and register definitions&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ARM-software/abi-aa/blob/main/sysvabi64/sysvabi64.rst&quot;&gt;Arm System V ABI for AArch64&lt;/a&gt; - The ELF/generic System V conventions that build on AAPCS64&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/en-us/cpp/build/arm64-windows-abi-conventions&quot;&gt;Microsoft ARM64 ABI conventions&lt;/a&gt; - Microsoft's platform-specific conventions, including X18 and unwind requirements&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms&quot;&gt;Apple Platform ABI for AArch64&lt;/a&gt; - Apple's platform-specific conventions&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.kernel.org/arch/arm64/index.html&quot;&gt;Linux kernel AArch64 architecture&lt;/a&gt; - Platform-specific details for Linux on AArch64&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26168/aarch64-register-conventions-what-survives-a-function-call-and-why</guid>
<pubDate>Thu, 03 Sep 2026 20:13:24 +0000</pubDate>
</item>
<item>
<title>Fighting Build-System Complexity: Alex Voste on Systems Programming, Performance and ForgeZero</title>
<link>https://coderlegion.com/25614/fighting-build-system-complexity-alex-voste-systems-programming-performance-forgezero</link>
<description>&lt;h2 id=&quot;from-systems-engineering-to-forgezero-alex-voste-on-performance-build-systems-assembly-and-the-fight-against-overhead&quot;&gt;From Systems Engineering to ForgeZero: Alex Voste on Performance, Build Systems, Assembly and the Fight Against Overhead&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Developer Stories | CoderLegion&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Meet &lt;strong&gt;Alex Voste&lt;/strong&gt;, a Canada-based systems-focused developer working across &lt;strong&gt;C, Go, x86_64 Assembly, NASM, FASM, C++, Linux, performance optimization, and developer tooling&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Alex describes himself as a network systems engineer and is currently building &lt;strong&gt;ForgeZero&lt;/strong&gt;, a hardened cross-platform build toolchain for C and Assembly developers.&lt;/p&gt;
&lt;p&gt;His approach to software is strongly shaped by a fascination with what happens underneath the abstractions — from memory and computer architecture to compilers, build systems, and raw execution speed.&lt;/p&gt;
&lt;p&gt;In this Developer Story, Alex shares how he moved from PHP and Java toward systems engineering, why he decided to build ForgeZero, his philosophy around performance and complexity, what he thinks modern build systems get wrong, and why he believes developers working close to the hardware will need strong fundamentals even in an increasingly AI-driven world.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-from-systems-engineering-to-forgezero&quot;&gt;1. From Systems Engineering to ForgeZero&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How did you get into systems engineering and what eventually led you to building ForgeZero?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I often describe myself as a network systems engineer, but let's be clear, that wasn't a title I woke up one morning and slapped on my LinkedIn. It took years of grinding through the trenches to actually earn it.&lt;/p&gt;
&lt;p&gt;I started way back with PHP and web development, then moved into Java, doing the usual backend work. Honestly, it got boring fast. I was building the same conventional stuff everyone else was, shipping the same CRUD, wiring the same frameworks, and calling it engineering when really it was just assembly-line work with better naming conventions.&lt;/p&gt;
&lt;p&gt;I wanted to go deeper: how programming languages actually work under the hood, how systems manage memory, or how my phone quietly phones home to corporate servers whether I asked it to or not.&lt;/p&gt;
&lt;p&gt;Building a custom build tool wasn't some boardroom decision. It started years ago as a passion project between me and a friend.&lt;/p&gt;
&lt;p&gt;We were two enthusiasts trying to write our own operating system in Assembly from scratch, controlling literally everything down to the bit.&lt;/p&gt;
&lt;p&gt;Tools like Make or CMake ran completely against that philosophy. We didn't want heavy, opaque black boxes making decisions on our behalf that we never signed off on.&lt;/p&gt;
&lt;p&gt;So I built my own toolchain, first in dependency-free Node.js, and later rebuilt entirely in Go, because at some point you get tired of fighting the tool instead of the actual problem.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-the-problem-with-modern-build-systems&quot;&gt;2. The Problem With Modern Build Systems&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What problem do you think modern build systems have, and what is ForgeZero trying to do differently?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I firmly believe most modern build systems, and honestly most software in general, run far too many redundant operations that nobody actually needs.&lt;/p&gt;
&lt;p&gt;They try to cover every edge case and every platform under the sun simultaneously, even when nobody asked, and you end up paying for that indecision in CPU cycles you never approved spending.&lt;/p&gt;
&lt;p&gt;ForgeZero's philosophy is heavily inspired by &lt;strong&gt;Gentoo&lt;/strong&gt; and &lt;strong&gt;Linux From Scratch (LFS)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A build toolchain should do exactly what the developer explicitly asks it to do, nothing more.&lt;/p&gt;
&lt;p&gt;No hidden magic, no unnecessary overhead, zero wasted cycles.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If I didn't type it, it shouldn't be running.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That principle sits at the heart of how I think about build tooling.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-why-build-another-build-system-&quot;&gt;3. Why Build Another Build System?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;People often ask why another build system is necessary. Why did you decide to build ForgeZero instead of using existing solutions?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;People ask me all the time: why reinvent the wheel?&lt;/p&gt;
&lt;p&gt;Honestly, I don't see ForgeZero as just another wheel.&lt;/p&gt;
&lt;p&gt;It's more like a wheel with a twin-turbo engine bolted on, built by someone who was personally offended by how slow the old wheel was.&lt;/p&gt;
&lt;p&gt;The codebase is lean, sitting around &lt;strong&gt;42k+ lines of clean code&lt;/strong&gt;, with no dead weight padding out a changelog just to look impressive.&lt;/p&gt;
&lt;p&gt;I originally built it purely to save my own time.&lt;/p&gt;
&lt;p&gt;But somewhere around the third cup of coffee at 3 AM, inspiration hits, and you start bolting on optimizations you never planned at 9 PM.&lt;/p&gt;
&lt;p&gt;My motivation was simple: I had a clear architectural idea for how build orchestration could be blazingly fast, and I wasn't going to sit around waiting for someone else to get to it.&lt;/p&gt;
&lt;p&gt;When I benchmarked it against real-world projects, ForgeZero came out multiple times faster than the existing solutions.&lt;/p&gt;
&lt;p&gt;My goal is to give developers what they actually care about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;raw speed&lt;/li&gt;
&lt;li&gt;intelligent caching&lt;/li&gt;
&lt;li&gt;real security&lt;/li&gt;
&lt;li&gt;total manual control&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Not a dashboard cheerfully telling them everything's fine while it quietly isn't.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-why-go-and-assembly-&quot;&gt;4. Why Go and Assembly?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Why did you choose Go as the foundation for ForgeZero, and where does Assembly fit into the project?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I chose Go as the foundation because it strikes a rare balance between simplicity and safety.&lt;/p&gt;
&lt;p&gt;Write pure C or Assembly for everything, and it's incredibly easy to leave behind a subtle memory bug or race condition you'll be debugging at 4 AM, hating your past self with real, focused intensity.&lt;/p&gt;
&lt;p&gt;Go gives me near-C performance while sparing me the catastrophic runtime failures I no longer have the patience to hunt at that hour.&lt;/p&gt;
&lt;p&gt;That said, I do use Assembly in ForgeZero, specifically &lt;strong&gt;Plan 9 Assembly&lt;/strong&gt;, on the performance-critical paths.&lt;/p&gt;
&lt;p&gt;That's where a huge chunk of our raw execution speed comes from, and no amount of clever Go abstraction replaces hand-placed instructions where it actually counts.&lt;/p&gt;
&lt;p&gt;When it's a choice between an optimization that adds complexity or keeping things simple for safety's sake, I'll take the optimization almost every time.&lt;/p&gt;
&lt;p&gt;ForgeZero has a fair amount of low-level, high-risk Go code where we bypass standard allocations entirely.&lt;/p&gt;
&lt;p&gt;As long as I keep tight control over those boundaries, the performance gains are massive, and I'd rather own that risk deliberately than inherit someone else's by accident.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-where-does-the-performance-difference-come-from-&quot;&gt;5. Where Does the Performance Difference Come From?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ForgeZero reportedly performs significantly faster than some existing build tools. What is behind that difference?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you look at the benchmarks where ForgeZero significantly outperforms tools like &lt;strong&gt;Ninja&lt;/strong&gt; on large task sets, the secret isn't some clever trick hiding in a corner.&lt;/p&gt;
&lt;p&gt;It's simplicity, plain and unglamorous.&lt;/p&gt;
&lt;p&gt;And I know that answer disappoints people hunting for a silver bullet.&lt;/p&gt;
&lt;p&gt;When your architecture isn't burning cycles managing legacy abstractions, allocation overhead, or bloated state graphs nobody asked it to maintain, execution ends up blindingly fast almost by default.&lt;/p&gt;
&lt;p&gt;We do fewer things, but we do them at the bare-metal limit, on purpose, every single time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-building-for-low-level-developers&quot;&gt;6. Building for Low-Level Developers&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What do you think existing developer tooling gets wrong about low-level developers?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What existing tooling gets wrong about low-level developers is assuming we want convenience over control.&lt;/p&gt;
&lt;p&gt;Most modern dev tools are built around a web-dev mindset: hide the complexity, abstract the system, install 500 MB of dependencies for something that should take twenty lines, and pretend the hardware isn't even there.&lt;/p&gt;
&lt;p&gt;Systems engineers, C programmers, Assembly devs, we don't want black boxes.&lt;/p&gt;
&lt;p&gt;We want to see the hardware.&lt;/p&gt;
&lt;p&gt;We want toolchains that stay out of our way, don't force opinionated directory structures on us like we're kids who need supervision, and don't burn cycles on telemetry or bloated config parsers nobody reads.&lt;/p&gt;
&lt;p&gt;Existing tools try to be everything for everyone and end up being genuinely excellent at nothing.&lt;/p&gt;
&lt;p&gt;Low-level devs just want a tool that gets out of the way, respects the hardware, and executes instantly.&lt;/p&gt;
&lt;p&gt;Full stop.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-open-source-and-building-in-public&quot;&gt;7. Open Source and Building in Public&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What role does open source play in your approach to software development?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;My core philosophy has always centered on building free, open-source software.&lt;/p&gt;
&lt;p&gt;That doesn't mean I never build proprietary products that generate revenue, but when it comes to fundamental tooling, I'd rather keep it open for the community, because gatekeeping a build tool behind a paywall feels beneath the entire point of building one.&lt;/p&gt;
&lt;p&gt;For instance, I'm currently working on a confidential AI project applied to medical engineering, more on that down the road.&lt;/p&gt;
&lt;p&gt;Building in public keeps your architecture honest, because anyone can audit your code, and nothing sharpens discipline quite like knowing a stranger might read your worst commit at 2 AM and judge you for it, correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-performance-vs-complexity&quot;&gt;8. Performance vs. Complexity&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How do you decide when a performance optimization is worth the additional complexity?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In theory, there's a point where chasing performance stops being worth the engineering complexity.&lt;/p&gt;
&lt;p&gt;But if I'm being completely honest about my own process, I don't always respect that limit, and I'm not going to pretend otherwise just to sound balanced in an interview.&lt;/p&gt;
&lt;p&gt;I have a relentless drive to push code until it literally cannot run any faster.&lt;/p&gt;
&lt;p&gt;As a general rule though, performance optimizations are worth the complexity when they scale linearly with user productivity.&lt;/p&gt;
&lt;p&gt;If an optimization saves a developer five seconds on every single build loop, that's hours of focus reclaimed across a real team every week.&lt;/p&gt;
&lt;p&gt;But if an optimization makes a codebase unmaintainable just to shave two microseconds off an idle thread nobody's even timing, you've crossed into vanity engineering.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Vanity engineering is just ego wearing a lab coat.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Balance comes down to measuring real-world impact over synthetic vanity, every time, no exceptions.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-ai-and-low-level-engineering&quot;&gt;9. AI and Low-Level Engineering&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How do you think AI will affect low-level software engineering?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;My stance on this is firm: &lt;strong&gt;AI will never fully master low-level systems engineering&lt;/strong&gt;, and I'll happily die on this hill.&lt;/p&gt;
&lt;p&gt;I might sound provocative, but based on my own experience, LLMs operate at a noticeably lower level of competence in systems programming compared to high-level web development.&lt;/p&gt;
&lt;p&gt;Low-level engineering demands deep spatial reasoning, hardware awareness, and architectural intuition that isn't sitting anywhere in a training set in a usable form.&lt;/p&gt;
&lt;p&gt;I actually tested this.&lt;/p&gt;
&lt;p&gt;I tried using an AI assistant to help build a low-level, cryptographically secure messenger.&lt;/p&gt;
&lt;p&gt;I wasn't asking it to write AES-256-GCM from scratch, I just wanted help with a simple client-server network implementation.&lt;/p&gt;
&lt;p&gt;It failed miserably.&lt;/p&gt;
&lt;p&gt;I spent days fighting hallucinations and sloppy memory handling before scrapping the AI output entirely and writing the whole thing myself, in a fraction of the time it cost me babysitting the assistant.&lt;/p&gt;
&lt;p&gt;AI can fetch docs, summarize a man page, and sound confident doing it.&lt;/p&gt;
&lt;p&gt;But it can't reason about bare-metal constraints the way someone who's actually been burned by them can.&lt;/p&gt;
&lt;p&gt;For low-level devs, understanding C, Assembly, and computer architecture is only going to matter more, not less, because someone still has to fix the broken code AI generates, and that someone needs to actually know what they're looking at.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;10-what-comes-next-for-forgezero-&quot;&gt;10. What Comes Next for ForgeZero?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Where do you want to take ForgeZero next?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;My immediate goal is assembling a core team of like-minded developers, engineers who are ready to break things, innovate, and move fast without asking permission for every single decision.&lt;/p&gt;
&lt;p&gt;I want ForgeZero to be completely free, hyper-fast, and genuinely intelligent, possibly integrating lightweight, targeted AI for build-graph optimization down the line, used smartly and sparingly, not bolted on as a marketing checkbox.&lt;/p&gt;
&lt;p&gt;I'm always inviting developers to jump into the repo and build this with me.&lt;/p&gt;
&lt;p&gt;No corporate bureaucracy, no meetings that could've been a commit message, just pure engineering.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;11-the-engineer-behind-the-optimization&quot;&gt;11. The Engineer Behind the Optimization&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Where does your obsession with performance and removing unnecessary complexity come from?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alex:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Honestly, a deep-seated refusal to accept artificial limits, the ones nobody ever actually proved necessary, just inherited from whoever came before.&lt;/p&gt;
&lt;p&gt;Most developers look at a slow tool or a bloated framework and think, well, I guess that's just how software is now, and move on with their day.&lt;/p&gt;
&lt;p&gt;I can't do that.&lt;/p&gt;
&lt;p&gt;When I see an idle loop burning CPU cycles, or a build tool taking 30 seconds to parse a dependency tree that should take 50 milliseconds, it feels like a personal insult.&lt;/p&gt;
&lt;p&gt;Call it perfectionism, call it an obsession with zero overhead.&lt;/p&gt;
&lt;p&gt;I genuinely love taking a bloated process, stripping away the noise, and watching it run at the absolute limit of the hardware.&lt;/p&gt;
&lt;p&gt;That feeling never gets old.&lt;/p&gt;
&lt;p&gt;The day it does is probably the day I stop doing this for a living.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;about-alex-voste&quot;&gt;About Alex Voste&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Alex Voste&lt;/strong&gt; is a developer based in Canada with a focus on systems programming, performance optimization, open source, and developer tooling.&lt;/p&gt;
&lt;p&gt;His technical interests include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;C&lt;/li&gt;
&lt;li&gt;Go&lt;/li&gt;
&lt;li&gt;x86_64 Assembly&lt;/li&gt;
&lt;li&gt;NASM&lt;/li&gt;
&lt;li&gt;FASM&lt;/li&gt;
&lt;li&gt;C++&lt;/li&gt;
&lt;li&gt;Linux&lt;/li&gt;
&lt;li&gt;Node.js&lt;/li&gt;
&lt;li&gt;Performance optimization&lt;/li&gt;
&lt;li&gt;Linkers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Alex is currently building &lt;strong&gt;ForgeZero&lt;/strong&gt;, a hardened cross-platform build toolchain for Assembly and C developers.&lt;/p&gt;
&lt;h4 id=&quot;forgezero&quot;&gt;ForgeZero&lt;/h4&gt;
&lt;p&gt;ForgeZero brings together tools including &lt;strong&gt;NASM, GAS, FASM, GCC, Clang, LD, and Zig&lt;/strong&gt; behind a unified workflow for low-level developers.&lt;/p&gt;
&lt;p&gt;You can explore the project here:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/forgezero-cli/forgezero&quot;&gt;https://github.com/forgezero-cli/forgezero&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CoderLegion Profile:&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/alexvoste&quot;&gt;https://coderlegion.com/user/alexvoste&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Alex's story is a reminder that software development doesn't have to stop at the abstraction layer.&lt;/p&gt;
&lt;p&gt;For some engineers, the interesting questions begin underneath it:&lt;/p&gt;
&lt;p&gt;What is the machine actually doing?&lt;/p&gt;
&lt;p&gt;Where are the cycles going?&lt;/p&gt;
&lt;p&gt;Why is this process taking so long?&lt;/p&gt;
&lt;p&gt;Which parts of the system are genuinely necessary?&lt;/p&gt;
&lt;p&gt;And perhaps most importantly:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if we remove everything that doesn't need to be there?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;ForgeZero is Alex's answer to those questions — a project built around speed, control, simplicity, security, and a strong dislike of unnecessary overhead.&lt;/p&gt;
&lt;p&gt;Whether you agree with all of Alex's opinions about modern tooling or AI, his approach highlights an important principle for developers at every level:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Understand the system underneath the abstraction.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That understanding becomes even more valuable when the tools themselves start writing the code.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25614/fighting-build-system-complexity-alex-voste-systems-programming-performance-forgezero</guid>
<pubDate>Fri, 28 Aug 2026 12:28:22 +0000</pubDate>
</item>
</channel>
</rss>