Closed36

ゼロからOS自作入門 21章

ackyacky

アプリからウィンドウ

なんとアプリケーションからウィンドウが開けるようになるのか。

ackyacky

ITSを設定しよう

20日目までに解消されなかったバグを修正する。バグは「アプリがシステムコールを実行中にタイマ割り込みが発生してコンテキストスイッチが起きる」時に発生する

  • syscall命令はCSとCCをOS用に設定するが、RSPは変更しない
  • システムコールの関数はRSPがアプリのスタックを指した状態で処理される

この状態でタイマ割り込みが発生した時に発生する

  1. システムコール実行中にタイマ割り込みが発生する
  • CSがOS用に設定される
  • CPUはCPL=0で動作する
  • 割り込み発生時点ではCPLは変化せず、TSS.RSP0に設定したスタックは使用しない
    • タイマ割り込みの処理がアプリのスタック上で実行される
  1. RestoreContent()がiret用に積む
  • アプリのコンテキストから次のコンテキストに切り替える一環でCR3レジスタを更新する
  • CR3は階層ページング(PML4)を指している
    • その時点でページマッピングが次のタスク用に更新される
    • OS用領域(PML4[0]~PML4[255])のマッピングはコンテキストで共通
    • アプリ用領域(PML4[256]以降)のマッピングは各コンテキストに固有
  • CR3の更新後はスタック領域が載っているページが無効化される

この状態でiret命令を実行すると、スタックへのアクセスが発生してページフォールトが発生する。つまり、OSがクラッシュする。

ackyacky

このバグは簡単な処理をしている間には発生しないので、見つかりにくい。

このバグは、「OS領域に確保したスタック上でタイマ割り込みが処理されるよ」ようにすることで解決できる。解決方法は2つある

  • システムコール開始時にスタックを入れ替える
    • SyscallEntry()の先頭でRSPを更新してからシステムコール表(syscall_table)に登録されている関数を呼び出す
    • SyscallEntry()に処理が移ってからRSPが更新されるまでの間に割り込みが発生すると意図しない動作になる
  • CPUの機能を使ってタイマ割り込み時に自動的にスタックを入れ替える

後者であれば、安全にスタックを入れ替えられる。今回はIST(Interrupt Stack Trable)を使ってスタックを入れ替える。ISTはTSSに含まれている

ackyacky

文字列表示システムコール

ackyacky

今回最初に覚えるべきシステムコールは2つある

  • ターミナルに対して文字列を表示する
  • アプリケーションを終了する

らしい。なるほど、「ターミナルで起動したアプリケーションがターミナルにメッセージを表示する」ことが実現できればかなり前進できる気がする。

ackyacky

とりあえずはprintf()から勉強か

  • システムコールはOS本体に備わる機能である
  • アプリケーションはシステムコールをsyscall命令を通じで呼び出す
  • システムコールの処理はOS本体の実行ファイルに含まれている
ackyacky

なるほど、Cのprintf()の話はおもしろいな。実現方法は問わないけど、実現する方法は決まっていると。

で、Linuxは「システムコールとしては単純な文字列表示を適用して、書式整形機能はライブラリ側に持たせる」と。

今回はこの方法を採用するのか。それで実現方法は上の図と

ackyacky

システムコールを実装する前に仕様を考える

  • システムコールがsyscall命令で呼ばれたとき
    • システムコールの入り口は1つしかないので、呼び出し元のアプリケーションを特定できない
    • アプリケーションを特定しないと文字列を表示するターミナルが特定できない
    • 複数のターミナルに対応できるためには必須の機能
  • システムコールからターミナルへ文字列を表示するとき
    • 表示対象のターミナルを特定するのは必須
    • そこでアプリケーションとターミナルの対応表を作成する
      • LUT 対応表を使ってアプリケーションとターミナルを紐付ける
  • アプリケーションを識別する
    • タスクIDからアプリケーションを特定する
ackyacky

対応表を使ってアプリケーションとターミナルを紐付ける

ここからやな。(*terminals)[task_id] = terminal; これでタスクIDとターミナルを紐付けですね。

初期は自体は、main関数で行っているけど、それをterminalで使えるようにヘッダで extern std::map<uint64_t, Terminal*>* terminals; って書くことで同じものを見ているようにしているんだな。

ちょっと複雑かも

ackyacky
SYSCALL(PutString) {
  const auto fd = arg1;
  const char* s = reinterpret_cast<const char*>(arg2);
  const auto len = arg3;
  if (len > 1024) {
    return {0, E2BIG};
  }

  if (fd == 1) {
    const auto task_id = task_manager->CurrentTask().ID();
    (*terminals)[task_id]->Print(s, len);
    return {len, 0};
  }
  return {0, EBADF};
}

これはわかりにくいな

引数 意味
fd ファイルディスクリプタ番号
s 表示したい文字列へのポインタ
len 文字列の長さ(NULL終端を含まず)

ファイルディスクリプタがターミナルを表しているのか。

そして、エラーコードを扱えるようにResultをアップデートしているのか

struct Result {
  uint64_t value;
  int error;
};

#define SYSCALL(name)                                                     \
  Result name(uint64_t arg1, uint64_t arg2, uint64_t arg3, uint64_t arg4, \
              uint64_t arg5, uint64_t arg6)

で、struct構造体が現状の環境では最大で128ビットに収まるってのもミソらしい。128ビットならシステムコールの戻り値であるRAX:RDXに乗っかるらしい。

ackyacky

次にNUL終端していない文字列対応ようにPrint()関数の修正か

void Terminal::Print(const char* s, std::optional<size_t> len) {
  const auto cursor_before = CalcCursorPos();
  DrawCursor(false);

  if (len) {
    for (size_t i = 0; i < *len; ++i) {
      Print(*s);
      ++s;
    }
  } else {
    while (*s) {
      Print(*s);
      ++s;
    }
  }

  DrawCursor(true);
  const auto cursor_after = CalcCursorPos();

  Vector2D<int> draw_pos{ToplevelWindow::kTopLeftMargin.x, cursor_before.y};
  Vector2D<int> draw_size{window_->InnerSize().x,
                          cursor_after.y - cursor_before.y + 16};

  Rectangle<int> draw_area{draw_pos, draw_size};

  Message msg = MakeLayerMessage(
      task_id_, LayerID(), LayerOperation::DrawArea, draw_area);
  __asm__("cli");
  task_manager->SendMessage(1, msg);
  __asm__("sti");
}

修正は3点か

  1. lenで文字列長を受け取れる
  2. lenを使って文字列制御ができる
  3. 画面の再描画を行う

なるほどなー

ackyacky

で、これが描画範囲の計算の図か。こうやって表現するとわかりやすい

ackyacky

システムコールを呼ぶ準備ができたので、次はコール側を修正ってことか

つまりprintf()の先では、最終的にはシステムコールのwrite()が呼ばれていることになる。
OSのシステムコールの呼び出しとは&バイナリエディタの使い方

なるほど、writeがシステムコールを担当していて、そのwriteを上書きしてしまえば、printfからでも任意のシステムコールが呼び出せるのか。
ってことでwriteを実装と

struct SyscallResult {
  uint64_t value;
  int error;
};
struct SyscallResult SyscallPutString(uint64_t, uint64_t, uint64_t);

ssize_t write(int fd, const void* buf, size_t count) {
  struct SyscallResult res = SyscallPutString(fd, (uint64_t)buf, count);
  if (res.error == 0) {
    return res.value;
  }
  errno = res.error;
  return -1;
}

次がwriteで呼び出しているアセンブリと

global SyscallPutString
SyscallPutString:
    mov eax, 0x80000001
    mov r10, rcx
    syscall
    ret
ackyacky

これで準備できたと。あとは、いつも通りprintfを使って表示と。あとは、リンクを直すようにすれば良いんだな。

掛け算まで動いた

ackyacky

アプリが終了するとOS全体が死んでしまう

なるほど。だから、rpnでは無限ループにしているのか。

CPU上で動作しているアプリが終了することは、直後からそのCPU上でOS本体のプログラムが動作再開することである。つまり、CPUの視点では、リング3からリング0へと動作モードが変わることに他ならない。

なるほどなー。アプリケーションの終了時は、システムコールを使ってリング0で動作(OS本体の再開)させるようにしないといけないってことか。

で、アプリケーション(リング3)からシステムコールを使ってリング0に遷移した後、もとに戻らないようにしないといけないってことか。つまり、アプリケーションの終了用システムコールは、永遠に処理が終わらないシステムコールを呼ぶことと同意ってことか。

ackyacky

アプリがCPU例外で終了したら、アプリだけが終了する

これはお行儀の悪いアプリや、意図的に不正な処理をさせようとするアプリに対処するための機能

なるほど。例外自体が不正以外で使うことがお行儀が悪いってイメージだからな

ackyacky

やること

  • システムコールを呼ぶ
  • システムコールの処理が終わってもsysret命令を使わない
    • 代わりにOSの処理(Terminal::ExecuteFile()を呼び出す)

戻るというのは呼び出しの逆をたどれば良いってことか。

ackyacky

上の部分はアセンブリで実現をしないといけないからアセンブリのしゅうせいからか。。ここは書いてあることをそのまま実装しかないな

ackyacky

.exitに処理が時点でRAXにはCallApp()で保存したOS用スタック領域のポインタが入っている。

なるほどな。で、これを操作するために6つのpop命令を実行するのか。

  1. CallApp():OS用スタックにレジスタ群を保存する
  2. CallApp():アプリ用スタックに切り替え
  3. CallApp():アプリを起動
  4. SyscallEntry.exit:OS用スタックに切り替え
  5. SyscallEntry.exit:OS用スタックからレジスタ群を復帰
  6. SyscallEntry.exit:呼び出しの次の行に戻る

mov[r9], rsp でOS用スタックを指すポインタを保存しているのか

ackyacky

で、最後に保存したOS用スタックポインタを返しているんだな。なるほどな。

ackyacky

スタックポインタの復帰

ackyacky

OS用のスタックポインタの復帰は SyscallEntry.exit の最初の処理、mov rsp, rax である。

このあたりの実装はよく調べないとわからんな。でも、Exit処理をSyscallから設定すれば良いのか

SYSCALL(Exit) {
  __asm__("cli");
  auto& task = task_manager->CurrentTask();
  __asm__("sti");
  return { task.OSStackPointer(), static_cast<int>(arg1) };
}

で、Exitを追加したからsyscall_tableも更新と

extern "C" std::array<SyscallFuncType*, 3> syscall_table{
  /* 0x00 */ syscall::LogString,
  /* 0x01 */ syscall::PutString,
  /* 0x02 */ syscall::Exit,
};

なるほどな、RAXにtask.OSStackPointer()を、RDXにstatic_cast<int>(arg1)を対応させているのか。そして、現在のタスクが保持していたOS用のスタックポインタの値をシステムコールの第一引数に返すのか。

ackyacky

なるほどな、アプリケーションコールのexit部分でスタックポインタがOSように復元しているのか。。これは難しい。

ackyacky
  • Linuxではアプリケーションの終了を return 0; とすることで正常終了を表現している
    • Linuxはスタートアップルーチンが呼ばれることからこれで正常終了になる
    • スタートアップルーチンになかにmain()終了後にexit()が呼ばれるようになっている
  • Mikanosでは SyscallExit(0); で終わらないと正常終了にならない
    • 現状のサンプルでは終了コードが変わることを確認するために逆ポーランド記法の結果を設定している
    • 0を返さないとOS自体が異常終了する
    • returnにしてしまうと異常終了になるのでreturnをしてはいけない
      • C言語のルールに従うために、mainの戻り値をvoidに設定する
ackyacky

ここはmakefileの整理だけか

ackyacky

ここもシステムコールを使ってウィンドウを開くようにしているのか。

ackyacky

ここもいくつか表示するための設定が書かれていないけど、とりあえずコードを整理して、描画処理を書いておわりと

ackyacky

ウィンドウに文字を書く

中断が入って長引いたけど、これでこの章は最後

ackyacky
extern "C" void main(int argc, char** argv) {
  auto [layer_id, err_openwin]
    = SyscallOpenWindow(200, 100, 10, 10, "winhello");
  if (err_openwin) {
    SyscallExit(err_openwin);
  }

  SyscallWinWriteString(layer_id, 7, 24, 0xc00000, "hello world!");
  SyscallWinWriteString(layer_id, 24, 40, 0x00c000, "hello world!");
  SyscallWinWriteString(layer_id, 40, 56, 0x0000c0, "hello world!");
  SyscallExit(0);
}

アプリケーションからレイヤIDを指定して、文字を書き込んでいるのかで、文字の書き込みはウィンドウの相対位置なんだな。

ackyacky

やばい。ここだけで1ヶ月かかってしまった。

このスクラップは2021/11/12にクローズされました