更新履歴
- 2025-11-24:「DOS 向け C プログラムの開発とデバッグ例」を追記
以下は普段自分が使っている DOS プログラム開発環境や開発方法の書き散らしである。
コンパイラとか
DOS の開発では C と x86 アセンブラしか使っていないので下表のような感じ。 OpenWatcom は暇があったら新バージョンを試してみたいなあと思いつつも横着して未だに 1.9 系を使っている。 また、JWasm が MASM 互換なので普段書いているアセンブラは MASM 文法である。
| 項目 | 内容 | URL |
|---|---|---|
| C コンパイラ | OpenWatcom 1.9 | http://www.openwatcom.org |
| アセンブラ | JWasm | https://github.com/Baron-von-Riedesel/JWasm |
| リンカ | JWlink | https://github.com/Baron-von-Riedesel/jwlink |
| デバッガ | exdeb | https://www.vector.co.jp/soft/dos/prog/se008450.html |
C コンパイラ
C を使ってはいるものの、標準 C ライブラリ無しで開発をやっている。 これは PC-98 向けライブラリが入手できておらず、PC-98 向けのプログラムで PC/AT 互換機向け C ランタイムをリンクして非互換性で何か変なことが起こった時に調査や修正をやりたくないためである。
注意点として、OpenWatcom で 16bit 環境をターゲットにした C のソースをコンパイルすると、一部の計算が OpenWatcom が持っているライブラリ中の関数を呼び出すコードになる場合がある。
直ぐ思いつくところだと 32bit 幅の整数 a と b の乗算 a * b がそうで、その時は自作の mul_uint32(a, b) を定義してそれ以来ずっとそのまま使っている。
が、考えてみると、標準 C ライブラリ関数を使わず標準 C ランタイムをリンク不要にするのが必要なことなので、(C ランタイムに依存していないなら)これらの関数を OpenWatcom のライブラリからリンクしてしまっても問題ないような気がする。
-2 や -ms あたりはプログラムによっても変わってくるが OpenWatcom C コンパイラで使っているコンパイルオプションは以下の通り。
C99 が使えるのには相当救われている。
-s、-zl、-zls は OpenWatcom のランタイムライブラリをリンク不要にするのに必要で、-r はアセンブラを書く時に関数呼び出しでセグメントレジスタが破壊される可能性について考えたくないので付けている。
そういえば OpenWatcom v2 だと UTF-8 サポートがあるらしい。
> wcl -c -bt=dos -za99 -os -s -zl -zls -r -2 -ms program.c
| オプション | 意味 |
|---|---|
-c | コンパイルのみ行う |
-bt=dos | 16bit DOS をターゲットにする |
-za99 | C99 を使う |
-os | サイズ最適化する |
-s | スタックオーバーフローチェックをしない |
-zl | デフォルトライブラリへの参照を追加しない |
-zls | 自動で追加されるシンボル参照を追加しない |
-r | 関数の呼び出された側でセグメントレジスタを保持する |
-2 | 286 命令まで使う |
-ms | メモリモデルを small にする |
OpenWatcom 付属の c_readme.pdf によると -r オプションは後方互換性のために追加されたオプションで、関数呼び出しがセグメントを保存しないよう変更されたのは、セグメントを解放しようとする関数のエピローグでセグメントレジスタが pop されて無効なセレクタであることによる一般保護例外が発生するのを防ぐためとのこと。
OpenWatcom では far ポインタを :> 演算子で生成できる。
char __far* p = (char __far*)(segment :> offset);
*p = 0xff;
アセンブラ
アセンブラのオプションは単純で基本的に以下のように出力オブジェクトファイル名の指定のみである。
> jwasm -Fo program.obj program.asm
セグメントの名前とクラス名を OpenWatcom の C コンパイラと揃える必要がある。
DATA クラス中のセグメントの分類は stackoverflow の記事による。
普段 SEGMENT - ENDS を使っているので気にして書いているが、.code や .data と言った簡略化セグメント記法を使えば勝手に揃うのかもしれない。
| クラス名 | セグメント名 | 中身 |
|---|---|---|
CODE | _TEXT | 機械語命令列 |
DATA | _DATA | 変更されるデータ |
DATA | CONST | 変更されない文字列 |
DATA | CONST2 | 変更されないデータ |
BSS | _BSS | 初期値なしデータ |
STACK | STACK | スタック |
C のコードと関数を呼び出し合う時には呼出規約を守らなくてはならない。 OpenWatcom を使っているのもあり、折角なので自分は殆ど watcall を使っている。 watcall の仕様については、整数の範囲では、一応原則以下のルールに沿ったコードを書いて現状不具合には遭遇していない。 ただ、良くわかっていないところもある(32bit 幅の数値を引数に渡す時のレジスタの組み合わせなど)ので、不安な時は C のコードを書いて生成されたコードを見て、それに従って引数のレジスタ割り付けをしている。
- 関数のシンボル名は suffix として
_が付く。 - 引数は左から4つ迄
AX、DX、BX、CXの順でレジスタに乗る。残りは右からスタックに積む。- この時 8bit 整数引数は 16bit 幅に拡張される。
- 引数渡しに使ったレジスタは破壊して良い。それ以外のレジスタは保存される。セグメントレジスタの保存は
-rコンパイルオプションで制御される。また、AXはvoid関数でも破壊できる。 - 戻り値は 8bit か 16bit の場合は
AXに、32bit の場合はDX:AXに返る。 - スタックの始末は呼び出された側で行う。
DFは関数の冒頭と末尾で 0 にする。
プログラムのエントリポイントはアセンブラ側に置くようにしている。
また、メモリモデルやスタック位置に合わせて各種セグメントレジスタや SP の初期化処理を行う必要がある。
初期化処理については S.W.Homepage の「MASM のメモ → モデルと、スタートアップ」に記述がある。
リンカ
出力が MZ EXE 形式であること、デフォルトライブラリを使用しないこと、セグメントの順番を与えてリンクしている。
option に map を指定しておくとマップファイルが得られるので、デバッグ時にシンボルのアドレスを知るのに使える。
> jwlink format dos option nodefaultlibs,map name output.exe ^
file { hoge.obj fuga.obj } ^
order clname CODE segment _TEXT ^
clanme DATA segment CONST segment CONST2 segment _DATA ^
clname BSS ^
clanme STACK
また、Make や CMake は使っていない。 多少複雑なプログラムであっても、コンパイル、アセンブル、リンクに割と時間がかからないのでビルド処理をバッチファイルに書いて毎回フルビルドしている。 でも、だいぶ規模が大きいプロジェクトだったら Make できるようにした方が良い気はする。
DOS 向け C プログラムの開発とデバッグ例
C のソースコードを DOS 向けにコンパイルできたとしても、ソースコードレベルデバッグができないとコンパイル後の命令列とソースコードを見比べながらデバッグすることになりなかなか大変である。 「全部アセンブラで書けば命令列を見ながらでもソースコードレベルデバッグできますよ」とは知人の発言だが、やはり、複雑なプログラムを作ろうと思うと流石に C の方がアセンブラより捗る。 PC-98 の DOS でも動くソースコードレベルデバッグができる開発環境を入手するのも簡単ではなさそう、というかそもそもそういったものがあるかもちゃんと把握できていない。
で、結局のところ、その時は C で書いているプログラムを Windows や Linux でもビルドできるようにして、C の部分の開発とデバッグを非 DOS 環境で行った。 DOS 汎用でアーキテクチャ固有のハードウェアに触らないプログラムが作れればよかったのでこの方法が採用できた。 (DOS 向けの部分では C 標準ライブラリ無しで)単一のコードベースを DOS と非 DOS 環境の両方でビルド可能にするため、大体以下のような作業をした。
- OpenWatcom では DOS 向けビルドで
__DOS__が定義されるので#if defined(__DOS__)等で実装を分ける。- 例えば、far ポインタは DOS でだけ必要なので、空の
#defineを使って far ポインタが書かれていても非 DOS 環境ではただのポインタになるようにする。
- 例えば、far ポインタは DOS でだけ必要なので、空の
- 必要な範囲で DOS 用に C 標準ライブラリと同じか似たインターフェースの関数を実装する。
- ファイル読み込みなどは非 DOS 環境向けコードでは C 標準ライブラリの関数を呼んで済ませてしまいたかったため。
printf()は諦めてputstr()やprint_u16_hex()、print_u8_dec()のような関数に分解し、非 DOS 向けコードでもこれらの関数を呼ぶようにした。- DOS では
FILE*でなく 16bit 幅整の数をハンドルとしてファイルを取り扱うなどといった型の違いは__DOS__の#ifdefとtypedefを組み合わせてどうにかした。
- 16bit x86 のセグメントの制約があるので、巨大な連続したメモリが必要ないような処理になるよう実装したり、一瞬しか使わない定数データは far なデータセグメントに置くようにしたりなど工夫する。
C 標準ライブラリのサブセットを作るのはそこまで大変でもなかったが、qsort() を後から速度重視でアセンブラに書き換えた時になんかバグって苦労した記憶がある。
セグメントレジスタの制約に対処するためのデータ配置の調整は、C の方で良い感じに #pragma を使ったりリンカスクリプトを調整したりが必要で割と面倒だった。
それでも作業が進んで DOS と非 DOS 環境共通の下回りができてしまうと、実質非 DOS 環境での C 開発の流れで実装・デバッグ・修正ができるようになり開発速度は格段に上昇した。
最初に書いた通り、DOS 汎用でないプログラムとこの方法は相性が微妙だが、条件さえ合えば相当開発しやすくなるので個人的には結構おすすめである。