DOS Timer Stuff

In this episode I discover some a seriously WTF aspect of DOS game programming. Namely how I need to get a millisecond timer.

I'm still pretty new to the realm of serious DOS game programming. I did a lot of QBasic as a kid, but moved to Windows 95 progamming around the time I started doing anything serious with C and C++. So here's some stuff that I'm pretty sure any DOS game dev will already be well versed in, but was a bit of a surprise to me.

There's a piece of hardware on the IBM PCs, the Intel 8253/8254 Programmable Interval Timer chip, which is responsible for firing off an interrupt at about 18.2hz (by default).

In DOS, you can replace the DOS timer interrupt handler with your own handler.

The 8253/8254 actually is capable of sending signals at about 1.19318mhz. The way it gets to an output of 18.2hz is by dividing that frequency by 65535 (1193180hz / 65535 ≈ 18.2hz). That 65535 value is stored in a two-byte register called the divisor. That divisor value is fairly trivial to change. If you lower it, the interrupt handler function will be called much faster.

So just to summarize so far... You can replace the DOS timer interrupt function with your own function, and you can change the rate at which that interrupt handler is called.

So you write a function, use setvect(0x08, your_function_here) (interrupt 0x08 documented here) (under Watcom C/C++) to set the timer interrupt to it, and sure enough, your code function gets executed 18.2 times per second. You change the clock divisor, and it calls at an even higher rate.

Then you exit your application and find out that the system time still says the time that you started your application. Time did not pass while your program ran, apparently.

Turns out, that original DOS timer interrupt that you replaced was responsible for updating the system clock. So it's pretty easy to just getvect() to get a function pointer for the original DOS timer interrupt and call that and the end of your own, right? Sure enough, if you do this, the system clock once again gets updated.

Errr... sort of.

See, now the system clock is way too far ahead of whatever time it really is after running your program. The reason it's so far ahead, is because you called the DOS timer interrupt handler too fast. The original DOS timer interrupt handler will update the system clock forward by 1/18.2 seconds every time you call it. It works great as long as you call it at a rate of 18.2hz, but now you're calling it every millisecond (1000hz) or so. (Milliseconds because that's the hard-coded divisor I went with.)

The final trick here is to keep calling the original DOS timer interrupt, but at the original 18.2hz rate. This means probably keeping a counter going inside the timer interrupt and calling the original handler only once the counter passes a certain threshold. It's a dumb, simple, solution.

More information here.


Here's the timer code for the DOS game as it stands right now.

#include "timer.h"

#include <stdio.h>
#include <string.h>
#include <conio.h>
#include <dos.h>
#include <stdlib.h>

// This is a different one that gets called from 0x08.
//#define TIMER_INTERRUPT 0x1c

#define TIMER_INTERRUPT 0x08

#pragma aux timer_clearInterrupt =              \
    "mov al,20H",                               \
    "out 20H,al"

#pragma aux timer_cli =                         \
    "cli"

#pragma aux timer_sti =                         \
    "sti"

void timer_clearInterrupt();
void timer_cli();
void timer_sti();

static uint32_t timeValue = 0;
static int32_t nextOldTimer = 0;
static uint32_t timerInitCounter = 0;

static void (__interrupt __far *oldDosTimerInterrupt)();

static void __interrupt __far newCustomTimerInterrupt()
{
    timeValue++;

    nextOldTimer -= 10;
    if(nextOldTimer <= 0) {

        nextOldTimer += 182;
        oldDosTimerInterrupt();

    } else {

        // Make sure we still execute the "HEY I'M DONE WITH THIS
        // INTERRUPT" signal.

        timer_clearInterrupt();
    }
}

uint32_t timer_get(void)
{
    return timeValue;
}

void timer_init(void)
{
    // The clock we're dealing with here runs at 1.193182mhz, so we
    // just divide 1.193182 by the number of triggers we want per
    // second to get our divisor.
    uint32_t c = 1193181 / (uint32_t)1000;

    // Increment ref count and refuse to init if we're already
    // initialized.
    timerInitCounter++;
    if(timerInitCounter > 1) {
        return;
    }

    // Swap out interrupt handlers.
    oldDosTimerInterrupt = _dos_getvect(TIMER_INTERRUPT);
    _dos_setvect(TIMER_INTERRUPT, newCustomTimerInterrupt);

    timer_cli();

    // There's a ton of options encoded into this one byte I'm going
    // to send to the PIT here so...

    // 0x34 = 0011 0100 in binary.

    // 00  = Select counter 0 (counter divisor)
    // 11  = Command to read/write counter bits (low byte, then high
    //       byte, in sequence).
    // 010 = Mode 2 - rate generator.
    // 0   = Binary counter 16 bits (instead of BCD counter).

    outp(0x43, 0x34);

    // Set divisor low byte.
    outp(0x40, (uint8_t)(c & 0xff));

    // Set divisor high byte.
    outp(0x40, (uint8_t)((c >> 8) & 0xff));

    timer_sti();
}

void timer_shutdown(void)
{
    // Decrement ref count and refuse to shut down if we're still in
    // use.
    timerInitCounter--;
    if(timerInitCounter > 0) {
        return;
    }

    timer_cli();

    // Send the same command we sent in timer_init() just so we can
    // set the timer divisor back.
    outp(0x43, 0x34);

    // FIXME: I guess giving zero here resets it? Not sure about this.
    // Maybe we should save the timer values first.
    outp(0x40, 0);
    outp(0x40, 0);

    timer_sti();

    // Restore original timer interrupt handler.
    _dos_setvect(TIMER_INTERRUPT, oldDosTimerInterrupt);
}

void timer_delay(uint32_t ms)
{
    uint32_t startTimer = timer_get();
    while(timer_get() - startTimer < ms) {
    }
}

Posted: 2017-04-13

Tags: dos, devlog