Rust で DOS

ここでは Rust での x86 リアルモードプログラミングと DOS アプリ作成を扱います。

DOS でのプログラム実行の仕組みから、いわゆるフリースタンディング環境で、Rust でどうやってリアルモードのバイナリを吐くのかまでを簡単に見ていくことにします。

なぜ Rust か

単に好きな言語だからですが、利点としては一般的に以下の点が挙げられると思います。

  • Rust のツールチェインには GNU 開発ツール並みのパワーがある。
  • C/C++ 並みのパフォーマンスがありバイナリも小さめ。
  • 型安全でメモリ安全。
  • モダンな機能も使える。

GNU 開発ツール並みの力があれば、安全で機能性や生産性が高く見える Rust を使わない手はないと考えました。
なお、そのターゲット環境として、今回 DOS を選びました。

なぜ DOS なのか

もはや DOS というシステムを日常生活で見かけることはないでしょう(僕も生まれてこの方 DOS というものが動いているのを認識したことはないです)が、ここでは低レイヤーへの足掛かりとして DOS を使います。

DOS とは、Disk Operating System の略で、x86 リアルモードで稼働する OS のことです(参考: MS-DOS - Wikipedia )。
なお、リアルモードでは、保護機構が働きません。そのため、プログラムのバグや IO のハングでシステムは簡単にフリーズしてしまいます。現在の OS ではプロテクトモードを活用することで、システムの暴走を防いでいるのでした。

OS を利用する側としては「ありがとう、プロテクトモード!」というところですが、これから Low-Level プログラミングを学ぼうとする僕のような人間にとっては最初はこれがとても厄介なものに映ります。
そもそも、Linux や他の OS では、不用意なハードウェア操作によるシステム障害を防ぐためにプロテクトモードを導入しています。しかし、僕らは自分の手で中身をいじって見ないことには、納得できません。
せっかく目の前に面白そうなオモチャがあるのに、プロテクトモード守られたままでは手軽に遊ぼうにも、無知な僕らでは遊び方ががわからないのです。

PC の心臓部である周辺 LSI を意のままに操り、これに慣れ親しむためには、高い塀や金庫に守られていない「自由な環境」が必要不可欠です。それは、自作 OS や自作ブートローダーなどでもいいのでしょうが、低レイヤーへの足掛かりとしては、それらを考えるよりも DOS を扱った方が入門として簡単そうに思えました。これが今回 DOS を選んだ一つの理由です。

また、自作 OS や Linux のドライバの再実装しているときには、実際に取り組む前に、DOS で適当なアプリケーションを書いて周辺機器の動作を軽くテストして雰囲気を掴むこともできると思います。僕以外にも、このような使い方をしている方がいたりするのではないでしょうか。

ここでは、自由に配布できる DOS パッケージとして開発された FreeDOS を使用します。

DOS 環境の用意

まず、起動可能な FreeDOS イメージを作ることから始めましょう。
FreeDOS |The FreeDOS Projectの CD イメージを使用してfreedos.imgファイルに FreeDOS をインストールします。操作環境は macOS を想定しています。


$ brew install qemu
$ qemu-img create -f raw freedos.img 100M
$ wget http://www.freedos.org/download/download/FD12CD.iso
$ qemu-system-i386 freedos.img -cdrom FD12CD.iso -boot d 
#基本的にガイドに従うだけです。再起動するか尋ねられるので、一度再起動し、インストール作業を進めてください。
#インストールが完了したら、終了し。以降は下記のコマンドで起動できます。
$ qemu-system-i386 freedos.img -boot c 
#これで qemu で DOS を起動できます。

手打ちで作るアセンブリプログラム

まず手始めに DOS を起動して、DOS 環境でのプログラミングを体験しましょう。
FreeDOS を起動すると。C:\>という FreeCom のコマンドプロンプトが表示されます。

embed-1-dos.png

DOS では debug コマンドでプログラミングできます。

debug でアセンブラ

debugコマンドを実行するとプロンプトが-に変化します。
ここで使えるコマンドは?を入力することで表示できますが、ここでは下記の表に示すコマンドを使用します。なお。DOS のプロンプトでは大文字小文字の区別はないので、以降それらの差異は気にしないでください。

コマンド 記号の意味 概要
A Assemble アセンブリコードを入力する
U Unassemble 逆アセンブルを行う
R Register レジスタ内容を表示もしくは設定する
N Name ファイル名を指定する
W Write メモリ内容をファイルに出力する
? help ヘルプ画面の表示
Q quit 終了

最初にrコマンドで、リアルモード x86 のレジスタ群を確認して見ましょう。
下記の図のようになります。

embed-1-debug-r.png

2行にわたってAX,BXをはじめとする各レジスタの値が 16 進数で表示されています。

注:debug コマンドでは基本的に 16 進数を扱います。

COM 形式と EXE 形式

いよいよ DOS で本物のアセンブリを入力していきますが、その前に DOS プログラムの約束事を押さえておきましょう。
DOS プログラムの実行形式には COM 形式と EXE 形式があります。
ここでは、より単純な COM 形式を扱います。

COM 形式のプログラムは、アドレス 0x100 から始まる決まりになっています。
先頭の 256 バイトが空いていることに違和感を覚えるかもしれませんが、これは ELF 実行形式の ELF ヘッダーのようのものと思うと理解しやすいでしょう。
先頭のアドレス空間は PSP(Program Segment Prefix)と呼ばれ、プログラム実行のために必要な情報が格納されています。ちなみに、プログラムの引数もこの PSP のアドレス空間に格納されます。ご自身でのカスタマイズに備て覚えておいてもいいでしょう。

アセンブルしてみる

以上より、アセンブルは 0x100番地から開始しないといけません。

a 100と、aコマンドの後ろに開始アドレスを16進数で入力します。
入力が終わると083F:0100と表示されます。これは左4桁がセグメントアドレス、右4桁がオフセットアドレスを指しています。
ここでは詳しく扱うつもりはないので、今は右のオフセットアドレスだけに注目してください。

下記に示すアセンブリリストを1行ずつ入力してみてください。


mov ah, 2
mov dl,41
int 21
int 20

1行ずつ入力するごとにオフセットアドレスが2ずつ増えていきます。
この作業では、0x100 番地にmov ah,2のコードが、0x102 番地にmov dl,41のコードが展開されていきます。驚くほどに簡単ですが、これが debug コマンドでのアセンブルです。
int 20の入力後に Enter キーのみ入力すると、アセンブリモードを抜け、コマンドプロンプトに戻ります。

embed-1-input.png

なお、オフセットアドレスから今回のアセンブリプログラムは合計 8 バイトであることがわかります。
小さいですね。プログラムの内容も簡単に説明しておきましょう。

  1. 1~2 行目では、mov(MOVe)命令を使用し、AHレジスタに2DLレジスタに41を設定しています。
  2. デバッガの x86 アセンブリはインテル形式を使用しているので、ソースが右、デストが左になります。
  3. AHDLレジスタは、それぞれ、最初にrコマンドで表示したAXDXレジスタの上位半分と下位半分(High と Low の 8bit)です。
  4. 3~4 行目では、ソフトウェア割り込みint(INTerruption) 0x210x20を連続して実行しています。
  5. ソフトウェア割り込み 0x20は UNIX の exit システムコールに相当し、プログラムを終了し、メモリ資源を DOS に開放します。
    ソフトウェア割り込み 0x21は Linux のシステムコールに相当します。
  6. 割り込み0x21を使用して、キーボード入力やディスク入出力、画面出力などを処理するのですが、実際に何をするかは、機能番号をAHレジスタに設定することで指定します。
  7. パラメータが必要な場合には、他の汎用レジスタを通じて DOS に通達されます。
  8. ここで、AHに指定した機能番号2は"一文字出力"を行うためのサービスで、ASCII コードをDLレジスタに設定するとその文字が画面に表示されます。
  9. ちなみに、ASCII コード0x41Aです。
    そのため、このコードを実行するとなので画面上に A の文字が出力されるはずです。

確認してみましょう。

プログラムの確認

アセンブルが終わったら、今度はdコマンドで展開されたメモリイメージを確認してみます。
開始および終了アドレスをd 100,10fのように指定すると、0x100番地からの 16 バイトを表示できます。

embed-1-debug.png

見慣れない人には呪文のように見えるかもしれませんが、偶数バイトに注目して見てみてください。
02412120 となっていることに気が付きませんか?これは先ほど手打ちしたアセンブリの各命令のオペランド値そのものになっています。
2021の前にあるバイト値がCDであることから、x86 のソフトウェア割り込みintの機械語コードがCDであることがわかります。

逆アセンブル

ついでですから、逆アセンブルも体験しておきましょう。
u 100,108とアドレス範囲を指定します。すると 2 バイトごとに先ほど入力したアセンブリソースが現れます。

embed-1-unas.png

この内容は、単に入力したテキストを表示しているわけではなく、デバッガが命令コードとオペランドバイトを解析した上で出力しています。
その証拠に、108 番地以降の領域をプログラムと勘違いして意味のない命令に変換してしまっているのが分かります。

この逆アセンブル結果が示すバイナリデータとアセンブリソースの対応は重要です。
プログラムもその本体はただのバイナリデータに過ぎないということです。コンピュータ上ではバイトこそ全てであり、データ、実行コード、スタック、空き領域はプログラムがバイト群に与えた一つの解釈に過ぎないことを肝に銘じましょう。

プログラムの保存

最後に手作業で作成したプログラムをファイルとして保存します。
nコマンドでhello.comと名付け、そのサイズを CXレジスタに設定します(r cx 8)。r コマンドは後ろにレジスタ名を指定することでその内容を変更できます。
w 100でメモリ内容を 100 番地からBX:CXバイト分 hello.comに書き込めます。
最後にqコマンドでデバッガを終了しましょう。

embed-1-save.png

DOS プロンプト上からdirコマンドで新たに作成されたhello.comを確認した上で、実行します。こんな単純な 8 バイトが本当に動くのかと不安でしたが、無事に A の文字が出力されるのを確認できました。

embed-1-exec.png

この HELLO.COMには一切の無駄がありませんね。
これは私たちが全てを把握した機械語コードです。こうしてみると、個人的には、プログラムの本質を直感的に理解する上では、モダンな OS よりも DOS の方が入門としては遥かに優れた環境ではないかと思えるわけです。

Rust で DOS アプリを作る

とはいうものの、全てをアセンブリ言語で開発することには無理があります。
アセンブリソースは可読性が低く、コードの再利用も難しいためあまり良くありません。
そこで Rust の出番となります。ここでは、debug コマンドで作ったような一文字を出力するプログラムを Rust で作成することを目指します。

一部 Rust では、ブートローダーなど x86 リアルモードで動作するコードはコンパイルできないという情報が流れていますが、これは誤りです。
Rust でも、target specification をちゃんと設定し、いくつかのツールを用いれば、x86 リアルモードに対応したコードを出力できます。

ただし、DOS アプリをビルドするためにはそれなりの下準備が必要となります。
リンカースクリプトやスタートアップコード、ターゲットシステム向けの環境構築など、全ての御膳立てを自分で用意する必要があるのです。

まず、Rust をインストールしていない方は、インストールしましょう。


#インストールが完了していなければ、下記コマンドを実行し、ガイドに従って rust をインストールしてください。
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

詳細は、Install Rust - Rust Programming Language を参照してください。
以下のコマンドでプロジェクトを始めます。


$ cargo new rust_dos --bin

なお、DOS アプリを構築するためには、Nightly チャンネルでのみ利用できる実験的な機能が必要となりますので、先に進む前に Rust の Nightly バージョンのツールチェインをインストールする必要があります。

Nightly Rust のインストール

Rust でつくる DOS アプリでは、Nightly で提供される asm マクロInline Assemblyなどを使用するので、nightly が必要なのです。
Nightly ツールチェインは以下の方法で有効にできます。

  1. 現在のディレクトリで有効にするには以下のコマンドを実行します。
  2. $ rustup override add nightly
  3. パッケージのトップディレクトリに rust-toolchain というファイルを作成してツールチェイン名nightlyを書き込むと、指定したツールチェインを使うようにもできます。
  4. $ echo nightly > rust-toolchain

今回は2番で対応します。


$ cd rust_dos
$ echo nightly > rust-toolchain

ちなみに、Rust のツールチェインは stable, beta, nightly があります。詳細が気になる方は、 G - How Rust is Made and “Nightly Rust” - The Rust Programming Languageを確認してください。

Target Specification

Rust のビルドマネージャとして使用されるcargoでは、デフォルトでホストシステム用にビルドを行います。そのため、macOS で実行すれば、デフォルトのままでは macOS 用にビルドされてしまいます。

これを防ぐため、cargoでは、Rust コンパイラでサポートされている範囲(Platform Support - Rust Forge)なら、--targetパラメータ[1]を介して様々なターゲットシステムを指定することで、クロスコンパイルの機能を利用できます。

[1] ターゲットトリプル
パラメータで指定するターゲットは、CPU アーキテクチャ、ベンダー、OS、ABI などを記述するターゲットトリプルによって記述されます。
例:x86_64-unknown-linux-gnu
詳細は Cross-compilation using Clang — Clang 10 documentation を参照してください。

ただし、残念ながら、Rust には、x86 リアルモードに対してコンパイラ組み込みのターゲットが用意されていません。
このような場合にはどうすればいいのでしょうか?

幸いなことに、Rust では様々なシステムに対応できるように、JSON ファイルで独自のターゲットを定義することもできます。つまり、x86 リアルモードをターゲットとするように、明確にターゲットシステムを定義してあげれば、Rust の力を DOS に活かすことができるのです。

それならば!と、rustc_target::spec::TargetOptions - Rustを見て、逐一 target specification を手作りしていくのもいいのですが、正直、難しいので、ターゲットとするシステムに近いコンパイラ組込みターゲットを参考にカスタマイズしていきましょう。 今回はi586-unknown-linux-gnuを参考にし、これを 16bit コードを出力するように変更します。

i586-unknown-linux-gnu

i586-unknown-linux-gnuについては、$ rustc +nightly -Z unstable-options --print target-spec-json --target i586-unknown-linux-gnu を実行することで確認することができます。 以下のようになっていました。


{
  "arch": "x86",
  "cpu": "pentium",
  "data-layout": "e-m:e-p:32:32-f64:32:64-f80:32-n8:16:32-S128",
  "dynamic-linking": true,
  "env": "gnu",
  "executables": true,
  "has-elf-tls": true,
  "has-rpath": true,
  "is-builtin": true,
  "linker-flavor": "gcc",
  "linker-is-gnu": true,
  "llvm-target": "i586-unknown-linux-gnu",
  "max-atomic-width": 64,
  "os": "linux",
  "position-independent-executables": true,
  "pre-link-args": {
    "gcc": [
      "-Wl,--as-needed",
      "-Wl,-z,noexecstack",
      "-m32"
    ]
  },
  "relro-level": "full",
  "stack-probes": true,
  "target-c-int-width": "32",
  "target-endian": "little",
  "target-family": "unix",
  "target-pointer-width": "32",
  "vendor": "unknown"
}

上記と rustc_target::spec::TargetOptions - Rustrustc_target::spec::Target - Rust の他、LLVM のドキュメントを参考に x86 リアルモード用のものを書きましょう。

i586-rust_dos.json

先に完成形を示します。
x86 リアルモード用の target specification は以下のようになります。
ほとんどのフィールドは、LLVM がそのプラットフォーム用のコードを生成するためにわずかな変更とともにそのまま必要です。


{
    "arch": "x86",
    "cpu": "pentium",
    "data-layout": "e-m:e-p:32:32-f64:32:64-f80:32-n8:16:32-S128",
    "dynamic-linking": false,
    "executables": true,
    "has-elf-tls": false,
    "has-rpath": true,
    "is-builtin": false,
    "linker-flavor": "ld.lld",
    "linker": "rust-lld",
    "llvm-target": "i586-unknown-none-code16",
    "max-atomic-width": 64,
    "os": "none",
    "relocation-model": "static",
    "position-independent-executables": false,
    "relro-level": "off",
    "stack-probes": true,
    "target-c-int-width": "32",
    "target-endian": "little",
    "target-pointer-width": "32",
    "vendor": "unknown",
    "panic-strategy": "abort",
    "disable-redzone": true,
    "features": "-mmx,-sse,+soft-float"
}

以下に各フィールドと変更した意味について簡単に説明します。

  • archcpuは変更する理由がないので、そのまま使用しています。
  • data-layoutフィールドは、様々な型のサイズを定義するものです。
  • 変更の必要がないのでそのまま借用します。-区切りです。
    ⚙ D19392 Emit code16 in assembly in 16-bit modeも参考にしました。
  • 動的リンクではないので、dynamic-linkingfalseに設定します。
  • ライブラリじゃないので、"executables": trueのままです。
  • スレッドローカルストレージなどは使用しないので、has-elf-tlsfalseにします。
  • has-rpathはリンカーがrpathをサポートしているかどうかですが、
  • 今回は Rust に同梱されているリンカーを使用するので、よくわからないのでそのままにしました。
  • コンパイラ組み込みのターゲットではないので、"is-builtin": false,です。
  • "max-atomic-width"は、アトミック操作を実行できるビット単位の最大整数サイズです。
  • が、これもよくわからないので、そのままにしておきました。
  • 静的リンクなので、relocation-modelstaticを明示的に指定します。
  • 同じような理由で、"position-independent-executables"falseにしています。
  • 同じく動的リンクではないので、"relro-level"offでいいです。
  • stack-probescompiler_builtins::probestack - Rustのことです。
  • 今回は関係なさそですが、trueでも false でも影響がなさそうなので trueのままにしました。
  • no-default-librariestrueにして明示してもいいのでしょうが、
  • カスタムターゲットではデフォルトでtrueになっているので。ここでは追加しません。

他に特にビルドに重要そうなフィールドとして、リンカーや LLVM ターゲットなどに関するいくつかのフィールドがあります。

まず、今回はホストシステムのデフォルトのリンカーではなく、Rust に同梱されているクロスプラットフォームに対応した LLD を使用するようにして、ビルド自体のポータビリティを少しでも確保します。


    "linker": "rust-lld",
    "linker-flavor": "ld.lld",

参考元では、pre-link-argsにリンカーのオプションをいくつか渡していますが、gccのものが指定されていますし、ここではそれらは使用しないので、まるっと削除しました。

なお、フリースタンディングな環境に対して 16bit コードを出力するため、llvm-targetosのフィールドを以下のように変更したことにも注意してください。


    "llvm-target": "i586-unknown-none-code16",
...
    "os": "none",

  1. 指定すべき os がないのでosフィールドとllvm-targetの OS を noneと変更しています。
  2. target triple で基盤となる OS がないことを指定することで、ホスト OS に固有のランタイムがリンクされることなどを防ぐことができます。
  3. llvm-targetの ABI にcode16を指定しています。
  4. LLVM ターゲットを、GNU などの ABI の名前ではなくcode16で終わるように変更することで、Rust が完全に 16bit リアルモードに対応できるようになります(正確には、32bit プレフィクス付きの 32bit 対応プロセッサで動作する 16bit コードです)。

また、上記の設定により GNU 開発ツールや他のプラットフォーム関連の設定が消えたことにも注目してください。以下を参考にしました。

最後に残りの追加されたフィールドについても見ていきましょう。


    "panic-strategy": "abort",

この設定は、ターゲットについて、パニックでのスタックの巻き戻しをサポートしないことを指定しています。代わりにプログラムは直接abortする必要があります。
下記参照してください。

続いて、


    "disable-redzone": true

Red Zone は System V ABI での最適化オプションのようです。が、ここでは関係ないので無効にしています。
割り込みによってスタックが破壊するのを防ぐようです。以下も参照してください。

最後に、


    "features": "-mmx,-sse,+soft-float"

上記は SIMD 命令を無効化するものです。こちらも詳細は下記をご参照ください。

以上です。

設定を使用する

上記で見てきた target specification を i586-rust_dos.json ファイルに書き込んで保存します。
これをビルド時の --targetパラメータに指定するか、トップディレクトリの.cargo/configファイルに下記のように設定することで、ホスト OS ではなくカスタムターゲット用にビルドできるようになります。
また、これによって、上述したようにホスト OS の CRT(C RunTime startup routines)がリンクされることなどを防ぐことができます。


#in .cargo/config

[build]
target = "i586-rust_dos.json"

これで下準備の半分は終わりました。
実際に DOS 版アプリの作成を試みていきましょう。

標準ライブラリを無効にする

今はcargo new rust_dos --bin を実行していくつかのファイルを追加しただけなので、下記のディレクトリ構成になっていることでしょう。


$ pwd
/Users/ell/code/project/rust/rust_dos
$ tree .
.
├── Cargo.toml
├── i586-rust_dos.json
├── rust-toolchain
└── src
    └── main.rs

1 directory, 4 files

上記はbinクレート[2]で、main.rsが最上位の匿名モジュールとして扱われます。

[2] クレートとは、一つの Rust プログラムの単位のことで、いくつかのモジュールで構成されます。

なお、デフォルトでは、すべての Rust クレートは、ホストオペレーティングシステムに依存する標準ライブラリ(stdクレート)などをリンクしますが、ここでは、ホスト OS に依存するライブラリを使用することはできません。そのため、クレートトップのmain.rs#![no_std]を指定して、標準ライブラリがリンクされるのを防ぎましょう。
DOS アプリの開発はベアメタルでの開発と同じです。

ちなみに、#![no_std]を指定すると、stdクレートではなく、core - Rust クレートがリンクされるようになります。
coreクレートは、環境 (アーキテクチャや OS) に依存しないコードを含むstdクレートのサブセットです。そのため、println!など環境依存のものは使用できません。
逆に、環境非依存の標準ライブラリで提供されている多くの機能は core クレートを用いることで利用できるので安心してください。

上記踏まえ、最初の src/main.rs を下記のように変更しましょう。
標準ライブラリを利用できないので、println!(...は削除して、no_std属性を追加しています。


#![no_std]

fn main() {

loop {}

}

なお、もともとは、cargo newで以下のようなコードが生成されていました。ご参考までに。


fn main() {
    println!("Hello, world!");
}

Panic の実装

Rust ではプログラミングエラーに対して、定義されたパニックハンドラで対処します。これは、プログラムの異常終了処理を安全に行うための機構ですが、残念ながらno_std環境では提供されていません。未定義のままです。
このため、no_std環境ではpanic_handlerを自分で定義する必要があります。

ここではとりあえず簡単なもので済ませます。
src/main.rsに以下のコードを追加します。


#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

core::panic::PanicInfo - Rustには、パニックが発生した場所とパニックメッセージを提供します。
が、今のところ、この関数でできることはあまりないので、無限ループするだけです。 なお、この関数は決して return すべきではないので、 never 型を返すことで発散する関数としてます。

上記も参考にしてみてください。
これでmain.rsは下記のようになっています。


#![no_std]

use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

fn main() {
    loop {}
}

これで準備万端?と言いたいところですが、まだまだダメです。
今のままでは、一体誰が Rust のmain関数を呼ぶのかわかりません。

Rust RunTime startup routines 的なものはあるの?

多くの人がプログラムを実行したときに最初に呼び出される関数をmain関数であると誤解していますが、実際にはホスト OS でランタイムのスタートアップコードが最初に実行され、それがmain関数を呼び出します。
なお、RunTime startup routines はmain関数を呼び出す重要な役目を負っていますが、そのほかにも、プログラム実行前の初期化処理など、多くの仕事を裏方で担当しています。

今回はベアメタル環境と同じなので、ランタイムのスタートアップコードはありません。
そのため、このコードをこのままビルドしようとするとerror: requires start lang_itemのエラーでビルドに失敗します。
カスタムターゲットへのビルドでは、ランタイムライブラリなどがビルド処理から追い出され、start エントリを持った実行コードが行方不明になってしまうのです。結果、リンカーがエラーメッセージを表示するというわけ。

いくつか解決策が考えられると思います。
ぼくが思いついたのは以下の二つです。

  1. main関数を呼び出すランタイムスタートアップルーチンに相当するものを自前で用意
  2. もしくはそれらを用意せず、同じmain.rsのクレートトップ内に自前でエントリポイントを定義

実際にはどちらもやることに全く差はないのですが、ここではより単純なものを考えたいので後者の方法をとることにしました[3]。

[3]1 番目の方法では、main_startを実装する場所を分けるというだけで、2番目の方法ではただ単にこれを分離しないという意味以上のものはありません。
本当はこれらをクレートに分離し、_startを含む方を常に「実行ファイルの先頭」に位置させ、この中からmain関数を呼び出すようにするのがいいと思います。

まず、bin クレートトップに#![no_main]属性を記載することで、Rust コンパイラーにこのプログラムは通常のエントリポイント呼び出しの流れに乗らないクレートであることを知らせます。
次に、すでにmain関数を呼び出すものは存在しないことがわかっているので、main関数の名前を変更することにします。改名先は、慣例にならって、ランタイムの最初のシンボル名としてよく用いられる_startというシンボル名にしました。


#![no_std]
#![no_main]

use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[link_section=".startup"]
#[no_mangle]
fn _start() -> ! {
    loop {}
}

_startシンボルは、リンカースクリプトで ENTRY ポイントとして参照するので#[no_mangle]属性でマングリングを防止しています。

ところで、COM プログラムは0x100番地からスタートしますから、_startシンボルは実行コードの先頭0x100から配置しなければなりません。実行コードでの配置先はリンカのセクションで指定するので、この下準備のために、#[link_section = ".startup"]を指定することで、まず対象のシンボルを、.startupというセクションに配置するようにもしています。
これで、実行コードでの展開先をリンカースクリプトで制御できるようになるはずです。

DOS アプリ作成の試み

といっても、先ほどdebugコマンドで作成したアセンブリソースを _start内部にインラインアセンブリとして記述していくだけです。
asm! マクロを使用しましょう!
使用方法はasm - The Rust Unstable Bookを参照してください。


mov ah, 2
mov dl,41
int 21
int 20

上記を下記のように_startの中に落とし込んでみました。
デフォルトでは AT&T 記法となるため、オペランドの向きが先ほどと反対になるので注意してください 。 定数には$$、レジスタには%を前置します。僕は AT&T 記法の方が好きです。


#![feature(asm)]

#[link_section=".startup"]
#[no_mangle]
fn _start() -> ! {
    unsafe {
        asm!("mov $$0x2, %ah
              mov $$0x41,%dl
              int $$0x21
              int $$0x20"
              ::: "eax", "edx");
    }
    loop {}
}

最後のloop式は、戻り値として () を返すのではなく、本当に値を返さない関数とするために置いたに過ぎません。本当はこの余分なloopを配置したくはなかったのですが、never 型をどのように返せばいいのか私にはわからず、現在は上記のようなコードとしています。

プログラム自体はloopが処理される前にint $0x20で抜けるので害はないのですが、ビルドしたバイナリには不格好なjmp命令が紛れ込むことになります。
debugコマンドで作成したバイナリと同一バイナリ作成を試みていたので少し悲しいです。

リンカースクリプト

さていよいよ最後の仕上げの前段階としてリンカースクリプトの作成に取り組んでいきましょう。
先ほども述べたように、プログラムの配置場所はリンカースクリプトで定義するのでした。

プログラムは 0x0100 番地以降に配置されますから、プログラムの先頭アドレスを 0x0100 番地に設定する必要があります。Rust でもこのような場合には、リンカースクリプトと呼ばれるプログラムでリンカーローダーを制御するのです。

なお、今回用いるリンカースクリプトでは、すこしお堅めに Memory Configuration の機能を利用することにしましょう。以下のようになります。


ENTRY(_start)

MEMORY {
  dos : org = 0x100, len = (0xFFFF - 0x100)
}

SECTIONS {
  .text   : { *(.startup) *(.text .text.*) }   > dos
  .rodata : { *(.rodata .rodata.*) } > dos
  .data   : { *(.data) }   > dos
  .bss    : { *(.bss) }    > dos
  .stack  : { *(.stack) }  > dos
}

まず、最初にENTRY()でエントリーポイントを_startで定義します。先ほど、Rust 側でマングリングを防止していたので、ここで_startのシンボル名を使用することができます[4]。

[4]エントリポイントをリンカスクリプトで定義するのは、リンクされていないシンボルが消されないようにするためです。ここではそれ以上の意味はありません。

アドレスの細かい配置先は SECTIONS で設定しますが、全体をどのように区画するかはまず「Memory configuration」で指定します[5]。
今回は1セグメントしか使用しないので、アドレス 100 番地(org=0x100)から始まる 65280 バイト(len=0xFFF - 0x100)のdosという名前のメモリ空間を定義しました。

[5] ちなみに、メモリ空間はオーバーラップしなければ複数指定できます。

残りの部分では、具体的にセクションをまとめています。
まず、SECTIONS文で.textセクションを定義していますね。*(.text)は 「 すべての入力ファイル中の.textセクション」を 1 か所に統合することを意味しています。まとめられたデータは、左端に記述された.textセクションとして出力されます。他も同じなので読み替えてください。

なお、*(.text)の前に、*(.startup)があることに注意してください。
先ほど、#[link_section=".startup"]属性を指定して、_startシンボルを.startupセクションに置いてましたね。これで、_startシンボルを実行コードの先頭に配置できます。

また、すべてのセクションは dos メモリ空間に展開するよう指示しています(> dos)。メモリ空間を dos に特定することで、各入力セクションのデータは順番にアドレス0x100番地から格納されていきます。

上記はファイル名link.xで保存してください。
そして、このリンカースクリプトを使用することをビルドマネージャであるcargoに知らせましょう。
build設定にコンパイラオプションを渡すようにするだけです。


[build]
target = "i586-rust_dos.json"
rustflags = ["-C", "link-arg=-Tlink.x"]

さて、これで9割型作業は完了しました。仕上げましょう。

cargo-xbuild

ビルドのためには、cargo-xbuildというツールが必要になります。

今回の DOS アプリにはcoreクレートが暗黙的にリンクされるのでした。
しかし、コンパイラ組み込みのターゲットならいざ知らず、残念ながら、カスタムターゲットに対してはプリビルドされた core ライブラリは提供されていません。このような場合には、最初にcoreライブラリなどをカスタムターゲット用に手動でリビルドして色々準備する必要があります。

下準備がまだ続くのかと暗くなりそうなところですが、安心してください。
ここで、cargo-xbuildというツールが登場します。

cargo-xbuildcoreライブラリなど、カスタムターゲット用のライブラリのリビルド作業を自動化してくれるツールです。
このツールのおかげでクロスビルドが格段に楽になります。
もちろんこれを使用しない手はありません。

以下のコマンドでインストールしましょう。
また、このコマンドは Rust のソースコードに依存しているので、それらもついでにインストールします。


$ cargo install cargo-xbuild
$ rustup component add rust-src

さて、いよいよビルドといきたいところですが、最後にもう少しだけ設定が残っています。

サイズ最適化: 今回の場合は全く問題ないのですが、リンカースクリプトでは Memory Configuration の機能でバイナリサイズをギチギチに制限しています。生成するバイナリが指定範囲をオーバーする可能性を少しでも減らすためにサイズ最適化を施しましょう。
rust コンパイラにopt-level = zのパラメータを渡すことで実現できます。

cargo.tomlに以下の設定を追加してください。


[profile.release]
opt-level = "z"

最適化: 速度とサイズのトレードオフ - The Embedded Rust Book も参考にしてください。
以上で準備は完了です。

ビルド

ディレクトリ構成は以下のようになっています。


$ tree -a
.
├── .cargo
│   └── config
├── .gitignore
├── Cargo.toml
├── link.x
├── i586-rust_dos.json
├── rust-toolchain
└── src
    └── main.rs

トップディレクトリで下記のコマンドを実行することでビルドできます。
試して見ましょう。


$ cargo xbuild --release

以下のようになりましたか?


$ cargo xbuild --release
    Updating crates.io index
   Compiling core v0.0.0 (/Users/ell/.rustup/toolchains/nightly-x86_64-apple-darwin/lib/rustlib/src/rust/src/libcore)
   Compiling compiler_builtins v0.1.23
   Compiling rustc-std-workspace-core v1.99.0 (/Users/ell/.rustup/toolchains/nightly-x86_64-apple-darwin/lib/rustlib/src/rust/src/tools/rustc-std
-workspace-core)
   Compiling alloc v0.0.0 (/var/folders/6t/4dzf30_92qb2fzwzxbph2wvc0000gn/T/xargo.WivshmyX2DVY)
    Finished release [optimized] target(s) in 27.09s
   Compiling rust_dos v0.1.0 (/Users/ell/code/project/rust/rust_dos)
    Finished release [optimized] target(s) in 1.86s

corecompiler_builtinallocなどが合わせてコンパイルされているのが分かりますね。
ちなみに、ここでビルドされたファイルはtarget/i586-rust_dos/release/rust_dosになります。


$ file target/i586-rust_dos/release/rust_dos
target/i586-rust_dos/release/rust_dos: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

なお上記のfileコマンドの出力からわかる通り、このファイルは ELF 形式であり、このままではまだ実行できません(シンボルやセクション関連の不要な情報が付加されているため)。

バイナリ作成

DOS で実行可能なファイルを作成するためには、よぶんな情報すべてを削除した「単純バイナリ形式」に変換しなければなりません。

バイナリの操作が必要になりますが、ここでは LLVM と cargo で利用可能な以下のツールを利用します。

  • llvm-tools-preview
  • cargo-binutils

まず、インストールしましょう。


$ cargo install cargo-binutils
$ rustup component add llvm-tools-preview

上記のツールではobjdumpsizenmなどでバイナリ調査を行うことができます。
残念ながら、i8086 には対応していないので、ここではシンボル名を表示するだけにします。

とりあえず、_startシンボルが 0x100 に配置されているか確認して見ましょう。


$ cargo nm target/i586-rust_dos/release/rust_dos
00000100 T _start

良さげです。_startシンボルが 0x100 にいそうですね。
では、早速cargo objcopy-O binaryオプションを使用して、よぶんな情報すべてを削除した「単純バイナリ形式」に変換しましょう。


$ cargo objcopy -- -I elf32-i386 -O binary --binary-architecture=i386:x86 target/i586-rust_dos/release/rust_dos target/i586-rust_dos/release/rust_dos.com
$ file target/i586-rust_dos/release/rust_dos.com 
target/i586-rust_dos/release/rust_dos.com: COM executable for DOS

再びfileコマンドでチェックすると、なんと今度は「COM executable for DOS」 と表示されました。
余分な、情報をこそぎ落としただけですが、どうやらバイナリの形式から、これが DOS アプリの COM 形式であることを認識してくれたようです。

念のためにできあがったプログラム内容を逆アセンブルで確認します(残念ながら、cargo-binutilsは現在 i8086 には対応していないので、GNU の binutils を使用します)。


$ brew install i386-elf-binutils

皆さんならおそらく使い慣れてるであろうobjdump-Dオプションを使いますが、今回はファイル形式 が失われているため、-b binaryオプションで対象ファイルがバイナリ形式であること、および-m i8086オプションで対象アーキテクチャが i8086 であることを objdumpに伝えます。

また、単純バイナリ形式ではプログラムの開始アドレス情報も失われてしまいますので、 --adjust-vma=0x0100オプションでオフセットアドレスを0x0100に設定します。 結果を見ると、 なんと意図どおりのコードが出力されているのが分かります。コードは大丈夫のようですし、アドレスもロードされるべき 0x0100 からはじまっています。


$ i386-elf-objdump -D -b binary -m i8086 --adjust-vma=0x0100 target/i586-rust_dos/release/rust_dos.com

target/i586-rust_dos/release/rust_dos.com:     file format binary


Disassembly of section .data:

00000100 <.data>:
 100:   b4 02                   mov    $0x2,%ah
 102:   b2 41                   mov    $0x41,%dl
 104:   cd 21                   int    $0x21
 106:   cd 20                   int    $0x20
 108:   eb fe                   jmp    0x108

すごくないですか?jmp が余分ではありますが、見事に Rust で「手打ち」とほぼまったく同じ味がだせたわけです。Cと GNU 開発ツールに負けず劣らずのこのたぐいまれな柔軟性も、Rust の醍醐味と言えるのではないでしょうか?
サイズはわずかに 10byte で、そのバイナリのすべてを私たちは把握することができています。

mac から DOS ディスクへのコピー

わずか 10byte のrust_dos.comファイルが出来上がりましたので、早速 FreeDOS に書き込んで実験して見ましょう。Linux であれば、ループバックマウントで一発なのですが、mac ではhdiutilを使用します。
一番最初に作った freedos.img をマウントします。


$ hdiutil attach freedos.img 
/dev/disk2          	FDisk_partition_scheme         	
/dev/disk2s1        	DOS_FAT_16                     	/Volumes/FREEDOS2016

これで、/Volumes/FREEDOS2016を介して、作成したrudt_dos.comを配置できます。
いったんアタッチしてしまえば、通常のファイルシステムと同様いアクセス可能です。


$ pwd
/Users/ell/code/project/rust/rust_dos
$ cp target/i586-rust_dos/release/rust_dos.com /Volumes/FREEDOS2016/

操作後はデタッチ処理を忘れないようにしてください。
上記完了したら、ディスクイメージをデタッチします。


$ hdiutil detach disk2
"disk2" ejected.

rust_dos.comの転送が終わったら、早速 FreeDOS 上で実行して見てください。無事「A」の文字が出力されれば OK です。


$ pwd
/Users/ell/code/project/freedos
$ qemu-system-i386 freedos.img -boot c

UNIX 上の Rust を用いて DOS アプリケーション開発ができることが見事に証明されました。

embed-1-rust_dos.png

ここまでの内容は、下記の prottype ブランチにあります。

GitHub - o8vm/rust_dos at prototype

おまけとして、もう少し Rust ぽさが出るようにスタートアップのインターフェース周りを整えましょう。

もう少し Rust ぽく

スタートアップのインターフェースを少し Rust ぽくして見ました。
なお、この作業に伴って以下の特徴も実装してます。

  • main インタフェース - The Embedonomiconに従ってmainの呼び出し処理を型安全にしました。
  • Rust でお馴染みのprintln!を実装しました。
  • DOS のexitシステムコールも実装しています。
  • Rust で低レイヤーな操作を体験して欲しかったので、似非キーボードドライバモジュールを実装してます。

とりあえずコードを見て見ましょう。

main と_start の分離

_startlibクレートに分離します。
main.rsには、main 関数と他の主な処理を呼び出す役割を担ってもらいます。
lib.rsにはスタートアップ処理と他の主な処理のロジックやモジュールを実装します。

main.rsは以下です。


// in src/main.rs
#![no_std]
#![no_main]

use rust_dos::*;

entry!(main);

fn main() {
    println!("Hello, World!");
    // println!("Hit any Key, please.");
    // dpkey::keymap();
}

lib.rsは以下です。


// in src/lib.s
#![feature(asm)]
#![no_std]

#[macro_use]
pub mod dos;
pub mod dpkey;

#[link_section=".startup"]
#[no_mangle]
fn _start() -> ! {
    extern "Rust" {
        fn main() -> ();
    }
    unsafe {
        main();
    }
    dos::exit(0);
}

#[macro_export]
macro_rules! entry {
    ($path:path) => {
        #[export_name = "main"]
        pub fn __main() -> () {
            // type check the given path
            let f: fn() -> () = $path;

            f()
        }
    }
}

libクレートには、#![no_main]は要らないので削除してます。
ここでは、_startlib.rsに移動して、main を呼び出すインターフェースを型安全にするためにマクロを定義して外部から参照できるようにしています。

なお、extern "Rust"は、外部にある Rust ABI のmainを呼び出すために、main の定義をインポートしてます。外部関数は安全ではないので、実行する部分はunsafeに包んでおきます。

また、今回はprintln!マクロまでを実装し、キーボードから発せられるシステムスキャンコードを覗くことを目的としていますので、さらにそれらの処理はdosモジュールとして分離しておきます。パニックハンドラーも。

dos モジュールと exit

dos モジュールでは、他のモジュールの指定とプログラムを終了するためのexit関数を実装しています。


// in src/dos.rs
#[macro_use]
pub mod console;
pub mod panic;
pub mod io;
pub mod kbc;

pub fn exit(rt: u8) -> ! {
    unsafe {
        asm!("mov $$0x4C, %ah
              int $$0x21"
              :
              : "{al}"(rt)
              : "eax");
    }
    loop {}
}

exit関数が内部で実行するのがint 0x20ではないことに疑問をお持ちの方もいるかもしれないですが、 DOS のファンクション0x4Cは、ALに戻り値を指定してプログラムを終了できる、より柔軟なファンクションコールです。ここでは一応 DOS で戻り値を扱えるようにするために、あえてint 0x20ではなく、こちらを使用することにしました。

なお、lib.rsでは、mainから問題なく返ってきた場合にはこのexitを使用して、終了コード 0 でプログラムを終了します。

dos::console モジュール

このモジュールにはprintlnを実装しています。 ここら辺の説明は組込み/ベアメタル Rust クックブック(電子版) - sabizen - BOOTHがよく纏まっていますのでそちらを参考にしてください。


// in src/dos/console.rs
use core::fmt::{self, Write};

#[macro_export]
macro_rules! print {
    ($($arg:tt)*) => {
        $crate::dos::console::_print(format_args!($($arg)*))
    };
}

#[macro_export]
macro_rules! println {
    ($fmt:expr) => {
        print!(concat!($fmt, "\r\n"))
    };
    ($fmt:expr, $($arg:tt)*) => {
        print!(concat!($fmt, "\r\n"), $($arg)*)
    };
}

pub fn _print(args: fmt::Arguments) {
    let mut writer = DosWriter {};
    writer.write_fmt(args).unwrap();

}

struct DosWriter;

impl Write for DosWriter {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for c in s.bytes() {
            printc(c);
        }
        Ok(())
    }
}

fn printc(ch: u8) {
    unsafe {
        asm!("mov $$0x2, %ah
              int $$0x21"
              :
              : "{dl}"(ch)
              : "eax", "edx");
    }
}

printfを実装するよりもはるかに簡単ですね。

dos::panic モジュール

上記でprintln!を実装しているので、PanicInfoも出力できるようになっています。
モジュールに分離して、println!で詳細表示するようにしましょう。


// in src/dos/panic.rs
use core::panic::PanicInfo;
use super::exit;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    println!("{}", _info);
    exit(1);
}

ここでもexitも活用しています。

dos::io モジュール

I / O 操作に関する基本的な関数をインラインアセンブリで実装しています。


// in src/dos/io.rs
pub fn inb(port: usize) -> u8 {
    let mut ret: u8;
    unsafe {
        asm!("xorl %eax, %eax
              inb  %dx,  %al"
             : "={al}"(ret)
             : "{dx}"(port)
            );
    }
    ret
}

pub fn inw(port: usize) -> u16 {
    let mut ret: u16;
    unsafe {
        asm!("xorl %eax, %eax
              inw  %dx,  %ax"
             : "={ax}"(ret)
             : "{dx}"(port)
            );
    }
    ret
}

pub fn outb(data: u8, port: usize) {
    unsafe {
        asm!("outb %al, %dx"
             :
             : "{al}"(data), "{dx}"(port)
            );
    }
}

pub fn outw(data: u16, port: usize) {
    unsafe {
        asm!("outw %ax, %dx"
             :
             : "{ax}"(data), "{dx}"(port)
            );
    }
}

C でもよくある形なので見慣れているかと思います。

dos::kbc モジュール

KBC を扱うための定数や基本的な関数を定義しています。
(これはあまり良くない出来です)


// in src/dos/kbc.rs
use super::io::{inb, outb};

// 8042 keyboard controller definition
pub const KBC_CTRL: usize = 0x64;
pub const KBC_DATA: usize = 0x60;
pub const IO_WAIT:  usize = 0x80;

// status
pub const KBC_OBF:  u8 = 0x01;
pub const KBC_IBF:  u8 = 0x02;
pub const KBC_BUSY: u8 = KBC_OBF | KBC_IBF;

// 8042 command byte
pub const KBC_GET_CTRL:  u8 = 0x20;
pub const KBC_SET_CTRL:  u8 = 0x60;

// 8042 control mode
pub const KBC_DO_XLAT:   u8 = 0x40;
pub const KBC_DIS_MOUSE: u8 = 0x20;
pub const KBC_DIS_KEY:   u8 = 0x10;
pub const KBC_SYS_FLAG:  u8 = 0x04;
pub const KBC_INT_MOUSE: u8 = 0x02;
pub const KBC_INT_KEY:   u8 = 0x01;

// Device command byte
pub const KBC_WRITE_LED: u8 = 0xED;
pub const KBC_SET_REPEAT:u8 = 0xF3;
pub const KBC_ACK:       u8 = 0xFA;

// 8259 programmable interrupt controller
pub const PIC_MIMR: usize = 0x21;
pub const PIC_IMR_KEY: u8 = 0x2;


// read the status register of 8042 KBC
//  return data
pub fn kbc_status() -> u8 {
    inb(KBC_CTRL)
}

pub fn kbc_command(cmd: u8) {
    loop {
        if kbc_status() & KBC_IBF == 0 {
            break;
        }
        inb(IO_WAIT);
    }
    outb(cmd, KBC_CTRL);
}

pub fn kbc_write(data: u8) {
    inb(KBC_DATA);
    loop {
        if kbc_status() & KBC_BUSY == 0 {
            break;
        }
        inb(IO_WAIT);
    }
    inb(IO_WAIT);
    outb(data, KBC_DATA);
}

pub fn kbc_read() -> u8 {
    loop {
        if kbc_status() & KBC_OBF != 0 {
            break;
        }
        inb(IO_WAIT);
    }
    inb(IO_WAIT);
    inb(KBC_DATA)
}

pub fn disable_keyint() {
    let mut imr: u8;
    imr = inb(PIC_MIMR);
    imr |= PIC_IMR_KEY;
    outb(imr, PIC_MIMR);
}

pub fn enable_keyint() {
    let mut imr: u8;
    imr = inb(PIC_MIMR);
    imr &= !PIC_IMR_KEY;
    outb(imr, PIC_MIMR);
}

構造体を定義してそれにメソッドなどを実装するのがよかったなと思います。 元々 Rust の経験が乏しく、今回は深夜に脳死でコードを書いていたので、Cのクセが抜けずにそのようにできませんでした。
結果、次の keymap で遊ぶプログラムに Rust らしさが皆無に... いつか書き直したいです。

キーボードドライバもどき

このモジュールは、Rust でオレオレ「キーボードマップ」を自分で書き下ろすことで、キーボード処理のエッセンス(キーボードドライバの雰囲気)を掴んでもらうために書いています。

元々キーボードのキー入力では、ASCII コードが直接配信されているわけではありません。
スキャンコードと呼ばれるインデックス番号が配信されています。しかもこのスキャンコードは以下の2種類が存在しています。

  1. キーボードスキャンコード: これはキーボードが PC 本体に向けて送信するコードです。
  2. システムスキャンコード: これは PC 内部のキーボードコントローラが、CPU に転送するコードです。

加えて、スキャンコードは見かけ上のキー配列とは無関係なインデックス番号が割り振られていたりします。
以下の資料を参照して見てください。

PS/2 Keyboard - OSDev Wiki

上記表を見ればお分かりいただけるかと思いますが、元々キーボード自体は独立したスイッチにすぎまないのです。アルファベットの大文字Aや小文字のc, 記号!を認識するなど、さまざまなキーの組み合わせに意味を持たせているのは、実はキーボード入力を処理する割り込み処理ルーチンの役目です。そして、スキャンコードと ASCII コードを対応付けるのがキーボードマップと呼ばれているものです。

ここでは、ステムスキャンコードを ASCII コードへ変換するプログラムを Rust で実装してみました。
以下のようになります。


// in src/dpkey.rs

use crate::dos::kbc::*;

const MOD_ALT:  u8 = 8;
const MOD_CTRL: u8 = 4;
const MOD_SHIFT:u8 = 2;
const MOD_CAPS: u8 = 1;

const ESC:    u8 = 0x01;
const CTRL:   u8 = 0x1D;
const KEY_A:  u8 = 0x1E;
const KEY_SQ: u8 = 0x28;
const SHIFT:  u8 = 0x2A;
const ALT:    u8 = 0x38;
const CAPS:   u8 = 0x3A;

static MAP_PLAIN: [u8; 11] = [b'a', b's', b'd', b'f', b'g', b'h', b'j', b'k', b'l', b';', b':'];
static MAP_CTRL:  [u8; 11] = [0x01, 0x13, 0x04, 0x06, 0x07, 0x08, 0x0A, 0x0B, 0x0C, 0x7F, 0x7F];
static MAP_SHIFT: [u8; 11] = [b'A', b'S', b'D', b'F', b'G', b'H', b'J', b'K', b'L', b':', b'"'];
static MAP_ALT:   [u8; 11] = [0x81, 0x93, 0x84, 0x86, 0x87, 0x88, 0x8A, 0x8B, 0x8C, 0xFF, 0xFF];

pub fn keymap() {
    let mut ret: u8;
    let mut up:  u8;
    let mut ch:  u8;
    let mut map: &[u8];

    let mut modifier: u8 = 0;
    let mut capslock: u8 = 0;

    kbc_command(KBC_GET_CTRL);
    ret = kbc_read();
    kbc_command(KBC_SET_CTRL);
    ret &= !KBC_INT_KEY;
    kbc_write(ret);

    loop {
        ret = kbc_read();
        up = ret & 0x80;

        ret &= 0x7F;
        match ret {
            ESC   => break,
            ALT   => {
                if up != 0 { 
                    modifier &= !MOD_ALT; 
                } else { 
                    modifier |= MOD_ALT; 
                }
                continue;
            },
            CTRL  => {
                if up != 0 { 
                    modifier &= !MOD_CTRL; 
                } else { 
                    modifier |= MOD_CTRL; 
                }
                continue;
            },
            SHIFT => {
                if up != 0 { 
                    modifier &= !MOD_SHIFT; 
                } else { 
                    modifier |= MOD_SHIFT; 
                }
                continue;
            },
            CAPS  => {
                if up != 0 { 
                    modifier &= !MOD_CAPS; 
                    capslock ^= 1;
                } else { 
                    modifier |= MOD_CAPS; 
                }
                continue;
            },
            KEY_A ..= KEY_SQ => {
                if up != 0 {
                    continue;
                } else {
                    if modifier & MOD_SHIFT != 0 {
                        map = &MAP_SHIFT;
                    } else if modifier & MOD_CTRL != 0 {
                        map = &MAP_CTRL;
                    } else if modifier & MOD_ALT != 0 {
                        map = &MAP_ALT;
                    } else {
                        map = &MAP_PLAIN;
                    }
                }
                ch = if let Some(&num) = map.get((ret - KEY_A) as usize) {
                    num
                } else {
                    b'X'
                };
                if capslock == 1 {
                    if modifier & MOD_SHIFT == 0 {
                        if ch >= b'a' && ch <= b'z' {
                            ch -= 0x20;
                        }
                    } else {
                        if ch >= b'A' && ch <= b'Z' {
                            ch += 0x20;
                        }
                    }
                }
            },
            _     => continue,
        }
        print!("{:02X}", ch);
        print!(" ");
    }

    kbc_command(KBC_GET_CTRL);
    ret = kbc_read();
    kbc_command(KBC_SET_CTRL);
    ret |= KBC_INT_KEY;
    kbc_write(ret);
}

このコードでは、Shift や Capslock,ALT キーが押下されているかなども判定してキー修飾も反映しています。
その意味で心臓部は、MAP_PLAINMAP_CTRLMAP_SHIFTMAP_ALTのキーマップでしょうか。これらはその名の通で、MAP_PALINは何も修飾キーが押下されていない場合のキーマップ、MAP_CTRLは Ctrl キーが押下されている場合のキーマップとなります。残りもそれぞれの修飾キーが押下されている場合のキーマップを格納しています。

コード上では、US 配列のキーボードを対象としており、真ん中一列A'までしか対応していませが、あとは、全キーに対応させたうえで割り込み処理ルーチンとして完成させれば、立派なキーボードドライバができあがります。
あまりに簡単で少し驚くくらいですが、なんと Linux カーネルのキーボードドライバも基本骨格は上記のようなコードと同じなのです。以下参考にしてみてください。

linux/keyboard.c at master · torvalds/linux · GitHub

それでは、最後に実機デモといきましょう。

デモ

上記の COM アプリを組み込んだ DOS image を実機で稼働させてみたデモがあるので、載せておきます。

なお、ここでは手軽にテストしたかったので、floppy を用いています。

embed-1-dpkey.gif

qemu でもいいのですが、qemu は 8042 周りの細かい処理のエミュレーションは不完全な様子でした。皆さんがテストされる際には、bochs を使用するのがいいでしょう。

bochs: The Open Source IA-32 Emulation Project (Home Page)

macOS にて、bochs でテストする手順を記載します。参考にしてみてください。


$ brew install bochs
$ cargo xbuild --release
$ cargo objcopy -- -I elf32-i386 -O binary --binary-architecture=i386:x86 target/i586-rust_dos/release/rust_dos target/i586-rust_dos/release/rust_dos.com
$ hdiutil attach freedos.img 
/dev/disk2          	FDisk_partition_scheme         	
/dev/disk2s1        	DOS_FAT_16                     	/Volumes/FREEDOS2016
$ cp target/i586-rust_dos/release/rust_dos.com /Volumes/FREEDOS2016/
$ hdiutil detach disk2
$ cd /Path/to/DirectoryOfFreedos.img
$ cat bochsrc 
ata0-master: type=disk, mode=flat, path="freedos.img"
boot: disk
$ bochs

本プログラムは bochs と実機で問題なく動作することを確認しています。 なおここまでの内容は、GitHub - o8vm/rust_dos: Rust DOS : Creating a DOS executable with Rust - x86 real-mode programming. にあります。

まとめ

debug コマンドから始まり Rust に至り、果てはキーボード処理の実装まで。すこし長くなりすぎてしまいましたが、いかがでしたでしょうか。Rust は、real mode プログラミングでも十分に力を発揮して、ハードウェアレベルの処理でもいろいろお楽しみできる能力を持つことを示せたかなと思います。

我 DOS とリアルモードプログラミング、ハードウェア操作を極めん という方は下記の本は参考になるかもしれません。

記載内容に間違いなどあったら修正しますので、下記コメントにお願いします。