If you’ve spent any time around x86 assembly and then gone to look at ARM, the first thing you probably noticed is how much cleaner it feels. That’s not an accident. ARM was designed from day one around a philosophy called RISC (Reduced Instruction Set Computer), which trades a huge, irregular menu of complex instructions for a small set of simple, fast, predictable ones. Instead of one instruction doing five things at once (the CISC way, which is how x86 grew up), ARM prefers to do those five things as five simple instructions, each of which the CPU can execute in a single, predictable cycle. This sounds like it should be slower, and instruction-for-instruction it sometimes is, but it makes the hardware simpler, cheaper to build, and dramatically more power efficient, which is exactly why ARM ended up running basically every phone, tablet, and now a growing share of laptops and servers on the planet.
This post is meant as a practical, hands-on tour of ARM assembly: the registers you have to work with, the instructions you’ll actually use, how the CPU makes decisions and loops, how functions call each other, and a couple of worked examples to tie it together. We won’t cover every corner of the architecture (there’s a lot of it), but by the end you should be able to read and reason about real ARM assembly, and understand why it’s shaped the way it is.
ARM Versions
“ARM” isn’t a single, static thing. It’s a family of architectures that has evolved over decades, and different versions add capabilities the earlier ones didn’t have. The two you’ll run into most often today are:
- ARMv7, which is 32-bit. This is the architecture behind most older and mid-range Android phones, embedded devices, and microcontrollers running Cortex-A/R/M cores.
- ARMv8, which introduced a 64-bit execution state (called AArch64) alongside backward compatibility with 32-bit code (AArch32). This is what modern phones, Apple Silicon Macs, and most current ARM servers run.
The jump from v7 to v8 wasn’t just “add more bits.” AArch64 cleaned up a lot of the instruction set’s rough edges: it doubled the number of general-purpose registers, dropped some of the more exotic addressing tricks that made compilers’ lives hard, and reworked the calling convention to be more consistent. It’s worth knowing which version you’re targeting before you start writing assembly, because the register names, instruction mnemonics, and calling conventions genuinely differ between AArch32 and AArch64. Everything below focuses on the classic 32-bit ARM instruction set, since that’s the clearest entry point for learning the concepts, but the underlying ideas (RISC design, load-store architecture, condition flags, a link register for returns) carry straight over into AArch64.
One more thing worth knowing: ARM Holdings doesn’t manufacture chips. It designs the instruction set architecture and licenses it (or licenses ready-made core designs like the Cortex series) to companies like Apple, Qualcomm, and Samsung, who build their own silicon around it. That’s a big part of why ARM chips show up in such a wild range of devices, from a coin-cell-powered sensor to a datacenter server, all speaking a broadly compatible instruction set.
Basic Concepts
Registers
Registers are where the actual work happens. Unlike memory, which is comparatively slow to access, registers sit right next to the ALU (arithmetic logic unit) and can be read or written in a single cycle. Because of that, ARM assembly (like most RISC assembly) is built around the idea of getting values out of memory and into registers as quickly as possible, doing your computation there, and only writing back to memory when you actually need to. This is called a load-store architecture, and it’s one of the defining traits of RISC design: arithmetic and logic instructions never touch memory directly, only registers. Loading and storing are handled by their own dedicated instructions.
Classic 32-bit ARM gives you sixteen general-purpose-looking registers, though a few of them have jobs baked into the architecture:
| Register | Purpose |
|---|---|
| R0-R12 | General-purpose registers |
| R13 | Stack pointer (SP) |
| R14 | Link register (LR) |
| R15 | Program counter (PC) |
| CPSR | Current Program Status Register |
R0 through R12 are yours to use freely for whatever intermediate values, loop counters, or function arguments you need. In practice, though, calling conventions (more on that later) assign informal roles to the low ones, so R0-R3 usually end up carrying function arguments and return values, while the rest are used for local computation.
The last three deserve special attention because they’re not just “extra” registers, they’re load-bearing parts of how the CPU operates:
- SP (R13), the stack pointer, always points at the top of the current call stack. Every time you push a value (say, to save a register before calling a function) or set up local variables, you’re really just moving SP and writing to the memory it points at. Because the stack grows and shrinks constantly during normal execution, SP is effectively “hot” almost all the time.
- LR (R14), the link register, is what makes function calls possible without needing a full call stack frame for every single call. When you execute a branch-and-link instruction (
BL), the CPU automatically stashes the address of the next instruction into LR before jumping. When the function is done, it jumps back through LR instead of manually reading a return address off the stack. This is faster than the x86 approach of pushing a return address onto memory for every call, though it does mean that if a function calls another function, it has to manually save the old LR value (usually onto the stack) before making the inner call, or the return address gets clobbered. - PC (R15), the program counter, holds the address of the instruction the CPU is currently fetching. What makes ARM interesting here is that PC is just a regular register you can read and write directly. You can do arithmetic on it, load a new value into it to jump somewhere, or even use it as an operand in an addressing calculation (we’ll see this with PC-relative loads). That flexibility is powerful, but it’s also a classic source of bugs if you’re not careful about pipeline effects, since the value you read from PC is usually a few instructions ahead of where you’d naively expect.
Alongside those sits the CPSR, the Current Program Status Register, which isn’t a general-purpose register at all but a bundle of status flags and processor mode bits. The four flags you’ll interact with constantly are N (negative), Z (zero), C (carry), and V (overflow). Comparison and arithmetic instructions update these flags, and a huge chunk of ARM’s control flow (every conditional branch, and actually every conditionally-executed instruction) reads them back. We’ll come back to this when we talk about branching, because it’s genuinely one of the more elegant parts of the ARM design.
Instruction Set
ARM’s instruction set is built around a few core principles that all trace back to the RISC philosophy: keep instructions simple, keep them a fixed size (32 bits each, in classic ARM), and let the compiler (or you) combine them to build up complex behavior rather than baking that complexity into the hardware. A fixed instruction width might sound like a minor detail, but it has real consequences: the CPU never has to guess where one instruction ends and the next begins, which massively simplifies instruction fetching and decoding and is a big part of why ARM pipelines are efficient.
Broadly, the instructions you’ll use fall into four families:
| Instruction Type | Example | Description |
|---|---|---|
| Data Processing | ADD R0, R1, R2 | Adds R1 and R2, stores in R0 |
| Load/Store | LDR R0, [R1] | Loads data from address in R1 |
| Control Flow | B label | Branches to the specified label |
| Bitwise | EOR R0, R1, R2 | Exclusive ORs R1 and R2 |
Data processing instructions (ADD, SUB, MOV, CMP, and friends) are the workhorses: they operate purely on registers and immediate values, never on memory. Load/store instructions (LDR and STR) are the only way to move data between registers and memory, which, again, is the load-store architecture at work. Control flow instructions redirect execution, either unconditionally or based on the CPSR flags. And bitwise instructions manipulate individual bits, which turns out to matter a lot more in low-level and embedded work than it does in typical application code, since you’re often packing flags, masks, and hardware register fields into individual bits.
The general shape of an ARM assembly line is:
LABEL: INSTRUCTION OPERAND
A label is just a name you can branch to or reference later; it doesn’t generate any code by itself. Here’s a small example that shows the basic data-processing instructions in action:
start: MOV R0, #5 ; Move 5 into R0
MOV R1, #10 ; Move 10 into R1
ADD R2, R0, R1 ; Add R0 and R1, store in R2Nothing exotic here: MOV puts an immediate value into a register, and ADD reads two registers, adds them, and writes the result to a third. Notice that ARM’s three-operand form (destination, then two sources) is different from x86’s typical two-operand form where the destination is also one of the sources. This is a small thing, but it means ARM code often needs fewer instructions to compute an expression, because you’re not constantly shuffling values to avoid overwriting something you still need.
Data Types
ARM doesn’t have “typed” registers the way a high-level language has typed variables. A register is just 32 bits, and it’s up to your instructions to decide whether those bits represent a signed integer, an unsigned integer, a memory address, or something else. What ARM does care about is the size of the data you’re loading, storing, or operating on, because that determines how many bytes get touched in memory and how the value gets sign-extended or zero-extended into a register.
| Data Type | Size | Description |
|---|---|---|
| Byte | 8 bits | Typically used for characters |
| Halfword | 16 bits | Used for smaller integers |
| Word | 32 bits | Common size for integers |
| Double | 64 bits | Used for larger integers/floats |
The “word” size (32 bits on classic ARM) is the natural size the architecture is built around, matching the width of its general-purpose registers, and it’s what most instructions default to unless you explicitly ask for a byte or halfword variant (like LDRB for a byte load or LDRH for a halfword load). Double-word operations exist for things like 64-bit arithmetic or floating point, but they’re handled either by pairing two registers together or, on chips with a floating point unit, by a separate set of floating point/SIMD registers entirely. One detail worth being aware of if you ever poke at raw memory dumps: ARM can run in either little-endian or big-endian mode, though little-endian is by far the default and most common configuration in practice, matching what you’d see on x86.
Addressing Modes
Addressing modes are the different ways an instruction can compute the memory address it’s going to read from or write to, and ARM offers a genuinely flexible set of them. This flexibility exists because memory access patterns in real programs aren’t all the same: sometimes you know the exact address at compile time, sometimes you’re following a pointer, and sometimes you’re walking through an array or a struct field at a known offset from some base address. Having dedicated addressing modes for each of these patterns means you don’t have to burn extra instructions computing addresses by hand.
| Addressing Mode | Example | Description |
|---|---|---|
| Immediate | MOV R0, #5 | Load an immediate constant |
| Register | LDR R1, [R2] | Load value from address in R2 |
| Base Offset | LDR R3, [R4,#8] | Load from R4 plus offset 8 |
| PC-Relative | LDR R0, [PC,#4] | Load from address relative to PC |
LDR R0, =0x1000
LDR R1, [R2]
LDR R3, [R4, #8]
LDR R4, [PC, #4]The base-offset mode is the one you’ll lean on most once you start working with real data structures, because it’s exactly how array indexing and struct field access get compiled: R4 holds the base address of some object, and the constant offset picks out a specific field or element within it. PC-relative addressing looks unusual at first, but it’s how the assembler smuggles large constants (like the =0x1000 in the first line above) into your code: rather than trying to encode an arbitrary 32-bit value directly into a 32-bit instruction (which doesn’t leave room for the opcode), the assembler stores the constant nearby in memory as a “literal pool” and generates a PC-relative load to fetch it. It’s a neat workaround for a real limitation of fixed-width instruction encoding, and it’s worth understanding because it explains why LDR Rd, =value behaves differently under the hood from a plain MOV Rd, #value.
Control Flow
Branching
Branches are how ARM implements everything from if statements to loops to function calls, and they lean heavily on those CPSR flags we mentioned earlier. Here’s the elegant part: almost every ARM instruction, not just branches, can be made conditional by attaching a two-letter condition code to its mnemonic. BEQ (branch if equal) is really just B with the EQ condition attached, and the same EQ suffix works on ADD, MOV, or almost anything else. The CPU checks the relevant CPSR flags before executing the instruction and simply skips it (treats it as a no-op) if the condition isn’t met.
CMP R0, R1
BEQ equal
B end
equal:
; Do something
end:Here, CMP doesn’t produce a result register at all, its whole job is to subtract R1 from R0 internally and update the CPSR flags based on that subtraction, without keeping the result anywhere. BEQ then checks whether the Z (zero) flag got set, which happens exactly when R0 and R1 were equal. If it’s set, execution jumps to equal; if not, it falls through to the unconditional branch to end. This pattern (compare, then conditionally branch) is the assembly-level equivalent of an if/else, and it’s worth internalizing because you’ll see it constantly.
The ability to conditionally execute any instruction, not just branches, is a genuine ARM specialty (this is sometimes called predication). It means short if bodies can sometimes be compiled without a branch at all, which matters a lot for performance: branches can stall a CPU’s instruction pipeline if the processor guesses wrong about which way the branch will go, so avoiding a branch entirely for a small conditional operation can be a real win.
Loops
Loops are just branches that point backward instead of forward. There’s no dedicated “loop” instruction in classic ARM (unlike, say, x86’s LOOP); you build one out of a comparison, a conditional branch, and a branch back to the top:
MOV R0, #0
loop:
CMP R0, #10
BGT end_loop
ADD R0, R0, #1
B loop
end_loop:Walk through it and it reads almost like pseudocode: R0 starts at 0, and on every pass we compare it against 10. BGT bails out to end_loop once R0 exceeds 10; otherwise we increment R0 and unconditionally jump back to the top of the loop. It’s a tiny example, but it’s exactly the shape a compiler generates for a for loop, just without the syntactic sugar. One thing to notice: because there’s no dedicated loop counter register or instruction, the compiler (or you) is completely free to choose which register holds the counter and how the exit condition is checked, which is part of why ARM code can be so tightly optimized for a specific situation.
Procedures (Functions)
Calling a function in ARM assembly comes down to two things: jumping to the function’s address, and remembering where to jump back to afterward. That’s exactly what the branch-and-link instruction does:
BL my_function
MOV PC, LR ; ReturnBL (branch with link) jumps to my_function, but before it does, it automatically saves the address of the instruction right after the BL into LR. That’s the “link” part: it links the call site back to the return point. When my_function is done, it returns simply by copying LR into PC, which is exactly what MOV PC, LR does here. On modern ARM cores you’ll more commonly see BX LR used for this instead, since BX (branch and exchange) also handles switching between ARM and Thumb instruction encoding correctly, but the underlying idea, jump back to the address saved in LR, is identical.
my_function:
ADD R0, R0, #1
MOV PC, LRThis tiny function takes whatever’s in R0, adds 1 to it, and returns, with the result still sitting in R0. That’s not an accident either: it’s following the standard ARM calling convention (formally, the AAPCS, Procedure Call Standard), which says arguments come in through R0-R3 and a simple return value goes back out through R0. Because both the caller and the function agree on this convention without needing to communicate about it explicitly, you can call functions written by someone else (or generated by a compiler from a completely different language) and things just work, as long as everyone respects the same register roles.
The one thing this simple example glosses over is what happens when a function needs to call another function. Since BL only has one LR to work with, if my_function itself calls something else with BL, the original return address in LR gets overwritten. The standard fix is to push LR onto the stack at the start of the function and pop it back before returning, which is why you’ll frequently see a PUSH {LR} / POP {PC} pair (or the fuller STMFD/LDMFD forms) bracketing any non-trivial function body.
Advanced Topics
Bitwise Operations
MOV R0, #0xFF
AND R1, R0, #0x0F
ORR R2, R0, #0xF0
EOR R3, R0, R1Bitwise instructions operate independently on each bit position, and they show up constantly in low-level code for jobs that have nothing to do with arithmetic in the mathematical sense. AND with a mask like 0x0F is how you isolate specific bits (here, the low nibble) while zeroing out everything else; this pattern is often called “masking,” and it’s exactly how you’d read a specific flag out of a hardware status register. ORR does the opposite job, setting specific bits without disturbing the rest, which is how you’d enable a flag in a control register without accidentally clobbering the other settings already there. EOR (exclusive or) has a neat property: XOR-ing a value with itself always produces zero, and XOR-ing twice with the same operand gets you back where you started, which is why it shows up in everything from simple checksum tricks to register-swapping tricks to basic encryption.
Shifting and Rotating
LSR R0, R1, #2
ASR R2, R3, #1
ROR R4, R5, #3Shift and rotate instructions move the bits of a value left or right by some number of positions, but the differences between them matter a lot depending on what the value represents. LSR (logical shift right) shifts bits right and fills the vacated high bits with zero, which is the right choice for unsigned values, since it’s a clean, predictable way to divide by a power of two. ASR (arithmetic shift right) does the same shift but fills the vacated bits with copies of the original sign bit instead of zero, which preserves the sign of a signed (two’s complement) number: shifting a negative number right with ASR still gives you a negative number, correctly rounded toward negative infinity, in the same way dividing a negative number by 2 should. ROR (rotate right) is different again: instead of discarding the bits that fall off one end, it wraps them around to the other end, which is handy for things like implementing certain hash functions or manipulating bit patterns where no information should be lost.
What’s genuinely distinctive about ARM here is that these shifts aren’t limited to standalone instructions. Classic ARM has a barrel shifter built directly into its data path, which means almost any data-processing instruction can shift one of its operands for free, as part of the same instruction, with no extra cycle cost. So something like ADD R0, R1, R2, LSL #2 (add R1 to R2 shifted left by 2, storing the result in R0) is a single instruction that both shifts and adds. This is a big part of why ARM can be so efficient at classic patterns like array indexing, where you need to multiply an index by the element size (a power-of-two shift) and add it to a base address, all in one shot.
Debugging Techniques
Debugging assembly is a different experience from debugging a high-level language, mostly because there’s no runtime throwing helpful exceptions or stack traces at you; when something goes wrong, you’re staring at raw register and memory state and have to reconstruct what happened. A few habits go a long way:
- Comment your intent, not just the mechanics. A comment like
; move 5 into R0is nearly useless; a comment explaining why you’re setting up that value (what it represents in your algorithm) is what actually helps you (or anyone else) later. - Watch the registers, not just the output. Most tools for working with ARM assembly (whether that’s a hardware debugger like OpenOCD/GDB against real silicon, or an emulator like QEMU) let you single-step instructions and inspect the full register file and CPSR flags after each one. Since so much of ARM’s control flow depends on those flags, watching them change as you step through a comparison and branch is often the fastest way to understand a bug.
- Use breakpoints to isolate the failure, not just to stop the program. Set one right before the suspicious branch or loop, then step through a single iteration by hand, checking that the register values match what you expect at each point. Assembly bugs are frequently off-by-one errors in a loop bound or a forgotten register save around a function call, and those are much easier to spot when you’re watching them happen live rather than staring at static code.
Example Programs
Factorial Calculation
.data
number: .word 5
result: .word 0
.text
.global _start
_start:
LDR R0, =number
LDR R1, [R0]
MOV R2, #1
MOV R3, #1
factorial_loop:
CMP R3, R1
BGT end_factorial
MUL R2, R2, R3
ADD R3, R3, #1
B factorial_loop
end_factorial:
LDR R0, =result
STR R2, [R0]This program is a good chance to see everything above working together in one place. The .data section reserves storage for two words in memory: number, holding the value 5 we want the factorial of, and result, where we’ll eventually park the answer. The first two instructions load the address of number into R0 (that’s what LDR R0, =number does, using the PC-relative literal pool trick we talked about earlier), and then dereference that address to pull the actual value, 5, into R1. From there, R2 is our running product (it has to start at 1, since multiplying by 0 would zero everything out) and R3 is our loop counter, also starting at 1.
The loop itself is the same shape as our earlier generic loop example: compare the counter against the limit, bail out once we’ve gone past it, otherwise do the work (MUL R2, R2, R3 multiplies the running product by the current counter) and increment. Once R3 exceeds R1, we fall through to end_factorial, compute the address of result, and store the final product there. It’s a small program, but notice how much of it is bookkeeping: loading an address, dereferencing it, and storing back out, with the actual “factorial logic” boiling down to a single MUL inside the loop.
Fibonacci Sequence
.data
n: .word 10
result: .space 40
.text
.global _start
_start:
LDR R0, =n
LDR R1, [R0]
MOV R2, #0
MOV R3, #1
MOV R4, #0
write_fib:
CMP R4, R1
BGE end_fib
LDR R5, =result
STR R2, [R5, R4, LSL #2]
ADD R6, R2, R3
MOV R2, R3
MOV R3, R6
ADD R4, R4, #1
B write_fib
end_fib:This one’s a bit denser, so it’s worth going slowly. n holds how many Fibonacci numbers we want (10), and .space 40 reserves 40 bytes, room for ten 32-bit words, to hold the output sequence. R2 and R3 track the “current” and “next” Fibonacci values (starting at the classic 0 and 1), and R4 is our index into the output array, starting at 0.
The interesting line is STR R2, [R5, R4, LSL #2], which packs a whole array-indexing calculation into one instruction thanks to that barrel shifter we talked about earlier. R5 holds the base address of result, R4 is the element index, and LSL #2 shifts that index left by 2 (multiplying it by 4) before adding it to the base address, because each word is 4 bytes wide. So this single instruction is doing the equivalent of result[R4] = R2 in C, address computation and all, without a separate multiply instruction. After the store, we compute the next Fibonacci number the usual way (current + next), shuffle the values along (R3 becomes the new “current,” the sum becomes the new “next”), bump the index, and loop back until R4 reaches R1, our target count.
Performance Optimization
Writing correct assembly is one skill; writing assembly that actually runs fast on real hardware is a slightly different one, because it requires some understanding of how the CPU pipeline and memory hierarchy behave underneath your instructions. Two of the most common, high-leverage techniques are:
- Loop unrolling. Every pass through a loop pays some overhead for the comparison and branch, and on top of that, branches can stall a pipelined CPU if it mispredicts which way the branch will go. If you know a loop is going to run a fixed, small number of times, you can duplicate the loop body a few times per iteration (process two or four elements per pass instead of one) to cut down how many times you pay that comparison-and-branch cost, at the price of a larger, less flexible code footprint.
- Reducing memory access. Because registers are so much faster than main memory (and even faster than cache, which itself is much faster than DRAM), the biggest performance win in tight assembly loops is often just keeping frequently used values in registers across iterations instead of reloading them from memory every pass. Every avoidable
LDRorSTRyou cut out is time you’re not spending waiting on the memory system.
MOV R0, R1
ADD R0, R0, #10Even a two-instruction example like this hints at the mindset: R0 is loaded once from a register (which is essentially free) rather than from memory, and the addition happens entirely in registers with an immediate operand, no memory traffic at all. Scale that habit up across a whole loop, and the difference between “constantly touching memory” and “doing almost everything in registers” is often the single biggest lever you have for making hand-written ARM assembly fast.
