Information wants to be free...

Serial Port Floppy Drive Emulation

While working on another project I needed to figure out a way to emulate a floppy drive. After doing some research I learned more about how the Interrupt Vector Table on PCs work and how TSR programs operate under DOS. So the result is a DOS TSR program that intercepts BIOS INT 13H calls and forwards these over the serial port to a remote Linux box that operates on a floppy disk image.

I have borrowed some code from my previous Kermit project that also uses x86 assembly with serial ports. This program also shares some of the same limitations; hard coded with baudrate 9600 on the COM1 port. The TSR has been tested on the Bochs emulator and on a real 25MHz 80486SX PC.

Here is the TSR part of the program, assembled with NASM as follows: nasm serialfd.asm -fbin -o serialfd.com

org 0x100
bits 16
cpu 8086

COM1_BASE equ 0x3f8
COM1_THR  equ COM1_BASE + 0 ; Transmitter Holding Buffer
COM1_RBR  equ COM1_BASE + 0 ; Receiver Buffer
COM1_IER  equ COM1_BASE + 1 ; Interrupt Enable Register
COM1_FCR  equ COM1_BASE + 2 ; FIFO Control Register
COM1_IIR  equ COM1_BASE + 2 ; Interrupt Identification Register
COM1_LCR  equ COM1_BASE + 3 ; Line Control Register
COM1_LSR  equ COM1_BASE + 5 ; Line Status Register
COM1_DLL  equ COM1_BASE + 0 ; Divisor Latch Low Byte
COM1_DLH  equ COM1_BASE + 1 ; Divisor Latch High Byte

section .text
start:
  jmp main

int13_interrupt:
  ; Allow other interrupts:
  sti

  ; Check if accessing drive 0 (A:) or drive 1 (B:)
  ; If not, then jump to original interrupt instead.
  cmp dl, 0
  je _int13_interrupt_dl_ok
  cmp dl, 1
  jne original_int13
_int13_interrupt_dl_ok:

  ; Only operation 0x02 (Read) and 0x03 (Write) are forwarded.
  ; The rest are bypassed directly and returns OK.
  cmp ah, 2
  je _int13_interrupt_ah_ok
  cmp ah, 3
  jne _int13_interrupt_end
_int13_interrupt_ah_ok:

  ; Save registers:
  push bx
  push cx
  push dx

  ; Save sectors and operation information on stack for use later:
  push ax
  push ax
  push ax

  ; Register AL already set.
  call com_port_send
  mov al, ah
  call com_port_send
  mov al, cl
  call com_port_send
  mov al, ch
  call com_port_send
  mov al, dl
  call com_port_send
  mov al, dh
  call com_port_send

  ; Retrieve sector information (stack AL) into DL register:
  pop dx
  xor dh, dh

  mov ax, 512
  mul dx ; DX:AX = AX * DX
  mov cx, ax

  ; Determine receive (Read) or send (Write) from operation (stack AH):
  pop ax
  cmp ah, 3
  je _int13_interrupt_send_loop

_int13_interrupt_recv_loop:
  call com_port_recv
  mov [es:bx], al
  inc bx
  loop _int13_interrupt_recv_loop
  jmp _int13_loop_done

_int13_interrupt_send_loop:
  mov al, [es:bx]
  call com_port_send
  inc bx
  loop _int13_interrupt_send_loop

_int13_loop_done:

  ; Retrieve sector information (stack AL) as sectors handled:
  pop ax

  ; Restore registers:
  pop dx
  pop cx
  pop bx

_int13_interrupt_end:
  ; AL register will have same value as upon entering routine.
  xor ah, ah ; Code 0 (No Error)
  clc ; Clear error bit.
  retf 2

original_int13:
  jmp original_int13:original_int13 ; Will be overwritten runtime!

; Send contents from AL on COM1 port:
com_port_send:
  push dx
  mov dx, COM1_THR
  out dx, al
  mov dx, COM1_LSR
_com_port_send_wait:
  in al, dx
  and al, 0b00100000 ; Empty Transmit Holding Register
  test al, al
  jz _com_port_send_wait
  pop dx
  ret

; Return contents in AL on COM1 port:
com_port_recv:
  push dx
_com_port_recv_wait:
  mov dx, COM1_IIR
  in al, dx
  and al, 0b00001110 ; Identification
  cmp al, 0b00000100 ; Enable Received Data Available Interrupt
  jne _com_port_recv_wait
  mov dx, COM1_RBR
  in al, dx
  pop dx
  ret

; TSR end marker:
tsr_end:

main:
  ; NOTE: No protection to prevent TSR from being loaded twice or more!

  ; Set Baudrate on COM1 to 9600, divisor = 12:
  mov dx, COM1_LCR
  in al, dx
  or al, 0b10000000 ; Set Divisor Latch Access Bit (DLAB).
  out dx, al

  mov dx, COM1_DLL
  mov al, 0xc
  out dx, al
  
  mov dx, COM1_DLH
  mov al, 0
  out dx, al

  mov dx, COM1_LCR
  in al, dx
  and al, 0b01111111 ; Reset Divisor Latch Access Bit (DLAB).
  out dx, al

  ; Disable and clear FIFO on COM1, to put it in 8250 compatibility mode:
  mov dx, COM1_FCR
  mov al, 0b00000110 ; Clear both FIFOs.
  out dx, al
  ; NOTE: Not tested what happens if this is run on an actual 8250 chip...

  ; Set mode on COM1 to 8 data bits, no parity and 1 stop bit:
  mov dx, COM1_LCR
  mov al, 0b00000011 ; 8-N-1
  out dx, al

  ; Enable interrupt bit on COM1:
  mov dx, COM1_IER
  in al, dx
  or al, 0b00000001 ; Enable Received Data Available Interrupt
  out dx, al

  ; Call DOS to get original interrupt handler:
  mov al, 0x13
  mov ah, 0x35
  int 0x21
  mov word [original_int13 + 3], es
  mov word [original_int13 + 1], bx

  ; Call DOS to set interrupt handler:
  mov al, 0x13
  mov ah, 0x25
  ; DS is already same as CS, no need to change.
  mov dx, int13_interrupt
  int 0x21

  ; Terminate and Stay Resident:
  mov dx, tsr_end
  shr dx, 1
  shr dx, 1
  shr dx, 1
  shr dx, 1
  add dx, 0x11 ; Add 0x1 for remainder and 0x10 for PSP.
  mov ax, 0x3100
  int 0x21
          


And here the Linux counterpart in C, just compile with GCC:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <termios.h>
#include <sys/ioctl.h>
#include <string.h>

#define REGISTER_AL 0
#define REGISTER_AH 1
#define REGISTER_CL 2
#define REGISTER_CH 3
#define REGISTER_DL 4
#define REGISTER_DH 5

#define SECTOR_SIZE 512
#define HEADS_PER_CYLINDER_DEFAULT 2

#define OPERATION_READ_DISK_SECTORS 0x02
#define OPERATION_WRITE_DISK_SECTORS 0x03

static uint16_t get_sectors_per_track(FILE *fh)
{
  uint16_t spt;

  fseek(fh, 24, SEEK_SET); /* Offset in Volume Boot Record. */

  if (fread(&spt, sizeof(uint16_t), 1, fh) != 1) {
    return 0; /* Error */
  }

  /* Currently handling 720K and 1.44M floppies. */
  if (spt == 9 || spt == 18) {
    return spt; /* Valid */
  }

  return 0; /* Invalid */
}

static void display_help(char *progname)
{ 
  fprintf(stderr, "Usage: %s <options>\n", progname);
  fprintf(stderr, "Options:\n"
     "  -h          Display this help and exit.\n"
     "  -d DEVICE   Use TTY DEVICE.\n"
     "  -a IMAGE    Floppy IMAGE for A:\n"
     "  -b IMAGE    Floppy IMAGE for B:\n"
     "  -H HPC      Force HPC heads per cylinder.\n"
     "  -S SPT      Force SPT sectors per track.\n"
     "  -v          Verbose debugging output.\n"
     "\n");
}

int main(int argc, char *argv[])
{
  int result = EXIT_SUCCESS;
  int i, c, arg;
  int cylinder, sector, lba;
  struct termios attr;
  unsigned char registers[6];
  int debug_output = 0;
  char *tty_device = NULL;
  int tty_fd = -1;
  FILE *fh;
  char *floppy_a_image = NULL;
  char *floppy_b_image = NULL;
  FILE *floppy_a_fh = NULL;
  FILE *floppy_b_fh = NULL;
  uint16_t floppy_a_spt = 0;
  uint16_t floppy_b_spt = 0;
  int spt = 0;
  int hpc = HEADS_PER_CYLINDER_DEFAULT;
  char *operation;

  while ((c = getopt(argc, argv, "hd:a:b:H:S:v")) != -1) {
    switch (c) {
    case 'h':
      display_help(argv[0]);
      return EXIT_SUCCESS;

    case 'd':
      tty_device = optarg;
      break;

    case 'a':
      floppy_a_image = optarg;
      break;

    case 'b':
      floppy_b_image = optarg;
      break;

    case 'H':
      hpc = atoi(optarg);
      break;

    case 'S':
      spt = atoi(optarg);
      break;

    case 'v':
      debug_output = 1;
      break;

    case '?':
    default:
      display_help(argv[0]);
      return EXIT_FAILURE;
    }
  }

  if (tty_device == NULL) {
    fprintf(stderr, "Please specify a TTY!\n");
    display_help(argv[0]);
    return EXIT_FAILURE;
  }

  if (floppy_a_image == NULL && floppy_b_image == NULL) {
    fprintf(stderr, "Please specify at least one floppy image!\n");
    display_help(argv[0]);
    return EXIT_FAILURE;
  }

  if (hpc == 0) {
    fprintf(stderr, "Invalid heads per cylinder!\n");
    return EXIT_FAILURE;
  }

  /* Open serial TTY device. */
  tty_fd = open(tty_device, O_RDWR | O_NOCTTY);
  if (tty_fd == -1) {
    fprintf(stderr, "open() on TTY device failed with errno: %d\n", errno);
    return EXIT_FAILURE;
  }

  /* Set TTY into a very raw mode. */
  memset(&attr, 0, sizeof(struct termios));
  attr.c_cflag = B9600 | CS8 | CLOCAL | CREAD;
  attr.c_cc[VMIN] = 1;

  if (tcsetattr(tty_fd, TCSANOW, &attr) == -1) {
    fprintf(stderr, "tcgetattr() on TTY device failed with errno: %d\n", errno);
    close(tty_fd);
    return EXIT_FAILURE;
  }

  /* Make sure TTY "Clear To Send" signal is set. */
  arg = TIOCM_CTS;
  if (ioctl(tty_fd, TIOCMBIS, &arg) == -1) {
    fprintf(stderr, "ioctl() on TTY device failed with errno: %d\n", errno);
    close(tty_fd);
    return EXIT_FAILURE;
  }

  /* Get information about floppy A: */
  if (floppy_a_image != NULL) {
    floppy_a_fh = fopen(floppy_a_image, "r+b");
    if (floppy_a_fh == NULL) {
      fprintf(stderr, "fopen() for floppy A: failed with errno: %d\n", errno);
      result = EXIT_FAILURE;
      goto main_end;
    }

    if (spt == 0) {
      floppy_a_spt = get_sectors_per_track(floppy_a_fh);
    } else {
      floppy_a_spt = spt;
    }
    if (floppy_a_spt == 0) {
      fprintf(stderr, "Invalid sectors per track for floppy A:\n");
      result = EXIT_FAILURE;
      goto main_end;
    }
  }

  /* Get information about floppy B: */
  if (floppy_b_image != NULL) {
    floppy_b_fh = fopen(floppy_b_image, "r+b");
    if (floppy_b_fh == NULL) {
      fprintf(stderr, "fopen() for floppy B: failed with errno: %d\n", errno);
      result = EXIT_FAILURE;
      goto main_end;
    }

    if (spt == 0) {
      floppy_b_spt = get_sectors_per_track(floppy_b_fh);
    } else {
      floppy_b_spt = spt;
    }
    if (floppy_b_spt == 0) {
      fprintf(stderr, "Invalid sectors per track for floppy B:\n");
      result = EXIT_FAILURE;
      goto main_end;
    }
  }

  /* Process input and output. */
  while (1) {
    for (i = 0; i < 6; i++) {
      if (read(tty_fd, &registers[i], sizeof(unsigned char)) != 1) {
        fprintf(stderr, "read() failed with errno: %d\n", errno);
        result = EXIT_FAILURE;
        goto main_end;
      }
    }

    if (debug_output) {
      fprintf(stderr, "AL: 0x%02x\n", registers[REGISTER_AL]);
      fprintf(stderr, "AH: 0x%02x\n", registers[REGISTER_AH]);
      fprintf(stderr, "CL: 0x%02x\n", registers[REGISTER_CL]);
      fprintf(stderr, "CH: 0x%02x\n", registers[REGISTER_CH]);
      fprintf(stderr, "DL: 0x%02x\n", registers[REGISTER_DL]);
      fprintf(stderr, "DH: 0x%02x\n", registers[REGISTER_DH]);
    }

    if (registers[REGISTER_DL] == 0x00) {
      spt = floppy_a_spt;
      fh = floppy_a_fh;
    } else if (registers[REGISTER_DL] == 0x01) {
      spt = floppy_b_spt;
      fh = floppy_b_fh;
    } else {
      fprintf(stderr, "Error: Invalid drive number: %02x\n",
        registers[REGISTER_DL]);
      result = EXIT_FAILURE;
      goto main_end;
    }

    /* CX =       ---CH--- ---CL---
     * cylinder : 76543210 98
     * sector   :            543210
     * LBA = ( ( cylinder * HPC + head ) * SPT ) + sector - 1
    */

    cylinder = ((registers[REGISTER_CL] & 0xc0) << 2)
      + registers[REGISTER_CH];
    sector = registers[REGISTER_CL] & 0x3f;
    lba = ((cylinder * hpc + registers[REGISTER_DH]) * spt) + sector - 1;

    if (debug_output) {
      fprintf(stderr, "Cylinder: %d\n", cylinder);
      fprintf(stderr, "Sector  : %d\n", sector);
      fprintf(stderr, "SPT     : %d\n", spt);
      fprintf(stderr, "HPC     : %d\n", hpc);
      fprintf(stderr, "LBA     : %d\n", lba);
      fprintf(stderr, "Offset  : 0x%x\n", lba * SECTOR_SIZE);
    } else {
      switch (registers[REGISTER_AH]) {
      case OPERATION_READ_DISK_SECTORS:
        operation = "Read";
        break;
      case OPERATION_WRITE_DISK_SECTORS:
        operation = "Write";
        break;
      default:
        operation = "Unknown";
        break;
      }
      fprintf(stderr, "%s %c: sector=%d, cylinder=%d count=%d\n",
        operation, (registers[REGISTER_DL] == 0x00) ? 'A' : 'B',
        sector, cylinder, registers[REGISTER_AL]);
    }

    if (fh != NULL) {
      if (fseek(fh, lba * SECTOR_SIZE, SEEK_SET) == -1) {
        fprintf(stderr, "fseek() failed with errno: %d\n", errno);
        result = EXIT_FAILURE;
        goto main_end;
      }
    }

    switch (registers[REGISTER_AH]) {
    case OPERATION_READ_DISK_SECTORS:
      if (debug_output) {
        fprintf(stderr, "READ SECTOR DATA:\n");
      }
      for (i = 0; i < (SECTOR_SIZE * registers[REGISTER_AL]); i++) {
        if (fh != NULL) {
          c = fgetc(fh);
        } else {
          c = 0xFF; /* Dummy data if image is not loaded. */
        }
        if (debug_output) {
          fprintf(stderr, "%02x ", c);
          if (i % 16 == 15) {
            fprintf(stderr, "\n");
          }
        }
        write(tty_fd, &c, sizeof(unsigned char));
      }
      break;

    case OPERATION_WRITE_DISK_SECTORS:
      if (debug_output) {
        fprintf(stderr, "WRITE SECTOR DATA:\n");
      }
      for (i = 0; i < (SECTOR_SIZE * registers[REGISTER_AL]); i++) {
        read(tty_fd, &c, sizeof(unsigned char));
        if (fh != NULL) {
          fputc(c, fh);
        }
        if (debug_output) {
          fprintf(stderr, "%02x ", c);
          if (i % 16 == 15) {
            fprintf(stderr, "\n");
          }
        }
      }
      if (fh != NULL) {
        fflush(fh);
      }
      break;

    default:
      fprintf(stderr, "Error: Unhandled operation: %02x\n",
        registers[REGISTER_AH]);
      result = EXIT_FAILURE;
      goto main_end;
    }
  }

main_end:
  if (tty_fd != -1) close(tty_fd);
  if (floppy_a_fh != NULL) fclose(floppy_a_fh);
  if (floppy_b_fh != NULL) fclose(floppy_b_fh);

  return result;
}
          


I have also uploaded the code to GitHub in case case of further improvements in the future.

Topic: Scripts and Code, by Kjetil @ 03/07-2020, Article Link