What is the x86 "ret" instruction equivalent to?

Say I'm writing a routine in x86 assembly, like, "add" which adds two numbers passed as arguments.

For the most part this is a very simple method:

push ebp
mov ebp, esp
mov eax, [ebp+8]
add eax, [ebp+12]
mov esp, ebp
pop ebp
ret

But, is there any way I could rewrite this method to avoid the use of the "ret" instruction and still have it produce the exact same result?


Sure.

push ebp
mov ebp, esp
mov eax, [ebp+8]
add eax, [ebp+12]
mov esp, ebp
pop ebp

pop ecx  ; these two instructions simulate "ret"
jmp ecx

This assumes you have a free register (e.g, ecx). Writing an equivalent that uses "no registers" is possible (after all the x86 is a Turing machine) but is likely to include a lot of convoluted register and stack shuffling.

Most current OSes offer thread-specific storage accessible by one of the segment registers. You could then simulate "ret" this way, safely:

 pop   gs:preallocated_tls_slot  ; pick one
 jmp   gs:preallocated_tls_slot

This does not need any free registers to simulate ret, but it needs 4 bytes of memory (a dword). Uses indirect jmp. Edit: As noted by Ira Baxter, this code is not reentrant. Works fine in single-threaded code. Will crash if used in multithreaded code.

push ebp
mov  ebp, esp
mov  eax, [ebp+8]
add  eax, [ebp+12]
mov  ebp, [ebp+4]
mov  [return_address], ebp
pop  ebp

add  esp,4
jmp  [return_address]

.data
return_address dd 0

To replace only the ret instruction, without changing the rest of the code. Not reentrant. Do not use in multithreaded code. Edit: fixed bug in below code.

push ebp
mov  ebp, esp
mov  ebp, [ebp+4]
mov  [return_address], ebp
pop  ebp

add  esp,4
jmp  [return_address]

.data
return_address dd 0

Some other answers present ideas for avoiding registers entirely. This is slower and usually not needed.

(Much slower if you don't have a red-zone below ESP/RSP you can use, like the x86-64 System V ABI guarantees for user-space. But no other x86/x86-64 ABIs guarantee a red-zone, so debuggers evaluating a print some_func(123) while stopped at a breakpoint could clobber space below ESP, or a Unix signal handler. See Is it valid to write below ESP? for more about the safety of data below ESP, especially on Windows.)


In typical 32-bit calling conventions, EAX, ECX, and EDX, are all call-clobbered. (i386 System V, and all of Windows cdecl, stdcall, fastcall, etc.)

The Irvine32 calling convention has no call-clobbered registers, that's the one case I know of where this won't work.

So unless you're using a custom calling convention that returns something in ECX, you can safely replace ret with pop ecx/jmp ecx and still produce "the exact same result" and fully obey the calling convention. (64-bit integers are returned in EDX:EAX, so in some functions you can't clobber EDX).

add:
    mov   eax, [esp+4]
    add   eax, [esp+8]
    ;;ret
    pop   ecx
    jmp   ecx           ; bad performance: misaligns the return address predictor stack

I also removed the stack-frame overhead / noise for readability.

ret is basically how you write pop eip (or IP / RIP) in x86, so popping into an architectural register and using a register-indirect jump is architecturally equivalent. (But much worse microarchitecturally because of call/ret special handling for branch prediction.)


To avoid registers, in a function with a stack arg, we can overwrite one of the args. In the standard calling conventions, functions own their incoming args and can use those arg-passing slots as scratch space, even if they're declared as foo(const int a, const int b).

add:
    mov   eax, [esp+4]    ; arg1
    add   eax, [esp+8]    ; arg2
    ;;ret
    pop   [esp]           ; copy return address to arg1, and do ESP+=4
    jmp   [esp]           ; ESP is pointing to arg1

This wouldn't work for a function with no args, or with only register args. (Except in Windows x64, where you could copy the retaddr into the 32-byte shadow space above the return address.)

Despite the pseudocode in the Operation section in Intel's ISA manual (https://www.felixcloutier.com/x86/pop) showing DEST ← SS:ESP; happens before ESP += 4, the Description section says "If the ESP register is used as a base register for addressing a destination operand in memory, the POP instruction computes the effective address of the operand after it increments the ESP register." Also that "POP ESP increments the stack pointer (ESP) before data at the old top of stack is written into the destination." So it's really tmp = pop ; dst = tmp. AMD doesn't mention either corner-case at all.

If I'd left in the legacy stack-frame crap with EBP, I could have avoided an [ESP] destination pop, using EBP as a temporary before restoring it. mov ebp, [ebp+4] / mov [esp+8], ebp / pop ebp / add esp,4 / jmp [esp], but that's hardly better or easier to follow. (The saved EBP value is below the return address, and you can't safely move ESP up past it either.) And this temporarily breaks legacy backtraces following a chain of EBP pointing to saved-EBP.

Or you could save / restore another register to use as a temporary for copying the return address over an arg. But that seems pointless vs. pop [esp] once you sort out exactly what that does.


Avoiding RET is terrible for performance

(Unless your caller also avoided call, manually pushing a return address.)

Mismatched call/ret lead to bad performance for future ret instructions going back up the call-stack in parent functions.

See Microbenchmarking Return Address Branch Prediction, and also Agner Fog's microarch and optimization guides. Specifically the part that's quoted and discussed in Return address prediction stack buffer vs stack-stored return address?

(Fun fact: most CPUs special case call +0, because it's not rare for code to use call next_instruction / pop ebx as part of for position-independent 32-bit code to work around the lack of RIP-relative addressing. See the stuffedcow.net blog post.)

Note that a tailcall like jmp add instead of call add / ret is fine: that doesn't cause a mismatch because the first ret is returning to the most recent call (in the parent of the function that ended with a tailcall). You could look at it as making the body of the 2nd function "part of" the function that did the tailcall, as far as call / ret is concerned.


Haven't tested, but you may be able to do a ret without using a GPR like this:

add esp,4
jmp dword ptr [esp-4]