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