Run Length Encoding in Arm64-assembly: Complete Solution & Deep Dive Guide

a close up of a sign with numbers on it

Run-Length Encoding in Arm64 Assembly: A Zero-to-Hero Guide

Run-Length Encoding (RLE) is a foundational lossless data compression algorithm that transforms consecutive data runs into a compact format. This guide provides a deep dive into implementing RLE in Arm64 assembly, offering unparalleled control over memory and performance by operating directly on the hardware.


The Unseen Bottleneck: Taming Repetitive Data

Imagine you're working with satellite imagery. Vast stretches of the image are just the blackness of space or the blue of an ocean—pixel after pixel of the exact same color. Storing or transmitting this raw data is incredibly inefficient. You're wasting space and bandwidth on redundant information. This is a classic data problem, and it’s not just limited to images; log files, genetic sequences, and simple graphics all suffer from it.

This is where the frustration sets in for performance-oriented developers. High-level languages and their libraries often hide the underlying mechanics, leaving you with a black box. What if you could seize control at the most fundamental level? What if you could dictate exactly how each byte is read, processed, and written, squeezing every last drop of performance out of the CPU?

This guide promises to give you that control. We will explore Run-Length Encoding (RLE), a beautifully simple yet effective compression technique. More importantly, we'll implement it from scratch in Arm64 assembly, the language of modern mobile and server CPUs. By the end, you won't just understand RLE; you'll have mastered its low-level implementation, a skill that separates good programmers from great ones.


What is Run-Length Encoding?

Run-Length Encoding (RLE) is a form of lossless data compression where sequences of identical data values (runs) are stored as a single data value and a count, rather than as the original run. It's exceptionally effective when the data contains many such runs.

For example, consider the following string:

"AAAAABBBBBBBCC"

A naive representation would store all 14 characters. Using RLE, we can compress this down to:

"5A7B2C"

We've reduced 14 characters to just 6. The core principle is simple: count consecutive occurrences of a character and then store that count followed by the character itself. Since no information is lost, the original data can be perfectly reconstructed, making it a lossless compression method.

Pros & Cons of RLE

Like any algorithm, RLE has its strengths and weaknesses. Understanding them is key to knowing when to apply it.

Pros (Advantages) Cons (Disadvantages)
Simplicity: The algorithm is straightforward to implement, making it fast and computationally inexpensive. Inefficient with Non-Repetitive Data: In the worst-case scenario (no runs), RLE can actually increase the data size. "ABCDE" would become "1A1B1C1D1E".
Lossless: The original data can be perfectly reconstructed from the compressed data. Limited Compression Ratio: Compared to more complex algorithms like Lempel-Ziv (used in ZIP) or Huffman coding, RLE's compression ratio is often lower.
Fast Execution: Due to its simple logic, RLE encoding and decoding are extremely quick, making it suitable for real-time applications. Sensitive to Data Structure: Its effectiveness is entirely dependent on the presence of long runs of identical data elements.

Why Implement RLE in Arm64 Assembly?

While you could implement RLE in any high-level language, doing so in Arm64 assembly offers unique advantages and profound learning opportunities. The Arm64 architecture (also known as AArch64) powers everything from smartphones to the world's fastest supercomputers, making it a critical skill for performance-focused engineering.

  • Maximum Performance: Writing in assembly gives you direct control over the CPU's registers and instructions. You can eliminate abstractions and overhead, crafting code that is optimized for speed and efficiency.
  • Memory Mastery: You manage every byte of memory yourself. This forces you to understand pointers, memory layouts, and buffer management intimately, preventing common bugs and optimizing data access patterns.
  • Understanding the Machine: It bridges the gap between software and hardware. You learn how conditional branches, memory loads/stores, and arithmetic operations actually work, providing insights that make you a better programmer in any language.
  • Essential for Systems Programming: For work in operating systems, device drivers, embedded systems, and high-performance computing, assembly language is not just an academic exercise—it's a necessary tool.

This challenge from the kodikra.com Arm64 Assembly learning roadmap is designed to build these fundamental skills, preparing you for more complex low-level programming tasks.


How Does RLE Work? The Logic and Arm64 Implementation

We need to create two core functions: encode and decode. The logic for each involves iterating through memory, counting characters, and carefully writing the output to a destination buffer while avoiding overflows.

The Encoding Logic

The encoder scans the input string, character by character, and identifies consecutive runs. When a run ends, it writes the count and the character to the output buffer.

A special consideration is handling counts. A simple implementation might just store a single-digit count. However, a run can be longer than 9 characters (e.g., 12 'W's). Our implementation must handle multi-digit numbers by converting the integer count into a sequence of ASCII characters.

Encoding Algorithm Flow

Here is a visual representation of the encoding process logic.

    ● Start Encode
    │
    ▼
  ┌──────────────────┐
  │ Initialize Pointers │
  │ & Set Run Char    │
  └────────┬─────────┘
           │
           ▼
    ┌────────────────┐
    │ Loop: Read Char│
    └───────┬────────┘
            │
            ▼
    ◆ Same as Run Char?
   ╱                  ╲
 Yes ⟶ ┌───────────────┐  No
  │    │ Increment Run │   │
  │    │ Count         │   │
  │    └───────────────┘   │
  │                        │
  └──────────┬─────────────┘
             │
             ▼
      ┌──────────────────┐
      │ Write Prev. Run  │
      │ (Count + Char)   │
      └──────────────────┘
             │
             ▼
      ┌──────────────────┐
      │ Reset Run Count  │
      │ & Set New Char   │
      └────────┬─────────┘
               │
               ▼
        ◆ End of Input?
       ╱               ╲
     No ─────────────── Loop
     │
    Yes
     │
     ▼
    ● End

The Decoding Logic

The decoder does the reverse. It scans the compressed string, parsing numbers to determine the run count. Once it encounters a non-digit character, it knows that's the data to be repeated. It then writes that character to the output buffer the specified number of times.

If a character is not preceded by a number, it implies a run count of 1.

Decoding Algorithm Flow

This diagram illustrates the logic for the decoding process.

    ● Start Decode
    │
    ▼
  ┌──────────────────┐
  │ Initialize Pointers │
  │ & Reset Run Count │
  └────────┬─────────┘
           │
           ▼
    ┌────────────────┐
    │ Loop: Read Char│
    └───────┬────────┘
            │
            ▼
        ◆ Is Char a Digit?
       ╱                  ╲
     Yes ⟶ ┌────────────────┐ No
      │    │ Update Run Count │  │
      │    │ (Count = Count*10 │  │
      │    │        + Digit)  │  │
      │    └────────────────┘  │
      │                        │
      └──────────┬─────────────┘
                 │
                 ▼
          ┌──────────────────┐
          │ Write Prev. Char │
          │ `Run Count` times│
          └──────────────────┘
                 │
                 ▼
          ┌──────────────────┐
          │ Reset Run Count  │
          │ & Set New Char   │
          └────────┬─────────┘
                   │
                   ▼
            ◆ End of Input?
           ╱               ╲
         No ─────────────── Loop
         │
        Yes
         │
         ▼
        ● End

The Complete Arm64 Assembly Solution

Below is the full, commented source code for both the encode and decode functions. This solution is designed for clarity and adherence to the ARM Procedure Call Standard (AAPCS), where arguments are passed in registers x0-x7 and the return value is placed in x0.

This code is part of an exclusive module from the kodikra.com Arm64 Assembly curriculum, crafted to build practical, low-level programming expertise.


.global encode
.global decode

// Internal helper function to write a multi-digit number to the output buffer.
// x10: number to write
// x5: current output pointer
// x6: output end pointer
// Returns updated output pointer in x5, or xzr if overflow.
write_number:
    stp x29, x30, [sp, -16]! // Save frame pointer and link register
    stp x19, x20, [sp, -16]! // Save callee-saved registers
    stp x21, x22, [sp, -16]!

    mov x19, x5              // Copy output pointer
    mov x20, x10             // Number to convert
    mov x21, 10              // Divisor
    mov x22, sp              // Use stack as a temporary buffer for digits

.Lwrite_number_loop:
    udiv x10, x20, x21      // quotient = number / 10
    msub x11, x10, x21, x20 // remainder = number - (quotient * 10)
    add x11, x11, '0'      // Convert remainder to ASCII digit
    strb w11, [sp, -1]!      // Push digit onto stack
    mov x20, x10             // number = quotient
    cbnz x20, .Lwrite_number_loop

.Lpop_digits_loop:
    cmp sp, x22              // Have we popped all digits?
    b.eq .Lwrite_number_done
    ldrb w11, [sp], 1        // Pop digit from stack
    cmp x19, x6              // Check for buffer overflow
    b.eq .Loverflow
    strb w11, [x19], 1       // Store digit in output buffer
    b .Lpop_digits_loop

.Lwrite_number_done:
    mov x5, x19              // Return updated pointer in x5
    b .Lwrite_number_exit

.Loverflow:
    mov x5, xzr              // Signal overflow by returning NULL

.Lwrite_number_exit:
    ldp x21, x22, [sp], 16
    ldp x19, x20, [sp], 16
    ldp x29, x30, [sp], 16
    ret

// encode: Implements run-length encoding.
// Args:
//   x0: const char *source
//   x1: size_t source_len
//   x2: char *dest
//   x3: size_t dest_len
// Returns:
//   x0: size_t encoded_len, or 0 on error
encode:
    stp x29, x30, [sp, -16]!
    stp x19, x20, [sp, -16]!
    stp x21, x22, [sp, -16]!
    stp x23, x24, [sp, -16]!

    mov x19, x0              // x19: source pointer
    add x20, x19, x1             // x20: source end pointer
    mov x21, x2              // x21: dest pointer (start)
    mov x5, x2               // x5: dest pointer (current)
    add x6, x2, x3             // x6: dest end pointer

    mov x22, 0               // x22: run_count
    mov x23, -1              // x23: run_char (init with invalid char)

.Lencode_loop:
    cmp x19, x20             // Reached end of source?
    b.eq .Lencode_finalize

    ldrb w24, [x19], 1       // w24: current_char, advance source pointer

    // First character of a new run
    cmp x22, 0
    b.eq .Lstart_new_run

    // Continue existing run
    cmp w24, w23             // Is current char same as run char?
    b.eq .Lcontinue_run

.Lprocess_run:
    // Run ended. Write count and char to dest.
    // If run_count is 1, just write the char.
    cmp x22, 1
    b.eq .Lwrite_char_only

    // Write multi-digit count
    mov x10, x22             // Pass run_count to write_number
    bl write_number
    cbz x5, .Lencode_error   // Check for overflow from write_number

.Lwrite_char_only:
    cmp x5, x6               // Check for buffer overflow before writing char
    b.eq .Lencode_error
    strb w23, [x5], 1        // Write run_char to dest

.Lstart_new_run:
    mov w23, w24             // Set new run_char
    mov x22, 1               // Reset run_count to 1
    b .Lencode_loop

.Lcontinue_run:
    add x22, x22, 1          // Increment run_count
    b .Lencode_loop

.Lencode_finalize:
    // Process the very last run after loop finishes
    cbz x22, .Lencode_done   // If source was empty, nothing to do
    cmp x22, 1
    b.eq .Lwrite_char_only_final
    mov x10, x22
    bl write_number
    cbz x5, .Lencode_error

.Lwrite_char_only_final:
    cmp x5, x6
    b.eq .Lencode_error
    strb w23, [x5], 1

.Lencode_done:
    sub x0, x5, x21          // Calculate encoded length
    b .Lencode_exit

.Lencode_error:
    mov x0, 0                // Return 0 on error

.Lencode_exit:
    ldp x23, x24, [sp], 16
    ldp x21, x22, [sp], 16
    ldp x19, x20, [sp], 16
    ldp x29, x30, [sp], 16
    ret

// decode: Implements run-length decoding.
// Args:
//   x0: const char *source
//   x1: size_t source_len
//   x2: char *dest
//   x3: size_t dest_len
// Returns:
//   x0: size_t decoded_len, or 0 on error
decode:
    stp x29, x30, [sp, -16]!
    stp x19, x20, [sp, -16]!
    stp x21, x22, [sp, -16]!
    stp x23, x24, [sp, -16]!

    mov x19, x0              // x19: source pointer
    add x20, x19, x1             // x20: source end pointer
    mov x21, x2              // x21: dest pointer (start)
    mov x5, x2               // x5: dest pointer (current)
    add x6, x2, x3             // x6: dest end pointer

    mov x22, 0               // x22: run_count accumulator

.Ldecode_loop:
    cmp x19, x20             // Reached end of source?
    b.eq .Ldecode_finalize

    ldrb w23, [x19], 1       // w23: current_char

    // Check if character is a digit '0'-'9'
    sub w24, w23, '0'
    cmp w24, 9
    b.hi .Lnot_a_digit       // If char > '9' or < '0' (unsigned), it's not a digit

    // It's a digit, update the run_count
    mov x10, 10
    mul x22, x22, x10        // run_count = run_count * 10
    add x22, x22, x24        // run_count = run_count + digit_value
    b .Ldecode_loop

.Lnot_a_digit:
    // Not a digit, so it's the character to repeat.
    // If run_count is 0, it means it was an implicit 1.
    cmp x22, 0
    mov x24, 1
    csel x22, x22, x24, ne   // If count != 0, use count. Else, use 1.

.Lwrite_decoded_chars:
    cmp x22, 0               // Is count zero?
    b.eq .Lreset_for_next_run // If so, we are done writing this run

    cmp x5, x6               // Check for buffer overflow
    b.eq .Ldecode_error
    strb w23, [x5], 1        // Write the character
    sub x22, x22, 1          // Decrement count
    b .Lwrite_decoded_chars

.Lreset_for_next_run:
    mov x22, 0               // Reset run_count for the next number
    b .Ldecode_loop

.Ldecode_finalize:
    // After loop, if there's a pending run, write it.
    // This handles cases where the string ends right after a number.
    // Our logic handles this correctly by processing the last character run before exiting.
    // If the last character was a digit, the loop ends, and we are done.
    // If the last was a letter, it was processed in .Lnot_a_digit.
    sub x0, x5, x21          // Calculate decoded length
    b .Ldecode_exit

.Ldecode_error:
    mov x0, 0                // Return 0 on error

.Ldecode_exit:
    ldp x23, x24, [sp], 16
    ldp x21, x22, [sp], 16
    ldp x19, x20, [sp], 16
    ldp x29, x30, [sp], 16
    ret

Code Walkthrough: Deconstructing the Arm64 Solution

Understanding assembly code requires a methodical approach. Let's break down the key parts of the encode and decode functions.

Register Usage Conventions (AAPCS)

Our code follows the standard ARM procedure call convention:

  • x0-x7: Used for function arguments and return values.
  • x0: Specifically holds the return value.
  • x19-x28: Callee-saved registers. If our function uses them, it must save their original values on the stack and restore them before returning. We use these for storing important variables like pointers and counters that need to persist across function calls.
  • x29: Frame Pointer (FP).
  • x30: Link Register (LR), holds the return address.

The first and last lines of each function (stp/ldp) are for saving and restoring these callee-saved registers and the frame/link pointers, which is standard practice for well-behaved functions.

The encode Function Breakdown

  1. Initialization:
    mov x19, x0              // x19: source pointer
    add x20, x19, x1             // x20: source end pointer
    mov x21, x2              // x21: dest pointer (start)
    mov x5, x2               // x5: dest pointer (current)
    add x6, x2, x3             // x6: dest end pointer
    
    mov x22, 0               // x22: run_count
    mov x23, -1              // x23: run_char (init with invalid char)

    We copy the arguments from x0-x3 into callee-saved registers (x19, x21, etc.) so they are preserved. We calculate the end-of-buffer pointers for easy boundary checks. x22 will hold our character count, and x23 holds the character of the current run.

  2. The Main Loop (.Lencode_loop):
    cmp x19, x20             // Reached end of source?
    b.eq .Lencode_finalize
    
    ldrb w24, [x19], 1       // w24: current_char, advance source pointer

    The loop continues until our source pointer (x19) reaches the end (x20). The instruction ldrb w24, [x19], 1 is powerful: it loads a byte from the address in x19 into w24 and then increments x19 by 1 (post-index addressing).

  3. Run Logic:

    The code checks if the new character (w24) is the same as the one we're currently counting (w23). If it is, we simply increment the counter (x22) at .Lcontinue_run. If it's different, it means the previous run has ended. We jump to .Lprocess_run to write the results.

  4. Writing the Output (.Lprocess_run):

    A key detail is handling single-character runs. If run_count is 1, we don't write the number '1'. The code checks cmp x22, 1 and branches to .Lwrite_char_only to skip number writing. For counts > 1, we call our helper function write_number.

  5. The write_number Helper:

    This utility function is crucial for handling counts like 10, 11, 12, etc. It uses division and subtraction (udiv, msub) to extract digits one by one, converts them to ASCII by adding '0', and pushes them onto the stack. It then pops them off in the correct order to write to the destination buffer. This is a classic integer-to-string conversion routine in assembly.

The decode Function Breakdown

  1. Initialization:

    Similar to encode, we set up our source and destination pointers. The key variable here is x22, which acts as our run count accumulator.

  2. The Main Loop (.Ldecode_loop):

    The core of the decoder is distinguishing between digits and characters.

    sub w24, w23, '0'
    cmp w24, 9
    b.hi .Lnot_a_digit
    We subtract the ASCII value of '0' from the current character. If the result is between 0 and 9, it's a digit. The b.hi (branch if higher, for unsigned numbers) instruction cleverly handles this check.

  3. Accumulating the Count:

    If the character is a digit, we update our accumulator:

    mul x22, x22, x10        // run_count = run_count * 10
    add x22, x22, x24        // run_count = run_count + digit_value
    This allows us to parse multi-digit numbers like "12" correctly. The first digit '1' sets x22 to 1. The second digit '2' makes it `(1 * 10) + 2 = 12`.

  4. Writing the Decoded Run (.Lnot_a_digit):

    When we encounter a non-digit, we know the number part is over. The character we just read is the one to be repeated. We check if the accumulated count is 0; if so, it's an implicit run of 1. Then, a simple loop (.Lwrite_decoded_chars) writes the character to the destination buffer the required number of times, decrementing the count until it hits zero.


Where is RLE Applied in the Real World?

While not the most powerful compression algorithm available today, RLE's simplicity and speed make it a valuable tool in specific domains:

  • Early Image Formats: Simple image formats like Windows Bitmap (.BMP), PC Paintbrush (.PCX), and Targa (.TGA) use RLE to compress image data, especially for images with large areas of solid color.
  • Fax Machines: The ITU T.4 standard for Group 3 fax machines used a combination of RLE and Huffman coding to compress black-and-white images for transmission over phone lines.
  • Computer Graphics & Video: In video editing and animation, RLE is sometimes used for encoding masks or alpha channels, where large transparent or opaque regions are common.
  • Genomic Data: DNA sequences can contain long runs of the same nucleotide, making RLE a candidate for a first-pass compression step.

Frequently Asked Questions (FAQ)

1. Is Run-Length Encoding lossless or lossy?

RLE is a lossless compression algorithm. This means that the original data can be perfectly and completely reconstructed from the compressed data. No information is discarded during the compression process, which is critical for applications like text files, program executables, and scientific data where every bit matters.

2. What is the worst-case scenario for RLE?

The worst-case scenario occurs when the input data has no consecutive repeating characters. For example, compressing the string "ABCDEFG" would result in "1A1B1C1D1E1F1G". In this case, the "compressed" data is twice the size of the original. This is why RLE is only suitable for data known to have long runs.

3. How does this specific Arm64 code handle runs longer than 9 characters?

Our implementation includes a dedicated helper function called write_number. This function performs an integer-to-ASCII conversion. When it needs to write a count like 12, it mathematically separates it into the digits 1 and 2, converts them to their ASCII representations ('1' and '2'), and writes them sequentially to the output buffer. The decoder is built to parse these multi-digit numbers by accumulating them until a non-digit character is found.

4. Why learn assembly for a compression algorithm when Python or C++ can do it?

Learning assembly for tasks like this provides three key benefits: 1) Performance: You can create the fastest possible implementation by avoiding high-level abstractions. 2) Control: You manage memory directly, which is crucial in resource-constrained environments like embedded systems. 3) Understanding: It reveals how computers actually execute instructions, making you a more knowledgeable and effective programmer even when you return to high-level languages.

5. Can this Arm64 assembly code be optimized further?

Yes, there are always potential micro-optimizations. For example, one could explore using SIMD (NEON) instructions to process multiple bytes at once to find the end of a run faster. The write_number function could also be implemented without using the stack for extremely performance-sensitive scenarios. However, the current implementation prioritizes clarity and correctness, which are the most important goals when first learning an algorithm.

6. What are some common alternatives to RLE?

For more general-purpose compression, more advanced lossless algorithms are used. The most common are Huffman Coding, which assigns shorter codes to more frequent characters, and the Lempel-Ziv (LZ) family (like LZ77 and LZ78), which works by finding and replacing repeated sequences of data. Algorithms used in formats like ZIP, GZIP, and PNG are typically based on a combination of LZ77 and Huffman Coding.


Conclusion: Mastering the Fundamentals

You've now journeyed from the high-level concept of Run-Length Encoding to its intricate, low-level implementation in Arm64 assembly. We've seen that RLE is a trade-off: it offers incredible speed and simplicity at the cost of compression efficiency on varied data. By building it from scratch, you've done more than just learn an algorithm; you've practiced direct memory manipulation, register management, and algorithmic thinking at the hardware level.

This is the kind of deep, foundational knowledge that sets you apart. The skills honed in this kodikra.com module—from pointer arithmetic to crafting control flow in assembly—are directly applicable to performance engineering, cybersecurity, and systems development. You are now better equipped to understand, analyze, and optimize code at its most fundamental level.

Ready for the next challenge? Explore our complete Arm64 Assembly learning roadmap to tackle more complex algorithms and system interactions, or dive deeper into the language itself with our guide to master Arm64 Assembly from the ground up.


Disclaimer: The assembly code provided is based on the AArch64 instruction set architecture. It is designed for educational purposes within the kodikra.com curriculum and may require adaptation for specific assemblers or operating system environments. Always ensure your toolchain (like GCC or Clang) is up-to-date.


Published by Kodikra — Your trusted Arm64-assembly learning resource.