N.Mです。今回の記事はMikanOS本の話です。
はじめに
ゼロからのOS自作入門をコードを写経しながら読了しました。 1 以下は完成したOSのスクリーンショットです。
以下のようなことを知れる、とても良い本でした。
- OSが内部でどう処理を回しているのか、プロセス 2 をどう管理しているのか
- どのようなレジスタがあって、それらをどう扱っているのか
- アプリケーションをどうロードし、実行しているのか
- 仮想アドレスやページングの仕組みをどう実現しているか
- 今まで聞いたことはあったようなFAT32(ファイルフォーマット)やUTF-8(文字コード)の具体的な仕様 3 などなど...
今回の記事は、コードを写経していた際につまづいたポイントと解決方法を備忘録的な感じでまとめたいと思います。あまり調べても情報がネット上に出てこなかったものをピックアップします。
開発環境
使用したマシン
CPU: 13th Gen Intel(R) Core(TM) i7-13700H (2.40 GHz)
RAM: 32GB
ストレージ: 932GB (1TBのSSD)
OS: Windows 11 Home (24H2)
上記のマシンにVirtualBoxでUbuntuを仮想OSとして入れ、そのUbuntuでMikanOSのコンパイルや動作確認をしていました。環境構築には以下の記事を参考にしています。
仮想LinuxOS
VirtualBoxバージョン: 7.1.6 r167084 (Qt6.5.3)
Ubuntuバージョン: Ubuntu 22.04.5 LTS
QEMUバージョン: 6.2.0
clangバージョン: 14.0.0-1ubuntu1.1
ld.lldバージョン: 14.0.0
(今回紹介するポイントで自分がつまづき、ネット上ではそういう情報をあまり聞かなかったのは、主に自分の環境のld.lldのバージョンが異なるためだったようです。)
つまづいたポイント
[osbook_day18c] rpnアプリを実行するタイミングでOSがクラッシュ
症状・原因
osbook_day18cの段階のコードでrpnアプリを実行するとOSがクラッシュし、OSが再起動してしまいました。
調べたところ、このXの投稿を見つけました。
osbook_day18cの段階では、ELF形式のrpnアプリのファイルの中身をバッファメモリ (file_buf) にコピーし、そのバッファメモリ上のエントリーポイントからrpnアプリの処理を実行しようとしています。
しかし、 rpnアプリのファイル上でのLoadセクションの位置と本来メモリ上に配置されるべき位置が合わない ため、実行しようとした際にクラッシュしていたようです。
解決策
19章でELFヘッダを読んで、Loadセクションを正しい位置に配置する処理を書いているので、一旦ここでのクラッシュは無視して先に進んで良さそうです。
どうしてもosbook_day18cの段階で動かしたい場合は、4.5節の「ローダを改良する」を参考に、自前でLoadセクションを正しい位置に配置する処理を、kernel/terminal.cppに書く必要があります。
修正したosbook_day18cの段階のTerminal.cpp
void CalcElfLoadAddressRange(Elf64_Ehdr* ehdr, uint64_t& first, uint64_t& last) {
Elf64_Phdr* phdr = reinterpret_cast<Elf64_Phdr*>((int64_t)ehdr + ehdr->e_phoff);
first = UINT64_MAX;
last = 0;
for (Elf64_Half i = 0; i < ehdr->e_phnum; i++) {
if (phdr[i].p_type != PT_LOAD) {
continue;
}
first = std::min(first, phdr[i].p_vaddr);
last = std::max(last, phdr[i].p_vaddr + phdr[i].p_memsz);
}
}
void CopyLoadSegments(Elf64_Ehdr* ehdr, std::vector<uint8_t>& elfMemBuf) {
Elf64_Phdr* phdr = reinterpret_cast<Elf64_Phdr*>((int64_t)ehdr + ehdr->e_phoff);
for (Elf64_Half i = 0; i < ehdr->e_phnum; i++) {
if (phdr[i].p_type != PT_LOAD) {
continue;
}
uint64_t segm_in_buf = (uint64_t)(&elfMemBuf[0]) + phdr[i].p_vaddr;
uint64_t segm_in_file = (uint64_t)ehdr + phdr[i].p_offset;
memcpy(reinterpret_cast<void*>(segm_in_buf), reinterpret_cast<void*>(segm_in_file), phdr[i].p_filesz);
uint64_t remain_bytes = phdr[i].p_memsz - phdr[i].p_filesz;
memset(reinterpret_cast<void*>(segm_in_buf + phdr[i].p_filesz), 0, remain_bytes);
}
}
int ExecuteElf(std::vector<uint8_t>& elfFileBuf, std::vector<char*>& argv) {
auto elf_header = reinterpret_cast<Elf64_Ehdr*>(&elfFileBuf[0]);
uint64_t app_first_addr;
uint64_t app_last_addr;
CalcElfLoadAddressRange(elf_header, app_first_addr, app_last_addr);
std::vector<uint8_t> elf_mem_buf(static_cast<uint32_t>(app_last_addr));
CopyLoadSegments(elf_header, elf_mem_buf);
auto entry_addr = elf_header->e_entry;
entry_addr += reinterpret_cast<uintptr_t>(&elf_mem_buf[0]);
using Func = int (int, char**);
auto f = reinterpret_cast<Func*>(entry_addr);
return f(argv.size(), &argv[0]);
}
TerminalクラスのExecuteFileメソッドを実行する際にこのExecuteElfを呼び出します。ExecuteElfでは、最初にCalcElfLoadAddressRangeで、ELFヘッダからLoadセクションの存在するメモリの範囲を取得します。取得したメモリ範囲をもとに、必要な分のバッファメモリ elf_mem_buf を確保し、CopyLoadSegmentsで正しい位置にLoadセクションを配置します。配置後、エントリーポイントからelf_mem_buf上のrpnアプリケーションを実行するという流れです。
なお、このコードは19章ですぐに不要となります...
[osbook_day19a] 標準ライブラリを使用したrpnを実行するとOSがクラッシュ
症状・原因
osbook_day19aの段階のコードで、標準ライブラリにあるatolを使用したrpn(osbook_day18dのもの)を実行するとOSがクラッシュしました。しかし、atolを使用せず、自前で処理を書いたバージョン(osbook_day18cのもの)を実行した場合は正常に動作しました。
その先の20章でlargeという大きなサイズのグローバル変数の配列を使うアプリを作りますが、その配列のサイズが小さい(3 * 1024以下)とクラッシュせず、大きいとクラッシュするというように症状が変わっていました。
20章のデバッグ表示を使ってデバッグをした結果、OS側のページフォールトで落ちていることがわかり、 Loadセクション用に作っておく物理メモリのページ数の計算に不具合 があることがわかりました。
kernel/terminal.cppにあるTerminalクラスのCopyLoadSegmentsで、Loadセクション用のメモリを確保するためにページを作成しています。その際、以下のようにLoadセクションのメモリサイズから必要なページ数を計算しています。
const auto num_4kpages = (phdr[i].p_memsz + 4095) / 4096;
しかし、Loadセクションの開始アドレスが4KiBページのアドレス境界と一致するとは限らないようです。4 そのため、本来は4KiBページのアドレス境界とLoadセクションの開始アドレスとの差も含めないと、必要なページ数の計算結果が本来よりも少ない値となります。結果、Loadセクションの後半をコピーする際、ページの範囲外にアクセスしていたようです。
例えば、ページの開始アドレスとLoadセクションの開始アドレスの差が100バイト分で、Loadセクションのメモリサイズが4096バイトであったとします。上記の計算だと必要なページ数は1ですが、開始アドレスがずれて、Loadセクションがページの境界をまたぐ関係で本来は2ページ必要です。そのため、Loadセクションの末尾から100バイトを2ページ目にコピーしようとした際、2ページ目は作られておらず、範囲外のメモリにアクセスすることになり、ページフォールトで落ちます。
解決策
単純に必要なページ数を計算する際に、ページの開始アドレスとLoadセクションの開始アドレスの差を考慮すれば解決します。自分はnum_4kpagesの計算を以下のように修正しました。
Elf64_Xword page_start_offset = (reinterpret_cast<Elf64_Xword>(phdr[i].p_vaddr) & 0xfff);
const auto num_4kpages = (phdr[i].p_memsz + page_start_offset + 4095) / 4096;
page_start_offsetにページの開始アドレスとLoadセクションの開始アドレスの差を代入し、num_4kpagesでそれを使用しています。
[osbook_day20b] while(1);で無限ループするようにしたrpnを実行するとOSがクラッシュ
症状・原因
20章のコードを書いてから、rpnコマンドやlargeコマンドを呼び出すとクラッシュするようになりました。20章で実装するデバッグ表示を確認したところ、アプリ内でページフォールトが発生し、特にRIPのアドレスが0などの範囲外を指していることがわかりました。
gdbでデバッグし、ステップ実行の様子を確認すると、アプリ内のmain関数には移動して処理を開始できていることがわかっているものの、while(1);で止まらず、さらにRIPが進んでしまったり、入るはずのない分岐に入ってreturnしていることがわかりました。
調べたところ、以下の記事が見つかりました。
while(1);というように副作用を供わない無限ループはC++では未定義動作になるようです。 5 while(1);の部分を修正したところ、想定した挙動になり、クラッシュしなくなりました。
解決策
副作用を伴う、ループの中で変数をいじるような無限ループであれば問題ないため、rpnやlargeに書いている最後のwhile(1);を以下のように書き直します。
// 前略
extern "C" int main(int argc, char** argv) {
volatile int temp = 0;
while(1) {
temp = 0;
}
//return atoi(argv[1]);
}
whileループの中で、適当に値を代入することで、副作用の伴うループにしています。なお21章まで行くと、アプリ内で無限ループする必要がなくなり、このコードは不要になります。
[osbook_day23b] paintアプリで線を引くタイミングでOSがクラッシュすることがある
症状・原因
osbook_day23bでpaintアプリを作りますが、出てきたpaintアプリのウィンドウでクリックした際にOSがクラッシュすることがありました。
デバッグでいろいろ試しているうちに、その前に作ったeyeアプリやpaintアプリでマウスの判定位置がおかしいことに気がつきました。コードを調べていたところ、ターミナルのウィンドウとアプリの開いたウィンドウが同一のターミナル用のタスクと紐づいていることに気がつきました。
MikanOSではウィンドウ立ち上げ時にウィンドウのレイヤーとタスクのIDを紐づけ、layer_task_mapにその対応関係を登録します。アプリでウィンドウを立ち上げる時は、そのアプリを動かしているタスクと紐づきますが、そのタスクはターミナルのタスクでもあります。アプリ側でイベントを受け取る際、ターミナルのウィンドウがアクティブだと、ターミナルウィンドウからのイベントメッセージも入ってくる可能性があります。
このウィンドウとタスクの対応関係はMikanOSが取っている仕組み上、しょうがないもの6として、ターミナルウィンドウからのイベントが入ってきた場合、ウィンドウの範囲外を描画できてしまう箇所がありました。
解決策
apps/paint/paint.cppにある元々のマウスボタンが押された際の処理は以下の通りです。
else if (events[0].type == AppEvent::kMouseButton) {
auto& arg = events[0].arg.mouse_button;
if (arg.button == 0) {
press = arg.press;
SyscallWinFillRectangle(layer_id, arg.x, arg.y, 1, 1, 0x000000);
}
}
上記コードだと、ターミナルウィンドウをアクティブにして、ターミナルウィンドウの右下あたりをクリックすることで、アプリのウィンドウの範囲外に描画処理を行うことができます。その際に確保していないメモリにアクセスする可能性があります。そのため、以下のコードのように、クリック時もクリックした位置がウィンドウの範囲内かどうかの判定が必要です。
else if (events[0].type == AppEvent::kMouseButton) {
auto& arg = events[0].arg.mouse_button;
if (arg.button == 0) {
press = arg.press;
if (IsInside(arg.x, arg.y)) {
SyscallWinFillRectangle(layer_id, arg.x, arg.y, 1, 1, 0x000000);
}
}
}
このコードに修正したあとは、OSがクラッシュしなくなりました。
[おまけ] ターミナル内のカーソルの点滅
症状・原因
sortやgrepなどのアプリで標準入力を受けつける際、ターミナルのカーソルが点滅しないことに気が付きました。この場合、アプリ終了後も点滅しなくなりました。特定の章のコードが原因ではないですが、30章を読んでいるタイミングで気になったので調査、対策をしてみました。 7
ターミナルではタイマーの仕組みで0.5秒ごとにタイムアウトのイベントを発行し、ターミナルのタスクでイベントを受け取ることで、カーソル点滅の処理を行っています。しかし、 キー入力のイベントを受け取った処理の途中でアプリが実行され、アプリの処理が完了するまでキー入力のイベントが終わらないので、アプリ実行中は次のイベントを受け取る処理が走らない ことに気がつきました。
解決策
ターミナルでアプリ実行中も点滅用タイマー用のイベントを処理できるように、ターミナルのタスクとは別に、カーソル点滅用のタスクを作りました。引数のdataにTerminalのポインタを渡すことで、そのTerminalから元のターミナルタスクを取得でき、ターミナルに対して描画更新のイベントを発行できます。
namespace {
void TaskTerminalDisplay(uint64_t task_id, int64_t data) {
Terminal* terminal = reinterpret_cast<Terminal*>(data);
__asm__("cli");
Task& task = task_manager->CurrentTask();
__asm__("sti");
auto add_blink_timer = [task](unsigned long t) {
timer_manager->AddTimer(Timer{t + static_cast<int>(kTimerFreq * 0.5),
1, task.ID()});
};
add_blink_timer(timer_manager->CurrentTick());
while (true) {
__asm__("cli");
auto msg = task.ReceiveMessage();
if (!msg) {
task.Sleep();
__asm__("sti");
continue;
}
__asm__("sti");
bool window_isactive = (terminal->LayerID() == active_layer->GetActive());
switch (msg->type) {
case Message::kTimerTimeout:
add_blink_timer(msg->arg.timer.timeout);
if (window_isactive && terminal->GetCanBlinkCursor()) {
const auto area = terminal->BlinkCursor();
Message msg = MakeLayerMessage(
terminal->UnderlyingTask().ID(), terminal->LayerID(),
LayerOperation::DrawArea, area);
__asm__("cli");
task_manager->SendMessage(1, msg);
__asm__("sti");
}
break;
default:
break;
}
}
}
} // namespace
元のコードではターミナルがアクティブであるかどうかをイベントで受け取っていましたが、タスクが2つある状況だと、タスク間でイベントの転送が必要そうです。また、アクティブウィンドウがどうかはグローバル変数であるactive_layerで取得できるアクティブウィンドウのレイヤーIDと比較すればわかります。そのため、イベントでの対応はやめて、active_layerとの比較で対応しています。
もともとあったTaskTerminalで別タスクを作り、そのタスクでTaskTerminalDisplayを実行するように設定しています。
void TaskTerminal(uint64_t task_id, int64_t data) {
// 前略
uint64_t displayTaskId;
if (show_window) {
Task& displayTask = task_manager->NewTask();
displayTaskId = displayTask.ID();
displayTask
.InitContext(TaskTerminalDisplay, reinterpret_cast<int64_t>(terminal))
.Wakeup();
}
while (true) {
__asm__("cli");
auto msg = task.ReceiveMessage();
if (!msg) {
task.Sleep();
__asm__("sti");
continue;
}
__asm__("sti");
switch (msg->type) {
case Message::kKeyPush:
if (msg->arg.keyboard.press) {
const auto area = terminal->InputKey(msg->arg.keyboard.modifier,
msg->arg.keyboard.keycode,
msg->arg.keyboard.ascii);
if (show_window) {
Message msg = MakeLayerMessage(
task_id, terminal->LayerID(), LayerOperation::DrawArea, area);
__asm__("cli");
task_manager->SendMessage(1, msg);
__asm__("sti");
}
}
break;
case Message::kWindowClose:
CloseLayer(msg->arg.window_close.layer_id);
if (terminal->LayerID() == msg->arg.window_close.layer_id) {
__asm__("cli");
if (show_window) {
task_manager->FinishOtherTask(displayTaskId, 0);
}
task_manager->Finish(terminal->LastExitCode());
}
break;
default:
break;
}
}
}
30章ではウィンドウが閉じられる際のイベントが追加され、そこでターミナル用のタスクを終了していました。独自に作ったタスクも止める必要があるので、TaskManagerクラスに新規作成したFinishOtherTaskメソッドで停止しています。元のコードだとterminal->LayerID() == msg->arg.window_close.layer_idの条件分岐はありませんでした。しかしそれだと、starsアプリのようにウィンドウだけ作ったアプリを実行し、残ったstarsのウィンドウを消した際、ターミナルのタスクが終了してターミナルが動かなくなります。それを防ぐために条件分岐を追加しています。
FinishOtherTaskでは以下のような処理で、指定したIDのタスクをTaskManagerから消して、終了したタスクに登録しています。元々あったFinishタスクから、次のタスクに切り替える仕組み(RotateCurrentRunQueueやRestoreContext)を外したようなコードです。引数で与えられたタスクIDが現在のタスクのものだった場合は元のFinishタスクを呼び出すようにしています。
void TaskManager::FinishOtherTask(uint64_t task_id, int exit_code) {
if (task_id == CurrentTask().ID()) {
Finish(exit_code);
return;
}
auto it = std::find_if(tasks_.begin(), tasks_.end(),
[task_id](const auto& t){ return t.get()->ID() == task_id; });
tasks_.erase(it);
finish_tasks_[task_id] = exit_code;
if (auto it = finish_waiter_.find(task_id); it != finish_waiter_.end()) {
auto waiter = it->second;
finish_waiter_.erase(it);
Wakeup(waiter);
}
}
その他、ターミナルのカーソル回りで気になった以下の箇所を修正してみました。
-
Terminalクラスに点滅処理が可能かを示すcan_bllink_cursor_を追加しました。-
Printメソッドなどで、カーソルを消してから処理を行い、処理後に再度カーソルを表示する箇所があります。そのような場所ではカーソル点滅用タスクからカーソルを点滅させることを想定していないように見えたので、追加しました。 -
カーソル点滅用のタスクからは、
TerminalクラスのGetCanBlinkCursorメソッドでアクセスできるようにしています。
-
-
Scroll1メソッドやExecuteLineメソッドでRedrawを呼んで再描画するように修正しました。- スクロール時におかしな位置にカーソルが出たり、コマンド実行時にカーソルの点灯が残ってしまうことがあったため、修正しています。
コード全体が気になる方はこのリポジトリのterminal.hpp, terminal.cpp, task.hpp, task.cppあたりをご覧ください。
まとめ
つまづいたところの解決で数日使ったところもありましたが、デバッグを通じて仕組みの理解につながったのと、Linuxでのデバッグ方法 8 に触れることができたので、結果として良かったと思います。
-
2025年のGW中に友人たちと15章まで読み進め、10月から一人で続きを再開していました。 ↩
-
本の中ではTaskと呼ばれていました。 ↩
-
普段は意識しなくても良いような仕様も、OS側にはその仕様に従った処理を作っていく必要がありますね。 ↩
-
昔のバージョンのld.lldだとセクションのアドレスが4KiBページのアドレス境界と一致するようになっていたようですが、ld.lldのバージョン10から仕様が変わったようです。 uchan-nos/mikanosの修正コミット ↩
-
長年C++には触れてきているつもりですが、あまり
while(1);のようなコードを書くことが無かったため、この仕様は知りませんでした。 ↩ -
MikanOSのイベントシステムは、ウィンドウごとにイベントを管理できるようにする、受け取るイベントの種類にフィルタをかけられるようにするといった改善の余地がありそうかなと思いました。 ↩
-
この部分が、自分がMikanOSに書いた大きな独自実装となります。 ↩
-
gdbを使用してデバッグするようで、「UEFIアプリケーションをデバッグする」のページが参考になりました。 ↩
