最近『はじめて読む 486』という本を読んでいます。内容は Intel x86 系のプロセッサの一種である Intel 486 を解説する、というもので本自体は昔のものですが CPU と OS の勉強に役立っています。解説では要所要所で MS-DOS で動かせるサンプルコードが登場するので、これを動かすための環境を FreeDOSQEMU で作成しました。

  1. FreeDOS のインストール
  2. CPUID 命令
  3. 開発環境構築
  4. 動作確認

環境:

  • OS (ホスト): Arch Linux
    • kernel: linux 4.18.9
  • QEMU: 3.0.0
  • FreeDOS: 1.2

1. FreeDOS のインストール

FreeDOS はオープンソースな MS-DOS 互換 OS です。MS-DOS 用に作成されたアプリケーションであれば基本的には FreeDOS でも動くはずとの触れ込みなので今回の用途にはもってこいです。

(ちなみに MS-DOS も Microsoft/MS-DOS でソースが公開されているのでこれを使うという手もきっとあるんだと思います。ビルドとかデプロイとかイメージできていませんが)

またここではマイクロプロセッサは 486 を使いたいのですが、もちろん実際の PC が持つ CPU とは異なるので、マシンエミュレータである QEMU を使用します。

FreeDOS の入手

FreeDOS の CD イメージは 公式ページ から入手します。いくつか選択肢がありますがここでは「CDROM “standard” installer」を選びます。

QEMU の用意

手元のマシンに QEMU を用意します。ダウンロードページを見るとわかるように多くの OS でパッケージが提供されており簡単にインストールできます。

Run FreeDOS by QEMU

まずは QEMU 実行用にハードディスクイメージを作成します。ハードディスクイメージとは ArchWiki いわく、

CD-ROM やネットワークからライブシステムを起動するのでない (そしてオペレーティングシステムをハードディスクイメージにインストールしない) 限り、QEMU を実行するにはハードディスクイメージが必要になります。ハードディスクイメージはエミュレートするハードディスクの内容を保存するファイルです

というものです。ゲスト OS が使用するブロックデバイスを (ホスト OS から見たディスクイメージとして) 用意するという感じに捉えれば良さそうです。

ここでは 100 MB の raw フォーマットイメージを作成します。

$ qemu-img create -f raw freedos.img 100M

準備が整ったのでインストールを始めます。やることは

  1. ダウンロードした FreeDOS CD イメージから FreeDOS のインストーラを起動
  2. インストーラに上で用意したディスクイメージへ FreeDOS をインストールしてもらう

の 2 点です。ユーザが能動的にやるのはインストーラの起動ぐらいですね。

ダウンロードした FreeDOS CD イメージを指定してエミュレートを開始します。

$ qemu-system-x86_64 -cpu 486 -rtc base=localtime \
    -drive file=freedos.img,format=raw \
    -cdrom FD12CD.iso \
    -boot d

オプションの指定が多いですが、概要は

  • cpu: エミュレートする CPU の指定
  • rtc: Real Time Clock に関する設定
  • drive: ブロックデバイス (ドライブ) の設定
  • cdrom:
    • -drive file=freedos.img,media=cdrom の省略形
  • boot: どのドライブから OS を起動するか
    • d は CD-ROM ドライブを表す

という感じです。

実行すると新たなウィンドウが開き、インストール画面が表示されます。

「Install to harddisk」 を選択します。

partition

あとは画面の指示に従っていきます。

途中でディスクがまだパーティショニングされていないみたいだけどどうする? 的なことを聞かれました。どうやらここでパーティショニングの設定ができるようなので以下のようにお願いしました。

partition

リスタート後、インストールが始まります。

ブートメニュー:

boot-menu

まずはどれでもいいので 1 を選択すると、FreeDOS が起動します。

ハードディスクイメージにインストール後は boot オプションを指定せずに起動できます。

$ qemu-system-x86_64 -cpu 486 -rtc base=localtime \
    -drive file=freedos.img,format=raw

参考:

2. CPUID 命令

QEMU で -cpu 486 を指定しているのですが、できれば OS 目線からも 486 が動いていることを確認したいなーと思い調べたところ、どうやら CPUID 命令というのが使えそうです。

CPUID は 486 の後期以降に導入された x86 の機械語命令で、プロセッサの特定やサポートする機能を判断するために使用されます。詳細は Intel の資料 Processor_Identification.pdf を参照なのですが、簡単な使い方は必要な情報の種類を EAX レジスタで指定し、汎用レジスタへの出力を見るという感じです。

現環境で CPUID が使用できるかわからなかったのですが悩むより試しに実行してみるのが早そうです。FreeDOS で CPUID を使用した簡単な命令を実行するために DEBUG コマンドを利用します。

> debug
;; 0B1A:0100 からアセンブリ言語で命令を入力
-A 100
0B1A:0100 mov eax,0
0B1A:0106 cpuid
[needs 586]
0B1A:0108

;; 0B1A:0100 から命令を実行。0B1A:0108 に break point 設定
-G=100 108
...

;; レジスタの表示
-r ebx
EBX 756E6547 :
-r edx
EDX 49656E69 :
-r ecx
ECX 6C65746E :

途中「needs 586」というメッセージが表示されているのが気になりますが、EBX から ECX にかけて (47, 65, 6E, 75, 69, 6E, 65, 49, 6E, 74, 65, 6C, x86 はリトルエンディアンなのでこの並び) GenuineIntel がセットされており、どうやら CPUID がサポートされているとみて良さそうです。

プロセッサを特定するためには EAX に 1h を入れて CPUID 命令を実行します。実行後 EAX レジスタにプロセッサシグネチャと呼ばれるものがセットされます。

-A 100
0B1A:0100 mov eax,1
0B1A:0106 cpuid
[needs 586]
0B1A:0108
-G=100 108
-r eax
EAX 00000480 :

Intel の資料では、下位から数えて 20-27 ビットを拡張ファミリ、8-11 ビットをファミリコードと呼んでいます。この組み合わせで CPU が 386 なのか 486 なのかといったことが判断できるようです。上の場合それぞれ 00000000, 0100 となっておりここから 486 プロセッサファミリだとわかります。

3. 開発環境構築

インストール直後の FreeDOS は MS-DOS を再現するために必要最低限なパッケージのみを含みます。具体的には FreeDOS Software のうち BASE にリストされているパッケージ群です。この中には例えばテキストエディタである edit や上でも使用した debug 等が含まれます。

『はじめて読む 486』を読み進めるにあたって少なくとも C コンパイラは必要になるので Development パッケージのリストから Open Watcom C Compiler をインストールすることにしました。他にも選択肢はあるかと思いますが、 tkmc/486 リポジトリの README で紹介されているのでこれが無難そうです。

どうやってパッケージを FreeDOS に持っていくかですが、ここではループバックデバイスを利用します。ループバックデバイスは Linux (少なくとも。Unix にもあったのかは調査不足) においてイメージファイルを擬似的にブロックデバイスとして扱うために使用されるものです。今回の場合 FreeDOS 用に作成したハードディスクイメージをループバックデバイスによりマウントし、そこに OpenWatcom パッケージを配置すれば目的を達成できます。

$ mkdir -p /mnt/freedos
$ mount -t msdos -o loop,offset=32256 freedos.img /mnt/freedos

-o loop によりループバックデバイスを使用するということを示しています。 offset は何 byte 目からマウントするかという設定です。今回の場合 freedos.img のパーティショニングが

$ fdisk -l freedos.img 
Disk freedos.img: 100 MiB, 104857600 bytes, 204800 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x00000000

Device       Boot Start    End Sectors  Size Id Type
freedos.img1 *       63 204623  204561 99.9M  6 FAT16

のようになっており、1 セクタのサイズが 512 bytes, デバイスの開始セクタが 63 なので 63 * 512 = 32256 ということになります。

また -t オプションはファイルシステムを指定するために使用します。

マウントできればあとはこちらのもので、

$ ls /mnt/freedos/
autoexec.bat  command.com  fdconfig.sys  fdos  kernel.sys  test.txt

のように見えるのでホストから好きなようにいじくれます。終了後は忘れずに umount することと QEMU で使用している最中のものは mount しないことが注意点です。

Open Watcom の場合は解凍後の ow ディレクトリを C:\devel\ow\.. となるように配置するのが良さそうです。そうすれば devel\ow\owsetenv.bat を叩くことで必要な環境変数の設定を完了できます。

この bat は毎回手で叩くのは面倒なので FreeDOS 起動時に呼ばれる C:\autoexec.bat 内で叩くようにするのが吉だと思います。

> more setup.bat
...
call C:\devel\ow\owsetenv.bat
...

Open Watcom の使用方法についてはあまり深入りしたくないので、さっくりと以下のよく使うコマンドだけ整理して覚えました。

コンパイル:

wcc -ecc -ms a.c

  • ecc: 呼出規約として cdecl を使用
    • 呼出規約は ABI の一部で C でいう関数をどのように呼び出すかということを定めたもの
    • 呼出規約を合わせておけばアセンブリで書いた関数が C のコードから呼べるし、その逆も可
  • ms: small memory model (small code/small data)
    • プログラム内でセグメントレジスタの値を操作しないようにする
    • リアルモードの場合アプリケーションからアクセスできるメモリサイズはコード、データそれぞれでオフセット 16 bits 分の 64 KB になる
    • 『はじめて読む 486』のコードはスモールモデルでコンパイルすることが前提

コンパイルとリンク:

wcl -ecc -ms -fe=b.exe a.obj b.c

  • fe: 作成する実行ファイル名

アセンブリ:

wasm -ms c.asm

ディスアセンブリ:

wdis -l=d.lst d.obj

  • l: 出力ファイル名

けっこう調べたのですが wcc のコマンドとしてアセンブリを吐くというものは見つかりませんでした。なので C のソースからどのようなアセンブリが吐かれるのか、というのを見る場合は一度オブジェクトファイルにしてから wdis にするしかないと思います。

参考:

4. 動作確認

3 で開発環境を整えたので試しにいくつかプログラムを書いてみます。

Hello World

hello.c:

#include <stdio.h>

void main(void)
{
    printf("hello world\n");
}

実行:

> wcl -ecc -ms -fe=hello hello.c
> hello.exe
hello world

アセンブリ言語で C から呼び出せる関数を書く

2 つの int 値を減算するだけの int sub(int x, int y) をアセンブリ言語で定義します。

sub.asm:

.386p

_TEXT  segment byte public use16 'CODE'
       assume cs:_TEXT

;; int sub(int x, int y);
public _sub
_sub   proc near
       push bp
       mov  bp,sp
       mov  ax,[bp+4]
       mov  cx,[bp+6]
       sub  ax,cx
       pop  bp
       ret
_sub   endp

_TEXT  ends
       end

main.c:

#include <stdio.h>

int sub(int x, int y);

void main(void)
{
    int result;

    result = sub(10, 8);
    printf("result = %d\n", result);
}

実行:

> wasm -ms sub.asm
> wcl -ecc -ms -fe=main main.c sub.obj
> main.exe
result = 2

アセンブリ言語の細かいお作法はあまりわかっていないのですが、『はじめて読む 486』のサンプルコードだったり、やりたいことに近い処理を C で書いて wdis で逆アセンブリしたりを参考に何とか書いています。このあたりのまとまった資料として以下は目を通しました。

簡易な リアルモード <-> プロテクトモード

最後に『はじめて読む 486』の 4 章末尾にある簡易なリアルモード -> プロテクトモード -> リアルモード移行を行うプログラムを実行してみます。内容としては CR0 レジスタの PE (Protected mode Enabled) ビットを操作するだけです。

必要なファイルは以下の 3 つです。『はじめて読む 486』はサンプルコードが全て GitHub に上げられているようなのでそちらへのリンクを載せておきます。

実行:

> wasm proto0_a.asm
> wcl -ecc -ms testprot.c proto0_a.obj
> testprot.exe

1 点詰まった点があり、FreeDOS ブートメニューの 1 (FreeDOS with JEMMEX) や 2 (with EMM386) を選択して OS を起動すると、上のプログラム実行時に (恐らくエラーで) リブートがかかるというのがありました。

これが何故か、というのは自信を持って答えられないのですが、1, 2 ではいずれもプログラムを仮想 86 モードで動かすことになってしまい、その場合特権レベル 3 での実行になるので CR0 への操作が失敗する、というように考えています。

仮想 86 モードは本来 MS-DOS 以降の OS が MS-DOS 用のアプリケーションを実行できるように設けられた機能なのですが、MS-DOS でもこれを利用してメモリ管理システムに拡張が加えられていたようです。

MS-DOS のメモリ領域を大別すると (それぞれ 2 番目の名称は『はじめて読む 486』 より)

  • コンベンショナルメモリ、MS-DOS メモリ領域 (-640 KB)
  • UMA (Upper Memory Area), VRAM・ROM 領域 (640 KB - 1 MB)
  • HMA (High Memory Area), プロテクトモードメモリ (1 MB - 1 MB + 64 KB)
  • EMB (Extended Memory Block), プロテクトモードメモリ (1 MB + 64 KB - )

のように分けられます。

当初の MS-DOS のメモリ空間は 1 MB までで、また実際にアプリケーションに割り当てられる領域は MS-DOS メモリ領域の 640 KB のみのようです。

この MS-DOS のメモリ空間を拡張するためにいくつか管理方式が存在します。

  • XMS (eXtended Memory Specification)
  • EMS (Expanded Memory Specification)
  • UMB (Upper Memory Block)

これらのうちブートメニュー 1 の JEMMEX は XMS, 2 は EMS 方式を有効にして OS を起動するようで、それぞれの拡張では仮想 86 モードを利用するために作成したプログラムが上手く動かなかったのではと考えています。

(MS-DOS にシステムログみたいのがあればもう少しちゃんとわかりそうだけどあるんだろうか…)

ちなみにこのあたりの HMA, EMS や UMB は『はじめて読む 486』の 11 章 仮想8086モード で触れられているので、それを読みながら少し整理してみます。

HMA

HMA はリアルモードでもアクセスできる領域 (仮想 86 モードは必要ない) であり、

MS-DOS 5.0 からは HMA に DOS 本体を置くことによってアプリケーションソフトウェアの利用できるメインメモリを空けておくことができるようになりました

というように使われたそうです。FreeDOS だと HMA を利用可能にする設定は C:\FDCONFIG.SYS に含まれているのがわかります。

124?DOS=HIGH # HMA に MS-DOS をロードする
234?DEVICE=C:\FDOS\BIN\HIMEMX.EXE # 必要なドライバの有効化

EMS

EMS では VRAM・ROM 領域の一部を使用してハードウェア的に、あるいはソフトウェア的にメモリを拡張します。386 以降では仮想 86 モードを利用した機構を利用してソフトウェア的に実現されるのが主流になりました。プロテクトモードメモリを 16 KB のページ (CPU のページングとは別なので本では EMS ページと呼んでいる) に区画し、VRAM・ROM に確保した EMS ページフレームを通して EMS ページとの読み書きを行います。

使えるメモリ量は大幅に増えると思いますが、アプリケーション側が EMS を知って使わないといけなさそうなのでけっこう辛そうです。

FreeDOS だと C:\FDCONFIG.SYS の以下で有効にしているように見えます。

2?DEVICE=C:\FDOS\BIN\JEEMM386.EXE X=TEST I=TEST I=B000-B7FF NOVME NOINVLPG

UMB

EMS とほぼ同様なのですが、EMS が EMS ドライバソフトウェアを使用して EMS ページフレームと EMS ページのマッピングを切り替えるのに対し、こちらはマッピングが固定になるようです。なので使用可能な領域はそこまで増えないのですが、配置されるプログラムが UMB を知る必要がないので例えばデバイスドライバソフトウェアや常駐型プログラムを配置することで MS-DOS メモリ領域の節約に使われたそうです。

C:\FDCONFIG.SYS では以下のように UMB を有効化しています。

12?DOS=UMB

ということでこれ以降は『はじめて読む 486』のサンプルコードを動かす場合 FreeDOS ブートメニューでは 4 (with some drivers) を選択して実行するようにしました。特に問題なくプログラムは動作できています。