Way 2.1 to pin a non-masked interrupt, attach a crystal with a frequency of 10KHz (not sure about this value, but with another value idea is same), and in the non-masked interrupt handler, increase the value of the variable globalMicroseconds. That is, each crystal tick - means that one microsecond has passed.
Way 2.2 use a programable timer chip, as is done in many examples of homemade Z80. For example, Z80CTC or Intel 8255 / 8254. But it can be done in different ways. At the moment I see it this way: the timer is connected to an interrupt of Z80, when the timer is triggered, I increase the value of the variable globalMicroseconds, and restart the timer. The timer works on its own crystal.
This is not a complete answer, but I'd like to mention what you've described is not "cheating", it's the standard way to count time. If you want to count time in the most generic and flexible manner, using an external timer, like a Z80-CTC is the way to go. And ultimately, this is exactly how millisecond-counting is implemented in Arduino internally as well!
One thing to keep in mind is the fact that an Atmega chip is a microcontroller, in other words, a microprocessor core with a bunch of builtin peripherals, while the Z80 is only a microprocessor. Please take a look of the block diagram of an Atmega328P.

As, you can see, an Atmega328P microcontroller is pretty much a complete computer on a chip: there's a AVR microprocessor with its own flash and SRAM, but there are also 8-bit and 16-bit timers attached to the system bus, not too different from a Z80 with an external timer chip.
In the Arduino development environment, the function millis()
is controlled by interrupts generated by 8-bit timer 0. The complete implementation is available in hardware/cores/arduino/wiring.c.
Here's an overview of Arduino's implementation. I hope it helps.
First, the global variables are defined as counters,
volatile unsigned long timer0_overflow_count = 0;
volatile unsigned long timer0_millis = 0;
static unsigned char timer0_fract = 0;
The init()
function is called to enable the Timer0 interrupt. This occurs before Arduino executes the setup()
function.
void init()
{
// set timer 0 prescale factor to 64
#if defined(__AVR_ATmega8__)
sbi(TCCR0, CS01);
sbi(TCCR0, CS00);
#else
sbi(TCCR0B, CS01);
sbi(TCCR0B, CS00);
#endif
// enable timer 0 overflow interrupt
#if defined(__AVR_ATmega8__)
sbi(TIMSK, TOIE0);
#else
sbi(TIMSK0, TOIE0);
#endif
}
The interrupts from Timer0 is handled by function TIMER0_OVF_vect
, which updates the global variables corresponding to timing.
SIGNAL(TIMER0_OVF_vect)
{
// copy these to local variables so they can be stored in registers
// (volatile variables must be read from memory on every access)
unsigned long m = timer0_millis;
unsigned char f = timer0_fract;
m += MILLIS_INC;
f += FRACT_INC;
if (f >= FRACT_MAX) {
f -= FRACT_MAX;
m += 1;
}
timer0_fract = f;
timer0_millis = m;
timer0_overflow_count++;
}
When millis()
is called, the function simply copies and returns the global counter. Before reading the global counter, millis()
also temporally disables the interrupts to prevent the global counter from incrementing while reading them.
unsigned long millis()
{
unsigned long m;
uint8_t oldSREG = SREG;
// disable interrupts while we read timer0_millis or we might get an
// inconsistent value (e.g. in the middle of a write to timer0_millis)
cli();
m = timer0_millis;
SREG = oldSREG;
return m;
}
Furthermore, the delayMicroseconds()
function in Arduino is implemented through cycle-counting, but the millisecond-delay function is simply a busy-loop.
void delay(unsigned long ms)
{
unsigned long start = millis();
while (millis() - start <= ms)
;
}
In principle, if you add an external timer to your Z80 computer, you can implement an interrupt handler and provide an assembly routine to return the current value of your counter just like how Arduino does it. Although it's more difficult if you want low overhead and high resolution.