<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged arm</title>
<link>https://coderlegion.com/tag/arm</link>
<description>Powered by Question2Answer</description>
<item>
<title>Booting ARM64 Linux in my Rust KVM Hypervisor: Debugging a Memory Issue</title>
<link>https://coderlegion.com/26346/booting-arm64-linux-in-my-rust-kvm-hypervisor-debugging-a-memory-issue</link>
<description>&lt;h3 id=&quot;motivation&quot;&gt;Motivation&lt;/h3&gt;
&lt;p&gt;The time has come: the big test. I got some simple binaries running on my Rust&lt;br&gt;
KVM hypervisor (at this point I am thinking about giving it a proper name). The&lt;br&gt;
next thing has to be Linux. For this series I will show how to actually boot&lt;br&gt;
Linux on my Rust KVM hypervisor on ARM64 CPU (I am using Raspberry Pi 4 which&lt;br&gt;
supports hardware accelerated virtualization).&lt;/p&gt;
&lt;h3 id=&quot;cross-compiling-linux-kernel&quot;&gt;Cross-compiling Linux kernel&lt;/h3&gt;
&lt;p&gt;In one of my &lt;a rel=&quot;nofollow&quot; href=&quot;../kvm-rust-arm-part1&quot;&gt;previous articles&lt;/a&gt; I have described how to&lt;br&gt;
set up the cross-compilation environment with a simple &lt;code&gt;Dockerfile&lt;/code&gt;. We can&lt;br&gt;
reuse it to cross-compile the Linux kernel for ARM64 (after adding some Linux&lt;br&gt;
build dependencies like &lt;code&gt;bc&lt;/code&gt;, &lt;code&gt;flex&lt;/code&gt;, &lt;code&gt;python3&lt;/code&gt;, &lt;code&gt;libssl-dev&lt;/code&gt; and &lt;code&gt;bison&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;docker run --network host -it --rm \
       -e CROSS_COMPILE=aarch64-linux-gnu- -e ARCH=arm64 \
       -v &quot;$(pwd)&quot;:&quot;/home/docker/kvm-rust&quot; -w &quot;/home/docker/kvm-rust/linux-7.1.3&quot; \
       arm-rust-build /bin/bash -c 'make defconfig &amp;amp;&amp;amp; make'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resulting ARM64 Linux kernel image (&lt;code&gt;Image&lt;/code&gt;) should be located in&lt;br&gt;
&lt;code&gt;arch/arm64/boot/&lt;/code&gt; directory.&lt;/p&gt;
&lt;h3 id=&quot;arm64-boot-protocol&quot;&gt;ARM64 boot protocol&lt;/h3&gt;
&lt;p&gt;The full ARM64 Linux boot protocol is quite straightforward and can be found in&lt;br&gt;
the Linux kernel documentation:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.kernel.org/doc/html/v7.1/arch/arm64/booting.html&quot;&gt;https://www.kernel.org/doc/html/v7.1/arch/arm64/booting.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We simply need to parse the 64-byte header and verify the magic field (it should&lt;br&gt;
equal &lt;code&gt;0x644d5241&lt;/code&gt;). I will just highlight a few important points. We should&lt;br&gt;
read &lt;code&gt;image_size&lt;/code&gt; and &lt;code&gt;text_offset&lt;/code&gt; fields and note that the image should be&lt;br&gt;
loaded at an offset of text_offset bytes  at a 2 MiB boundary. For our kernel,&lt;br&gt;
as I checked, &lt;code&gt;text_offset&lt;/code&gt; is zero and &lt;code&gt;image_size&lt;/code&gt; is the amount of memory the&lt;br&gt;
decompressed kernel image occupies in RAM, which is not necessarily the same as&lt;br&gt;
the file size as reported by &lt;code&gt;stat&lt;/code&gt;. Finally, the registers &lt;code&gt;x1&lt;/code&gt;, &lt;code&gt;x2&lt;/code&gt; and &lt;code&gt;x3&lt;/code&gt;&lt;br&gt;
must be set to zero, while &lt;code&gt;x0&lt;/code&gt; must point to the device tree blob (&lt;code&gt;dtb&lt;/code&gt;) in&lt;br&gt;
the RAM. So, for this I chose to load the &lt;code&gt;dtb&lt;/code&gt; at address &lt;code&gt;0x40000000&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;Image&lt;/code&gt; itself on the first 2 MiB boundary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rs&quot;&gt;const LOAD_ADDR: u64 = 0x40_000_000;
const KERN_OFFS: u64 = 0x200_000;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;obtaining-the-device-tree-blob&quot;&gt;Obtaining the device tree blob&lt;/h4&gt;
&lt;p&gt;So, for ARM64 we do need a device tree blob that will tell the Linux kernel on&lt;br&gt;
what kind of hardware it is running on. And this step is quite easy, we will&lt;br&gt;
can simply reuse our command for running QEMU, with one small addition (i.e.&lt;br&gt;
adding &lt;code&gt;dumpdtb&lt;/code&gt; option to &lt;code&gt;-M virt&lt;/code&gt; argument):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;#!/bin/sh

KERNEL=/home/pi/Image
qemu-system-aarch64                                             \
        -M virt,dumpdtb=virt.dtb                                \
        -smp 1                                                  \
        -enable-kvm                                             \
        -cpu host                                               \
        -kernel ${KERNEL}                                       \
        -append &quot;console=ttyAMA0&quot;                               \
        -serial stdio                                           \
        -nographic                                              \
        -nodefaults
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives us a consistent snapshot of the hardware we were running on (in this &lt;br&gt;
case it's emulated hardware for QEMU &lt;code&gt;virt&lt;/code&gt; machine). We could (and we should)&lt;br&gt;
edit and prune it for our own purposes, but let's leave that for another time.&lt;/p&gt;
&lt;h4 id=&quot;implementing-boot-in-rust&quot;&gt;Implementing boot in Rust&lt;/h4&gt;
&lt;p&gt;So, loading the kernel in Rust is quite straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rs&quot;&gt;impl VM {
    pub fn arch_load_linux(&amp;amp;mut self, vcpu: &amp;amp;mut VCPU, args: &amp;amp;Args) -&amp;gt; io::Result&amp;lt;()&amp;gt; {
        let header = get_linux_header(&amp;amp;args.binary)?;
        let linux_mem_idx = self.add_mem_region((KERN_OFFS + header.image_size) as usize, LOAD_ADDR)?;

        match &amp;amp;args.dtb {
            Some(dtb_path) =&amp;gt; { self.load_file_to_memory(linux_mem_idx, &amp;amp;dtb_path, 0x0)?; },
            None =&amp;gt; { return Err(io::Error::other(&quot;Device tree blob not specified.&quot;)); }
        }

        self.load_file_to_memory(linux_mem_idx, &amp;amp;args.binary, KERN_OFFS)?;

        vcpu.set_one_reg(&quot;x0&quot;, LOAD_ADDR)?;
        vcpu.set_one_reg(&quot;x1&quot;, 0x0)?;
        vcpu.set_one_reg(&quot;x2&quot;, 0x0)?;
        vcpu.set_one_reg(&quot;x3&quot;, 0x0)?;

        vcpu.set_one_reg(&quot;pc&quot;, LOAD_ADDR + KERN_OFFS)?;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that the &lt;code&gt;get_linux_header()&lt;/code&gt; function was omitted here as it only parses&lt;br&gt;
the aforementioned ARM64 Linux image headers in a boilerplate manner.&lt;/p&gt;
&lt;h3 id=&quot;function-not-implemented&quot;&gt;Function not implemented&lt;/h3&gt;
&lt;p&gt;As soon as I tried to run my hypervisor with Linux &lt;code&gt;Image&lt;/code&gt;, I ran into an issue:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pi@raspberrypi:~ $ ./rust -d virt Image 
x0 = 0x0
pc = 0x1000
magic 0x644d5241, image_size: 38731776, file_size: 38040064, text_offset: 0x0
Loading &quot;virt&quot; to 0x0...
Loading &quot;Image&quot; to 0x200000...
x0 = 0x40000000
pc = 0x40200000
x0 = 0xfffffbfffda35000
pc = 0xffffd902f0f8a42c
Error: Os { code: 38, kind: Unsupported, message: &quot;Function not implemented&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;trying-strace&quot;&gt;Trying strace&lt;/h4&gt;
&lt;p&gt;First thing I turned to was &lt;code&gt;strace&lt;/code&gt;; it's easy to use and gets some nice&lt;br&gt;
information really fast:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ioctl(5, KVM_ARM_SET_DEVICE_ADDR or KVM_GET_ONE_REG, 0x7fcee98768) = 0
x0 = 0x40000000
ioctl(5, KVM_ARM_SET_DEVICE_ADDR or KVM_GET_ONE_REG, 0x7fcee98768) = 0
pc = 0x40200000
ioctl(5, KVM_RUN, 0)                    = -1 ENOSYS (Function not implemented)
ioctl(5, KVM_ARM_SET_DEVICE_ADDR or KVM_GET_ONE_REG, 0x7fcee988a8) = 0
x0 = 0xfffffbfffda35000
ioctl(5, KVM_ARM_SET_DEVICE_ADDR or KVM_GET_ONE_REG, 0x7fcee988a8) = 0
pc = 0xffffd902f0f8a42c
Error: Os { code: 38, kind: Unsupported, message: &quot;Function not implemented&quot; }
+++ exited with 1 +++
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I didn't learn much other than the fact that &lt;code&gt;KVM_RUN&lt;/code&gt; failed completely with&lt;br&gt;
&lt;code&gt;ENOSYS&lt;/code&gt; error code. My first attempt to find out what was going on was&lt;br&gt;
searching the ARM64 KVM source code for &lt;code&gt;ENOSYS&lt;/code&gt; in Linux repository. It didn't&lt;br&gt;
take long, as it appeared only in one place which was a promising lead:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://elixir.bootlin.com/linux/v7.1.3/source/arch/arm64/kvm/mmio.c#L188&quot;&gt;https://elixir.bootlin.com/linux/v7.1.3/source/arch/arm64/kvm/mmio.c#L188&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;As we can see, the function in question is &lt;code&gt;io_mem_abort()&lt;/code&gt; in the Linux kernel.&lt;br&gt;
Now, &lt;code&gt;strace&lt;/code&gt; got us that far, but this is a Linux kernel function and not much&lt;br&gt;
we can find out more about it with &lt;code&gt;strace&lt;/code&gt; (which traces only system calls like&lt;br&gt;
&lt;code&gt;ioctl&lt;/code&gt;). I haven't even considered &lt;code&gt;GDB&lt;/code&gt; because this was the host kernel,&lt;br&gt;
and this would require a really complex time-consuming setup. Luckily, there was&lt;br&gt;
a much easier way.&lt;/p&gt;
&lt;h4 id=&quot;analyzing-io-memory-abort-with-ebpf&quot;&gt;Analyzing IO memory abort with eBPF&lt;/h4&gt;
&lt;p&gt;The first thing I did was to list &lt;code&gt;kprobe&lt;/code&gt;s in &lt;code&gt;bpftrace&lt;/code&gt; and see if I can hook&lt;br&gt;
into the &lt;code&gt;io_mem_abort&lt;/code&gt; at all via eBPF:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pi@raspberrypi:~ $ sudo bpftrace -l 'kprobe:io_mem_abort'
kprobe:io_mem_abort
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I was in luck, so I could at least confirm that this function was the one&lt;br&gt;
causing the issue, so I attached a &lt;code&gt;kretprobe&lt;/code&gt; to it, ran &lt;code&gt;bpftrace&lt;/code&gt; alongside&lt;br&gt;
my hypervisor and got the confirmation I needed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pi@raspberrypi:~ $ sudo bpftrace -e '
&amp;gt;    kretprobe:io_mem_abort {
&amp;gt;        printf(&quot;ret=%d\n&quot;, retval);
&amp;gt;    }'
Attaching 1 probe...
ret=-38
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;finding-out-what-really-happened&quot;&gt;Finding out what really happened&lt;/h4&gt;
&lt;p&gt;Now that I have confirmed &lt;code&gt;io_mem_abort&lt;/code&gt; was the culprit, it was time to&lt;br&gt;
analyze it more deeply. I attached an additional &lt;code&gt;kprobe&lt;/code&gt; so I could print both&lt;br&gt;
the return value and the &lt;code&gt;fault_ipa&lt;/code&gt; argument:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int io_mem_abort(struct kvm_vcpu *vcpu, phys_addr_t fault_ipa)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The updated &lt;code&gt;bpftrace&lt;/code&gt; script printed the fault address.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;pi@raspberrypi:~ $ sudo bpftrace -e '
&amp;gt;    kretprobe:io_mem_abort {
&amp;gt;        printf(&quot;ret=%d\n&quot;, retval);
&amp;gt;    }
&amp;gt;    kprobe:io_mem_abort {
&amp;gt;        printf(&quot;0x%llx\n&quot;, arg1);
&amp;gt;    }'
Attaching 2 probes...
0x47fff000
ret=-38
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the result surprised me as I initially suspected it was trying to access some&lt;br&gt;
device that I haven't created; however, I realized that this was actually just&lt;br&gt;
beyond the memory I had allocated for the Linux kernel to use. So it clicked: I probably hadn't allocated enough guest memory. Instead of allocating just&lt;br&gt;
&lt;code&gt;header.image_size + KERN_OFFS&lt;/code&gt; I increased it to 1 GiB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rs&quot;&gt;let linux_mem_idx = self.add_mem_region(1024 * 1024 * 1024, LOAD_ADDR)?;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;With that adjustment, the &lt;code&gt;ENOSYS&lt;/code&gt; error disappeared. The guest was clearly&lt;br&gt;
executing code, although I still wasn't seeing any output on the serial console.&lt;br&gt;
That meant the memory issue was solved, but there was at least one more thing&lt;br&gt;
left before we would actually see Linux boot.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26346/booting-arm64-linux-in-my-rust-kvm-hypervisor-debugging-a-memory-issue</guid>
<pubDate>Sun, 06 Sep 2026 18:39:35 +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-1&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>Porting my Rust KVM hypervisor to ARM64: Running the binary</title>
<link>https://coderlegion.com/23276/porting-my-rust-kvm-hypervisor-to-arm64-running-the-binary</link>
<description>&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;/h3&gt;
&lt;p&gt;In the &lt;a rel=&quot;nofollow&quot; href=&quot;../kvm-rust-arm-part1&quot;&gt;previous article&lt;/a&gt; we ported the register handling&lt;br&gt;
of my Rust KVM hypervisor from x86 to ARM64. By tracing QEMU with strace and&lt;br&gt;
inspecting the Linux headers, we discovered that ARM64 accesses CPU state&lt;br&gt;
through the &lt;code&gt;KVM_GET_ONE_REG&lt;/code&gt; and &lt;code&gt;KVM_SET_ONE_REG&lt;/code&gt; interface and reconstructed&lt;br&gt;
the register &lt;code&gt;id&lt;/code&gt; encoding in Rust.&lt;/p&gt;
&lt;p&gt;At that point the hypervisor compiled successfully, but trying to access even&lt;br&gt;
the program counter resulted in an &lt;code&gt;ENOEXEC&lt;/code&gt; error. In this article we'll&lt;br&gt;
continue the reverse-engineering process, initialize the vCPU correctly, and&lt;br&gt;
finally execute our first ARM64 guest.&lt;/p&gt;
&lt;h3 id=&quot;second-roadblock-enoexec-when-getting-setting-pc&quot;&gt;Second roadblock: ENOEXEC when getting / setting PC&lt;/h3&gt;
&lt;p&gt;I was rather optimistic this would work out of the box, but even after trying&lt;br&gt;
to do something as simple as reading the &lt;code&gt;PC&lt;/code&gt; register I hit a snag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ioctl(5&amp;lt;anon_inode:kvm-vcpu:0&amp;gt;, KVM_ARM_SET_DEVICE_ADDR or KVM_GET_ONE_REG, 0x7fdf2c81e8) = -1 ENOEXEC (Exec format error)
Error: Os { code: 8, kind: Uncategorized, message: &quot;Exec format error&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Luckily, I had a concrete lead now: error code and KVM command. So I searched&lt;br&gt;
for the &lt;code&gt;KVM_GET_ONE_REG&lt;/code&gt; in the Linux kernel code on Bootlin Elixir and found&lt;br&gt;
the following code in &lt;a rel=&quot;nofollow&quot; href=&quot;https://elixir.bootlin.com/linux/v6.1.21/source/arch/arm64/kvm/arm.c#L1298&quot;&gt;arch/arm64/kvm/arm.c&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;case KVM_SET_ONE_REG:
case KVM_GET_ONE_REG: {
struct kvm_one_reg reg;

r = -ENOEXEC;
if (unlikely(!kvm_vcpu_initialized(vcpu)))
break;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is exactly the error we were getting and we immediately found what was&lt;br&gt;
missing: vCPU initialization. Actually, if we take a closer look at the code we&lt;br&gt;
will see the comment kernel developers have left us:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;int kvm_arch_vcpu_create(struct kvm_vcpu *vcpu)
{
int err;

/* Force users to call KVM_ARM_VCPU_INIT */
vcpu-&amp;gt;arch.target = -1;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;initializing-the-vcpu&quot;&gt;Initializing the vCPU&lt;/h4&gt;
&lt;p&gt;Following the lead I looked for &lt;code&gt;KVM_ARM_VCPU_INIT&lt;/code&gt; in the original &lt;code&gt;strace&lt;/code&gt; log&lt;br&gt;
and found:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;137152 ioctl(12&amp;lt;anon_inode:kvm-vcpu:0&amp;gt;, 0x4020aeae /* KVM_ARM_VCPU_INIT */, 0x7fff57e028) = 0
137156 ioctl(10&amp;lt;anon_inode:kvm-vcpu:0&amp;gt;, 0x4020aeae /* KVM_ARM_VCPU_INIT */, 0x7f82f70e68) = 0
137152 ioctl(10&amp;lt;anon_inode:kvm-vcpu:0&amp;gt;, 0x4020aeae /* KVM_ARM_VCPU_INIT */, 0x7fff57dd78) = 0
137152 ioctl(10&amp;lt;anon_inode:kvm-vcpu:0&amp;gt;, 0x4020aeae /* KVM_ARM_VCPU_INIT */, 0x7fff57e048) = 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This wasn't really helpful: we again only see addresses, but we see no hints of&lt;br&gt;
underlying structure. So let's first take a look at how &lt;code&gt;KVM_ARM_VCPU_INIT&lt;/code&gt; is&lt;br&gt;
defined in the Linux headers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ grep -Rn KVM_ARM_VCPU_INIT /usr/include/
/usr/include/linux/kvm.h:1622:#define KVM_ARM_VCPU_INIT   _IOW(KVMIO,  0xae, struct kvm_vcpu_init)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A quick search for &lt;code&gt;struct kvm_vcpu_init&lt;/code&gt; revealed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ grep -Rn 'struct kvm_vcpu_init' /usr/include/ -A3
/usr/include/aarch64-linux-gnu/asm/kvm.h:112:struct kvm_vcpu_init {
/usr/include/aarch64-linux-gnu/asm/kvm.h-113-   __u32 target;
/usr/include/aarch64-linux-gnu/asm/kvm.h-114-   __u32 features[7];
/usr/include/aarch64-linux-gnu/asm/kvm.h-115-};
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;getting-struct-data-with-python-gdb&quot;&gt;Getting struct data with Python GDB&lt;/h4&gt;
&lt;p&gt;I didn't want to spend much time understanding every target and feature flag. My&lt;br&gt;
goal was simply to reproduce QEMU's behavior as quickly as possible (and I&lt;br&gt;
didn't even have to understand it completely). Luckily, this structure was&lt;br&gt;
really simple (basically an array of eight &lt;code&gt;32&lt;/code&gt;-bit integers), so I decided to&lt;br&gt;
tackle this with Python GDB automation. I created a breakpoint to stop exactly&lt;br&gt;
on &lt;code&gt;ioctl&lt;/code&gt; call when we are in &lt;code&gt;KVM_ARM_VCPU_INIT&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-py&quot;&gt;import gdb
import struct

def log(line):
    print(line)
    with open(&quot;gdb-debug.out&quot;, &quot;a&quot;) as f:
        f.write(line + &quot;\n&quot;)

class IoctlBreakpoint(gdb.Breakpoint):

    def stop(self):
        if x1 == 0x4020aeae: # KVM_ARM_VCPU_INIT
            ptr = int(gdb.parse_and_eval(&quot;$x2&quot;))
            vcpu_init_mem = gdb.selected_inferior().read_memory(ptr, 32) # u32 + 7 * u32
            target_features = struct.unpack(&quot;&amp;lt;8I&quot;, bytes(vcpu_init_mem))
            log(f&quot;KVM_ARM_VCPU_INIT: target={target_features[0]} features={target_features[1:]}&quot;)        
            return False

if __name__ == &quot;__main__&quot;:
    tracer = IoctlBreakpoint(&quot;ioctl&quot;)
    gdb.execute(&quot;handle SIGUSR1 SIGUSR2 nostop noprint pass&quot;)
    gdb.execute(&quot;run&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I ran my QEMU command with GDB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;gdb -ex 'source ./trace-arm.py' ./start-qemu.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This has automatically yielded great results:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;KVM_ARM_VCPU_INIT: target=5 features=(0, 0, 0, 0, 0, 0, 0)
KVM_ARM_VCPU_INIT: target=5 features=(12, 0, 0, 0, 0, 0, 0)
KVM_ARM_VCPU_INIT: target=5 features=(12, 0, 0, 0, 0, 0, 0)
KVM_ARM_VCPU_INIT: target=5 features=(12, 0, 0, 0, 0, 0, 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For now I want to try just setting &lt;code&gt;target&lt;/code&gt;, but leave &lt;code&gt;features&lt;/code&gt; zeroed out. I&lt;br&gt;
could immediately reconstruct this in Rust:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rs&quot;&gt;impl VCPU {
    pub fn arm_vcpu_init(&amp;amp;self) -&amp;gt; io::Result&amp;lt;()&amp;gt; {
        let mut vcpu_init : kvm_vcpu_init = unsafe { std::mem::zeroed() };
        // KVM_ARM_VCPU_INIT: target=5 features=(0, 0, 0, 0, 0, 0, 0)
        vcpu_init.target = 5;
        let ret = unsafe { libc::ioctl(self.fd, KVM_ARM_VCPU_INIT, &amp;amp;mut vcpu_init) };
        if ret &amp;lt; 0 {
            return Err(io::Error::last_os_error());
        }
        Ok(())
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;third-roadblock-no-kvm-exit&quot;&gt;Third roadblock: No KVM exit&lt;/h3&gt;
&lt;p&gt;Soon after this I was able to get and set registers properly. The first ARM test&lt;br&gt;
binary I compiled was based on working UART code from my hobby operating system&lt;br&gt;
I started on Raspberry Pi. I expected at least a fault exit, if not an MMIO exit&lt;br&gt;
when UART code was called. However, &lt;code&gt;KVM_RUN&lt;/code&gt; never returned.&lt;/p&gt;
&lt;p&gt;This is where I was stuck for a while. I considered creating a separate thread&lt;br&gt;
that repeatedly queried &lt;code&gt;KVM_GET_ONE_REG&lt;/code&gt; for &lt;code&gt;PC&lt;/code&gt;, but from my own working&lt;br&gt;
experience I knew that calling KVM &lt;code&gt;ioctl&lt;/code&gt; calls asynchronously was a really bad&lt;br&gt;
idea. I also considered adding hardware breakpoint capabilities to at least&lt;br&gt;
trigger &lt;code&gt;KVM_EXIT_DEBUG&lt;/code&gt;, but this would require more infrastructure for just&lt;br&gt;
checking simple binary.&lt;/p&gt;
&lt;h4 id=&quot;writing-a-simple-binary&quot;&gt;Writing a simple binary&lt;/h4&gt;
&lt;p&gt;I decided to try adding a signal handler instead. If I interrupted &lt;code&gt;KVM_RUN&lt;/code&gt;&lt;br&gt;
call with &lt;code&gt;SIGINT&lt;/code&gt; maybe I could get registers then synchronously. I lowered&lt;br&gt;
my expectations for initial binary and just created something really simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;.section .text

.macro curr_el_to reg
mrs\reg, CurrentEL
lsr\reg, \reg, #2
and\reg, \reg, #0xf
.endm

.globl _start
_start:
mrsx0, mpidr_el1
andx0, x0, #0xFF
cbzx0, control
b .

control:
curr_el_to x0
b .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It looks like much, but this code will just load current CPU privilege level to&lt;br&gt;
&lt;code&gt;x0&lt;/code&gt; register (being that level &lt;code&gt;EL=1&lt;/code&gt; is operating-system level, &lt;code&gt;x0&lt;/code&gt; should be&lt;br&gt;
set to &lt;code&gt;0x1&lt;/code&gt;). The &lt;code&gt;mpidr_el1&lt;/code&gt; code is for checking if we are on control CPU&lt;br&gt;
(this should be unnecessary here, but I added this out of paranoia caused by&lt;br&gt;
real-life issues I had on ARM).&lt;/p&gt;
&lt;h4 id=&quot;installing-signal-handler&quot;&gt;Installing signal handler&lt;/h4&gt;
&lt;p&gt;Finally, I added a signal handler:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rs&quot;&gt;extern &quot;C&quot; fn handler(_sig: libc::c_int) { }

unsafe fn install_interrupt_signal() {
    let mut sa: libc::sigaction = std::mem::zeroed();
    sa.sa_sigaction = handler as *const() as usize;
    libc::sigemptyset(&amp;amp;mut sa.sa_mask);
    sa.sa_flags = 0;
    libc::sigaction(libc::SIGINT, &amp;amp;sa, std::ptr::null_mut());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The handler itself does nothing. I only needed the signal to interrupt the blocking &lt;code&gt;KVM_RUN&lt;/code&gt; call. So my final KVM loop looked like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rs&quot;&gt;    unsafe { install_interrupt_signal() };

    let run = vcpu.kvm_run_mem as *mut kvm_run;

    loop {
        let ret = unsafe { libc::ioctl(vcpu.fd, KVM_RUN, 0usize) };
        if ret &amp;lt; 0 {
            if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
                vcpu.print_regs()?;
            }
            return Err(io::Error::last_os_error());
        }

        let exit_reason = unsafe { (*run).exit_reason };
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;running-the-binary&quot;&gt;Running the binary&lt;/h3&gt;
&lt;p&gt;Finally, it was time to run the gues:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;pi@raspberrypi:~ $ ./rust hello-world-arm.img
x0 = 0x0
pc = 0x1000
Loading &quot;hello-world-arm.img&quot; to 0x1000...
^Cx0 = 0x1
pc = 0x101c
Error: Os { code: 4, kind: Interrupted, message: &quot;Interrupted system call&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can see that &lt;code&gt;PC&lt;/code&gt; did change and that &lt;code&gt;x0&lt;/code&gt; really is set to current CPU&lt;br&gt;
privilege level: &lt;code&gt;0x1&lt;/code&gt;. Why it didn't work for my UART code is probably that the&lt;br&gt;
guest itself requires more initialization than I though (x86 went more smoothly&lt;br&gt;
and felt more plug-and-play).&lt;/p&gt;
&lt;h3 id=&quot;conclusion-2&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Although x86 and ARM64 share the same KVM interface at a high level, their&lt;br&gt;
userspace APIs differ significantly. Porting my hypervisor turned out to involve&lt;br&gt;
much more than renaming registers: ARM64 uses per-register &lt;code&gt;id&lt;/code&gt;s instead of a&lt;br&gt;
single register structure, requires explicit vCPU initialization through&lt;br&gt;
&lt;code&gt;KVM_ARM_VCPU_INIT&lt;/code&gt; and demanded a fair amount of reverse engineering before I&lt;br&gt;
could successfully execute guest code. Fortunately, the same workflow that&lt;br&gt;
worked on x86 like combining strace, GDB and the Linux kernel source proved just&lt;br&gt;
as effective on ARM64 (although required a bit more effort).&lt;/p&gt;
&lt;p&gt;The code from this article can be found on my GitHub page:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/StjepanPoljak/kvm-rust/tree/kvm-arm-part2-code&quot;&gt;https://github.com/StjepanPoljak/kvm-rust/tree/kvm-arm-part2-code&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23276/porting-my-rust-kvm-hypervisor-to-arm64-running-the-binary</guid>
<pubDate>Wed, 22 Jul 2026 18:02:25 +0000</pubDate>
</item>
<item>
<title>The Rise of ARM Servers: Should Developers Care My Take?</title>
<link>https://coderlegion.com/20976/the-rise-of-arm-servers-should-developers-care-my-take</link>
<description>&lt;p&gt;You push a build, your CI runner fails on a dependency that only ships prebuilt x86 binaries, and your staging server’s performance bill spikes. You're juggling compatibility headaches, rising cloud costs, and pressure to optimize both speed and energy use all while shipping features.&lt;/p&gt;
&lt;p&gt;ARM servers are maturing fast. With major cloud providers offering Arm-based instances, improved tooling (multi-arch containers, cross-compilers), and real-world wins in cost and efficiency, ARM is no longer an academic alternative, it's a practical option. This post walks through what ARM servers mean for developers, when to care, and concrete steps to evaluate or migrate your workloads.&lt;/p&gt;
&lt;h5 id=&quot;why-arm-servers-are-suddenly-everywhere&quot;&gt;Why ARM servers are suddenly everywhere&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;ARM (Advanced RISC Machines) is a CPU architecture that emphasizes efficiency and a simpler instruction set.&lt;/li&gt;
&lt;li&gt;Historically dominant in mobile and embedded devices (think smartphones), ARM is now competitive in the server space thanks to:&lt;ul&gt;
&lt;li&gt;Custom cloud silicon (e.g., AWS Graviton, Ampere Altra) delivering good price/performance.&lt;/li&gt;
&lt;li&gt;Broader OS and tooling support (Linux distros, container runtimes, package managers).&lt;/li&gt;
&lt;li&gt;Developer-friendly ecosystems: languages (Go, Rust), runtimes (Node, Java, Python), and containerization workflows increasingly support arm64.&lt;/li&gt;
&lt;li&gt;Power efficiency and density advantages for data centers and edge deployments.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;should-developers-care-short-answer-yes-but-selectively-&quot;&gt;Should developers care? Short answer: Yes — but selectively.&lt;/h5&gt;
&lt;p&gt;Not every team needs to rush into ARM. But every developer should care enough to understand the trade-offs and be able to evaluate the benefits for their stack.&lt;/p&gt;
&lt;p&gt;Key areas where ARM matters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cost and price performance: For many cloud workloads, arm64 instances can be cheaper for the same throughput.&lt;/li&gt;
&lt;li&gt;Energy and sustainability: ARM’s efficiency lowers power draw relevant for green initiatives and edge deployments.&lt;/li&gt;
&lt;li&gt;Edge and IoT convergence: If you deploy to ARM-based edge devices, aligning server-side architecture reduces friction.&lt;/li&gt;
&lt;li&gt;Performance: Some integer-heavy server workloads perform very well on modern ARM CPUs; others (specialized AVX-heavy workloads) may still favor x86.&lt;/li&gt;
&lt;li&gt;Ecosystem maturity: Tooling has improved dramatically, multi-arch images, CI support, and native packages are common now.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When ARM might not be a fit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Heavy reliance on x86-only native binaries or proprietary drivers.&lt;/li&gt;
&lt;li&gt;Applications tightly coupled to SIMD/AVX-512 optimizations that aren’t available on ARM.&lt;/li&gt;
&lt;li&gt;Teams with large legacy stacks where migration cost outweighs operational savings.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;practical-checklist-should-your-team-pilot-arm-servers-&quot;&gt;Practical checklist: Should your team pilot ARM servers?&lt;/h5&gt;
&lt;p&gt;Use this quick checklist to decide whether to test ARM for your workloads:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Is your app primarily high-level language code (Go, Rust, Node.js, Python, Java)? → Good candidate.&lt;/li&gt;
&lt;li&gt;Do you use lots of native C/C++ extensions with prebuilt x86-only binaries? → Investigate compatibility or recompilation paths.&lt;/li&gt;
&lt;li&gt;Are you looking to reduce cloud costs or improve energy efficiency? → Worth testing.&lt;/li&gt;
&lt;li&gt;Do you deploy to edge or ARM-based devices? → Strong alignment benefit.&lt;/li&gt;
&lt;li&gt;Can your CI/CD pipeline build multi-arch images and run arm64 tests? → Necessary for safe rollout.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you checked two or more “yes” answers, consider a small pilot (e.g., one microservice or a staging environment).&lt;/p&gt;
&lt;h5 id=&quot;how-to-evaluate-arm-a-practical-approach&quot;&gt;How to evaluate ARM — a practical approach&lt;/h5&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Start small&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pick a stateless microservice or a low-risk backend job.&lt;/li&gt;
&lt;li&gt;Run benchmarks on both x86 and arm64 instances for representative workloads.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use multi-arch containers&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Build multi-architecture images to support both x86_64 and arm64:&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Using Docker buildx (example)
docker buildx create --use
docker buildx build --platform linux/amd64,linux/arm64 -t myorg/myapp:latest --push .
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run tests natively on arm64&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Avoid relying solely on emulation for performance tests. Launch a small arm64 instance in your cloud provider or use a local ARM machine.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Check dependencies&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Audit native modules and third-party binaries. For Node.js, look for native addons; for Python, check wheels availability for arm64.&lt;/li&gt;
&lt;li&gt;If native code exists, plan to recompile or replace with pure-language alternatives.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CI/CD updates&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Add arm64 runners or use cloud-based arm64 CI builders.&lt;/li&gt;
&lt;li&gt;Use cross-compilation for compiled languages where possible.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Measure and iterate&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Collect metrics: latency, throughput, CPU utilization, memory use, and cost.&lt;/li&gt;
&lt;li&gt;Compare price/performance, not just raw CPU numbers.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h5 id=&quot;concrete-migration-tips-and-commands&quot;&gt;Concrete migration tips and commands&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Detect architecture:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uname -m   # returns aarch64 for ARM64; x86_64 for x86
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Go: cross-compile for Linux/ARM64&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GOOS=linux GOARCH=arm64 go build -o myapp-linux-arm64 ./cmd/myapp
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Node.js: rebuild native modules on arm64&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm rebuild --arch=arm64
# or build multi-arch images and run `npm install` inside arm64 build step
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Python: prefer manylinux wheels or build from source in arm64 environment. Use &lt;code&gt;pip&lt;/code&gt; with platform-specific wheels if available.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Emulation (use sparingly for tests, not production):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Docker Buildx can use QEMU for cross-building, but native testing on arm64 instances will be more reliable.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;performance-and-compatibility-considerations&quot;&gt;Performance and compatibility considerations&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Performance varies by workload:&lt;ul&gt;
&lt;li&gt;Compute-bound tasks may do very well on modern ARM, but some vectorized workloads (heavily tuned for AVX) might still favor x86.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Library support:&lt;ul&gt;
&lt;li&gt;Popular libraries now ship arm64 builds, but niche or enterprise packages may lag.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Tooling:&lt;ul&gt;
&lt;li&gt;Container runtimes, orchestrators (Kubernetes), and observability stacks have arm64 support — though some vendor plugins may lag.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;devops-and-ci-implications&quot;&gt;DevOps and CI implications&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Build pipelines should produce and test multi-arch artifacts.&lt;/li&gt;
&lt;li&gt;CI runners: add arm64 runners (self-hosted or cloud-provided) to avoid blind spots.&lt;/li&gt;
&lt;li&gt;Monitoring: ensure observability and APM tools can instrument arm64 instances.&lt;/li&gt;
&lt;li&gt;Infrastructure as Code: update images and instance types in IaC templates (Terraform, CloudFormation).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keywords: arm64 CI runners, Kubernetes arm64, IaC for ARM&lt;/p&gt;
&lt;h5 id=&quot;example-migration-plan-minimal-risk-&quot;&gt;Example migration plan (minimal risk)&lt;/h5&gt;
&lt;ol&gt;
&lt;li&gt;Choose a candidate service (stateless, low-calls).&lt;/li&gt;
&lt;li&gt;Build multi-arch container images.&lt;/li&gt;
&lt;li&gt;Deploy to a small arm64 staging environment.&lt;/li&gt;
&lt;li&gt;Run full integration and load tests.&lt;/li&gt;
&lt;li&gt;Compare metrics and costs over a week under representative load.&lt;/li&gt;
&lt;li&gt;If results are positive, roll out more services in phases.&lt;/li&gt;
&lt;/ol&gt;
&lt;h5 id=&quot;common-pitfalls-to-avoid&quot;&gt;Common pitfalls to avoid&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Assuming everything “just works&quot;,  always test native dependencies.&lt;/li&gt;
&lt;li&gt;Skipping production-like tests, emulation can hide problems.&lt;/li&gt;
&lt;li&gt;Not updating CI,  builds that pass on x86 may fail on arm64 at deployment time.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;final-verdict-for-developers&quot;&gt;Final verdict for developers&lt;/h5&gt;
&lt;p&gt;ARM servers are no longer niche. For many modern applications, especially those written in portable languages and deployed in the cloud —&amp;gt; arm64 offers tangible benefits in cost and energy efficiency. Developers should:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Understand the architecture differences and test key workloads.&lt;/li&gt;
&lt;li&gt;Adopt multi-arch build pipelines and arm64 CI runners.&lt;/li&gt;
&lt;li&gt;Audit dependencies and plan recompilation or replacement where needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you maintain services that are performance sensitive or rely on specialized x86 optimizations, don’t rush  but do prepare. At minimum, integrate arm64 into your testing matrix so you aren’t surprised when your cloud provider offers a cheaper or faster option.&lt;/p&gt;
&lt;h5 id=&quot;call-to-action&quot;&gt;Call to action&lt;/h5&gt;
&lt;p&gt;Tried ARM in your stack? Share your wins, gotchas, or benchmark numbers in the comments — it helps other developers make better decisions. If you found this useful, share the post with your team and bookmark the page for migration checklists and follow-ups. Want a migration checklist tailored to your app? Reply with your stack details (language, dependencies, CI) and I’ll outline a concrete pilot plan.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20976/the-rise-of-arm-servers-should-developers-care-my-take</guid>
<pubDate>Fri, 19 Jun 2026 11:11:54 +0000</pubDate>
</item>
</channel>
</rss>