uchan; UEFI の機能を使って現在時刻を取得する - uchan-nos/os-from-zero GitHub Wiki

UEFI のランタイムサービスを用いて現在時刻を取得する方法、および MikanOS への機能追加方法を説明します。この方法を応用すると、UEFI のランタイムサービスを用いたその他の機能も実装できるでしょう。

UEFI ランタイムサービス

「ゼロからのOS自作入門」では UEFI のブートサービスしか用いていませんが、UEFI には OS の動作中にも使用できる「ランタイムサービス」が標準で搭載されています。ランタイムサービスは、現在時刻の取得、マシンの再起動や電源断など、OS 動作中に必要となる基礎的な機能を提供します。

レガシー BIOS は実行コードがリアルモード(16 ビットモード)でのみ提供されるため、BIOS の機能を呼び出すにはリアルモードに一時的に遷移する必要がありました。これは仮想 86 モードなどを用いることで実現できますが、手軽ではありませんでした。UEFI BIOS のランタイムサービスは Intel 64 モード(64 ビットモード)のまま利用可能ですので、OS から呼ぶのが非常に簡単です。

ランタイムサービスを OS に渡す

EDK II アプリケーションの中では gRT としてランタイムサービスの各機能(関数ポインタ)が記載されたテーブルにアクセスできます。gRT を OS に渡せば OS から各機能を使えるようになるはずです。

ということで、まずはブートローダ側から KernelMainNewStack()gRT を渡すように改造しました。

  typedef void EntryPointType(const struct FrameBufferConfig*,
                              const struct MemoryMap*,
                              const VOID*,
                              VOID*,
                              EFI_RUNTIME_SERVICES*);
  EntryPointType* entry_point = (EntryPointType*)entry_addr;
  entry_point(&config, &memmap, acpi_table, volume_image, gRT);

次に、受け取る側を改造します。gRT の型は EFI_RUNTIME_SERVICES* ですから、その型の引数を 1 つ追加すれば良さそうです。

しかし、話はそう単純ではありません。この型は EDK II で定義されている型であり、そのままでは main.cpp の中で使えません。似たようなケースで GOP の情報やメモリマップをブートローダから OS へ受け渡していますが、独自の構造体を定義して、UEFI の構造体を新たに定義した構造体へと変換しているのでした。gRT の受け渡しでもそうすべきでしょうか。

GOP やメモリマップの受け渡しと流儀を合わせるのは一つの手です。すなわち、gRT の受け渡しに必要な型を独自に定義するということです。しかし、gRT は巨大な構造体ですので、そこに登場するすべての型を自分で定義し直すのは現実的ではありません。(今回は時刻を取得するための関数 gRT->GetTime() を使いたいということですから、最低限、その関数だけを関数ポインタとして受け渡す、という手もあります。第 1 引数の型である EFI_TIME 構造体だけ定義すればなんとかなるため、そこまで大変ではないでしょう。)

そこで、EDK II のヘッダファイルをそのまま OS 側で利用する方法を採用することにしました。OS 側のソースコードから #include <Library/UefiRuntimeServicesTableLib.h> というようにインクルードするわけです。これで、自分で各種の型を書き下すことなく、gRT を素直に受け渡せるようになります。OS 側の改造は次の通りです。

kernel/uefi.hpp:

#include <Uefi.h>
#include <Library/UefiRuntimeServicesTableLib.h>

kernel/main.cpp:

#include "uefi.hpp"
《中略》
extern "C" void KernelMainNewStack(
    const FrameBufferConfig& frame_buffer_config_ref,
    const MemoryMap& memory_map_ref,
    const acpi::RSDP& acpi_table,
    void* volume_image,
    EFI_RUNTIME_SERVICES* rt) {
  MemoryMap memory_map{memory_map_ref};
  uefi_rt = rt;

この変更を入れた MikanOS をビルドするには、コンパイラ(clang++)を実行するときに EDK II のヘッダファイルが見えるようにしなければなりません。具体的にはコンパイラオプション -I を利用し、コンパイラにヘッダファイルを探すためのディレクトリを追加します。

-I$EDK2DIR/MdePkg/Include -I$EDK2DIR/MdePkg/Include/X64

さらに、UEFI の各関数は MS x64 ABI にしたがって呼ぶ必要があります。デフォルトで SystemV AMD64 ABI となってしまう Clang でビルドする際には、EFIABI マクロに MS x64 ABI を設定しておく必要があります。

-DEFIAPI='__attribute__((ms_abi))'

MikanOS のビルドスクリプト(build.sh)は、CPPFLAGS 変数でこれらの設定を調整できるようになっています。build.sh を呼び出す前に環境変数を調整しておきます。mikanos-build に含まれる buildenv.sh に、このための設定を加えました。

buildenv.sh:

BASEDIR="$HOME/osbook/devenv/x86_64-elf"
EDK2DIR="$HOME/edk2"
export CPPFLAGS="\
-I$BASEDIR/include/c++/v1 -I$BASEDIR/include -I$BASEDIR/include/freetype2 \
-I$EDK2DIR/MdePkg/Include -I$EDK2DIR/MdePkg/Include/X64 \
-nostdlibinc -D__ELF__ -D_LDBL_EQ_DBL -D_GNU_SOURCE -D_POSIX_TIMERS \
-DEFIAPI='__attribute__((ms_abi))'"
export LDFLAGS="-L$BASEDIR/lib"

gRT を使うように改造した MikanOS を皆さんの手元でビルドするには、$HOME/osbook の中で git pull を実行し、mikanos-build を最新版にしておきましょう。

現在時刻を取得する

ここまで来れば後は簡単です。ランタイムサービスを使って現在時刻を取得し、表示するターミナルコマンドを作ってみましょう。名前は date としました。

kernel/terminal.cpp:

#include "uefi.hpp"
《中略》
  } else if (strcmp(command, "date") == 0) {
    EFI_TIME t;
    uefi_rt->GetTime(&t, nullptr);
    if (t.TimeZone == EFI_UNSPECIFIED_TIMEZONE) {
      PrintToFD(*files_[1], "%d-%02d-%02d %02d:%02d:%02d\n",
          t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second);
    } else {
      PrintToFD(*files_[1], "%d-%02d-%02d %02d:%02d:%02d ",
          t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second);
      if (t.TimeZone >= 0) {
        PrintToFD(*files_[1], "+%02d%02d\n", t.TimeZone / 60, t.TimeZone % 60);
      } else {
        PrintToFD(*files_[1], "-%02d%02d\n", -t.TimeZone / 60, -t.TimeZone % 60);
      }
    }
  } else if (strcmp(command, "reboot") == 0) {

uefi_rt というポインタには、KernelMainNewStack() の中で gRT の値がコピーされています。したがって、そのまま関数を呼び出せばランタイムサービスを利用できるというわけです。

GetTime() の使い方は、詳しくは UEFI の仕様書に書いてあります。簡単に使うには、第 1 引数に EFI_TIME 型の変数へのポインタを指定し、第 2 引数はヌルポインタにしておきます。すると、現在時刻が第 1 引数で指定した変数へ書き込まれます。

GetTime() がタイムゾーンをサポートしている場合、t.TimeZone には UTC からの時差が分単位で記録されています。タイムゾーンがサポートされていない場合は EFI_UNSPECIFIED_TIMEZONE という特別な値になります。筆者の QEMU ではタイムゾーンはサポートされていませんでした。

QEMU で実行すると次のようになりました。時刻が(UTC で)取得できています。

注意

この方法によるランタイムサービスの呼び出しは、UEFI をサポートする機種なら対応していることが期待されます。しかし、一部の機種では上手く行かないという話を聞いています。

https://twitter.com/tenpoku1000/status/1377581533637185540?s=20

BS->ExitBootServices() 後に UEFI Runtime Services APIを通常の方法で call するとフリーズする機種があるということです。

とのことですので、この記事を参考にしても上手く行かない場合、機種が悪い可能性があります。その点、ご注意ください。

関連コミット

最後に、この記事に関連するコミットの URL を示します。

⚠️ **GitHub.com Fallback** ⚠️