🐯

ia16-elf-gcc で MS-DOS exe を作ってみる

2024/09/15に公開

c++ で 16bit DOS プログラムを作りたいなあ、と、こっちを書いたあとに知ったのですが。

16bit MS-DOS の実行ファイルを作れる gcc として、ia16-elf-gcc というのがあるようです。

g++(gcc) ver.6.3.0 なので c++14、モダンな c++ の機能が使えます。
auto もラムダも SFINE も。
c++20/23 のご時世なので c++14 は少し古めですが、watcom や dmc、その他 16bit DOS 用 c++コンパイラは c++03 もままならないことも多く...

ということで、少し、使ってみたメモ。

1. インストール

ubuntu 22.04 用のバイナリが提供されており、apt リポジトリ追加で簡単更新できるのですが、残念ながら ubuntu 23.xx 24.xx 用は用意されていないようです。

お試しは Win で WSL に ubuntu 22.04 を入れて行いました。
できた exe については dosbox-x で実行して確認。

インストールは、作者 のページに書いてあるとおりに

sudo add-apt-repository ppa:tkchia/build-ia16
sudo apt-get update
sudo apt-get install gcc-ia16-elf

をするだけ。

2. コンパイル

まずはお約束。

#include <stdio.h>
int main(void) {
    printf("Hello World!\n");
    return 0;
}

コンパイルは -O2 でオプティマイズ指定をして

ia16-elf-gcc -O2 -march=any_186 -mcmodel=small hello.c -o hello.exe

を実行、exe ファイルが作られます(6064 バイト)。

これを dosbox-x で実行、Hello World! が出力されます。

オプションの -march でターゲットCPU指定。

-march=any_186 にすれば 80186 や v20 以降で動作するコードのようなので、気分的に PC98 の V30 機以降用を想定して選んでみました。
-march=any とすればどの86でも動作で、無指定時はこれ。
-march には他にも個別名で、i8086,i80186(i186),i80286(i286),v30,v30mz、それらに対応する 8088/v20 系が用意されているようです。(--help -v 参照)

-mcmode でメモリーモデル指定。

c++ の場合は tiny, small モデルのみなので、無難に small を選択。
c言語の場合、tiny、small の他に medium モデルが使えるようです。

他のコンパイラにある compact、large、huge モデルは無し。

※ tiny(data+code 64KB)、small (data 64KB code 64KB)、medium (data 64KB code 64KBover)

3. auto、ラムダを使う

c++11,14 が使えるので、ちと無理やりですが auto とラムダを使ってみます。

c++11 その1
#include <stdio.h>

int main() {
    auto str = "Hello World!\n";
    auto fn  = [](char const* s) {
        printf("%s", s);
    };
    fn(str);
    return 0;
}
c++11 その2
#include <stdio.h>

int main() {
    auto str = "Hello World!\n";
    auto fn  = [str]() {
        printf("%s", str);
    };
    fn();
    return 0;
}

その1 は文字列へのポインタを関数引数で渡した例、その2 はキャプチャで渡した例です、

これらをオプティマイズ指定して、

ia16-elf-g++ -O2 -march=any_186 -mcmodel=small hello.cpp -o hello.exe

でコンパイルすると、c 版同様にこれらも 6064 バイト。

ia16-elf-g++ -c -S -O2 -march=any_186 -mcmodel=small hello.cpp -o hello.s

のような感じで、c 版、c++その1、その2 のアセンブラソースを出してみたところ、

	.arch i186,jumps
	.code16
	.att_syntax prefix
#NO_APP
	.section	.rodata.str1.1,"aMS",@progbits,1
.LC0:
	.string	"Hello World!"
	.section	.text.startup,"ax",@progbits
	.global	main
	.type	main, @function
main:
	pushw	$.LC0
	call	puts
	addw	$2,	%sp
	xorw	%ax,	%ax
	ret
	.size	main, .-main
	.ident	"GCC: (GNU) 6.3.0"

で、3つとも全く同じソースが生成されました。
キッチリ最適化されていて、たのもしい限りです。

4. std::string や std::stream を使ってみる

今度は std::string を使ってみます。

#include <cstdio>
#include <string>

int main() {
    std::string s = "Hello World!\n";
    std::printf("%s", s.c_str());
    return 0;
}

-O2 オプティマイズして exe サイズは 31472 バイト、-O0 オプティマイズ無しだと 49938 バイト、一気に太ります。

そして、iostream を使った例

#include <iostream>

int main() {
    std::cout << "Hello, World!\n";
    return 0;
}

をビルドすると、

/mnt/d/proj/zatsu/dossmp1/bld/gcc /mnt/d/proj/zatsu/dossmp1/bld
/usr/lib/x86_64-linux-gnu/gcc/ia16-elf/6.3.0/../../../../../ia16-elf/bin/ld: hello.exe section `.text' will not fit in region `csegvma'
/usr/lib/x86_64-linux-gnu/gcc/ia16-elf/6.3.0/../../../../../ia16-elf/bin/ld: Error: too large for a small or medium model .exe file.
/usr/lib/x86_64-linux-gnu/gcc/ia16-elf/6.3.0/../../../../../ia16-elf/bin/ld: region `csegvma' overflowed by 116640 bytes
(以下多数)

な感じに、small(,medium) モデルとして大きすぎると怒られてしまいます。

標準 c++ ライブラリは実質 32bit 環境以降が前提のようなリッチさのある仕様だから...
というのもあるし、たぶん string や stream 系 は、template で実装されていても char 用は予めバイナリ化してライブラリ(lib)用意してたりするので、未使用ルーチンが含まれやすいかもな、と。

このへんは仕方ないといえば仕方ないので、C 言語ベースの関数でやりくりするか、自前で交代処理を用意するしかなさそうです。

※ きっちりとしたヘッダ・オンリーの実装にすれば、必要ルーチンだけの生成にして小さくできるとは思う

5. far ポインタ

PC/AT のテキスト VRAM に直接、黄色字で Hello World を書き込む例です。

int main(void) {
    short __far* d = (short __far*)0xb8000000L + 80*2;
    char const*  s = "Hello World!";
    do {
        *d++ = (0x0e << 8) | (*(unsigned char*)s);
    } while (*++s);
    return 0;
}

テキスト VRAM のアドレスとして __far ポインタを使っています。

この __far ポインタの書き方は DOS系 c/c++ では一般的だろうで、ia16-elf-gcc でも c 言語としてはそのままコンパイルできるのですが、g++ でコンパイルすると、

../../src/far_smp.cpp:2:13: error: expected initializer before ‘*’ token
  short __far* d = (short __far*)0xb8000000L + 80*2;
             ^

のようなエラーが出ます。
どうも __far 指定が機能していないようです。

実際 ia16-elf-g++ では _fmemcpy 等の far 関数ライブラリや dos ライブラリ用に、class で far ポインタを部分実装したものが用意されていました。

ただ、そのポインタ class は直接メモリ参照を行えるようには作られていないので、__far* 宣言の代わりにはならず。

far ポインタ操作のいる処理は、c で書いて、関数経由で c++ で使うのがベターでしょうか。

6. __fastcall、__stdcall

関数引数をレジスタ渡しにする __fastcall 宣言は、警告になり、機能しませんでした。残念。

#include <stdio.h>

int __fastcall mul(int a, int b) {
    return a * b;
}

int main(void) {
    return mul(10, 11);
}

__fastcall の代わりに __stdcall を指定すると、こちらは機能しました。
例をオプティマイズ無でアセンブラソースをみると、引数の積み順は通常(cdecl)と同様で、ret 時に sp 減らす処理(ret $4)に変わっていました。

7. ライブラリ

includeディレクトリに newlib.h _newlib_version.h があるので、c 標準ライブラリは newlib のよう。
c++ 標準ライブラリは GNU のモノ。

dos.h や conio.h 等 DOS 系 c コンパイラ標準装備的なモノは入っているので dos プログラミングは手間なく出来そうな雰囲気です。

8. おわりに

今更 MS-DOS プログラミングを c++ でしたい、て時に、c++14 が使えるのは福音です。
言語機能として auto・ラムダあたりが普通に使えるのはうれしい。

8年前とはいえ gcc/g++ なので基本オプティマイズは、watcom,dmc 世代より強そうですし。(CPU固有最適化となると?ですが)

ただ、c++ で small モデルまでなのは、やっぱりきついですね。コード 64KB なんて簡単に使い切れる...

高機能な c++ 標準ライブラリが使えないのは、そらそうだよなぁくらいのことで、デメリットに感じないのですが、ちょっと試したプログラムが 64KB オーバーになったりするので、狭いなあ、と。

残念だったのは c++ で far ポインタが使えないこと。
今あえて 16bit DOS プログラムするときに VRAM 等に直接アクセスできないのはアテが外れた感じです。
c 言語側で処理すればいいので致命的ではないのですが。

ということで、コードが溢れる場合は、言語仕様が古くても watcom c++ が無難かなと思いつつ、コードサイズが 64KB に収まるなら、ia16-elf-gcc はかなり魅力的に思うのでした。

Discussion