Subroutines & the Stack

Assembly Fundamentals

Chapter 7 · Subroutines & the Stack

assembly1-6 ended with a forward pointer: longer jumps rely on JMP, "the same mechanism assembly1-7 uses to implement RET." This chapter makes good on that. You'll see how LC-3 calls and returns from reusable blocks of code, why a single register isn't enough once calls start nesting, and how to build a stack entirely out of ordinary instructions — because unlike some real chips this course will eventually meet, LC-3 doesn't give you one for free.

JSR / JSRR — Calling a Subroutine

JSR ("Jump to Subroutine") does two things in one instruction: it saves the address of the instruction right after itself into R7 — the return address — and then jumps to the subroutine, using the same PC-relative idea as BR, just with a wider offset:

0100opcode
1PC-relative
PCoffset1111 bits, sign-extended

Notice the offset here is 11 bits, not the 9 bits BR and LD use — LC-3 gives subroutine calls roughly four times the reach of a regular branch, since a subroutine is often placed much farther away in a program than a nearby loop target.

JSRR does the identical "save R7, then jump" job, but computes its target from a base register instead of a PC-relative offset — useful when the subroutine's address isn't known until runtime:

0100opcode
0register mode
00unused
BaseR3 bits
000000unused
JSR  MYSUB     ; R7 <- return address; PC <- PC-relative target
JSRR R3        ; R7 <- return address; PC <- value in R3

RET — Returning from a Subroutine

RET is, at the bit level, nothing more than JMP R7 — LC-3's specification doesn't even give it a separate opcode. JMP jumps unconditionally to whatever address sits in a chosen base register:

1100opcode
000unused
BaseR3 bits
000000unused

Set BaseR to 111 (R7) and you get exactly RET — jump to whatever address JSR/JSRR most recently saved there. The two instructions are a matched pair by convention, not by any special hardware connection between them: JSR happens to write R7, and RET happens to be the specific case of JMP that reads it back.

Why Nested Calls Need a Stack

R7 holds exactly one return address at a time. That's fine for a single, non-nested call — but consider subroutine A calling subroutine B, where B itself calls a third subroutine:

A:  ...
    JSR B        ; R7 <- "return to A", PC <- B
    ...

B:  ...
    JSR C        ; R7 <- "return to B" — OVERWRITES A's return address!
    ...
    RET           ; jumps using R7 — but R7 no longer points back to A

The instant B's own JSR C runs, it overwrites R7 with B's return address, permanently destroying the return address A was counting on. By the time B's RET eventually runs, R7 correctly returns to A's caller-side location — but only because C's own return already restored it along the way, which only works by luck of ordering, not by design. A single shared register simply cannot hold more than one pending return address, and nested calls need more than one.

LC-3 Has No Dedicated Stack Pointer — You Build the Stack Yourself

This is exactly the kind of problem a hardware stack — a last-in-first-out storage structure with a dedicated stack-pointer register the CPU manages automatically — is built to solve. LC-3 deliberately doesn't provide one. There's no register reserved by the hardware for this purpose, and no dedicated PUSH/POP instructions. By convention, LC-3 programs simply dedicate one general-purpose register — commonly R6 — to act as a software-maintained stack pointer, and build push/pop behavior out of instructions you already know:

; PUSH R0 onto the stack (stack conventionally grows downward)
ADD R6, R6, #-1   ; move the stack pointer down one slot
STR R0, R6, #0    ; store R0 at the new top-of-stack address

; POP into R0
LDR R0, R6, #0    ; read the value currently at top-of-stack
ADD R6, R6, #1    ; move the stack pointer back up

Both sequences lean directly on assembly1-3's base+offset addressing mode (STR/LDR with R6 as the base) and assembly1-4's load/store discipline — there's nothing new here mechanically, just a convention for how to use what you already have.

A Full Calling Convention — Saving R7 Around a Nested Call

With a working stack, A can safely call B, and B can safely call C, by pushing R7 before making its own nested call and popping it back right after:

A:      ...
        JSR B
        ...

B:      ; save R7 before B makes its own nested call
        ADD R6, R6, #-1
        STR R7, R6, #0

        JSR C          ; safe now — B's own return address is preserved on the stack

        ; restore R7 before returning to A
        LDR R7, R6, #0
        ADD R6, R6, #1
        RET              ; now correctly returns to A

This push-before-call, pop-before-return pattern is the actual calling convention that makes nesting safe to any depth — every subroutine that itself calls another subroutine follows the same rule.

Forgetting to save R7 is a classic, silent bug
Skip the push/pop around a nested JSR and the program won't crash immediately — it'll run RET successfully, just to the wrong place, because R7 no longer holds the return address you actually needed. The symptom shows up far from the actual mistake, which is exactly what makes it a notoriously easy bug to introduce and a hard one to trace back to its source.
LC-3's software stack is a real, deliberate design choice — not a missing feature
When cpu8bit1 covers the 6502's fixed, hardware-managed page-1 stack (with its own dedicated SP register) and the Z80's more flexible, general-purpose SP, both will read as two different, more automated answers to the exact same nested-return-address problem this chapter just solved entirely in software. LC-3 doesn't lack a stack because it's incomplete — it simply keeps stack management as one more thing built from primitives you already understand, rather than hiding it behind dedicated hardware.
ArchitectureStack pointerPush/Pop
LC-3 (this chapter)A general-purpose register (R6, by convention only)Built manually from ADD/STR/LDR
6502 (preview, cpu8bit1)Dedicated hardware SP, fixed to page 1 of memoryNative instructions, but constrained to that fixed page
Z80 (preview, cpu8bit1)Dedicated, flexible 16-bit SP registerNative PUSH/POP, anywhere in memory

Hands-On Exercises

Exercise 1

Explain, in your own words and using this chapter's own bitfield diagrams, exactly why RET requires no dedicated opcode of its own — what specific values does JMP's encoding need in order to behave as RET?

📄 View solution
Exercise 2

Using this chapter's own A-calls-B-calls-C example, explain precisely which register gets overwritten, when, and why the program's return path breaks specifically because a single register can't hold more than one pending return address at once.

📄 View solution
Exercise 3

Write a PUSH/POP pair (using this chapter's own R6-based convention) that saves and restores BOTH R0 and R7 around a nested JSR call, in the correct order — and explain why the restore order must be the exact reverse of the save order.

📄 View solution

Chapter 7 Quick Reference

  • JSR — saves the return address in R7, jumps via an 11-bit PC-relative offset (wider reach than BR's 9 bits)
  • JSRR — same save-and-jump behavior, but jumps to an address held in a base register
  • RET — literally JMP R7; no separate opcode, just a specific case of JMP
  • A single register (R7) can only hold one pending return address — nested calls overwrite it
  • LC-3 has no dedicated stack pointer register — by convention, a general-purpose register (commonly R6) plays that role
  • PUSH = decrement R6, then STR the value; POP = LDR the value, then increment R6
  • Push R7 before a nested JSR, pop it back before RET — the calling convention that makes any depth of nesting safe
  • Forgetting to save R7 doesn't crash the program — it returns to the wrong place, far from the actual mistake