Skip to content

Inline Assembly in C++ on Raspberry Pi

Introduction

While writing pure assembly files (.s or .S) is useful for stand-alone routines, there are times when you want to execute a few low-level CPU instructions directly inside your C++ code. Writing a full assembly function for simple actions (like reading a hardware register or invoking a specific instruction) adds unnecessary function call overhead.

This is where inline assembly comes in. GCC and Clang (the compilers used on Raspberry Pi OS) support extended assembly syntax that allows you to embed Arm64 assembly instructions directly within your C++ functions.

In this tutorial, you will learn the syntax of inline assembly, how to bind C++ variables to assembly registers, and how to write safe, optimized inline assembly code.

Basic vs. Extended Syntax

The C++ keyword for inline assembly is asm (or __asm__). There are two types of inline assembly: Basic and Extended.

Basic Inline Assembly

Basic inline assembly is simple but limited. It only executes instructions and cannot interact with C++ variables safely:

__asm__("yield"); // Tell the CPU to yield execution to other threads

Extended Inline Assembly

To read from or write to C++ variables, you must use Extended asm. Extended assembly uses the following structure:

1
2
3
4
5
6
__asm__ __volatile__ (
    "assembly code"
    : output operands        /* Optional */
    : input operands         /* Optional */
    : clobbered registers    /* Optional */
);
  • __volatile__ (or volatile): Prevents the compiler from optimizing out, moving, or deleting the assembly block. Always use it if your assembly has side effects (like reading hardware states or modifying system memory).

Operands and Constraints

To pass values between C++ and assembly, you specify operands. The compiler automatically maps C++ variables to available CPU registers based on constraints.

Syntax Example

Let's look at a function that adds two integers using inline assembly:

1
2
3
4
5
6
7
8
9
int add_asm(int a, int b) {
    int result;
    __asm__ (
        "add %w0, %w1, %w2"  // Assembly instruction
        : "=r" (result)      // Output operand (%0)
        : "r" (a), "r" (b)   // Input operands (%1 and %2)
    );
    return result;
}

How to read this:

  1. Instruction Template: "add %w0, %w1, %w2" * %0, %1, %2 refer to the operands listed below the code. * The w prefix (e.g., %w0) tells the compiler to use 32-bit registers (w0-w30) instead of 64-bit ones (x0-x30), matching the size of int variables.
  2. Output Operands: : "=r" (result) * = indicates that this variable is write-only. * r indicates that the variable must be placed in a general-purpose register. * (result) binds the C++ variable result to %0.
  3. Input Operands: : "r" (a), "r" (b) * r indicates that inputs a and b must be loaded into general-purpose registers. * a is bound to %1, and b is bound to %2.

Common Constraints for Arm64

Constraint Type Description
"r" Register General-purpose register (x0-x30 or w0-w30)
"w" Register SIMD/Vector/Floating-point register (v0-v31 or s0/d0 variants)
"I" Immediate Integer constant suitable for arithmetic instructions
"m" Memory Memory operand (allows direct memory referencing)
"+" Modifier Operand is both read from and written to
"=" Modifier Operand is write-only

Practical Example 1: High-Resolution Timer

Raspberry Pi's Arm64 cores have a built-in virtual counter register (CNTVCT_EL0) that counts clock cycles at a fixed system frequency. We can read this 64-bit register using a single mrs instruction.

#include <iostream>
#include <cstdint>

uint64_t get_system_counter() {
    uint64_t val;
    __asm__ __volatile__ (
        "mrs %0, cntvct_el0" // Read virtual counter system register into %0
        : "=r" (val)         // Output: %0 -> val
    );
    return val;
}

uint64_t get_counter_frequency() {
    uint64_t freq;
    __asm__ __volatile__ (
        "mrs %0, cntfrq_el0" // Read system counter frequency
        : "=r" (freq)
    );
    return freq;
}

int main() {
    uint64_t start = get_system_counter();
    uint64_t freq = get_counter_frequency();

    // Simulate some work
    volatile int sum = 0;
    for(int i = 0; i < 1000000; ++i) sum += i;

    uint64_t end = get_system_counter();
    double duration = (double)(end - start) / freq;

    std::cout << "Counter Frequency: " << freq << " Hz" << std::endl;
    std::cout << "Ticks elapsed:      " << (end - start) << std::endl;
    std::cout << "Time elapsed:       " << duration * 1000.0 << " ms" << std::endl;

    return 0;
}

Practical Example 2: Vector Math (SIMD Inline)

You can also use NEON registers in inline assembly. Let's write a function that multiplies four floats simultaneously using the vector registers.

#include <iostream>
#include <array>

void vector_multiply(const std::array<float, 4>& a, const std::array<float, 4>& b, std::array<float, 4>& result) {
    __asm__ __volatile__ (
        "ld1 {v0.4s}, [%1]\n\t"     // Load array 'a' into v0 (4 single-precision floats)
        "ld1 {v1.4s}, [%2]\n\t"     // Load array 'b' into v1
        "fmul v2.4s, v0.4s, v1.4s\n\t" // v2 = v0 * v1
        "st1 {v2.4s}, [%0]"         // Store v2 to 'result'
        :                           // No direct outputs bound via operand mapping (handled via memory)
        : "r" (result.data()), "r" (a.data()), "r" (b.data()) // Inputs %0, %1, %2
        : "v0", "v1", "v2", "memory" // Clobber list: tell compiler we overwrote these registers and memory
    );
}

int main() {
    std::array<float, 4> a = {1.5f, 2.0f, 2.5f, 3.0f};
    std::array<float, 4> b = {10.0f, 20.0f, 30.0f, 40.0f};
    std::array<float, 4> res;

    vector_multiply(a, b, res);

    std::cout << "SIMD Multiplication Result:" << std::endl;
    for (int i = 0; i < 4; ++i) {
        std::cout << a[i] << " * " << b[i] << " = " << res[i] << std::endl;
    }

    return 0;
}

The Clobber List Explained

At the end of the vector_multiply block, we specified "v0", "v1", "v2", "memory". This is the Clobber List. It tells the compiler: 1. We manually changed v0, v1, and v2. If the compiler was holding important variables in those registers, it must save them elsewhere before our block executes. 2. We modified "memory". This forces the compiler to write cached variables back to RAM before our assembly block runs and reload them afterward, preventing pointer optimization issues.

Best Practices and Pitfalls

  1. Don't Overuse It: Compilers are excellent at optimizing code. Write inline assembly only when standard C++ cannot generate the specific CPU instruction you need (like mrs, clz, or cache control instructions).
  2. Mark Clobbers Accurately: Forgetting to list a modified register in the clobber list is one of the hardest bugs to trace. The compiler will assume the register was untouched and may use corrupted values.
  3. Understand Register Prefixes: In Arm64, using %0 defaults to 64-bit x registers. For 32-bit types (like int), use the w modifier: %w0. For floating-point/vector registers, specify size suffixes or elements.
  4. Use __volatile__: If your block is reading status registers or memory-mapped hardware, __volatile__ is critical. Otherwise, the compiler might think the code is redundant and optimize it away.

Conclusion

Inline assembly is a bridge between C++ and low-level hardware control. It allows you to write clean C++ while retaining the ability to execute performance-critical CPU operations natively on your Raspberry Pi.

This completes our core series on Arm64 Assembly Programming on Raspberry Pi! You now have the knowledge to write standalone assembly programs, interface with C++, leverage NEON SIMD vector optimization, and write inline assembly blocks.