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


Networking DOS

... enough to be useful.

In this episode, we learn that TCP/IP sucks on DOS, and my quest to find a reasonable method of version control across two machines that can't actually talk to one anther directly.

Not without some big caveats, anyway.

I needed to get my DOS machine networked with my PC to transfer files back and forth. Null modem serial cable transfers are slooooooooow. Floppy disks are inconvenient and for the first few days I couldn't even find any.

Seriously. In the bay area, try finding a floppy disk for sale without going to Weirdstuff. VHS tapes? Sure. Super-proprietary DV tapes that nobody's looked for in over a decade? Practically in every corner store. Floppy disks? Hahahaha no. And I'm talking about the 3.5" 1.44M variety here. Not the older 5.25" kind.

Back to networking. There are a ton of different, incompatible network stacks for DOS. They're (almost) all ancient, lacking documentation, and only kinda half work most of the time. Oh yeah, and TCP/IP was in its infancy back then, so you mostly have to rely on weird esoteric protocols that nobody remembers or uses anymore. IPX, Netbios, various things with "Novell" at the beginning of them.

Some of the stacks available are...

mTCP

mTCP is a TCP driver and a collection of TCP/IP utilities for DOS. This (combined with a NIC-specific "packet driver") lets me transfer files to and from the 486 over FTP. It's incompatible with Microsoft's network client, though.

Make sure you pick some network card that has a DOS packet driver available for it. Two cards I've had success with are the 3COM Etherlink III (ISA) and the Linksys LNE100TX (PCI) cards.

mTCP worked well, and let me transfer files by FTP (with TCP) from one machine to the other over Ethernet using the 3COM Etherlink III card. But it was invonvenient to manually move stuff back and forth all the time, and made keeping track of versions and branches difficult.

Microsoft Network Client for DOS

Microsoft's network client was one of the ways you could do networking "back then". Using it opens up a lot of more conventional (if old) options for networking our machine.

There's one version of it for DOS that I just couldn't get to work, but even if you could, the TCP driver takes up 200kb of your 640kb of conventional memory, so good luck getting anything else done with that. My specific use case was to keep my code on a network share and compile from that, while using Git on the host computer to do version control. The compiler won't run with that much conventional memory being used. So the only-DOS version of the MS network client is out of the picture for the rest of this.

Microsoft Network Client for Windows 3.11

First, let's get this out of the way. Windows 3.11 supports TCP/IP out of the box. There's an update to the one that comes with it. Get it here. The old TCP/IP driver is buggy. Don't use it.

Mounting a Linux Samba share over TCP from within Windows 3.11 works. It works great. I could totally do all my work and file transferring using this if it weren't for one thing. My DOS game doesn't work under Windows 3.11. Windows 3.11 gets very cranky and demands that you reboot with some pretty dire warnings about how you have disturbed the sanctity of the system integrity or somesuch. It's mostly about how I'm talking to SoundBlaster card. So I'm limited to working in DOS, and using Windows for the occasional task outside of the normal development iteration process.

A lot of the Windows 3.11 networking features do work in DOS. The TCP/IP driver is not one of those features, as far as I can tell. At the moment I can use the "net" command to use network features like mounting network shares, but I'm limited to non-TCP protocols like Netbeui. So I'm still out of luck as far as mounting my Linux machine across the network while in DOS on the 486.

The "Solution"

Linux and modern Windows OSes like Windows 10 don't talk Netbeui, but another DOS/Win3.11 machine in a VM does! And no, I can't mount that Windows 3.11 share from a Linux box. I tried. The idea of both the DOS and Linux machines mounting the same share (through Netbeui and TCP respectively) to both have shared access to the files is so close to working, but the Linux CIFS/Samba tools seem to half-understand the Win3.11 shares. It can see a directory structure but no files, and fails to mount with stuff like smbfs.

So that's the state I'm in right now. Code files hosted on a DOS/Win3.11 VM, which I occasionally bring down so that the filesystem can then be mounted as a loopback device on my desktop and I can do commits to version control. It's a pretty weird setup, but it works.

There might be another solution to this, which would be to use something like Windows XP in a VM. It's old enough to still support Netbeui (I checked) and new enough that maybe smbfs won't screw up talking to it.

Honorable mentions

I made a couple of unsuccessful, or barely-half-successful attempts to mount the Linux drive on the DOS machine directly, or the DOS drive on the Linux machine directly. Sadly, neither of these worked very well.

I tried using mTCP's FTPSRV.EXE + CurlFTPFS on Linux to mount a DOS drive over the network. This seems to result in a sort of half-working filesystem that can read files okay, but can't edit them. I don't know if I need to blame CurlFTPFS or mTCP for this one.

Another thing I tried was NFS and, holy crap, the NFS protocol is much older than I knew. There are some NFS servers for DOS, which allowed me to mount from Linux after a LOT of messing around. Of the two NFS servers I tried, one just didn't work, and the other wouldn't allow me to read any directory where a listing would use more than 1024 bytes of data. Oh well.

Update: Since writing this article, I experimented with some other Windows operating systems in a VM. Windows XP didn't work (the DOS machine couldn't mount it). Windows 95 didn't work (the Linux machine couldn't mount it). Windows 98SE... did work!

So my code and data live on a Windows 98SE VM, which has a shared folder mounted by both the Linux and DOS machines. The rest of the Git repo lives on the Linux machine, but compiling and testing happens on the DOS machine.

Update 2 (2020-11-17): EtherDFS is a much better DOS/Linux shared drive setup. Use that instead of any of this garbage!

Posted: 2017-04-12


486 Acquired

This is mainly just a story of how I picked up my 486DX2s for testing. Nothing technically interesting here.

It is kinda long.

The Target

I'm trying to target a 486DX2 at 66mhz for this project. The other important specs are pretty common. VGA graphics and a SoundBlaster 2.0+ compatible sound card.

I hit up eBay for some 486 parts. Found a 486DX2 at 66mhz, on a motherboard with a couple of PCI slots. PCI-supporting 486 PCs weren't really that common, so I'm hoping this simplifies getting hardware supported by the thing. The main rare thing seems to be ISA VGA cards. Everyone is looking for them, and the ones online are expensive. I might just toss an old PCI GeForce card in there and not worry about it.

WeirdStuff

I was at WeirdStuff Warehouse looking for parts for the to-be-assembled 486, when I noticed a beat up old PC with no pricetag and no specs labeled on it. The only thing I could see on it were some serial ports, a VGA port, a pair of PS/2 ports, and - on the other side - a floppy drive an a 4x speed CD drive. I grew up with a 486 with a 2x speed CD drive, so that one marker put it in the right ballpark for being a slightly later 486.

One of the employees (or owner?) let me take it over to the workbench and crack it open. Couldn't tell much except that the bus speed was set at 33mhz with the jumpers. We plugged it into a monitor there and powered it on with no result. No signal. So I figured I'll need an ISA VGA card to stick in the thing, but otherwise it's probably pretty close to what I need.

One interesting sign was that the CPU had a heat sink on it. Just a heat sink, without a fan. Anything older than a 486/386 probably wouldn't bother with a heat sink. Anything newer (Pentium and up) would probably also have a fan.

I bought it with the knowledge that it'd probably be a bit of a fixer-upper. And it was. But when I got home and plugged it into my CRT, it popped up with a message that the CMOS settings were bad. Probably a dead battery on the board, but otherwise everything's good! It was exactly the right processor. An AMD 486DX2 at 66mhz. Perfect.

Well, that little dead battery turned out to be a lot more of a hassle than anticipated. The system wouldn't be able to recognize any hard drive without a working battery. The HD settings wouldn't be remembered between the time you set them and the time it looks for a boot device when you reboot.

486battery.jpg

To make matters worse, it turned out to be one of those Dallas 12887 Real Time Clock chips with the battery built in. Thanks to some handy internet guides, I learned how to modify these chips (with the help of a dremel and a soldering iron) to accept an external battery and bypass the internal one. By that night I had a functioning 486DX2! Note that the guides are mostly about the older 1287 (one '8', rather than two), but the modification is about the same for the 12887.

For anyone interested in the process, Peter H. Wendt's site has information on the mod. Terry Stewart's site also has information on it.

Other Stuff

Other nice stuff found at WeirdStuff: a couple of Vibra 16 (Low end SB16) cards, and a few 3COM Etherlink III ISA network cards. The Etherlink III cards are great, and I'll go over that more in the "Networking DOS" section.

Posted: 2017-04-11


0 1 2 3 4 5 6 7 [ 8 ] 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39

Previous | Next