<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel Petrovic</title>
    <description>The latest articles on DEV Community by Daniel Petrovic (@danielpetrovic).</description>
    <link>https://dev.to/danielpetrovic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4056694%2Fa9d7ab7b-2e2f-4d51-9abb-fc7b954b2766.jpg</url>
      <title>DEV Community: Daniel Petrovic</title>
      <link>https://dev.to/danielpetrovic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danielpetrovic"/>
    <language>en</language>
    <item>
      <title>AArch64 Register Conventions: What Survives a Function Call—and Why</title>
      <dc:creator>Daniel Petrovic</dc:creator>
      <pubDate>Thu, 03 Sep 2026 18:01:04 +0000</pubDate>
      <link>https://dev.to/danielpetrovic/aarch64-register-conventions-what-survives-a-function-call-and-why-ag3</link>
      <guid>https://dev.to/danielpetrovic/aarch64-register-conventions-what-survives-a-function-call-and-why-ag3</guid>
      <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 "the CPU saves it." 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;h2&gt;
  
  
  AArch64 vs ARM64: what's in a name?
&lt;/h2&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;h2&gt;
  
  
  The big picture
&lt;/h2&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 "32" 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;h2&gt;
  
  
  Caller-saved vs callee-saved: the conceptual key
&lt;/h2&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;"Caller-saved" means the caller must protect a value if it needs it after a call.&lt;/li&gt;
&lt;li&gt;"Callee-saved" 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 "if you modify it, you must restore it before returning."&lt;/p&gt;

&lt;h3&gt;
  
  
  A common misconception
&lt;/h3&gt;

&lt;p&gt;A callee-saved register isn't inherently a "long-lived register." 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. "Callee-saved" describes the register's contract, not the lifetime of the value stored in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope: what this article covers
&lt;/h2&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;h2&gt;
  
  
  The calling-convention register map
&lt;/h2&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 cheat sheet 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;h2&gt;
  
  
  X0–X7: arguments and results
&lt;/h2&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 "eight registers" does not necessarily mean "eight source-level arguments," 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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&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;h2&gt;
  
  
  X8: indirect results
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&gt;



&lt;h2&gt;
  
  
  X9–X15: caller-clobbered scratch registers
&lt;/h2&gt;

&lt;p&gt;These are caller-clobbered registers available for any intermediate use. The important nuance: "caller-saved" doesn't mean "temporary" 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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&gt;



&lt;h2&gt;
  
  
  X16–X17: IP0/IP1 and linker scratch
&lt;/h2&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;h2&gt;
  
  
  X18: platform register
&lt;/h2&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;h2&gt;
  
  
  X19–X28: callee-saved registers
&lt;/h2&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: "callee-saved" 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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&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;h2&gt;
  
  
  X29 and X30: frame pointer and link register
&lt;/h2&gt;

&lt;h3&gt;
  
  
  X29: frame pointer
&lt;/h3&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;h3&gt;
  
  
  X30: link register
&lt;/h3&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;h3&gt;
  
  
  Pointer Authentication and X30 security
&lt;/h3&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&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;h2&gt;
  
  
  SP, XZR, and W registers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SP: stack pointer
&lt;/h3&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;h3&gt;
  
  
  XZR: the zero register
&lt;/h3&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;h3&gt;
  
  
  W registers: the 32-bit view
&lt;/h3&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MOV     W0, #42
// X0 is now 0x000000000000002A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  SIMD and floating-point registers
&lt;/h2&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;h3&gt;
  
  
  V0–V7: argument and return registers
&lt;/h3&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&gt;



&lt;h3&gt;
  
  
  V8–V15: partially preserved
&lt;/h3&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;h3&gt;
  
  
  V16–V31: temporary registers
&lt;/h3&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;h2&gt;
  
  
  Condition flags: NZCV
&lt;/h2&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;"cc"&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&gt;



&lt;h2&gt;
  
  
  What this looks like in compiler output
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;foo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&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;/div&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. "Caller-saved" describes what happens across calls, not how long a value lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing inline assembly
&lt;/h2&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;"cc"&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;h2&gt;
  
  
  AAPCS64 vs platform ABI
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AArch64 architecture
        ↓
    AAPCS64
        ↓
    platform ABI
        ↓
language/runtime/toolchain conventions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  Practical cheat sheet
&lt;/h2&gt;

&lt;p&gt;Here's the compact cheat sheet - the part you'll want to bookmark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&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;/div&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&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;h2&gt;
  
  
  Official documentation and further reading
&lt;/h2&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 href="https://github.com/ARM-software/abi-aa" rel="noopener noreferrer"&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 href="https://developer.arm.com/documentation/ddi0602/latest" rel="noopener noreferrer"&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 href="https://github.com/ARM-software/abi-aa/blob/main/sysvabi64/sysvabi64.rst" rel="noopener noreferrer"&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 href="https://learn.microsoft.com/en-us/cpp/build/arm64-windows-abi-conventions" rel="noopener noreferrer"&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 href="https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms" rel="noopener noreferrer"&gt;Apple Platform ABI for AArch64&lt;/a&gt; - Apple's platform-specific conventions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.kernel.org/arch/arm64/index.html" rel="noopener noreferrer"&gt;Linux kernel AArch64 architecture&lt;/a&gt; - Platform-specific details for Linux on AArch64&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>cpp</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building a Linux Kernel Module in Pure x86</title>
      <dc:creator>Daniel Petrovic</dc:creator>
      <pubDate>Fri, 31 Jul 2026 13:38:31 +0000</pubDate>
      <link>https://dev.to/danielpetrovic/building-a-linux-kernel-module-in-pure-x86-i7l</link>
      <guid>https://dev.to/danielpetrovic/building-a-linux-kernel-module-in-pure-x86-i7l</guid>
      <description>&lt;p&gt;Most Linux kernel modules are written in C.&lt;/p&gt;

&lt;p&gt;The reasons are obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The kernel APIs are designed around C.&lt;/li&gt;
&lt;li&gt;Documentation assumes C.&lt;/li&gt;
&lt;li&gt;The build system naturally integrates with C.&lt;/li&gt;
&lt;li&gt;The compiler handles many low-level details for us.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is another approach: remove the compiler-generated layer and interact with the kernel using only the interfaces visible at the binary level.&lt;/p&gt;

&lt;p&gt;What if we remove the compiler-generated machinery and take responsibility for every detail ourselves?&lt;/p&gt;

&lt;p&gt;What if the only language between your code and the Linux kernel is x86-64 assembly?&lt;/p&gt;

&lt;p&gt;I used GAS (GNU Assembler) for this — the same assembler the kernel build system already uses behind the scenes. The module is built with the kernel's kbuild system, so no external toolchain is needed.&lt;/p&gt;

&lt;p&gt;Although this experiment uses x86-64 as its host platform, the core lessons&lt;br&gt;
apply to embedded Linux on ARM64 (AArch64) and RISC-V as well: ELF section&lt;br&gt;
flags, symbol export tables, and build-time validation remain part of the&lt;br&gt;
module contract. The exact &lt;code&gt;objtool&lt;/code&gt; checks and security-thunk sequences&lt;br&gt;
depend on the target architecture and kernel configuration.&lt;/p&gt;

&lt;p&gt;The objective was simple to describe, but less simple to implement:&lt;/p&gt;

&lt;p&gt;Create a Linux kernel module in pure x86 assembly, load it into a modern Fedora kernel, and make it print messages when it loads and unloads.&lt;/p&gt;

&lt;p&gt;The final assembly implementation is surprisingly small, but the surrounding kernel contracts are where most of the complexity lives.&lt;/p&gt;

&lt;p&gt;There were a few bumps along the way.&lt;/p&gt;

&lt;p&gt;The kernel rejected several iterations of the module, and each failure revealed another hidden rule of kernel development.&lt;/p&gt;

&lt;p&gt;This post documents the investigation process, the failures encountered, and the kernel mechanisms behind them.&lt;/p&gt;

&lt;p&gt;Everything in this post was tested on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt;
&lt;span class="go"&gt;x86_64

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;span class="go"&gt;7.1.4-204.fc44.x86_64

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;lsb_release &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="go"&gt;Distributor ID: Fedora
Description:    Fedora Linux 44 (Workstation Edition)
Release:        44
Codename:       n/a

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;as &lt;span class="nt"&gt;--version&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="go"&gt;GNU assembler version 2.46.1-1.fc44

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gcc &lt;span class="nt"&gt;--version&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="go"&gt;gcc (GCC) 16.1.1 20260515 (Red Hat 16.1.1-2)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The First Attempt: A Simple Assembly Module
&lt;/h2&gt;

&lt;p&gt;The initial implementation focused on the minimum required kernel interfaces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create an assembly file.&lt;/li&gt;
&lt;li&gt;Export entry and exit points.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;printk&lt;/code&gt; to write messages into the kernel log.&lt;/li&gt;
&lt;li&gt;Build with kbuild.&lt;/li&gt;
&lt;li&gt;Insert with &lt;code&gt;insmod&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a deeper look at what happens under the hood when you run &lt;code&gt;insmod&lt;/code&gt;, see &lt;a href="https://kernel-internals.org/modules/module-loading-internals/" rel="noopener noreferrer"&gt;Module Loading Internals&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A kernel module is organized into ELF sections. The ones that matter for a minimal module:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.modinfo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Null-separated key=value strings: &lt;code&gt;license&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;vermagic&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.init.text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Init code — freed after &lt;code&gt;mod-&amp;gt;init()&lt;/code&gt; returns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.exit.text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cleanup code — kept until unload&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first version looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.intel_syntax noprefix

.extern printk

.section .modinfo               # Module metadata
.asciz "license=GPL"
.asciz "description=Minimal assembly Linux kernel module"
.asciz "author=Daniel Petrovic"

.section .init.text

.globl init_module
.type init_module, @function
init_module:
    lea rdi, [rip + msg_load]  # RIP-relative: kernel modules are position-independent
    xor eax, eax
    call printk
    xor eax, eax
    ret

.globl cleanup_module
.type cleanup_module, @function
cleanup_module:
    lea rdi, [rip + msg_unload]
    xor eax, eax
    call printk
    ret

.section .rodata
msg_load:
    .asciz "asm_module: loaded\n"
msg_unload:
    .asciz "asm_module: unloaded\n"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the assembly as &lt;code&gt;asm_module.S&lt;/code&gt; and place this &lt;code&gt;Makefile&lt;/code&gt; beside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;obj-m&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; asm_module.o

&lt;span class="nv"&gt;KDIR&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; /lib/modules/&lt;span class="p"&gt;$(&lt;/span&gt;shell &lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;/build

&lt;span class="nl"&gt;all&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;KDIR&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;M&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;PWD&lt;span class="p"&gt;)&lt;/span&gt; modules

&lt;span class="nl"&gt;clean&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;$(&lt;/span&gt;MAKE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;KDIR&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;M&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;PWD&lt;span class="p"&gt;)&lt;/span&gt; clean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code was simple.&lt;/p&gt;

&lt;p&gt;The kernel was not impressed.&lt;/p&gt;

&lt;p&gt;The build succeeded, but loading failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;insmod: ERROR: could not insert module asm_module.ko: Invalid module format
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Something was wrong inside the module.&lt;/p&gt;

&lt;p&gt;Time to investigate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: How .modinfo Was Duplicated
&lt;/h2&gt;

&lt;p&gt;The first clue appeared in the kernel log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Only one .modinfo section must exist.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was not because assembly modules must never define &lt;code&gt;.modinfo&lt;/code&gt;. A pure&lt;br&gt;
assembly module normally supplies its own metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.section .modinfo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A C module usually produces the same kind of metadata through macros:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;MODULE_LICENSE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GPL"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;MODULE_DESCRIPTION&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Example module"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;MODULE_AUTHOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Author"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those macros emit &lt;code&gt;.modinfo&lt;/code&gt; entries; kbuild does not automatically create&lt;br&gt;
them for every assembly source file. It can, however, generate additional&lt;br&gt;
module metadata such as &lt;code&gt;vermagic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In this build, the generated metadata and the assembly-supplied metadata did&lt;br&gt;
not merge. The assembly section had no flags, while the generated section was&lt;br&gt;
allocatable, so the final module contained two separate sections with the same&lt;br&gt;
name:&lt;/p&gt;

&lt;p&gt;The kernel saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.modinfo
.modinfo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and refused to load it.&lt;/p&gt;

&lt;p&gt;The fix was to mark the assembly section allocatable so it was compatible with&lt;br&gt;
the generated metadata and the linker combined the entries into one section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.section .modinfo,"a"

.asciz "license=GPL"
.asciz "description=Minimal assembly Linux kernel module"
.asciz "author=Daniel Petrovic"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;"a"&lt;/code&gt; flag marks the section as allocatable, meaning it occupies memory in&lt;br&gt;
the loaded object.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 2: The Kernel Wants ELF Information
&lt;/h2&gt;

&lt;p&gt;After fixing &lt;code&gt;.modinfo&lt;/code&gt;, the build continued but &lt;code&gt;objtool&lt;/code&gt; complained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;init_module() is missing an ELF size annotation
cleanup_module() is missing an ELF size annotation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In normal assembly, this is perfectly valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function:
    mov eax, 1
    ret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CPU understands it.&lt;/p&gt;

&lt;p&gt;But the Linux kernel does more than execute code.&lt;/p&gt;

&lt;p&gt;It analyzes code.&lt;/p&gt;

&lt;p&gt;Tools like &lt;code&gt;objtool&lt;/code&gt; inspect functions for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stack correctness&lt;/li&gt;
&lt;li&gt;security issues&lt;/li&gt;
&lt;li&gt;control flow problems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;objtool&lt;/code&gt; relies on ELF symbol information to understand function boundaries.&lt;br&gt;
Without size annotations, assembly-written functions may not contain enough&lt;br&gt;
metadata for its analysis.&lt;/p&gt;

&lt;p&gt;The missing piece was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.size function_name, .-function_name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a GAS (GNU Assembler) directive that writes the function's size into&lt;br&gt;
the ELF symbol table. The expression &lt;code&gt;.-function_name&lt;/code&gt; subtracts the&lt;br&gt;
function's start address (&lt;code&gt;.&lt;/code&gt; is the current location counter,&lt;br&gt;
&lt;code&gt;function_name&lt;/code&gt; is where the function began). The result is the byte length&lt;br&gt;
of the function, allowing &lt;code&gt;objtool&lt;/code&gt; to determine its boundary.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.globl init_module
.type init_module,@function

init_module:
    xor eax,eax
    ret

.size init_module,.-init_module
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the ELF metadata correctly describes the function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Wrong Section Attributes
&lt;/h2&gt;

&lt;p&gt;The next warning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unexpected non-allocatable section
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel organizes memory into sections.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.init.text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Code used during initialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.exit.text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Code used during removal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.rodata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read-only data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our assembly contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.section .init.text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but the assembler did not know it was executable memory.&lt;/p&gt;

&lt;p&gt;The correct form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.section .init.text,"ax"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;a&lt;/code&gt; = allocatable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;x&lt;/code&gt; = executable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For exit code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.section .exit.text,"ax"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem 4: Fedora's Return Protection
&lt;/h2&gt;

&lt;p&gt;The next enemy was not caused by our code.&lt;/p&gt;

&lt;p&gt;It came from the kernel configuration.&lt;/p&gt;

&lt;p&gt;Fedora enables modern CPU security mitigations, including return thunk protection.&lt;/p&gt;

&lt;p&gt;Modern x86 Linux kernels may enable return-thunk based mitigations for &lt;a href="https://docs.kernel.org/admin-guide/hw-vuln/srso.html" rel="noopener noreferrer"&gt;speculative execution vulnerabilities&lt;/a&gt;. These mitigations change the expected return sequence for kernel code, and &lt;code&gt;objtool&lt;/code&gt; enforces the generated pattern. When they are enabled, kernel-generated code uses return thunks instead of direct &lt;code&gt;ret&lt;/code&gt; instructions. The thunk provides the mitigation sequence required by the kernel configuration. Any kernel module written in assembly must follow the same convention.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;objtool&lt;/code&gt; warned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'naked' return found in MITIGATION_RETHUNK build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The normal assembly return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is not what the kernel expects anymore.&lt;/p&gt;

&lt;p&gt;Instead, protected kernels use a return thunk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jmp __x86_return_thunk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the module imports it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.extern __x86_return_thunk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and returns through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jmp __x86_return_thunk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the module follows the kernel's security model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: The printk Mystery
&lt;/h2&gt;

&lt;p&gt;The goal was to print:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;asm_module: loaded
asm_module: unloaded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first attempt assumed that the C-level API name &lt;code&gt;printk&lt;/code&gt; was also the&lt;br&gt;
link-visible symbol name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.extern printk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;call printk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the natural choice.&lt;/p&gt;

&lt;p&gt;Kernel code uses the &lt;code&gt;printk()&lt;/code&gt; interface.&lt;/p&gt;

&lt;p&gt;But the module build failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: "printk" [asm_module.ko] undefined!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was confusing.&lt;/p&gt;

&lt;p&gt;The interface exists.&lt;/p&gt;

&lt;p&gt;The kernel uses it.&lt;/p&gt;

&lt;p&gt;Why can the module not call it?&lt;/p&gt;

&lt;h3&gt;
  
  
  Looking Inside the Running Kernel
&lt;/h3&gt;

&lt;p&gt;The first thing to check was whether the symbol existed.&lt;/p&gt;

&lt;p&gt;Linux exposes kernel symbols through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/kallsyms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Searching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;printk /proc/kallsyms | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0000000000000000 t umip_printk.cold
0000000000000000 t printk_prot.cold
0000000000000000 t __warn_printk.cold
0000000000000000 t printk_store_execution_ctx.cold
0000000000000000 T __pfx__printk
0000000000000000 T _printk
0000000000000000 t __pfx_printk_kthreads_check_locked
0000000000000000 t printk_kthreads_check_locked
0000000000000000 T __pfx__printk_deferred
0000000000000000 T _printk_deferred
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This answered one question:&lt;/p&gt;

&lt;p&gt;On this kernel, the implementation symbol is &lt;code&gt;_printk&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;printk&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;printk()&lt;/code&gt; is a C function or macro interface, while &lt;code&gt;_printk&lt;/code&gt; is an&lt;br&gt;
implementation symbol. Normal external C modules should use &lt;code&gt;printk()&lt;/code&gt; or&lt;br&gt;
&lt;code&gt;pr_info()&lt;/code&gt;, rather than call &lt;code&gt;_printk&lt;/code&gt; directly. This pure-assembly&lt;br&gt;
experiment has no C macro layer, so the implementation had to investigate the&lt;br&gt;
underlying link-visible symbol. Whether it can be used still depends on kernel&lt;br&gt;
exports.&lt;/p&gt;

&lt;p&gt;On this kernel, &lt;code&gt;include/linux/printk.h&lt;/code&gt; declares &lt;code&gt;_printk()&lt;/code&gt; and defines&lt;br&gt;
&lt;code&gt;printk&lt;/code&gt; as a macro that ultimately calls it. Depending on the kernel&lt;br&gt;
configuration, the macro may call &lt;code&gt;_printk&lt;/code&gt; directly or wrap it with&lt;br&gt;
additional indexing logic. Assembly does not run that C preprocessor mapping,&lt;br&gt;
so it must use the symbol resolved for the target kernel.&lt;/p&gt;

&lt;p&gt;The relevant definition is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
 * See the vsnprintf() documentation for format string extensions over C99.
 */&lt;/span&gt;
&lt;span class="cp"&gt;#define printk(fmt, ...) printk_index_wrap(_printk, fmt, ##__VA_ARGS__)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Existing Symbol vs Exported Symbol
&lt;/h3&gt;

&lt;p&gt;Kernel modules cannot call every function inside the kernel.&lt;/p&gt;

&lt;p&gt;There is a difference between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;function &lt;strong&gt;exists&lt;/strong&gt; in the kernel&lt;/li&gt;
&lt;li&gt;function is &lt;strong&gt;available&lt;/strong&gt; to modules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The kernel source may expose a C-level interface, but modules can only use&lt;br&gt;
symbols exported with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;EXPORT_SYMBOL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;EXPORT_SYMBOL_GPL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module build system checks exported symbols during &lt;code&gt;modpost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the chain is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Assembly
    |
    v
referenced symbol
    |
    v
modpost checks exports
    |
    v
Module.symvers
    |
    v
Allowed or rejected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assembly implementation had to target an exported kernel symbol rather&lt;br&gt;
than the C source-level API. Seeing a symbol in &lt;code&gt;/proc/kallsyms&lt;/code&gt; alone does not&lt;br&gt;
establish that it is available to a module.&lt;/p&gt;
&lt;h3&gt;
  
  
  Discovering Kernel Symbols
&lt;/h3&gt;

&lt;p&gt;The debugging lesson was important.&lt;/p&gt;

&lt;p&gt;When writing kernel assembly, do not guess symbols.&lt;/p&gt;

&lt;p&gt;Investigate.&lt;/p&gt;

&lt;p&gt;Useful commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;function_name /proc/kallsyms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shows whether the running kernel knows the symbol.&lt;/p&gt;

&lt;p&gt;For module exports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;function_name /lib/modules/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/build/Module.symvers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shows whether modules can use it.&lt;/p&gt;

&lt;p&gt;The kernel itself is the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Pure Assembly Module
&lt;/h2&gt;

&lt;p&gt;After fixing the metadata, ELF information, sections, and return mechanism, the final structure looked like this:&lt;/p&gt;

&lt;p&gt;Before calling &lt;code&gt;_printk&lt;/code&gt;, &lt;code&gt;xor eax,eax&lt;/code&gt; sets &lt;code&gt;AL&lt;/code&gt; to zero. Under the x86-64&lt;br&gt;
System V ABI, &lt;code&gt;AL&lt;/code&gt; records the number of vector registers used for a variadic&lt;br&gt;
call; this module passes none, so the required value is zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.intel_syntax noprefix

.extern _printk                     # Kernel symbol resolved from the running kernel
.extern __x86_return_thunk          # Return thunk selected by the kernel configuration

.section .modinfo,"a"               # "a" = allocatable
.asciz "license=GPL"
.asciz "description=Minimal assembly Linux kernel module"
.asciz "author=Daniel Petrovic"

.section .init.text,"ax"            # "ax" = allocatable + executable; kernel frees this after init
.globl init_module
.type init_module,@function
init_module:
    lea rdi,[rip + msg_load]        # RIP-relative: kernel modules are position-independent
    xor eax,eax                    # Required by x86-64 SysV ABI before variadic calls
    call _printk
    xor eax,eax                    # Return 0
    jmp __x86_return_thunk         # Protected return instead of bare ret
.size init_module,.-init_module

.section .exit.text,"ax"           # Exit code — called on rmmod
.globl cleanup_module
.type cleanup_module,@function
cleanup_module:
    lea rdi,[rip + msg_unload]
    xor eax,eax
    call _printk
    jmp __x86_return_thunk
.size cleanup_module,.-cleanup_module

.section .rodata,"a"
msg_load:
    .asciz "asm_module: loaded\n"
msg_unload:
    .asciz "asm_module: unloaded\n"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build, Load, and Verify
&lt;/h2&gt;

&lt;p&gt;From the directory containing &lt;code&gt;asm_module.S&lt;/code&gt; and the &lt;code&gt;Makefile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;make
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;insmod asm_module.ko
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dmesg | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1
&lt;span class="go"&gt;[...] asm_module: loaded

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;rmmod asm_module
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dmesg | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1
&lt;span class="go"&gt;[...] asm_module: unloaded
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rmmod&lt;/code&gt; takes the loaded module name, so omit the &lt;code&gt;.ko&lt;/code&gt; suffix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Writing this module was less about avoiding C and more about exposing everything C normally hides: ELF metadata, section placement, symbol visibility, calling conventions, and kernel security constraints.&lt;/p&gt;

&lt;p&gt;The compiler is not merely translating instructions. It is participating in a contract between your code, the linker, the loader, and the kernel.&lt;/p&gt;

&lt;p&gt;Removing that layer makes those contracts visible.&lt;/p&gt;

&lt;p&gt;Every problem in this post — the duplicate &lt;code&gt;.modinfo&lt;/code&gt;, the missing ELF size annotations, the wrong section attributes, the return thunk, the &lt;code&gt;_printk&lt;/code&gt; symbol — is something conventional C source and the kernel build system handle silently. Writing in assembly means handling all of it yourself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The complete module from this post is 30 lines of assembly. It was validated through five rounds of kernel rejection and careful reading of kernel log messages.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>kernel</category>
      <category>assembly</category>
      <category>x86</category>
    </item>
  </channel>
</rss>
