ia16-elf-gcc で MS-DOS exe を作ってみる
c++ で 16bit DOS プログラムを作りたいなあ、と、こっちを書いたあとに知ったのですが。
16bit MS-DOS の実行ファイルを作れる gcc として、ia16-elf-gcc というのがあるようです。
- https://launchpad.net/~tkchia/+archive/ubuntu/build-ia16
- https://github.com/tkchia/gcc-ia16
- 日本語での紹介は こちら とか こちら とか
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 とラムダを使ってみます。
#include <stdio.h>
int main() {
auto str = "Hello World!\n";
auto fn = [](char const* s) {
printf("%s", s);
};
fn(str);
return 0;
}
#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