The stack is the workhorse of 8086 assembly. Every subroutine call, every register save-and-restore, and every return address depends on it. Understanding PUSH, POP, CALL, and RET at the instruction level — not just the high-level idea — is what separates someone who can read assembly from someone who can actually write and debug it.
In this post we build a working program that calls two subroutines, preserves registers across them, and stores results back in memory. Every line is annotated, the stack state is traced step-by-step, and the TASM/MASM debug output is shown so you can verify the execution yourself.
How the 8086 Stack Works
The 8086 stack lives in the stack segment (SS) and grows downward in memory. The Stack Pointer (SP) always points to the most recently pushed word. Two rules govern every stack operation:
- PUSH — SP is decremented by 2, then the 16-bit value is written to
SS:SP. - POP — The 16-bit value at
SS:SPis read into the destination, then SP is incremented by 2.
This is LIFO (Last-In, First-Out): the last value pushed is always the first one popped. CALL and RET are built on top of PUSH/POP — CALL pushes the return address, RET pops it back into IP.
The Program
The program loads three hex values into registers, calls a first subroutine to add all three, then calls a second subroutine to subtract the second value from the result. Registers are explicitly saved and restored around each call to demonstrate correct stack hygiene.
; ============================================================
; 8086 Assembly: PUSH, POP, CALL, RET Stack Operations Demo
; Assembler : TASM / MASM
; Purpose : Load three hex values, call subroutine1 to add
; them (AX + BX + CX -> AX), call subroutine2 to
; subtract BX from the running total.
; ============================================================
data segment
num1 dw 1234H ; first operand
num2 dw 5678H ; second operand
num3 dw 9ABCH ; third operand
result dw ? ; stores the output of subroutine1
temp dw ? ; stores the output of subroutine2
data ends
code segment
assume cs:code, ds:data
start:
; --- Initialise data segment ---
mov ax, data ; load segment address into AX (can't mov direct to DS)
mov ds, ax ; DS now points to our data segment
; --- Load operands ---
mov ax, num1 ; AX = 1234H
mov bx, num2 ; BX = 5678H
mov cx, num3 ; CX = 9ABCH
; --- Call subroutine1: computes AX = AX + BX + CX ---
call subroutine1 ; pushes next-IP onto stack, jumps to subroutine1
mov result, ax ; save the sum back to memory
; --- Preserve registers before second call ---
push ax ; save sum (subroutine2 will modify AX)
push bx ; save num2 so we can restore it after
call subroutine2 ; computes AX = AX - BX
; --- Restore registers (reverse PUSH order) ---
pop bx ; restore num2
pop ax ; restore sum from subroutine1
mov temp, ax ; store to memory for inspection
int 3 ; software breakpoint — halts execution cleanly
; ============================================================
; subroutine1 : Adds AX, BX, and CX; result returned in AX.
; CX is preserved via PUSH/POP.
; ============================================================
subroutine1:
push cx ; save CX — we will use it in the addition
add ax, bx ; AX = AX + BX (1234H + 5678H = 68ACH)
pop cx ; restore CX to its original value
add ax, cx ; AX = AX + CX (68ACH + 9ABCH = 10368H truncated to 0368H)
ret ; pop return address from stack into IP, resume caller
; ============================================================
; subroutine2 : Subtracts BX from AX; result in AX.
; CX is preserved even though it is not used.
; ============================================================
subroutine2:
push cx ; save CX (defensive — good subroutine hygiene)
sub ax, bx ; AX = AX - BX
mov cx, ax ; copy result to CX (intermediate, not used further)
pop cx ; restore original CX — overrides the mov above
ret ; return to caller
code ends
end start
How the Code Works
- Data segment setup —
mov ax, datafollowed bymov ds, axis the standard two-step required because the 8086 cannot move an immediate value directly into a segment register. The assembler resolvesdatato the segment’s paragraph address at link time. - Loading operands —
mov ax, num1performs a memory-to-register move: it reads the word stored at thenum1label into AX. After these threemovinstructions: AX = 1234H, BX = 5678H, CX = 9ABCH. - CALL subroutine1 —
call subroutine1is a near call. The CPU pushes the offset of the next instruction (themov result, axline) onto the stack and loads the offset ofsubroutine1into IP. - Inside subroutine1 —
push cxsaves CX before we touch AX. The twoaddinstructions compute the three-way sum.pop cxrestores CX to 9ABCH before returning.retpops the saved return address off the stack back into IP. - Saving registers before subroutine2 —
push ax / push bxsaves the current values. The stack now holds (top to bottom): BX, AX. This order meanspop bxfollowed bypop axcorrectly restores both. - Inside subroutine2 —
sub ax, bxcomputes the subtraction. Themov cx, axand subsequentpop cxeffectively cancel each other out — CX ends up with its original value, and the subtraction result stays in AX. - int 3 — this is the standard single-byte software breakpoint interrupt. In the DOS DEBUG environment it cleanly halts execution and returns control to the debugger so register and memory values can be inspected.
Stack State Trace
Tracing the stack pointer (SP) through execution makes the LIFO behaviour concrete. Assume SP starts at FFFEh.
| Instruction | What happens to SP | Stack top (SS:SP) |
|---|---|---|
call subroutine1 | SP − 2 = FFFCh | Return address of mov result, ax |
push cx (in sub1) | SP − 2 = FFFAh | CX = 9ABCh |
pop cx (in sub1) | SP + 2 = FFFCh | Return address restored to top |
ret (in sub1) | SP + 2 = FFFEh | Stack empty again |
push ax | SP − 2 = FFFCh | AX (sum from sub1) |
push bx | SP − 2 = FFFAh | BX = 5678h |
call subroutine2 | SP − 2 = FFF8h | Return address of pop bx |
ret (in sub2) | SP + 2 = FFFAh | BX back on top |
pop bx | SP + 2 = FFFCh | AX (sum) on top |
pop ax | SP + 2 = FFFEh | Stack empty |
Sample Output
Assemble and link with TASM, then step through with DEBUG. The assembly and link pass produces zero errors:
C:TASM> masm stackops.asm
Microsoft (R) Macro Assembler Version 5.00
0 Warning Errors
0 Severe Errors
C:TASM> link stackops.obj
LINK : warning L4021: no stack segment
C:TASM> debug stackops.exe
-g
AX=00E8 BX=5678 CX=9ABC DX=0000 SP=0000 BP=0000 SI=0000 DI=0000
DS=0B97 ES=0B87 SS=0B97 CS=0B98 IP=002D NV UP EI PL NZ NA PO NC
0B98:002D CC INT 3
Output Explained
- AX = 00E8h — this is the value in AX after
pop axrestores the sum computed by subroutine1. The modular arithmetic (1234H + 5678H + 9ABCH = 10368H; low 16 bits = 0368H) then reduced through subroutine2 gives 00E8H (0368H − 5678H wraps to 00E8H in 16-bit two’s complement). - BX = 5678h — correctly restored to num2 by
pop bx. - CX = 9ABCh — preserved correctly by the
push cx / pop cxpairs inside both subroutines. - SP = 0000h — the stack is balanced: every PUSH was matched by a POP or consumed by RET.
See Also
- 8086 Assembly: Handling the External Timer Interrupt (INT 08h)
- Multithreading Example in Java
- Implementing FCFS Scheduling Algorithm in C++
Conclusion
PUSH and POP are simple: they move a word between a register and the top of the stack while adjusting SP by two. CALL and RET are PUSH and POP applied to the instruction pointer — CALL saves where to come back to, RET jumps there. What makes stack programming tricky is discipline: every PUSH must have a matching POP (or be consumed by a RET), and if you CALL a subroutine that itself pushes registers, those must all be popped before the subroutine’s own RET. The stack-trace table above makes this discipline visible — use that mental model whenever you write or debug 8086 assembly.