ARM Assembly: ∞ Ways to Return
ARM is unusual among the processors by having the program counter available as a “general purpose” register. Most other processors have the program counter hidden, and its value will only be disclosed as the return address when calling a function. If you want to modify it, a jumping instruction is used.
For example, on the x86, the program counter is called the instruction pointer, and is stored in eip
, which is not an accessible register. After a function call, eip
is pushed onto the stack, at which point it could be examined. Return is done through the ret
instruction which pops the return address off the stack, and jumps there.
Another example: on the MIPS, the program counter is stored into register 31
after executing a JALR
instruction, which is used for function calling. The value in there can be examined, and a return is a register jump JR
to that register.
ARM’s unusual design allows many, many ways of returning from functions. But first, we must understand how function calls work on the ARM.
On ARM, the program counter is register 15, or r15
, also called pc
. The instruction to call a function is bl
(for immediate offsets) or blx
(for addresses in registers). These instructions stores the return address in r14
, called the link register, or lr
. To return, we must put this value back into pc
.
Method 1
When writing non-leaf functions, i.e. functions that calls other functions, the value of lr
must be preserved, since calling another function will overwrite it. The most common way is to store it on the stack. On the ARM, there are convenient instructions push
and pop
which pushes and pops multiple values onto the stack. We typically use this to preserve the registers we modify. For example, if we want to preserve r3
, r4
, and lr
, we can write push {r3, r4, lr}
.
A normal function will look like:
push {r3, r4, lr} ; Save registers.
; Function body.
pop {r3, r4, pc} ; Restore registers and return.
This is our first way of returning: using push
to restore all the registers, except putting what was lr
when we are doing push
into pc
. This will overwrite pc
with the return address, achieving the return. Note that we could instead use r14
instead of lr
and r15
instead of pc
, but this is less clear on the intent.
Method 2
We can use an unconditional jump to register to return, which is useful in leaf functions where lr
is never stored on the stack. This is simply:
bx lr
This jumps to the address in lr
, setting pc
to lr
, and completing the return.
Method 3
Similar in rationale to method 2, but as stated in the beginning, ARM lets you manipulate the program counter as you would any other register. So… we have:
mov pc, lr
This copies lr
into pc
, also completing the return.
Method 4
To really get the point across, we can also use bitwise instructions to return. For example:
orr r15, r14, r14
This performs a bitwise OR of r14
(lr
) with itself, which results in the value of lr
, and stores this in r15
(pc
). This also copies lr
into pc
, completing the return.
Method n
Of course, there are many other ways of copying the value in one register into another, and to list that would be fairly silly. But as long as lr
at the beginning of the function call is placed into pc
, a return is completed.
But please, use the most sensible ways to return. This means you should prefer the first two, depending on whether the function is a leaf. As a distant third, use method 3 (mov pc, lr
).