How Are Strings Manipulated in Assembly Language?

How are strings manipulated in Assembly language

Strings are one of those things that feel trivial in a high-level language and then turn into a genuine education the first time you handle them in assembly. There’s no built-in “string type” — just bytes in memory, a convention for where they end, and a set of instructions (some surprisingly purpose-built) for moving, comparing, and searching through them. Once you understand those conventions and instructions, string handling in assembly stops feeling tedious and starts feeling like one of the more satisfying parts of low-level programming — you can see exactly where every byte goes.

What a String Actually Is at the Assembly Level

There’s no such thing as a “string object” at this level — just a contiguous run of bytes in memory, plus a convention for knowing where it ends. The two dominant conventions:

section .data
    c_string        db "Hello, world!", 0        ; null-terminated
    pascal_string   db 13, "Hello, world!"        ; length-prefixed (length=13)

x86 String Instructions: Purpose-Built Hardware Support

x86 is somewhat unusual among modern architectures in that it has a dedicated family of string instructions designed explicitly for operating on blocks of memory, byte by byte, word by word, or dword by dword — with automatic pointer advancement built in.

InstructionPurpose
MOVSMove data from [RSI] to [RDI], advancing both pointers
CMPSCompare [RSI] and [RDI], advancing both pointers
SCASCompare AL/AX/EAX against [RDI], advancing RDI
STOSStore AL/AX/EAX into [RDI], advancing RDI
LODSLoad [RSI] into AL/AX/EAX, advancing RSI

Each of these respects the Direction Flag (DF) in EFLAGS — CLD clears it (pointers increment), STD sets it (pointers decrement) — and each can be prefixed with REP, REPE/REPZ, or REPNE/REPNZ to repeat automatically based on the RCX counter and/or the zero flag.

Example: String Length (strlen) Using SCASB

; RDI = pointer to null-terminated string
; Returns length in RAX
strlen:
    push    rdi
    xor     al, al          ; search for the null byte
    mov     rcx, -1         ; max possible count (will underflow-count down)
    cld                     ; ensure forward direction
    repne   scasb           ; scan until AL (0) found or RCX hits 0
    not     rcx              ; RCX = -(count) - 1  -> invert to get length+1
    dec     rcx              ; subtract 1 for the null terminator itself
    mov     rax, rcx
    pop     rdi
    ret

Example: String Copy Using MOVSB

; RSI = source, RDI = destination, both null-terminated, dest has enough space
strcpy:
    cld
copy_loop:
    lodsb                   ; load [RSI] into AL, RSI++
    stosb                   ; store AL into [RDI], RDI++
    test    al, al
    jnz     copy_loop       ; continue until we just copied a null byte
    ret

Example: String Comparison Using CMPSB

; RSI, RDI = two null-terminated strings; returns ZF=1 if equal
strcmp_equal:
    cld
compare_loop:
    mov     al, [rsi]
    cmpsb                    ; compare [RSI] vs [RDI], advance both
    jne     not_equal
    test    al, al
    jz      strings_equal    ; hit null terminator on both — match
    jmp     compare_loop
not_equal:
    ; ZF is already 0 here
    ret
strings_equal:
    ; ZF is 1
    ret

ARM: No Dedicated String Instructions, But Efficient Loops

ARM doesn’t provide the same “block string operation” instruction family x86 does. Instead, string manipulation is expressed with ordinary load/store instructions inside loops — but ARM’s addressing modes (particularly post-increment addressing, covered in depth in addressing-mode discussions) make these loops compact and efficient.

ARM strlen Example

; R0 = pointer to null-terminated string
; Returns length in R0
strlen:
    MOV     R1, R0           ; R1 = working pointer
count_loop:
    LDRB    R2, [R1], #1     ; load byte, post-increment R1
    CMP     R2, #0
    BNE     count_loop
    SUB     R0, R1, R0        ; length = final pointer - start pointer
    SUB     R0, R0, #1        ; subtract 1 for the null terminator
    BX      LR

ARM strcpy Example

; R0 = dest, R1 = source
strcpy:
copy_loop:
    LDRB    R2, [R1], #1     ; load source byte, advance source pointer
    STRB    R2, [R0], #1     ; store to dest byte, advance dest pointer
    CMP     R2, #0
    BNE     copy_loop
    BX      LR

Internal Working Process: Scanning a String

flowchart TD
    A[Start: pointer at string base] --> B[Load byte at current pointer]
    B --> C{Byte == 0?}
    C -- No --> D[Process byte<br/>e.g. copy, compare, count]
    D --> E[Advance pointer by 1]
    E --> B
    C -- Yes --> F[Terminator found:<br/>stop, return length/result]

This loop — load, test, act, advance, repeat — is the fundamental shape behind almost every string operation you’ll write in assembly, whether you’re using x86’s dedicated string instructions or ARM’s general-purpose load/store-in-a-loop approach.

Comparison Table: x86 vs. ARM String Handling

Aspectx86/x86-64ARM
Dedicated string instructionsYes — MOVS, CMPS, SCAS, STOS, LODSNo — implemented via loops with LDRB/STRB
Automatic repetitionREP/REPE/REPNE prefixesManual loop with branch instruction
Pointer auto-advanceBuilt into string instructions via DF flagExplicit via post/pre-indexed addressing ([R1], #1)
Code density for simple copy/scanVery high (few instructions)Slightly larger but very predictable
Performance on modern CPUsREP MOVSB/STOSB are often microcode-optimized on modern Intel/AMD (fast-string optimization)Loop performance depends on compiler/hand-tuning and pipeline behavior

x86 advantage: extremely compact code for simple linear string operations, and on modern CPUs, REP MOVSB in particular benefits from hardware fast-string optimizations that can rival hand-tuned loops. ARM advantage: more predictable, uniform instruction timing (useful in real-time contexts), and no dependency on quirky flag-prefix combinations — everything is explicit in the loop.

Practical Use Cases

Operating System Interaction

Most OS APIs that accept or return strings — file paths, command-line arguments, environment variables — use null-terminated byte sequences (on Unix-like systems) or, in Windows’ case, frequently null-terminated UTF-16 sequences. That means low-level assembly interacting with OS syscalls or Win32 API calls needs to respect whichever convention the target OS expects, including the correct character width (1 byte vs. 2 bytes per character) when working with wide-character APIs.

Debugging and Performance Considerations

Best Practices

  1. Always know your string convention (null-terminated vs. length-prefixed) before writing any manipulation code — mixing conventions is a common source of bugs when combining code that assumes one over the other.
  2. Explicitly set the direction flag (CLD) before every x86 string-instruction sequence rather than assuming its prior state.
  3. Validate destination buffer sizes before copying — assembly won’t stop you from writing past the end of a buffer.
  4. Consider SIMD-based string routines for performance-critical, long-string operations once your scalar implementation is correct.
  5. Keep string helper routines (length, copy, compare, concatenate) as small, well-tested, reusable functions rather than inlining ad hoc string logic everywhere.

FAQs

Why does x86 have dedicated string instructions but ARM doesn’t? It reflects differing design philosophies — x86’s CISC heritage favored complex, purpose-built instructions for common patterns, while ARM’s RISC philosophy favors a small set of simple, general-purpose instructions (like post-indexed load/store) composed into loops, which tends to be more predictable for pipelining.

Is REP MOVSB always faster than a manual copy loop? Not always — for very short copies, the fixed setup overhead of the REP mechanism can outweigh its benefits. For long, well-aligned copies on modern CPUs, it often benefits from dedicated fast-string microcode paths and can be very competitive or faster.

How do I handle strings with embedded null bytes in assembly? You can’t rely on null-termination in that case — you need a length-prefixed or explicitly bounded representation instead, and all your routines (copy, compare, scan) need to use the known length rather than searching for a terminator.

What’s the safest way to avoid buffer overflows when writing string routines by hand? Always pass and check an explicit maximum length alongside any destination buffer pointer, and stop processing (or truncate deliberately) once that limit is reached — never trust that a source string will fit without checking.

Summary and Key Takeaways

String manipulation in assembly comes down to walking a sequence of bytes in memory according to a known convention — usually null-termination — and performing simple operations (copy, compare, scan, store) at each step. x86 offers a family of dedicated string instructions (MOVS, CMPS, SCAS, STOS, LODS) with automatic pointer advancement and optional repetition via REP prefixes, while ARM achieves the same results through general-purpose load/store instructions combined with post/pre-indexed addressing inside simple loops.

Key takeaways:

References

Exit mobile version