8080エミュレータを作ってCP/M-80を動かす(その3)

CP/M

CP/Mのシステムイメージファイルができたタイミングで、ディスクイメージも作成しておきましょう。CBIOS.ASMは、四つのディスクを想定したコードになっているので、ディスクイメージも四つ作成することにします。ディスクイメージの作成後は、一部のBIOSコールのエミュレーション用関数を実装し、CP/Mを起動するところまでやります。

Cpmtoolsのインストールとディスクイメージの作成

Cpmtools」は、LinuxなどのPOSIX準拠システム上で、CP/Mのディスクイメージを操作するのに便利なツール集です。まずはこれをインストールします。Ubuntu 22.04 LTSでは、次のコマンドでインストールできます。

$ sudo apt install cpmtools

diskdefsファイルを作成する

Cpmtoolsに含まれるコマンドは、カレントディレクトリなどにある「diskdefs」ファイルを参照して、操作するディスクのフォーマットを判断します。そのため、diskdefsファイルをカレントディレクトリに作成し、次の設定を記述します。

diskdef ibm-3740
  seclen 128
  tracks 128
  sectrk 64
  blocksize 2048
  maxdir 127
  skew 0
  boottrk 1
  os 2.2
end

「ibm-3740」は、ディスクフォーマット名を指定しないときに使われるデフォルトのディスクフォーマット名です。以降の記述では、CBIOS.ASMのカスタマイズ時に設定したパラメータを指定します。

ディスクイメージを作成してCP/M用のファイルをコピー

diskdefsファイルを作成したら、Cpmtoolsに含まれるコマンドでさまざまな操作が可能になります。例えば、「disk1.dat」という1Miバイトのディスクイメージファイルを作成し、それをCP/M用にフォーマットするには、次のコマンドを実行します。mkfs.cpmコマンドの「-b」オプションには、システムトラックに書き込むシステムイメージファイルを指定します。同オプションを指定しない場合には、システムトラックは「0xE5」で埋められます。0xE5は、CP/Mにおいて空き領域を示すコードです。

$ dd if=/dev/zero of=disk1.dat bs=4096 count=256
$ mkfs.cpm -b CPM.img disk1.dat

作成したCP/Mのディスクイメージファイルにファイルをコピーするには、cpmcpコマンドを使います。例えば、カレントディレクトリにある「PIP.COM」ファイルをdisk1.datファイルにコピーするには、次のようにcpmcpコマンドを実行します。

$ cpmcp disk1.dat PIP.COM 0:

最後に指定している「0:」はユーザー番号を示します。CP/M-80のファイルシステムでは(現在普通に使われている)名前付きのディレクトリは利用できませんが、その代わり、「0」~「15」までのユーザー番号でファイルを分別できます。デフォルトのユーザー番号は「0」です。

ディスクイメージファイルのファイル一覧を表示するには、cpmlsコマンドを使います。「MBASIC.COM」「PIP.COM」ファイルをコピーした状態でcpmlsコマンドを実行すると、次のように表示されます。

$ cpmls disk1.dat
0:
mbasic.com
pip.com

なお、MBASIC.COMは、Microsoft BASICの実行ファイルです。http://www.retroarchive.org/cpm/lang/lang.htm などから入手できます。

同様の手順で「disk2.dat」「disk3.dat」「disk4.dat」も作成しておきます。disk1.datを単純にコピーしても構いません。

BIOSコールのエミュレーション用関数を作成

カスタマイズしたCBIOS.ASMファイルでは、次のように四つのBIOSコールの処理を外部に丸投げしています。

conin:  ;console character into register a
        jmp     0ffe0h
        ret
conout: ;console character output from register c
        mov     a,c     ;get to accumulator
        jmp     0ffe1h
        ret
read:   ;perform read operation (usually this is similar to write
        jmp     0ffe2h
write:  ;perform a write operation
        jmp     0ffe3h

そのため、これに対応する処理をCで書く必要があります。

conin/conoutの実装

コンソールに対する文字の入出力をする「conin」「conout」は簡単で、次のように(ひとまず)実装できます。

void bios() {
  unsigned char c;

  switch (regPC) {
    case 0xFFE0:
      c = getchar();
      regA = (c & 0x7F);
      break;
    case 0xFFE1:
      c = regA;
      putchar(c);
      break;

  }
}

read/writeの実装

続いて、1セクター(128バイト)ずつディスクのデータを読み書きする「read」「write」を実装します。これらのBIOSコールでは、アクセス対象となるディスクに関する情報(トラック番号、セクター番号、DMAアドレス、ディスク番号)を取得する必要があります。ディスクに関する情報は、次のアセンブリコードで確保したBIOS領域に格納されます。そこで、まずはこの領域のアドレスを調べます。

track:  ds      2       ;two bytes for expansion
sector: ds      2       ;two bytes for expansion
dmaad:  ds      2       ;direct memory address
diskno: ds      1       ;disk number 0-15

ここで役立つのがCBIOS.ASMのアセンブル時に作成した「CBIOS.lst」ファイルです。これを見ると、この領域がアドレスF365Hから始まる部分にあることが分かります。

   295 F365             track:  ds      2       ;two bytes for expansion
   296 F367             sector: ds      2       ;two bytes for expansion
   297 F369             dmaad:  ds      2       ;direct memory address
   298 F36B             diskno: ds      1       ;disk number 0-15

これが分かれば、readは次のように実装できます。

#define DISKINFO 0xF365
void bios() {
  int i, fd, diskinfo, track, sector, dmaad, diskno;
  unsigned char buffer[128];
  switch (regPC) {

    case 0xFFE2:
      diskinfo = DISKINFO;
      track  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      sector = ram[diskinfo++] + (ram[diskinfo++] << 8);
      dmaad  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      diskno = ram[diskinfo];

      if (diskno == 0) fd = open("disk1.dat", O_RDONLY);
      else if (diskno == 1) fd = open("disk2.dat", O_RDONLY);
      else if (diskno == 2) fd = open("disk3.dat", O_RDONLY);
      else if (diskno == 3) fd = open("disk4.dat", O_RDONLY);

      if (fd == -1) {
        regA = 1;
        break;
      }
      if (lseek(fd, (track*64 + sector) * 128, SEEK_SET) == -1) {
        regA = 1;
        close(fd);
        break;
      }
      if (read(fd, buffer, 128) == -1) {
        regA = 1;
        close(fd);
        break;
      }
      for (i=0; i<128; i++) {
        ram[dmaad + i] = buffer[i];
      }
      regA = 0;
      close(fd);
      break;

  }
}

シンプルにするために、セクターを一つ読み出すたびにファイルをopen()/close()するコードにしています。こんなコードでも、実際のフロッピディスクに比べるとはるかに高速にデータを読み出せますから、特に問題はありません。

同様にwriteも次のように実装できます。

#define DISKINFO 0xF365
void bios() {
  int i, fd, diskinfo, track, sector, dmaad, diskno;
  unsigned char buffer[128];
  switch (regPC) {

    case 0xFFE3:
      diskinfo = DISKINFO;
      track  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      sector = ram[diskinfo++] + (ram[diskinfo++] << 8);
      dmaad  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      diskno = ram[diskinfo];

      if (diskno == 0) fd = open("disk1.dat", O_WRONLY | O_SYNC);
      else if (diskno == 1) fd = open("disk2.dat", O_WRONLY | O_SYNC);
      else if (diskno == 2) fd = open("disk3.dat", O_WRONLY | O_SYNC);
      else if (diskno == 3) fd = open("disk4.dat", O_WRONLY | O_SYNC);

      if (fd == -1) {
        regA = 1;
        break;
      }
      if (lseek(fd, (track*64 + sector) * 128, SEEK_SET) == -1) {
        regA = 1;
        close(fd);
        break;
      }
      for (i=0; i<128; i++) {
        buffer[i] = ram[dmaad + i];
      }
      if (write(fd, buffer, 128) == -1) {
        regA = 1;
        close(fd);
        break;
      }
      regA = 0;
      close(fd);
      break;

  }
}

readと違うのは、ファイルのopen()時に、同期書き込みを指示する「O_SYNC」フラグを指定していることです。これによって変更がすぐにディスクイメージファイルに反映されます。

CP/M-80起動用のプログラムを作成

BIOSコールをエミュレーションできたので、次に示すコードのCP/M-80起動用のプログラム「startcpm.c」を作成します。このプログラムでは、CP/MのシステムイメージをアドレスDC00Hに読み込み、ウォームブート用のルーチンがあるF203Hからエミュレーションを開始します。コールドブート用のルーチンがあるF200Hにジャンプした場合には、BOOTラベルに移動してシステムイメージの読み込みから処理をやり直します。FFE0Hより上位のアドレスにジャンプした場合には、bios()関数を呼び出します。なお、BIOSコールの呼び出し用アドレス(FFE0H~FFE3H)には、RET命令のオペコード(0xC9)を書き込んでいます。

#include "i8080.h"
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>

#define DISKINFO 0xF365
void bios() {
  int straddr, i, fd, diskinfo, track, sector, dmaad, diskno;
  unsigned char c, buffer[128];

  switch (regPC) {
    case 0xFFE0:
      c = getchar();
      regA = (c & 0x7F);
      break;
    case 0xFFE1:
      c = regA;
      putchar(c);
      break;
    case 0xFFE2:
      diskinfo = DISKINFO;
      track  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      sector = ram[diskinfo++] + (ram[diskinfo++] << 8);
      dmaad  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      diskno = ram[diskinfo];

      if (diskno == 0) fd = open("disk1.dat", O_RDONLY);
      else if (diskno == 1) fd = open("disk2.dat", O_RDONLY);
      else if (diskno == 2) fd = open("disk3.dat", O_RDONLY);
      else if (diskno == 3) fd = open("disk4.dat", O_RDONLY);

      if (fd == -1) {
        regA = 1;
        break;
      }
      if (lseek(fd, (track*64 + sector) * 128, SEEK_SET) == -1) {
        regA = 1;
        close(fd);
        break;
      }
      if (read(fd, buffer, 128) == -1) {
        regA = 1;
        close(fd);
      break;
    case 0xFFE3:
      diskinfo = DISKINFO;
      track  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      sector = ram[diskinfo++] + (ram[diskinfo++] << 8);
      dmaad  = ram[diskinfo++] + (ram[diskinfo++] << 8);
      diskno = ram[diskinfo];

      if (diskno == 0) fd = open("disk1.dat", O_WRONLY | O_SYNC);
      else if (diskno == 1) fd = open("disk2.dat", O_WRONLY | O_SYNC);
      else if (diskno == 2) fd = open("disk3.dat", O_WRONLY | O_SYNC);
      else if (diskno == 3) fd = open("disk4.dat", O_WRONLY | O_SYNC);

      if (fd == -1) {
        regA = 1;
        break;
      }
      if (lseek(fd, (track*64 + sector) * 128, SEEK_SET) == -1) {
        regA = 1;
        close(fd);
        break;
      }
      for (i=0; i<128; i++) {
        buffer[i] = ram[dmaad + i];
      }
      if (write(fd, buffer, 128) == -1) {
        regA = 1;
        close(fd);
        break;
      }
      regA = 0;
      close(fd);
      break;
    default:
      break;
  }
}

int main(int argc, char *argv[]) {
  unsigned char opcode;

BOOT:
  regPC = 0xF203;  // warm boot
  regSP = 0xFFFF;
  regA = regB = regC = regD = regE = regH = regL = 0;
  regF = 0b00000010;
  intr_flag = 0;

  FILE *fd = fopen("CPM.img", "r");
  if (!fd) {
    perror("can't open CPM.img");
    exit(EXIT_FAILURE);
  }
  int c;
  unsigned short i = 0xDC00;
  while ((c = fgetc(fd)) != EOF) {
    ram[i++] = (unsigned char)(c & 0xFF);
  }
  fclose(fd);

  for(int i=0; i<4; i++) ram[0xFFE0 + i] = 0xC9;

  printf("\nCP/M VERSION 2.2 EXTENSION 62k SYSTEM\n");
  printf("     Copyright (C) 1979 by Digital Research\n");

  for(;;) {
    opcode = fetch();
    exec(opcode);

    // CP/M cold boot
    if (regPC == 0xF200) {
      goto BOOT;
    }
    // CP/M BIOS call
    if (regPC >= 0xFFE0) {
      bios();
    }
  }
}

CP/M-80の起動

startcpm.cをビルドしてできたstartcpmファイルを実行すると、次のようにCP/M-80が起動してCP/M-80アプリを利用できます。

$ ./startcpm

CP/M VERSION 2.2 EXTENSION 62k SYSTEM
     Copyright (C) 1979 by Digital Research

A>dir
dir
A: PIP      COM : MBASIC   COM : CPUTEST  COM : 8080EXM  COM
A: 8080PRE  COM : TST8080  COM
A>tst8080
tst8080
MICROCOSM ASSOCIATES 8080/8085 CPU DIAGNOSTIC
 VERSION 1.0  (C) 1980

 CPU IS OPERATIONAL
A>

CP/M-80終了用のコードは未実装なので、終了させる場合にはCtrl+cを入力してください。

bios()関数のconinコードを改良する

このままでもある程度のCP/Mアプリは動くのですが、MBASICのようにうまく動かないアプリもあります。MBASICがうまく動かないのは、LinuxとCP/M-80では、改行キーを押したときに入力されるコードが違うためです。そこで、bios()関数のconinコードを改良して、「0x0A」が入力された場合には、それを「0x0D」に変換することにします。また、ついでなので、Ctrl+oの入力でプログラムを終了できるようにします。

具体的には、bios()関数のconinコードを次のように書き換えます。

    case 0xFFE0:
      c = getchar();
      if (c == 0x0A) {
        c = 0x0D;
      }
      if (c == 0x0F) {
        exit(0);
      }
      regA = (c & 0x7F);
      break;

この書き換えにより、次のようにMBASICが動作するようになります。

$ ./startcpm

CP/M VERSION 2.2 EXTENSION 62k SYSTEM
     Copyright (C) 1979 by Digital Research

A>mbasic
mbasic
BASIC-80 Rev. 5.21
[CP/M Version]
Copyright 1977-1981 (C) by Microsoft
Created: 28-Jul-81
32824 Bytes free
Ok
10 print "Hello!"
10 print "Hello!"
run
run
Hello!
Ok
system
system

A>

しかし、これでもまだ問題があります。入力した文字列が不必要にローカルエコー表示されてしまっています。これを回避するために、bios()関数をさらに次のように書き換えます。この改良コードでは、termiosという端末設定機能を使って、入力をRAWモード([Enter]キーを押さなくても入力を可能にするモード)にし、さらにローカルエコーを無効にしています。

#include <termios.h>
void bios() {
  int straddr, i, fd, diskinfo, track, sector, dmaad, diskno;
  unsigned char c, buffer[128];
  struct termios settings, save_settings;

  switch (regPC) {
    case 0xFFE0:
      tcgetattr(0,&save_settings);
      settings = save_settings;
      settings.c_lflag &= ~(ECHO|ICANON);
      settings.c_cc[VTIME] = 0;
      settings.c_cc[VMIN] = 1;
      tcsetattr(0, TCSANOW, &settings);
      c = getchar();
      tcsetattr(0, TCSANOW, &save_settings);
      if (c == 0x0A) {
        c = 0x0D;
      }
      if (c == 0x0F) {
        exit(0);
      }
      regA = (c & 0x7F);
      break;

  }
}

改良後は、MBASICも問題なく利用可能になります。

$ ./startcpm

CP/M VERSION 2.2 EXTENSION 62k SYSTEM
     Copyright (C) 1979 by Digital Research

A>mbasic
BASIC-80 Rev. 5.21
[CP/M Version]
Copyright 1977-1981 (C) by Microsoft
Created: 28-Jul-81
32824 Bytes free
Ok
10 print "Hello!"
run
Hello!
Ok
system

A>

ここまでの作業で、当初の目的は達成できました。作成したプログラムのコードは、https://tsueyasu.com/wp-content/uploads/2024/08/startcpm.zip から入手できます。次回は、スクリーンエディタ「WordMaster」を利用できるようにします。

コメント

タイトルとURLをコピーしました