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:
- Null-terminated strings (C-style): the string ends wherever a
0x00byte appears. This is the convention x86/ARM system programming and most OS APIs use by default. - Length-prefixed strings (Pascal-style): the string’s length is stored explicitly (often as a byte or word before the character data), so no terminator scanning is required.
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.
| Instruction | Purpose |
|---|---|
MOVS | Move data from [RSI] to [RDI], advancing both pointers |
CMPS | Compare [RSI] and [RDI], advancing both pointers |
SCAS | Compare AL/AX/EAX against [RDI], advancing RDI |
STOS | Store AL/AX/EAX into [RDI], advancing RDI |
LODS | Load [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
| Aspect | x86/x86-64 | ARM |
|---|---|---|
| Dedicated string instructions | Yes — MOVS, CMPS, SCAS, STOS, LODS | No — implemented via loops with LDRB/STRB |
| Automatic repetition | REP/REPE/REPNE prefixes | Manual loop with branch instruction |
| Pointer auto-advance | Built into string instructions via DF flag | Explicit via post/pre-indexed addressing ([R1], #1) |
| Code density for simple copy/scan | Very high (few instructions) | Slightly larger but very predictable |
| Performance on modern CPUs | REP 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
- Implementing your own C-runtime-style functions (
strlen,strcpy,strcmp,memcpy,memset) when no libc is available (bootloaders, kernels, embedded firmware). - Parsing text-based protocols or file formats directly in performance-critical assembly routines.
- Implementing simple command-line argument parsing in freestanding (no-OS-runtime) programs.
- Writing highly optimized
memcpy/memmovereplacements for specific, known alignment and size patterns.
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
- Common mistake: forgetting
CLDbefore usingREP MOVSB/STOSB/etc. If the Direction Flag was left set from earlier code, pointers will decrement instead of increment, silently corrupting memory in the “wrong” direction. - Common mistake: off-by-one errors around the null terminator — many bugs come from forgetting whether a computed length should or shouldn’t include the terminating byte.
- Common mistake: buffer overruns from copying a string into a fixed-size destination without checking the source length first — assembly gives you zero automatic bounds checking.
- Debugging tip: use a debugger’s memory view to watch the destination buffer live while stepping through a copy loop; off-by-one and direction-flag bugs become immediately visible.
- Optimization tip: on modern x86-64,
REP MOVSBwith properly aligned, sufficiently large buffers can outperform naive hand-written byte loops due to microarchitectural “fast string” support — but for very small, fixed-size copies, a few explicitMOVinstructions can beat the fixed overhead of setting up aREPoperation. - Optimization tip: SIMD instructions (SSE2/AVX on x86, NEON on ARM) can process 16+ bytes per instruction for string scanning/copying, dramatically outperforming a byte-at-a-time loop for long strings — worth reaching for once basic byte-loop logic works correctly.
Best Practices
- 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.
- Explicitly set the direction flag (
CLD) before every x86 string-instruction sequence rather than assuming its prior state. - Validate destination buffer sizes before copying — assembly won’t stop you from writing past the end of a buffer.
- Consider SIMD-based string routines for performance-critical, long-string operations once your scalar implementation is correct.
- 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:
- Know your string convention (null-terminated vs. length-prefixed) before writing manipulation code.
- x86’s string instruction family plus
REPprefixes offers very compact, and on modern CPUs often fast, string operations — but requires careful direction-flag management. - ARM achieves equivalent results through explicit, predictable load/store loops leveraging post/pre-indexed addressing.
- There’s no automatic bounds checking anywhere — buffer size validation is entirely the programmer’s responsibility.
- SIMD instructions are the natural next step for performance once correct scalar string routines are in place.
References
- Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 1 — String Operations chapter (MOVS, CMPS, SCAS, STOS, LODS, REP prefixes).
- AMD64 Architecture Programmer’s Manual, Volume 3 — General-Purpose and System Instructions.
- ARM Architecture Reference Manual — Load/Store instruction addressing modes.
- GNU C Library (glibc) manual — string function implementations and conventions.
- GNU Assembler (GAS) documentation — x86 string instruction syntax and ARM load/store syntax.
