Channel 0 of the 8253/8254 timer chip divides its 1 193 182 Hz input by 65 536 and pulses IRQ 0 of the 8259 interrupt controller 18.2065 times a second, every 54.9 ms. The 8259 passes it to the CPU as vector 08h. Nobody executes INT 08h; it simply happens between two instructions whenever interrupts are enabled.

What the BIOS handler does

StepWhat happens
1increment the 32-bit tick count at 0040:006Ch
2at 1800B0h ticks (24 hours) reset it to 0 and set the midnight flag at 0040:0070h
3count down the floppy motor timeout at 0040:0040h and switch the motor off at 0
4call INT 1Ch, the user timer hook (an IRET by default)
5send the end of interrupt: MOV AL, 20h / OUT 20h, AL
6IRET

The end of interrupt (EOI) tells the 8259 the handler has finished; until it arrives the controller sends no further interrupts of this or lower priority, which is every other IRQ but none. Forgetting it is the classic way to freeze a PC: the keyboard and the clock stop at once.

Hooking the tick

A program that wants to run something on every tick (a clock on the screen, music, a timeout) hooks INT 1Ch rather than 08h: the BIOS has already counted the tick and will send the EOI. The example installs a 1Ch handler that counts, lets about a second of ticks go by, puts the old vector back and prints the count:

prints: INT 1Ch ran 18 times (after about a second)
MOV AX, 351Ch ; DOS: get vector 1Ch
INT 21h ; ES:BX = the old handler
MOV WORD PTR OLD1, BX
MOV WORD PTR OLD1+2, ES
MOV AX, 251Ch ; DOS: set vector 1Ch to DS:DX
MOV DX, OFFSET TICK1
INT 21h
MOV AX, 40h
MOV ES, AX ; ES = the BIOS data area
CLI ; no tick between these two:
MOV BX, ES:[6Ch] ; the start, low word of the tick count
MOV COUNT1, 0 ; and our count from zero
STI
WAIT1:
MOV AX, ES:[6Ch]
SUB AX, BX
CMP AX, 18 ; 18 ticks: about one second
JB WAIT1
PUSH DS
MOV DX, WORD PTR OLD1
MOV DS, WORD PTR OLD1+2 ; DS:DX = the old handler
MOV AX, 251Ch ; put it back
INT 21h
POP DS
MOV DX, OFFSET RAN1
MOV AH, 09h ; DOS: print the $-string at DS:DX
INT 21h
MOV AX, COUNT1
CALL PDEC ; 18
MOV DX, OFFSET TIMES1
MOV AH, 09h ; DOS: print the $-string at DS:DX
INT 21h
TICK1 PROC FAR ; INT 1Ch: called by the BIOS on every tick
INC CS:COUNT1 ; DS is unknown here: go through CS
JMP DWORD PTR CS:OLD1 ; and on to the previous handler
TICK1 ENDP
OLD1 DD 0
COUNT1 DW 0
RAN1 DB "INT 1Ch ran $"
TIMES1 DB " times", 13, 10, "$"
PDEC PROC NEAR ; print AX in decimal; keeps every register
PUSH AX
PUSH BX
PUSH CX
PUSH DX
MOV BX, 10
XOR CX, CX ; digits on the stack
DECD1:
XOR DX, DX
DIV BX ; AX = AX / 10, DX = the last digit
PUSH DX
INC CX
TEST AX, AX
JNZ DECD1
DECD2:
POP DX ; the digits come back first to last
ADD DL, "0"
MOV AH, 02h
INT 21h
LOOP DECD2
POP DX
POP CX
POP BX
POP AX
RET
PDEC ENDP

Rules for a timer handler

It interrupts any code at any moment, so it must save every register it changes and may not assume anything about DS or ES. It must be short: the BIOS calls 1Ch before the EOI, so while it runs no other hardware interrupt gets through. It must not call DOS, which is not reentrant; the interrupted code may be in the middle of a DOS call itself. It should chain to the previous handler (here with JMP DWORD PTR), so that other resident programs hooked before it keep working. And the vector must be restored before the program exits: the memory the handler lives in is freed, and the next tick would jump into whatever is loaded there.

Faster ticks

Games and music players reprogram channel 0 for a higher rate (port 43h, then the divisor to port 40h) and hook 08h itself. Their handler must then call the old 08h only every n-th time, at the original 18.2 Hz, or the time of day runs fast, and send the EOI itself on the other calls. Before exit, the divisor goes back to 0 (65 536) and the vector to the old handler.