osdev-jpでは、OS開発に有用な情報を収集し公開しています

View My GitHub Profile

OpeLa 言語には当面の間はテンプレート(型を抽象化するための C++ の機能)に類するものを導入しない予定です。これは OpeLa 言語の複雑性回避のためです。

C++ のテンプレートの使い方と OpeLa での代替手段の検討

MikanOS では、テンプレートを一部で使っています。この処理を、OpeLa 言語にどうやって移植するかを検討してみます。

テンプレートを効果的に使っている例として MemMapRegister というクラステンプレートを見てみます。

template <typename T>
class MemMapRegister {
 public:
  T Read() const {
    T tmp;
    for (size_t i = 0; i < len_; ++i) {
      tmp.data[i] = value_.data[i];
    }
    return tmp;
  }

  void Write(const T& value) {
    for (size_t i = 0; i < len_; ++i) {
      value_.data[i] = value.data[i];
    }
  }

 private:
  volatile T value_;
  static const size_t len_ = ArrayLength<decltype(T::data)>::value;
};

このクラスは、メモリマップされたレジスタを読み書きするためのラッパクラスです。メモリマップされたレジスタの読み書きは、コンパイラ最適化の影響を避けるため volatile でアクセスする必要があります。さらに、ハードウェアの仕様で、アクセスするビット幅が決まっていることがよくあります。例えば xHCI の 64 ビットレジスタは、64 ビット幅で読み書きする必要があります。このクラスを使うと、そのようなアクセスを守らせることができます。レジスタを読み書きしたい場合に、誤って volatile 無しでアクセスしてしまったり、64 ビット幅で読まずにビットフィールドを直接読んでしまう、というようなことを防ぎます。

MemMapRegister クラスが提供する機能を、テンプレート無しの OpeLa 言語で実現する方法を考えてみます。1 つは、シンプルに volatile 相当の機能を OpeLa に用意すること。ただ、volatile では「64 ビットレジスタだが 32 ビット幅でアクセスする必要がある」というような特殊なケースを表現できません。

このようなケースは現実には少ないので、考えなくて良いかもしれません。実際、xHCI ではレジスタ幅と読み書きの幅が同じものしかありません。ただ、xHCI では 32 ビットの読み書き命令しか発行できない CPU において、64 ビットアドレスを保存するレジスタには低位、上位の順に 32 ビットアクセスしなさい、と決められています。volatile では、このアクセスパターンを保証はできません。

volatile ではない方法としては、読み書き用の関数を用意することが考えられます。関数にはレジスタのアドレス(言い換えれば void*)を渡し、関数の中で適切に読み書きするのです。関数はレジスタ幅とアクセスパターンによって複数用意します。「64 ビットレジスタを 64 ビット幅でアクセスする」もの、「 64 ビットレジスタを 32 ビット幅で低位→上位の順にアクセスする」もの、など。

この方法の明らかな欠点は、どのレジスタがどのアクセス方法であるかを、レジスタの利用者がいちいち記憶しておかねばならないことです。クラスでラップする MemMapRegister の方式であれば、レジスタとアクセス方法をヘッダファイルに記録しておくことができます。一方、関数を使ってレジスタをアクセスする場合、どの関数を呼ぶかは関数呼び出し側で選択しなければならないのです。

ということで、MemMapRegister に関しては volatile での代用で、当面は良いかなという気持ちです。

次に WithError を見てみます。

template <class T>
struct WithError {
  T value;
  Error error;
};

このクラスは、関数が戻り値とエラー値をセットで返す場合に使います。エラーであればエラー値(Error error)、成功したら値(T value)を返すような関数の戻り値型として指定します。C++ の関数は 1 つしか値を返せないため、このようなクラスが必要なのです。

テンプレート引数 T に指定される型は intPageMapEntry*FT_Faceusb::ClassDriver* など、本当に多様な型です。したがって、型の数だけ WithError をコピーするというような回避策は現実的ではありません。

選択肢の 1 つは、value を uint64_t 型としておき、ポインタであればアドレスを、64 ビット以下の整数ならそのまま格納する方法です。関数の戻り値を利用する側で適切なキャストを行います。このやり方は関数の戻り値型という重要情報が失われますから、できれば避けたいです。

もう 1 つの選択肢は、関数が複数の戻り値を返せるような文法にすることです。Go 言語では func f(i int) (int, Error) {...} という感じで記述できます。この方法はかなり有力な選択肢だと思います。型情報が失われることがありませんからね。加えて、はじめから複数の戻り値を返す関数に対し、エラー値も返すように改良したいとき、WithError と同様の、しかしフィールド数が異なる構造体を定義する必要がありません。テンプレートを使った手法より、むしろ幅広く問題に対処できています。

Ceil という関数テンプレートはどうでしょうか。

  template <class T>
  T Ceil(T value, unsigned int alignment) {
    return (value + alignment - 1) & ~static_cast<T>(alignment - 1);
  }

この関数は、与えられた整数を、指定されたアライメントに揃える関数です。例えばこんな使い方です。

uint64_t x = 0xffffffffdeadbeef;
assert(Ceil(x, 64) == 0xffffffffdeadbf00);

Ceil 関数の中で重要なのは static_cast<T>(alignment - 1) というキャストです。T が int かそれより小さい型のときはこのキャスト無しで ~(alignment - 1) としても良いのですが、T が uint64_t など、大きな型のときは必要です。なぜなら、alignment - 1 はそのままだと unsigned int なので、そのビット反転も unsigned int で計算されてしまい、上位ビットの情報が失われるからです。

テンプレートを用いない代替案としては、引数および戻り値の型を、可能な限り大きな整数型とする方法があります。64 ビット整数が最大の場合は次のように書くわけです。

uint64_t Ceil(uint64_t value, uint64_t alignment) {
  中身は同じ
}

関数の戻り値を変数に代入する際に、型推論を使おうと思うと困ったことになります。32 ビット整数をアライメントしたかっただけなのに、戻り値が 64 ビット整数になってしまった、なんてことになります。

……実際のところ、アライメントはほとんどの場合に「アドレス」に対して必要になる処理だから、64 ビット整数にしか使わないのです。ということで、Ceil はテンプレートを使わずに定義しても、実は何も困らないんじゃないかと思います。(テンプレートという機能があると、それを使おうという頭になってしまい、本当にテンプレートで書くべきか深く考えなくなるので、怖いですね)

次は DescriptorDynamicCast という関数を見てみます。

  template <class T>
  T* DescriptorDynamicCast(uint8_t* desc_data) {
    if (desc_data[1] == T::kType) {
      return reinterpret_cast<T*>(desc_data);
    }
    return nullptr;
  }

この関数は、いわゆるダウンキャストのための関数です。与えられたポインタが、キャスト先の型に変換できるかを調べ、変換できるなら変換し、できないなら nullptr を返します。関数の実装で T::kType を使っているのが特徴的です。kType は構造体の静的フィールドとして宣言するもので、例えば次のようになっています。

  struct EndpointDescriptor {
    static const uint8_t kType = 5;

このタイプ値と、実際に渡されてきた値を比較し、一致すれば desc_dataEndpointDescriptor だと判断し、キャスト成功とします。

この関数をテンプレート無しで代替するのはかなり難しいのではないかと思います。素直に実装するのであれば、if 文でタイプ値をチェックした後でキャストを行うことでしょう。

EndpointDescriptor* desc = desc_data[1] == EndpointDescriptor::kType
                         ? (EndpointDescriptor*)desc_data : nullptr;

このような具合です。長くなりすぎたので改行するほどです。非常に面倒ですね。これをテンプレート無しで、もっとシンプルに実装するアイデアは……今のところ思いついていません。

最後に usb/xhci/ring.hpp の Ring クラス を見てみます。このクラスはテンプレートを用いた Push メソッドを持っています。

  class Ring {
   public:
    ≪中略≫
    template <typename TRBType>
    TRB* Push(const TRBType& trb) {
      return Push(trb.data);
    }
    ≪中略≫

   private:
    ≪中略≫
    TRB* Push(const std::array<uint32_t, 4>& data);
    ≪中略≫
  };

テンプレート引数 TRBType は、いくつもある「○○TRB」という構造体を受け取る意図があります。TRB 構造体の 1 種である NormalTRB の定義は次のようになっています。

  union NormalTRB {
    static const unsigned int Type = 1;
    std::array<uint32_t, 4> data{};
    struct {
      ≪中略(各種のフィールドの定義)≫
    } __attribute__((packed)) bits;

    ≪中略≫
  };

最も外側は共用体になっていて、TRB というデータ構造を単なる 32 ビット整数の配列としてアクセスすることもできるし、bits 経由でフィールドを名前でアクセスすることもできる、という設計です。

関数テンプレートとして実装された 1 つ目の Push メソッドは、TRB を 32 ビット整数の配列として共通化して 2 つ目の Push に受け渡すというだけです。2 つ目の Push は、受け取った配列をリングキューの末尾に追加します。

なぜ、このような 2 段構えになっているかというと、コードサイズの削減のためです。テンプレートを使った実装では複数の型に対応できる利点がありますが、型の数だけコードがコピーされるため、機械語の量が増えてしまいます。仮に 2 段構えをやめて、1 つ目の Push 自身がリングキューへの追加をするように作ると、リングキューへの追加処理が TRB の種類の数だけコピーされるわけです。一方、2 段構えにしておけば、コピーされるのは 2 つ目の Push を呼び出すコードだけで、リングキューへの追加処理の機械語は 1 つだけ出力されます。

Push の例では、テンプレートの除去は簡単です。テンプレートを使わない Push を public して、呼び出し側で Push(trb.data) というように、.data を明示的に書いてあげれば良いですね。たいした手間ではないでしょう。