Designing Cycle-Exact Delays on Several 8-Bit CPUs

07:30 PM · Sep 12, 2026

Last week’s work with CPU-controlled 1-bit sound generation went reasonably smoothly. I calculated the time required to get my results, then picked out some Z80 instruction sequences to get those timings. That said, I was mostly just winging it when I did so, and that’s kind of obnoxious. We should be able to systematically generate those sequences, even if we don’t end up using formal assembly macros to do it. That also wasn’t remotely the first time I had to deal with this, either. Flipping through my article index offers these, at varying levels of detail:

  • The Sega Genesis and Dragon both needed precisely-timed loops to do digital sample playback, on the Z80 and the 6809 respectively.
  • The 6502-based Commodore 64 often requires precise timing for its advanced graphical effects, or even just to ensure a smooth display for less intense techniques. The also-6502-based Atari 2600 needs cycle-level precision to place its sprites down at all.

In those articles, and in this one, I’m only going to be interested in delay sequences that are determined at assembly time; these are statically known in advance. It is a harder problem to produce a sequence of code that can delay a cycle-exact amount that is determined during a program run. In effect, today we’re focusing on delay sequences that could be realized as macros, and avoiding cases where they could in principle be implemented as procedures. I have however looked at a couple of approaches for those on the 6502 chip before; once with a very exotic technique adapting the Atari 2600 “BigMove” demo to the C64 to investigate its video DMA behavior, and a more traditional technique on the Atari 2600 ourselves for a generic sprite placement routine. Only the former is truly a cycle-exact delay routine, but the five-cycle-precise sprite-placement system is more typical of what you actually need from a variable-but-precise delay routine. I didn’t look too closely at it specifically, but the Spectrum’s in-ROM sound control routine achieves 4-cycle precision on the Z80 with a technique that’s a bit of a hybrid of both of the 6502 techniques. Z80 cycles are generally about 4 times shorter than 6502 ones, too, so that is nothing at all to sneeze at.

I was relatively systematic when working on C64 delays. This week I’ll be generalizing the technique to work with all three of the 8-bit chips I focus on (6502, 6809, and Z80) and also apply it to a few others: the Game Boy’s Z80-like SM83, the Z80’s ancestor the Intel 8080, and the 6809’s ancestor the 6800.

The General Technique

The tightest delay loop you can write in assembly language will generally have semantics that look something like this:

def delay(n):
x = n
while x > 0:
x = x - 1

Many CPUs will have special instructions that do these operations more efficiently, or that collapse multiple parts of the operation into a single instruction, but that doesn’t really impact the generality of the technique or what we have to do with it. What we need is just to write the tightest delay loop we can that implements the above semantics, with a parameter n that specifies the number of times through the loop. Ideally the startup time is also short, but if we have to choose, we pick a tighter loop over a faster prelude. We’ll be able to work out its timing as a formula shaped like An + B.

With this in hand, we may delay nearly any length of time t with this procedure:

  1. If t is less than A+B, the loop itself is too long to express it; instead use a pre-written table of instruction sequences. If no sequence exists for t, this CPU cannot stall for that precise amount of time.
  2. Otherwise, compute n by subtracting B from t then dividing by A. If this divides exactly, we’re done; just write the loop.
  3. Otherwise, take the remainder r from that division and look it up in our pre-written table of straight-line delay codes. Add that delay before or after the loop.
  4. If there does not exist an entry for r in the table, subtract 1 from n and add A to r until either there does exist such an entry or n drops below zero. If n ends up greater than zero, generate code as per step 3. If n ends up as zero, omit the loop, as in step 1. If n ends up less than zero, this CPU cannot stall for this precise amount of time.

For any particular CPU, then, our task will be to create both the tight delay loop, and that pre-written table that fills in all possible gaps.

The Sharp SM83

Timing information for the SM83 taken from the RGBDS documentation.

This case is so simple that it normally wouldn’t deserve a technique like this. The SM83 is in the same general family of architectures as the 8080 and Z80, but unlike those its CPU is hard-synchronized to a memory running at a quarter of the speed. As a result, the effective clock speed is quartered, and in places where its instructions match the Z80, its timing may be determined essentially by dividing the time required by 4 and rounding up. It also lacks the dedicated looping instructions of the Z80. As a result, our tight loop looks like this:

        LD      B,n
loop    DEC     B
        BNE     loop

LD with immediate costs 2 cycles, DEC costs 1, and BNE costs 3 if a branch is taken and 2 if it isn’t. Working through it, the total cost of this loop is 4n+1. Our prewritten table needs to cover the values 1 through 4 at minimum.

This turns out to be offensively simple, as NOP on this chip itself costs a single cyle. The table simply provides 1 through 4 NOPs. Beyond that, the delays will trash B since it’s the loop variable and set the zero flag as the loop exits.

MOS Technology 6502

Timing information available at 6502.org’s reference, with WDC’s Programming the 65816 including the 6502, 65C02, 65802 being the definitive source.

The core delay loop for the 6502 is:

        LDX     #n
loop:   DEX
        BNE     loop
after:

All these instructions cost 2 cycles except for BNE when the branch is taken, which costs 3. This loop costs 5n+1 cycles. Usually. The 6502’s branch instructions cost an extra cycle if it has to “carry the one” while computing the target address on a branch; for our timing to work we will need to ensure that the loop label and the after label have the same high byte in their address.

The delay table has a gap this time: single-cycle delays are impossible. We’ll be building our delays out of NOP instructions (1 byte, 2 cycles) and zero-page CMP instructions (2 bytes, 3 cycles). Those add up neatly, but the lack of a single-cycle delay also means we need to provide a sequence for 7 cycles as well. Our table becomes:

  1. Impossible
  2. NOP
  3. CMP $00
  4. 2 + 2
  5. 2 + 3
  6. 2 + 2 + 2 turns out to be shorter than running the loop with n=1 here.
  7. 2 + 2 + 3
  8. 2 + 2 + 2 + 2

Waits above 3 here are presented as sums of smaller numbers with defined instructions.

Our remaining caveat is that the CMP instruction actually does hit memory; we should make sure that we do not pick a location here where reading the location has side effects. In particular, the Atari 2600 stores some collision flags at $00 and will clear the flags when they are read!

These delays will usually trash the X register (we use it to loop) and the flags (the loop and CMP both alter flags).

Later-Generation 65xx chips

The 6502’s successors offer additional capabilities and occasionally different execution modes. The 65C02 should work the same as the 6502, here, and so will the 65816 if it is in emulation mode. If it is not in emulation mode, these can still work on the 65816, but you will be obliged to have both the index and memory operations in 8-bit mode and the low byte of the Direct Page register must be exactly zero. On the plus side, you are no longer obliged to keep the loop inside the same bank; 8-bit branch instructions outside of emulation mode no longer suffer the one-cycle penalty for crossing page boundaries.

Because of that lack of penalty, and because of the interaction of CMP with both the direct-page and memory-width settings, a better choice on the 65816 for a 3-cycle delay is the unconditional short-range relative branch BRA $+2. (The $ here represents the program counter at the start of the instruction; as a two-byte instruction this should produce the byte pattern $80 $00.)

That leaves the index register width. DEX is always 2 cycles no matter what, so the only real penalty you pay for being in a 16-bit index mode is one byte and one cycle on the initial LDX. That alters the formula to be 5n+2 instead of 1, but the table above still basically works for your 2- and 3-cycle combiners.

The code isn’t portable off of the 65C02 specifically, but this page suggests that the 65C02 has some undocumented no-op opcodes that really do only take one cycle. Relying on these, however, is a bad idea; both the Rockwell 65C02 variant and the 65816 itself use those opcodes for actual operations.

Motorola 6809

Timing information available from the Color Computer Archive. The canonical source is the MC6809-MC6809E Microprocessor Programming Manual.

At the level we’re interacting with it, the 6809 ends up looking a lot like the 65C02. Our core delay loop is:

        LDB     #n
loop:   DECB
        BNE     loop

This works out to 5n+2 cycles. 6809 branches are fully predictable and do not incur costs for crossing page boundaries the way the 65xx chips do. This gives us the two-cycle NOP and the three-cycle branch-always instruction BRA $+2 as our core building blocks, which in turn gives us a table much like the 6502’s:

  1. Impossible
  2. NOP
  3. BRA $+2
  4. 2 + 2
  5. 2 + 3
  6. 2 + 2 + 2
  7. 2 + 2 + 3
  8. 2 + 2 + 2 + 2

Because of the way the 6809 specifies its instruction set extremely systematically, it also has BRN—”Branch Never”—available as a 3-cycle no-op that takes a one-byte argument that it always ignores, but I’m sticking with the branch-always version here for consistency with other processors.

Motorola 6800

Timing information available from 8-bit Era. The canonical source is the Motorola M6800 Programming Reference Manual.

We used very little of the 6809’s power, and the code for the loops will look quite similar, allowing for some changes in syntax. The main difference is timing; branches are 4 cycles on the 6800. The core loop uses the same secondary accumulator:

        LDA     B,#n
loop:   DEC     B
        BNE     loop

The formula here is now 6n+2, and the BRA $+2 approach is equivalent to two NOPs. We bring in the 6502-style zero-page CMP B $00 for our three-cycle delay.

  1. Impossible
  2. NOP
  3. CMP B $00
  4. 2 + 2
  5. 2 + 3
  6. 2 + 2 + 2
  7. 2 + 2 + 3
  8. 2 + 2 + 2 + 2
  9. 2 + 2 + 2 + 3

Zilog Z80

Timing information available from ClrHome’s table. The canonical source is the Z80 CPU User Manual.

This is where things start getting exciting. So far the only dedicated instructions we’ve been able to exploit are register decrements. The Z80 has a dedicated looping instruction so our core delay code is only two instructions long:

        LD      B,n
loop:   DJNZ    loop

The Z80 also has a significantly more fine-grained processor clock than any of the processors we’ve looked at before this, so the delay’s time formula comes out to 13n+2. We will have quite a few gaps here, but we’ll be able to close most of them.

No instruction is shorter than 4 cycles, but a lot of instructions, including NOP, are 4 cycles. Instructions that take a single-byte immediate argument are usually 7 cycles. 16-bit increments are 6 cycles. Conditional RET instructions are 5 cycles if the condition fails. All JP instructions are 10 cycles. JR is 12 cycles if the branch is taken and 7 if they aren’t. DJNZ branches are 13 cycles when taken and 8 when it isn’t.

  1. Impossible
  2. Impossible
  3. Impossible
  4. NOP
  5. Impossible unless we know a pre-existing flag value, in which case RET false_flag. Note that immediately after a DJNZ loop we will know that Z is set, so RET NZ will handle a 5-cycle remainder from any longer wait.
  6. INC BC
  7. LD B, 0
  8. 4 + 4
  9. SCF; RET NC, a guaranteed version of 4 + 5.
  10. JP $+3
  11. 4 + 7
  12. INC BC; DEC BC for a stronger no-op than just 6 + 6.
  13. 7 + 6
  14. 7 + 7
  15. 4 + 4 + 7
  16. 4 + 4 + 4 + 4
  17. 4 + 4 + 9
  18. 9 + 9

Intel 8080

Timing information available from PastRaiser. The canonical tables are in Appendix B of the Intel 8080 Assembly Language Programming Manual.

The 8080’s instruction set is a compatible subset of the Z80’s, but that turns out not to help us much. The “binary compatibility” of the Z80 guarantees only that identical binaries will compute identical functions. It makes no guarantees on timing at all. In particular, while NOP is still 4 cycles, MVI (the equivalent of LD B,n) is still 7, and the jump instructions are still 10, most register-register operations are now 5 cycles, and the 16-bit register increments are now 7. That changes which values we can get. The core loop doesn’t get a DJNZ this time:

        MVI     B,n
loop:   DCR     B
        JP      NZ,loop

The cycle count is 15n + 7, so the table will be even longer than the Z80’s.

  1. Impossible
  2. Impossible
  3. Impossible
  4. NOP
  5. MOV B,B
  6. Impossible
  7. MVI B,0
  8. 4 + 4
  9. 4 + 5
  10. 5 + 5
  11. 4 + 7
  12. 4 + 4 + 4
  13. 4 + 4 + 5
  14. 4 + 5 + 5
  15. 5 + 5 + 5
  16. 4 + 4 + 4 + 4
  17. 4 + 4 + 4 + 5
  18. 4 + 4 + 5 + 5
  19. 4 + 5 + 5 + 5
  20. 5 + 5 + 5 + 5
  21. 4 + 4 + 4 + 4 + 5 (1 byte shorter than 7 + 7 + 7)
  22. 4 + 4 + 4 + 5 + 5
  23. 4 + 4 + 5 + 5 + 5
  24. 4 + 5 + 5 + 5 + 5
  25. 5 + 5 + 5 + 5 + 5
  26. 4 + 4 + 4 + 4 + 5 + 5
  27. 4 + 4 + 4 + 5 + 5 + 5
  28. 4 + 4 + 5 + 5 + 5 + 5

The 28-cycle element handles the case of n=1, r=6, and gives us everything the Z80 does with one guaranteed gap at 6 instead of the potential one at 5.

Some Final, Cross-Platform Caveats

All of these code sequences, for all of the platforms, are making the very large assumption that they are the only code running on the system and that they have complete and unimpeded access to the memory that they are running out of. Not only is this not always true, it usually isn’t and care must be taken to make it true when necessary.

The simplest issue is that most old systems have some kind of interrupt system that can take over control of the system periodically. Code like this should be running either with interrupts disabled or under a circumstance where interrupts provably won’t fire. (For example, the NES usually doesn’t use its IRQ line, but the NMI line fires 60 times per second. If the developer knows they’re nowhere near the end of a frame, they have no work to do here. If the overall sequence might go longer than a frame, they will need to disable the PPU’s VBLANK interrupt.)

A more complex issue is contention over the memory bus. We saw this with the original code playing tones on the ZX Spectrum; there’s only 32KB of RAM that, across all the main Spectrum line, is guaranteed to never be contending with the video circuitry for memory access. The video circuitry also always wins, which will disrupt the timing on each instruction fetch in unpredictable ways. The story on the C64 is even more dire; there is nowhere in RAM or ROM that lets you escape from the VIC-II when it decides it needs to start fetching text or sprite data, and that brings the CPU to a crashing halt for 43 cycles or more. Contending with, or manipulating, these “badlines” are a core technique for advanced C64 graphics programming, but the only way to completely turn them off is to disable the display entirely. I actually did that when I experimented with C64 digital sound playback.

That C64 article also shows a way around periodic memory contention, too; it sets up timer interrupts of its own that will manage the action countdown, meaning that interruptions to the CPU do not mess with the actual program timing so much. This is also a crucially important technique for systems where we don’t have guarantees on system speed, like most 8086 or 68000-based platforms.

There’s a reason code like this fell out of fashion. But if you’re on an older system, it may well be just what you need.