In 16-bit PC-compatible x86 systems, the PIT (programmable interval timer) uses a clock input of 1.19318MHz to decrement a 16-bit counter. An interrupt is generated whenever the counter wraps around after 216 = 65536 increments. The BIOS-provided ISR (interrupt service routine) handling it then increments a software counter, at a frequency of 1.19318MHz / 65536 ~= 18.2 Hz.
Under DOS and other real-mode operating systems, the 16-bit PIT counter can be read directly from the relevant port in two 8-bit chunks, and this data can be combined with the software-maintained tick counter to achieve millisecond resolution. Basically, one winds up using a 48-bit tick counter, where the 32-bit software counter maintained by the BIOS constitutes the most significant bits, and the 16-bit PIT counter constitutes the least significant bits.
Since the data is not all read out in one fell swoop, there is a risk of race conditions which have to be handled appropriately. Also, some BIOSes used to program the PIT as a square-wave generator rather than a simple rate counter. While this does not interfere with the task of incrementing the software tick, it does interfere with a straightforward combination of the PIT counter register with the software tick. This necessitates a one-time initialization of the PIT to make sure it is operating in rate-counting mode.
Below is 16-bit assembly code, wrapped up as a Turbo Pascal unit, that I used for many years for robust timing with millisecond accuracy. The conversion from tick counts to milliseconds here is a bit of a black box. I lost my design documentation for it and can't quickly reconstruct it on the fly now. As I recall this fixed-point computation had a jitter small enough that milliseconds could be measured reliably. The calling conventions of Turbo-Pascal required returning a 32-bit integer result in the DX:AX
register pair.
UNIT Time; { Copyright (c) 1989-1993 Norbert Juffa }
INTERFACE
FUNCTION Clock: LONGINT; { same as VMS; time in milliseconds }
IMPLEMENTATION
FUNCTION Clock: LONGINT; ASSEMBLER;
ASM
PUSH DS { save caller's data segment }
MOV DS, Seg0040 { access ticker counter }
MOV BX, 6Ch { offset of ticker counter in segm.}
MOV DX, 43h { timer chip control port }
MOV AL, 4 { freeze timer 0 }
PUSHF { save caller's int flag setting }
CLI { make reading counter an atomic operation}
MOV DI, DS:[BX] { read BIOS ticker counter }
MOV CX, DS:[BX+2]
STI { enable update of ticker counter }
OUT DX, AL { latch timer 0 }
CLI { make reading counter an atomic operation}
MOV SI, DS:[BX] { read BIOS ticker counter }
MOV BX, DS:[BX+2]
IN AL, 40h { read latched timer 0 lo-byte }
MOV AH, AL { save lo-byte }
IN AL, 40h { read latched timer 0 hi-byte }
POPF { restore caller's int flag }
XCHG AL, AH { correct order of hi and lo }
CMP DI, SI { ticker counter updated ? }
JE @no_update { no }
OR AX, AX { update before timer freeze ? }
JNS @no_update { no }
MOV DI, SI { use second }
MOV CX, BX { ticker counter }
@no_update: NOT AX { counter counts down }
MOV BX, 36EDh { load multiplier }
MUL BX { W1 * M }
MOV SI, DX { save W1 * M (hi) }
MOV AX, BX { get M }
MUL DI { W2 * M }
XCHG BX, AX { AX = M, BX = W2 * M (lo) }
MOV DI, DX { DI = W2 * M (hi) }
ADD BX, SI { accumulate }
ADC DI, 0 { result }
XOR SI, SI { load zero }
MUL CX { W3 * M }
ADD AX, DI { accumulate }
ADC DX, SI { result in DX:AX:BX }
MOV DH, DL { move result }
MOV DL, AH { from DL:AX:BX }
MOV AH, AL { to }
MOV AL, BH { DX:AX:BH }
MOV DI, DX { save result }
MOV CX, AX { in DI:CX }
MOV AX, 25110 { calculate correction }
MUL DX { factor }
SUB CX, DX { subtract correction }
SBB DI, SI { factor }
XCHG AX, CX { result back }
MOV DX, DI { to DX:AX }
POP DS { restore caller's data segment }
END;
BEGIN
Port [$43] := $34; { need rate generator, not square wave }
Port [$40] := 0; { generator as programmed by some BIOSes }
Port [$40] := 0; { for timer 0 }
END. { Time }