18

Currently messing with 6502 assembly on a C64, and I don't understand why the JSR instruction is so weird.

According to the instruction table, JSR is a 3-byte instruction and only operates in absolute mode. However, JSR only increments the PC by 2 before pushing it on the stack. Which means the return address points to the last byte of the JSR instruction. It seems the RTS pops the value from the stack and increments it again before setting the PC to the corrected value.

My question is: Why? Why not just let JSR increment the PC by 3 instead of 2, and let RTS just pop and jump? This looks like a far more logical approach. Any reason for making this so complicated?

14
  • 1
    Maybe to have RTS account for crossing a page boundary.
    – Brian H
    Commented Apr 10, 2021 at 15:14
  • 2
    @BrianH Can you explain what you mean by this? Commented Apr 10, 2021 at 15:16
  • 2
    @Jeroen Jacobs: The 6502 does not have a linear address space. Instead, it's organized in 256 pages of 256 bytes. Jumps that cross page boundaries need a "correction" cycle that increases the PCH register so the adress doesn't wrap inside the page.
    – Janka
    Commented Apr 10, 2021 at 15:18
  • 2
    @Janka nop. That correction is only needed for relative jumps. Reading program code is done via PC, which can be incremented across page boarders without penalty.
    – Raffzahn
    Commented Apr 10, 2021 at 16:22
  • 2
    You're implying that doing that the 'logical way' is better but by what metric? JSR/RTS work perfectly so looking superficially unusual is no problem - it's not a beauty contest. When you explore how the CPU carries out the instructions, this allows simpler operation with simpler circuitry. So the actual metric of 'better' is lower transistor count, either to lower the price, improve manufacture or use those transistors for other functions.
    – TonyM
    Commented Apr 10, 2021 at 18:37

2 Answers 2

27

Why does the 6502 JSR instruction only increment the return address by 2 bytes?

Simply because the PC is already pushed before the second address byte is read. That way, the CPU need only buffer the lower target address byte and later read the higher one directly into the PC.


The workings are described in great detail in the original 1976 MCS 6500 Family Programming Manual (*1) in section 8.1 JSR - Jump to Subroutine on p.106..109. Same for how RTS resolves this in 8.2 RTS - Return from Subroutine (p.109..112)

The six clock cycles of a JSR are essentially:

  1. Read Opcode ($20); Increment PC
  2. Read ADL; Increment PC
  3. Buffer ADL
  4. Push PCH; Decrement S
  5. Push PCL; Decrement S;
  6. Read ADH;

And interleaved with the next instruction:

  1. Load PC with ADH/ADL; Fetch next OP with new PC

Step 3 is another result of making the 6502 as small as possible


It seems the RTS pops the value from the stack and increments it again before setting the PC to the corrected value.

Yes, it does so during the last cycle of an RTS. In fact, in doing so, it rereads the last byte of the JSR instruction again (and discards it).

My question is: Why?

The main reason is to save circuitry. The way it operates, it avoids the need to buffer the upper address byte. Otherwise it would have needed a whole additional 8 bit register to hold that value. The 6502 has only 16 registers total. Adding one more would be a considerable cost.

It's worth keeping in mind that the main success criteria for the 6502 wasn't its inherent beauty or the friendly smile of its developers. It was being dirt cheap. Not just a few percent cheaper, but up to ten times lower than its competition. Woz selected it for exactly that reason for the Apple II and so did Atari and others. It's also why Commodore bought MOS, it's well known how cost-aware Tramiel was ;)

Having to add a whole register just for a single function is, in that context, a no-go, especially if there's a way to do it in microcode.

Why not just let JSR increment the PC by 3 instead of 2, and let RTS just pop and jump?

It has to be incremented anyway, so no real gain here.

This looks like a far more logical approach.

In what logic? Maybe in CS class ivory tower logic, but real hardware is about implementing a concept in best possible fashion, not 'as in the books'. The important thing is the function provided, which is the same in either case.

The resulting effect is that any function that uses the pushed address, such as for accessing parameters, will need to increment it (or use an offset of one). This is in line with the general 6500 philosophy to spend as little hardware as necessary to provide a function, and let everything else be done by software.

Quite RISC-like, isn't it?


*1 - Always a great first read, together with the MCS6500 Family Hardware Manual.

8
  • 1
    Typo in the footnote? Commented Apr 10, 2021 at 16:34
  • 1
    @WayneConrad ROTFL ... yeah, then again, not wrong either. It's a RTFM story :))
    – Raffzahn
    Commented Apr 10, 2021 at 16:36
  • Thanks for this interesting explanation. The reason I find it complex and not very "logical", is because the return address on the stack does not point to the next instruction after return, but to the last byte of the JSR itself. This just feels totally weird to me (compared to x86 architecture where the return address pushed by CALL is the address of the actual next instruction, RET just pops and jumps) :) I'm not a hardware engineer, so I don't know the difficulties or costs of creating a CPU (certainly not back in those days). Commented Apr 10, 2021 at 17:29
  • 1
    @JeroenJacobs Well, an 8086 got more than 10 times the gate count of a 6502. Also, more important here, it handles data as 16 bit chunks, so no need to juggle with two bytes. In addition this is handled by special circuitry of the BIU anyway. It's predecessor, the 8080/Z80 line does in fact have a hidden register pair (WZ) to hold the read address thur all of this. The 6502 simply improved thereon.
    – Raffzahn
    Commented Apr 10, 2021 at 18:04
  • From what I can tell, in machines of the 6502 era, the main cost of registers was the wiring to get data into and out of them. Inserting an extra 8-bit latch on the input to the upper program counter byte, between the bus that fed it and the register itself, would seem like it would not have been difficult, especially if a dynamic latch would have sufficed (if the CPU wrote the high byte of the PC of the third byte of the instruction on the cycle after it fetched that byte, then wrote the high byte of the PC of the instruction after that to the same address, ...
    – supercat
    Commented Apr 10, 2021 at 21:32
8

The answer with anything of that age is "to save silicon". That pushed address was never intended for programmer use.

I coded 6502 professionally for years, using every possible trick to push the limits of the metal, and that issue never really came up for me. It sounds like you're trying to do a JMP to a variable address by pushing the address on the stack and going RTS.

Consider the indirect JMP command JMP (ADDR). This doesn't force you to use a Zero Page address, but you can if you wanna: JMP ($00C8). So you can use the cheaper Zero Page store commands.

I'm not sure why they don't have a zero-page JMP command like JMP($C8) but probably it's used too infrequently to spend the silicon on that.

Do not use an indirect address ending in $FF unless you want to meet a bug.

Of if you really want to do an RTS jump, and are loading an absolute address onto the stack, then use a compiler meta-instruction to decrement it by 1 before pushing it, so that the compiler takes care of the -1 for you.

3
  • I noticed this behaviour because I was playing with inline arguments. This requires pulling the return address and put it a zeropage address, and then use indirect-addressing. I was expecting to find my parameters there, but noticed the extra byte belonging to the original RTS instruction. I found this surprising, so that's why I asked here what the reason was. People seem to make more of this question than was my intention ;) Commented Apr 13, 2021 at 8:46
  • True, but it's fun. Yeah in the past when I've done that (put args under the return address), I popped the return address and stored it, then after collecting my args, I put back those same values. I considered it "not my data to tamper with" and that a future rev of the 6502 or backward compatible Zilog Z65 or something, might handle the values differently, so don't break it lol. Commented Apr 13, 2021 at 21:44
  • 1
    "Do not use an indirect address ending in $FF" - Ah yes, that bug :) (Though it was corrected in later 6502 variants, IIRC, so depending on your hardware you may miss out on the joy of meeting it!)
    – psmears
    Commented Feb 8 at 13:54

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .