uchan note

プログラミングや電子工作の話題を書きます

OS 自作に便利な C++ の機能 10 選(前編)

自作 OS Advent Calendar 2017 1 日目の記事です. 筆者が大好きな C++ の機能の中で,OS を書く際に便利なもの 10 個を紹介する予定です.

自作 OS Advent Calendar の紹介

自作 OS Advent Calendar とは自作 OS に関係すること(自作 OS そのものの紹介,開発環境の話,デバイスドライバの話,などなど)を扱うアドベントカレンダーです.

uchan_nos が把握する限り,自作 OS Advent Calendar の開催は今年で 3 度目です. 1 回目は Mopp さん主催の 自作 OS Advent Calendar 2014,2 回目は uchan_nos 主催の 自作 OS Advent Calendar 2016 です.

C++ の機能 5 選

前編では,C++ の機能の中でも 2003 年の規格(C++03)に含まれる,OS 開発に便利に使える 5 つの機能を紹介します.後編では C++11 以降に導入された機能を紹介する予定です.

紹介するのは次の 5 つの機能です.

ビットフィールドとインラインアセンブラは C 言語にもある機能ですね. OS を作る際にはどちらも非常に便利な機能です.

配置 new(placement new)はメモリ管理が無い世界で活躍する new です. メモリ管理なしでクラスを使う場合はほぼ必須といってもいい機能です.

継承とテンプレートは自作 OS に限らず広く使われる機能ですが,OS を書く際にも役立つのでご紹介します.

ビットフィールド

C++ ではデータの基本単位はバイトですが,時にはビット単位でデータを扱いたいときがあります. 最たる例がハードウェアのレジスタでしょう. レジスタはビットごとに意味があることが多く,バイト単位のアクセスでは不十分なのです.

そこでビットフィールドが登場します. ビットフィールドはビットごとに意味があるデータ構造を定義するものです. 特定のビットを読み書きするのにビット演算を使うこともできますが,ビットフィールドを使うともっと簡単にビット操作が可能です.

union PITControlRegister {
    uint8_t byte;
    struct {
        uint8_t count_mode_0 : 1;
        uint8_t count_mode_1 : 3;
        uint8_t read_load : 2;
        uint8_t counter_index : 2;
    } bits;
};

これは PIT(Programmable Interval Timer)のコントロールレジスタに対応するビットフィールドの定義です. コントロールレジスタについて詳しくは (PIT)8254 - OS Wiki8254) に解説があります.

このビットフィールドを用いて PIT のコントロールレジスタに値を書く例を示します.

PITControlRegister cr{}; // {} は C++11 からの初期化構文
cr.bits.count_mode_0 = 0; // 2 進数カウント
cr.bits.count_mode_1 = 2; // レートジェネレータ
cr.bits.read_load = 3; // 16 ビットリードロード
cr.bits.counter_index = 0; // カウンタ 0
io_out8(PIT_CTRL, cr.byte);

ビットフィールドを使わなくても,ビット和(|)とビットシフト(<<)を用いれば同様のことができます. ビットフィールドを用いた場合,それらの処理をコンパイラが裏で自動的にやってくれるので,ソースコードがとても読みやすくなります.

ちなみに cr{} の波かっこは,C++11 で導入された 一様初期化 というものです. 単に変数 cr をゼロ初期化しているだけです.

インラインアセンブラ

OS を書いていると,C/C++ソースコードの中で,C/C++ では書けない命令を呼び出したいことが往々にしてありますね. 例えば clistiinout などがあります. C/C++ の中からアセンブリ命令を使いたい場合,「30 日でできる!OS 自作入門」ではアセンブリでサブルーチンを定義し,C から関数として呼び出す方法を採用しています.

インラインアセンブラという機能を使うと,C/C++ソースコード中に直接アセンブリ言語ソースコードを埋め込むことができます.

uint64_t stack_pointer;
__asm__("movq %%rsp, %0" : "=m"(stack_pointer));

上記は,スタックポインタ rsp の値を C++ の変数 stack_pointer にコピーする処理です. この処理をアセンブリ言語で書いたサブルーチンで実現しようとすると,サブルーチン呼び出しでスタックがずれて結構面倒なのですが,インラインアセンブラではそういう面倒がありません.

インラインアセンブラで記述する中身(__asm__() の中身)は C/C++ の規格では定義されずコンパイラに依存するので,お使いのコンパイラにより文法が異なる可能性があります. といっても,uchan_nos が試した限り GCC と Clang では同じ書き方ができるようです. また,アセンブリ言語ニーモニックGNU Assembly の文法でしか書けないので,Intel 形式に慣れている方はちょっと変換を覚える必要があります. ※ClangでインラインアセンブラIntel 形式で書く方法をご存知の方は @uchan_nos まで是非メンションください.

インラインアセンブラの詳しい書き方は GCCのインラインアセンブラの書き方 for x86 を見るといいです.

配置 new

new 演算子はスコープに生存期間が紐づかない変数を生成するための演算子です.C++ で開発している人にとってはおなじみでしょう.

new 演算子は動的に確保したメモリ領域を,指定した型のコンストラクタで初期化する,というのが主な役目です. メモリ領域の確保をする点は malloc と似ていますが,コンストラクタによる初期化は new 特有です.

さて,OS 自作では,メモリ管理機構が無かったり,あったとしても起動後にメモリ管理機構が初期化されるまでの間は new 演算子を使えません. しかし,変数の定義された時点から遅らせてコンストラクタを呼び出したいという場面は往々にしてあり,new 演算子を使いたくなります. 次のソースコードを例に説明します.

// グローバル変数
PixelWriter* writer = nullptr;

// main 関数内
if (graphic_mode->pixel_formal == RGB32) {
    writer = new PixelWriterRGB32(graphic_mode);
} else if (graphic_mode->pixel_formal == BGR32) {
    writer = new PixelWriterBGR32(graphic_mode);
}

画面のピクセルフォーマットに応じて,適切な描画クラスをインスタンス化したいという需要があります. 描画クラスは,その後色々なところで使うのでグローバル変数にしてあります1

上記の例ではピクセルフォーマットが Red,Green,Blue,Reserved という順か Green,Blue,Red,Reserved という順かでインスタンス化するクラスを切り替えています. PixelWriterRGB32PixelWriterBGR32PixelWriter から派生したクラスです. これを new 無しで(つまり,スタック領域の変数だけを用いて)やることも不可能ではないでしょうが,new を使うのが自然だと思います.

配置 new を使うと,メモリ管理機構が無い状態で上記のことを実現できます.

配置 new(placement new)は,new を呼び出す側でメモリ領域を指定できる new です. 通常の new は領域を自動的に確保しますが,配置 new では領域を与えます. 上記の例を配置 new で書き直すとこうなります.

// グローバル変数
PixelWriter* writer = nullptr;
uint8_t writer_buf[256];

// main 関数内
if (graphic_mode->pixel_formal == RGB32) {
    writer = new(writer_buf) PixelWriterRGB32(graphic_mode);
} else if (graphic_mode->pixel_formal == BGR32) {
    writer = new(writer_buf) PixelWriterBGR32(graphic_mode);
}

new(buf) MyClass() と書くことで,buf が指すメモリ領域に対して MyClass のコンストラクタを呼び出し,インスタンスを構築することができます.便利ですね!

配置 new は自分で実装する必要がありますが簡単です. memory.cpp:4 にある通り,非常に小さな関数です. どこにもコンストラクタ呼び出しのコードがありませんが,new を使った時点でコンパイラが自動的に生成するので心配は要りません.

継承

継承は C++ の型システムの重要な機能です. 継承はフリースタンディング環境でも使える機能であり,もちろん OS 自作でも活用できます.

フリースタンディング環境における継承においては,純粋仮想関数にまつわる問題があります. 次のようなクラス定義があるとします.

class PixelWriter {
    …
public:
    …
    virtual void Write(const Point& pos, const Color& col) = 0;
};

このように純粋仮想関数を 1 つでも使うと,リンカが __cxa_pure_virtual が未定義だというエラーを出すことがあります.

__cxa_pure_virtual 関数は純粋仮想関数に対応する vtable の初期値です. vtable について詳しくは割愛しますが,継承を実現するためのものであり,実体は基底クラスに埋め込まれた関数ポインタの表(table)です. 純粋仮想関数は通常,関数本体を持ちません2ので,vtable の初期値に __cxa_pure_virtual 関数のポインタが設定されることになっています.

__cxa_pure_virtual 関数は,純粋仮想関数が呼び出された場合に呼び出されます. 純粋仮想関数が呼び出されるのはバグなので,__cxa_pure_virtual 関数はスタックトレースを表示して強制終了するなどの処理をすることが想定されます. 自作 OS 開発初期ではそういう処理をするのも大変なので,呼び出されたら無限ループするコードにしておけば問題ないでしょう.

extern "C" void __cxa_pure_virtual() {
    while (1);
}

参考記事 C++ - OSDev Wiki

テンプレート

テンプレートは型を抽象化するのに便利な機能です. どんな型に対しても同じようなコードを書く場合,テンプレートを用いることでコードをコピーすることなく実装ができます.

「30 日でできる!OS 自作入門」では FIFO8FIFO32 という型が登場します.それぞれ,8 ビット整数,32 ビット整数を扱える FIFO バッファの型です. また,それぞれの型を初期化するための関数 fifo8_initfifo32_init があります.

struct FIFO8 {
    unsigned char *buf;
    int p, q, size, free, flags;
};
void fifo8_init(struct FIFO8 *fifo, int size, unsigned char *buf);

struct FIFO32 {
    int *buf;
    int p, q, size, free, flags;
};
void fifo32_init(struct FIFO32 *fifo, int size, int *buf);

C 言語では,対象とする型が違う以外,両者のロジックは同じだとしても別々に定義を書く必要があります. C++ のテンプレート機能を使うと「任意の型を対象とする FIFO バッファ」を定義できます.

template <typename T>
struct FIFO {
    T *buf;
    int p, q, size, free, flags;
};

tempate <typename T>
void fifo_init(struct FIFO<T> *fifo, int size, T *buf);

template <typename T> と書いて,T を何らかの型を表す名前として宣言します. それに続くクラスや構造体,関数の定義の中で T を型名のように使うことができます. 型変数 T を使うことで,型に依存しない実装を共通化することができるようになりました.

上記FIFO 型をプログラム中で使う場合は T に具体的な型を指定する必要があります.

FIFO<int> fifo;
int fifobuf[128];
fifo_init(&fifo, 128, fifobuf);

もっと C++ らしく

上記のテンプレートのサンプルコードは C++ 的な書き方ではなく,「30 日でできる!OS 自作入門」の実装をなるべくそのままに,テンプレートを使うようにしたものです. せっかくなので,もう少し C++ っぽく書いたものを紹介します.(あくまで一例なので,皆さん是非自由に実装を楽しんでください!)

#include <iostream>

template <typename T, size_t N>
class ArrayQueue {
    T data_[N];
    size_t write_ = 0, read_ = 0, num_items_ = 0;

public:
    T& Front() { return data_[read_]; }
    const T& Front() const { return data_[read_]; }
    size_t Size() const { return num_items_; }

    void Pop() {
        if (num_items_ > 0) {
            read_ = (read_ + 1) % N;
            --num_items_;
        }
    }

    void Push(const T& item) {
        if (num_items_ < N) {
            data_[write_] = item;
            write_ = (write_ + 1) % N;
            ++num_items_;
        }
    }
};

int main()
{
    ArrayQueue<int, 2> queue;
    queue.Push(42);
    cout << q.Front() << endl; // 42
}

  1. グローバル変数は通常のプログラミングではあまり使いたくないものですが,(OS を書いているとほぼ必須である)割り込みハンドラに main 関数から値を渡すのには必要です.

  2. 純粋仮想関数の定義を書くことも可能です.Effective C++ 第 3 版,34 項参照.