この本を手に取る方はきっと「30日でできる!OS自作入門」[1]や「12ステップで作る組込みOS自作入門」[2]を知っている方が多いのではないかと思います.もし知らないという人は,どちらの本もベアメタルプログラミングを楽しむのに最適な本ですのでおすすめします.
上記2冊は開発言語としてC言語を採用しています.実社会でもC言語はOSを作る言語として人気で,例えばLinuxはドライバ等を含めてほぼ全体がC言語で書かれています.C言語は確かに良い言語です.仕様が比較的シンプルでコンパイル後にどんな機械語になるか想像しやすく,インラインアセンブラもサポートし,非常に多くの機器で使うことができます.新しいハードウェアが開発された際,アセンブラの次にサポートされるのはC言語であるという世界は今後も続くと思います.
CはOS開発で標準的な言語ですが,シンプルゆえに機能は貧弱です.もっと機能がたくさんある言語をOS開発でも使いたいと考える人は筆者を含めてたくさんいるはずです.筆者はC++*1が好きなので,C++でOSを開発する際の注意点やアイデアをまとめておこうと思いこの本を書いています.ですからこの本はC++に特化した話を書きますが,似たような機能を持つ他の言語で開発する際の参考にもなるかもしれません.
[*1] C++はCを拡張して作られたプログラミング言語で,OS作りにも活かせる便利な言語機能がCよりも豊富.ここ10年で言語仕様が大きく進化した.
OSを作る言語はC言語が人気です.CはもともとUNIX上で動かすアプリケーションを記述するために開発された言語ですが,アプリケーションにとどまらずOS本体もCで記述されるようになっていきます.現在ではUNIX系のOSはほとんどがCで開発されています.macOSのベースとなっているDarwinも大半がCです.Windowsはソースコードが公開されていませんが,「インサイドMicrosoft Windows」[3]によれば,2005年の段階では多くの部分がCで書かれているようです.
C++はOS開発言語としてはそれほどメジャーではありませんが,対応ハードウェアの多さで言えば多い部類です.Cほどの普及率ではありませんが,GCCがサポートするハードウェアの多くでC++を使うことができるでしょう.C++はCと相互運用性が高く,Cで書かれた関数をそのまま呼び出せますし,Cで書かれた構造体の定義をそのままC++でも利用することができるなど,Cの資産を活かすことができます.
C++はCに比べて機能が非常に多く,そのパワフルな言語機能を利用してOSを作ることができます.クラスと継承,テンプレート,型推論,ラムダ式.それらを利用してOSを開発できるのです.C++の機能を使いこなせれば開発が楽しく,効率的になることでしょう.ただ,C++の言語機能の中でも例外処理や実行時型情報など,ランタイムサポートを必要とするものはOS自身の開発では基本的に利用できません.また,標準C++ライブラリの多くの部分はOSに依存した実装になっており,やはりOS自身の開発には使えません.それらはコンパイラのオプションでOFFにし,OS開発中は機能を使わないようにする必要があります.それらの不便さを考えても,Cにはない豊富な機能を使えるのでC++でOSを開発することが筆者は好きです.
最近はRustでOSを書こうとしている人を多く見かけます.筆者はRustを書いたことはありませんが,話を聞く限りはOS開発に適している言語だと思います.機能も豊富ですし,言語仕様としてメモリ破壊などを起こしにくい設計になっています.RustでOSを書くことに興味があればぜひチャレンジするといいと思います.ただ,Rustは日進月歩の進化をしている途中であり,頻繁に言語仕様が変わるそうです.ちょっと前に書いたコードが次の世代のコンパイラだとエラーになることがある,ということです.また,C++に比べると歴史が浅い分,Rustをサポートするツールはまだまだ少なく,情報量も非常に少ないのが現状です.OS開発時にはこれらの点で苦労することがあるかもしれません.
その他の言語でもOS開発に使えるものがあります.筆者はJava,C#,Dといった言語でOSを開発する例を聞いたことがあります.本来,言語仕様は処理系の実装とは関係ないため,対象とするコンピュータ向けの機械語を出力できるコンパイラがあれば,その言語でOSを開発することは原理的に可能です.しかしOSを作りたいのにコンパイラを準備しなければならないのは大変すぎるので,現実的にはC,C++,Rustなど,機械語が出力できるコンパイラが整っている言語が選択されることになります.
歴史的には独自の言語でOSを作った例もあります.第五世代プロジェクト(先進的なコンピュータを開発するという目標で1982年に始まった国策プロジェクト)では,メモリ管理などの最低限の機能を提供するランタイムの上で,独自の言語を使って本格的なOSを実装していたそうです.その独自の言語は最終的にCに変換され,Cコンパイラで機械語になり,それがランタイムとリンクされてマシン上で動作します.
先にも書いた通り,Rustはメモリ安全性が高いと言われています.コンパイル時の静的解析に力をいれており,メモリ破壊につながる可能性があるロジックのミスを検出してくれるからです.コンパイルが正常に通ればメモリ破壊が無いことが期待できます.
その安全性を確保するために,Rustは実際にはメモリ安全なプログラムに対してもエラーを検出することがあり,プログラマとしてはエラーが検出され過ぎだと感じることがあるようです.false negative(エラーを見逃す)を減らす代わりにfalse positive(実際にはエラーではないがエラーだと判定する)が増えるのは許容する,というのは,安全なプログラミングを追求するという観点では妥当な設計でしょう.
Rustは優れた言語だと思いますが,C++が負けてばかりでは悔しいのでC++の方がRustより優れている部分をTwitterで募集したところ,いくつかの点でC++の方が優れていることが分かりました.それらは主に,グラフ構造の操作がC++の方が楽であること,テンプレートの機能がC++の方が強力であること,に集約されます.
グラフ構造とは点と辺で構成されたデータ構造のことです.グラフ構造は,同じく点と辺で構成された木構造とは異なり循環参照があることが特徴です.循環参照があるデータ構造をRustで操作しようとすると,操作の途中の時点で一時的にデータの整合性が崩れるため面倒なことになりがちだ,ということでした.
次にテンプレートの機能について.C++におけるテンプレートは,他の言語にある似たような機能「ジェネリクス」とは一線を画しています.テンプレートは型にとどまらず定数なども扱えますし,ダックタイピングができるという特徴もあります.もともとは型を抽象化する目的で導入されたテンプレートですが,意図せずチューリング完全になってしまった[4]ため,現在では幅広く活用される機能となっています.
すべての面においてC++はRustより優れている,と主張する気は全くありません.むしろRustの方が全体的には優れているのではないか,というのがRustを使ったことがない筆者の現時点での見方です.でも,筆者はC++が好きなので,しばらくはC++で開発を続けようかなと思っています.
この章ではC++のコア機能(標準ライブラリではない言語組み込みの機能)を活用するアイデアを紹介します.筆者が実際にOS開発で活用しているアイデアを多く含みます.紹介する機能の一部はC++17などの新しい規格で入ったものですので,試す際はその規格に対応したコンパイラを使ってください.
OSを書いているとハードウェアを制御する機会が多くあります.ハードウェアの制御では随所でビット幅を意識したプログラミングを求められます.そんなときにC++のビット幅が固定された整数型が役立ちます.<cstdint>
ヘッダ(それが無ければ<stdint.h>
でも大丈夫)を読み込むことで次のような整数型を使えるようになります.
int8_t
,int16_t
,int32_t
,int64_t
uint8_t
,uint16_t
,uint32_t
,uint64_t
一般的にOSはフリースタンディング環境*1向けにコンパイルします.フリースタンディング環境では標準ライブラリのほとんどは使えません.ただしOSの機能に依存しない<cstddef>
や<cstdint>
などの一部のヘッダファイルはフリースタンディング環境でも使えることになっています.したがってビット幅指定の整数型はOS開発でも使うことができます!
[*1] Freestanding environment.組み込みでOSが入っていない環境やOS自体が動作する環境はフリースタンディング環境である.OSの支援がある環境のことをホスト環境(hosted environment)と呼ぶ.
参考までに,C++17で規定されているフリースタンディング環境で使えるヘッダファイルの一覧を示します(表3.1).
ヘッダ名 | 説明 |
---|---|
<ciso646> |
|
<cstddef> |
size_t などの型 |
<cfloat> <limits> <climits> |
処理系の属性値(整数型の値の範囲など) |
<cstdint> |
uint32_t などの整数型 |
<cstdlib> |
strtol やmalloc などの宣言 |
<new> |
動的メモリ管理(配置newの定義を含む) |
<typeinfo> |
実行時型情報を実現するための構造体など |
<exception> |
例外クラスなど |
<initializer_list> |
initializer_list クラスの定義 |
<cstdarg> |
可変長引数関連(va_lsit など) |
<type_traits> |
型トレイト(std::enable_if など) |
<atomic> |
アトミック型の定義(シングルスレッドでは使用不可) |
「名前空間」はC++の特定の機能を意味するとともに,情報の分野で一般的な用語です.一般的にはある名前が見える範囲を意味します.例えばLinuxではネットワーク名前空間というものがあり,ネットワークインターフェース(eth0とか)の可視範囲をグローバルにしたり特定のプロセスに限ったりできます.
Cの世界では色々な名前がグローバルに露出しています.関数名,構造体タグ,列挙子などなど.関数名についてはstatic
宣言をすることでファイル内限定の名前にできますが,構造体タグや列挙子はユニークでなければなりません.C++では名前が見える範囲を制限する機能がいくつか提供されていて,その1つが名前空間です*2.すべてのソースコードを自分で開発している間はグローバルに名前が見えても不都合は少ないでしょう.しかし他人が開発したライブラリを使おうとすると,特に単純な名前は衝突する恐れがあります.そんなとき,名前空間によって名前が見える範囲を区切ることができます.
[*2] 名前空間の他には,クラス,スコープを持つ列挙型(enum class
)などが識別子が見える範囲を制御するのに使える.
namespace bitnos::usb { class ClassDriver { ... }; }
名前空間はnamespace
キーワードによって定義します.上記のコードは自作OS"bitnos"のUSBドライバをbitnos::usb
という名前空間の中に定義する例です.このように定義すると,名前空間の外側からはbitnos::usb::ClassDriver
と参照できます.bitnos
名前空間の中からであればusb::ClassDriver
でも参照できます.
あるファイル内でのみ見える名前を定義するとき,Cであればstatic
をつけることで実現します.C++でも依然としてその方法を使うことができますが,本来は記憶クラスの指定であるstatic
を,他のファイルから不可視にするという意味で使っており,分かりにくさの原因となっています.C++では無名名前空間という機能を使うことで同様のことが実現できます.
namespace { int foo; }
無名名前空間という名前の通り,名前を指定せずにnamespace
を宣言します.ファイル内では何も名前空間を指定せず,あたかもグローバル名前空間にある名前かのようにそのままfoo
と参照できますが,他のファイルからはfoo
を参照することはできません.無名名前空間で定義された変数の生存期間はグローバル変数と同様,プログラムが起動してから終了するまでです.
OS開発初期では動的なメモリ管理ができないためmalloc
やfree
関数は実装できず,したがってnew
/delete
演算子を使うことができません.OS開発がある程度進んで動的なメモリ管理ができるようになるとmalloc
とfree
が実装できます.そうなるとnew
/delete
演算子も使えるようになって欲しいと思うのが人情です.
malloc
とfree
は言語のコア機能ではなくライブラリですから,自分で定義すればよさそうだというのは分かると思いますが,言語組み込みであるnew
/delete
演算子は果たして改造できるのでしょうか?実は,それらの演算子は自分で独自に定義できるのです.
#include <stddef.h> void* operator new(size_t size) { return malloc(size); } void operator delete(void* obj) noexcept { }
このようにするだけです.new
演算子の引数は必要なメモリ領域のバイト数が渡されますから,そのバイト数だけメモリを確保し,ポインタを呼び出し元に返します.
malloc
関数とnew
演算子の違いmalloc
とnew
のどちらもメモリ領域を動的に確保するためのものですが,両者には大きな違いがあります.malloc
はただ単にメモリ領域を確保してそのポインタを返すだけです.一方で,new
はメモリ領域を確保した後にコンストラクタを呼び出すという重要な機能があります.クラスのインスタンスをきちんと生成するにはmalloc
ではダメで,必ずnew
を使うようにします.でなければインスタンスが正しく初期化されず,プログラムが誤動作します.
先ほど示したoperator new
の実装にはどこにもコンストラクタ呼び出しのコードが書かれていません.実は,コンストラクタ呼び出しのコードはコンパイラが自動的に挿入しますので,プログラマが明示的に書く必要はありませんし書けません.
new
演算子の使い所次のコードはカーネルから画面描画する例です.画面全体を白で塗りつぶします.
PixelWriter* pixel_writer; // ピクセル描画用クラス void KernelMain(GraphicInfo* graphic_info) { if (graphic_info->pixel_format == kRGB) { // 赤,緑,青 pixel_writer = new RGBPixelWriter(graphic_info); } else if (graphic_info->pixel_format == kBGR) { // 青,緑,赤 pixel_writer = new BGRPixelWriter(graphic_info); } for (int x = 0; x < graphic_info->width; ++x) for (int y = 0; y < graphic_info->height; ++y) pixel_writer->Write(x, y, {255, 255, 255}); // 画面を白で塗りつぶす ... }
カーネルのメイン関数KernelMain
には,画面描画に必要なパラメタが渡されます.その中にはピクセルのデータ形式を表すpixel_format
という情報があるとします.ピクセルのデータ形式とは「1ピクセルは32ビットであり,赤,緑,青の輝度情報がそれぞれ8ビットずつ先頭から詰めて配置されていて,最後の8ビットは予約領域である」というような情報です.ピクセルのデータ形式は機種によって違いますから,それぞれに適したピクセル描画用クラスを使い分ける必要があります.
適した実装を実行時に切り替える方法はいくつかありますが,ここではクラスの継承と仮想関数の手法により切り替えることにしました.すなわち,PixelWriter
というベースクラスと2つの派生クラスを定義し,派生クラスのどちらかを動的に生成するのです.この手法を使うにはnew
演算子を使ってインスタンスを生成するのが素直です.