Open27

【ゼロから作るOS自作入門】M1 MacでOSを作り、Goで実装できるようになるまで日誌。

まきぞうまきぞう

動機

先日DMM.goというイベントに参加し、清水さん( https://github.com/atsumarukun )という新卒のエンジニアとお話しする機会を得た!
その時、OSを自作した経験(アーキテクチャの考え方やメモリ使用量などを意識したコードを書いた知識)が実際の業務でも間接的に役立っていると伺い、将来的にコンテナ技術に強いSREになりたい僕としてはやってみたいと思い、始めることにした。
また、書籍で作ったOSを自分が得意になりたい言語で書き換えたりすると言語仕様の理解がすごく深まると伺い、やってみることにした。

方向性

まずは書籍通りに進めていき、M1 mac上でOSを動作させるところまで一周する。
その次に既存コードをGO言語に置き換えていくようにして進めていく。

このスクラップはつぶやきを含めて良い

エラーに立ち会って解決した記録として役に立つと思うが、基本的には学んだことをアウトプットするのが主旨なので、知ったことや思ったことなど、なんでも投稿しても良い。書体を気にする必要もないし、イラストとかも自分のために必要だと思ったら作るで良い。

終わらせることより理解することを意識しよう!!

技術書を読む上で感じている課題として、読んで終わってしまうというものがある。
原因として

  • 終わらせることを目的にしてしまう(目的と手段の入れ替わり)
  • 読んで終わり(なぜ?と深掘りせずに終わってしまう)

の2つがあると思っている。
これらの状況が起こるのを防ぐために、以下のことを意識しよう

  • 終わらせることを目的にしない。
    • 期限はなし
    • ページ数が進まないことは全く問題ではない
    • 理解せず進んでしまうことの方が問題
    • (期限が迫っているから、わからなくてもとりあえず終わらせよう)を防ぐ
  • 途中でも、「なぜ?」とか、「イメージがわかない」と思ったら手を止めて調べる。
    • 理解することを意識!
    • 疑問に思ったことは、スクラップにまとめてみよう!
    • 調べてわかったら、スクラップにまとめてみよう!

期限がない代わりにお約束

過去に書籍の内容をものにできずに途中で終わってしまった原因として

  • この書籍の内容が本当に役に立つのかわからなくなる
  • 実際の業務で使うことのイメージがわかない

と思い、「飛ばしても問題ないのでは?」と思ってしまうことが多い。
なので、約束として

  • 絶対に飛ばさない
  • 絶対に途中で諦めない
  • 直接業務に使わない知識なのはわかった上で楽しむ

を掲げてやっていこう。

OS作りを楽しみ、理解していく。
を目的として進めていく。

まきぞうまきぞう

「コンピュータシステムの理論と実装」にて、プログラミング言語が動くためにどのように機械語に変換されて、CPUが動き、そしてCPUがどうやって動いているかを学んできた。
実際のメモリ領域の振り方やバイナリの処理方法などについては理解できたが、OSについてはまだ理解が浅い状態だな。
SREとして活動するために武器の一つとして確立したいOSの知識を学ぶために、自作してみるのが一番だと思った!

まきぞうまきぞう

OSって既存のOSから自分で機能を追加することもできるのか。
確かに、LinuxもOSSだから、forkして機能つければ独自のOSだよなw

まきぞうまきぞう

OSって明確な定義はないんだな。
けどもインターフェースとしての役割を持っていることは間違いない。

  • 人間がアプリケーションを操作できるようにする(人間 ↔︎ アプリケーション)
  • アプリケーションがデバイスを操作できるようにする(アプリケーション ↔︎ 入出力機器)
  • アプリケーションが計算できるようにする(アプリケーション ↔︎ CPU)
まきぞうまきぞう

使いやすいインターフェースを提供できていることは、優れたOSである要素の一つ

まきぞうまきぞう

OSがないと、周辺機器の操作をすべてアプリケーションがやらなければならないので、たとえば新しいキーボードを買ったらそのキーボードとコンピュータを接続するためのアプリケーションを別途入れないといけないのか。。。
めちゃくちゃ大変やん

まきぞうまきぞう

詳細(どうやって実装されているか)を隠してインターフェースを実装することを抽象化と呼ぶ

まきぞうまきぞう

ネットワークも抽象化されている

OSはネットワーク通信の抽象化もしてくれている。
僕たちがPCをインターネットに繋いでブラウザを眺めたり、メールを送ったり、Slackでコミュニケーションをしている間にOSがネットワークの処理をになってくれている。

  • 伝送方式は?有線?無線?
  • 通信規格は?TCP?UDP?SMTP?
  • パケットをどうやって受信しているの?
  • 逆にどうやってパケットに分割しているの?
  • どうやって送信しているの?
  • 。。。。

OSがこういった関心ごとを抽象化してくれるおかげでアプリケーションは通信について細かい実装を気にしなくて済む!

まきぞうまきぞう

CPU(計算資源)をどう使うかも抽象化されている

プログラムがが処理を行う際、CPUによって命令が実行されていく。
ただ、プログラムからはCPUやメモリがどんな状態であるかやどんな命令を実行するのかを気にしなくても実行できる。
これもOSがCPUとアプリケーションのインターフェースになってくれているおかげで計算資源をどう使うかを抽象化してくれいているおかげだ。

  • CPUにどのくらいの命令を実行させる?
  • メモリをどのくらい割り当てて記憶できるようにする?

複数のアプリやOS自身も活動できるように計算資源を分配するのもOSの役目!

まきぞうまきぞう

インターフェースが統一されていると、実装が異なっても同じ使い方ができる。
OSのインターフェースもある程度統一しようという思想があり、共通のインターフェースとしてPOSIX(PotableOperating System Interface)というものがあるのか!

まきぞうまきぞう

システムコールはアプリケーションとハードウェアのインターフェース!

アプリ側で直接メモリなりディスクなりをいじれてしまうと、サードパーティアプリを入れた時に意図せず情報を抜き取られてしまって危ない!
セキュリティの観点から、OSがシステムコールという命令を提供することで、意図せずハードウェアへ命令が送信されてしまうことを防ぐ!

まきぞうまきぞう

UTMでx86のエミュレートするとスペック的に厳しいな。。。
遅すぎて開発どころではない。

まきぞうまきぞう

M1 Macで環境構築をし、Hello Worldを出力してみる

結構つまづいたので、m1 macで「ゼロから始めるOS自作入門」をやる人用にまとめとく。

まず準備として、MIKANOSの開発用のディレクトリを作成しておく。(ここではmikanos-devとする)

【環境構築】コンテナイメージを取得

まず、2つのイメージファイルを取得する必要がある。

m1 mac上だとCPUアーキテクチャの関係でツールが動かなかったりする場合があるので、mikanos-dockerコンテナ上でqemuを実行し、それをvncを使ってブラウザ上でリモート操作するという方向で進めていく。

// MIKANOS開発用のディレクトリに移動
cd path/to/mikan-os-dev
git clone https://github.com/sarisia/mikanos-docker/tree/master
git clone https://github.com/sarisia/mikanos-devcontainer/tree/master

m1 mac環境用に変える

まず、mikanos-devcontainer/.devcontainerをmikanos-devディレクトリに移動させる

mv -r mikanos-devcontainer/.devcontainer .

次に、

次に、devcontainerの設定をいじっていく。

vncに対応したイメージを元にdevcontainerを作成するよう、設定を変える。

		// Set `vnc` to spin up noVNC services. Useful in GitHub Codespaces.
		"args": {
-			"VARIANT": "latest"
		}

		// Set `vnc` to spin up noVNC services. Useful in GitHub Codespaces.
		"args": {
+			"VARIANT": "vnc"
		}

vnc用の設定をコメントアウト解除

-	// "forwardPorts": [
-	// 	6080
-	// ],
-	// "overrideCommand": false,
-	// "containerEnv": {
-	// 	// Port for noVNC Web Client & WebSocket
-	// 	"NOVNC_PORT": "6080",
-	// 	// VNC port QEMU listens. Default to 5900 + <display number>
-	// 	// If you run QEMU with "-vnc :1", then VNC_PORT should be 5901.
-	// 	"VNC_PORT": "5900",
-	// 	// QEMU launch options. Used in `run_image.sh`
-	// 	"QEMU_OPTS": "-vnc :0"
-	// },

+	"forwardPorts": [
+		6080
+	],
+	"overrideCommand": false,
+	"containerEnv": {
+		// Port for noVNC Web Client & WebSocket
+		"NOVNC_PORT": "6080",
+		// VNC port QEMU listens. Default to 5900 + <display number>
+		// If you run QEMU with "-vnc :1", then VNC_PORT should be 5901.
+		"VNC_PORT": "5900",
+		// QEMU launch options. Used in `run_image.sh`
+		"QEMU_OPTS": "-vnc :0"
+	},

cloneした段階でいくつかオプションの形式が古くなっているので、以下のように変更する

-	"settings": { 
-		"terminal.integrated.shell.linux": "/bin/bash"
-	},

-	"extensions": [
-		"ms-vscode.cpptools"
-	],
+	"customizations": {
+		// Set *default* container specific settings.json values on container create.
+		"settings": {
+			"terminal.integrated.shell.linux": "/bin/bash"
+		},
+		// Add the IDs of extensions you want installed when the container is created.
+		"vscode": {
+			"extensions": [
+				"ms-vscode.cpptools"
+			]
+		}
+	},

devcontainer自体のセットアップは公式のチュートリアルがあるのでそちらでインストールする。
https://code.visualstudio.com/docs/devcontainers/tutorial

mikanos-devにて、devcontainerを起動
><ボタンを押下して

「コンテナで再度開く」を選択すると開く

【Hello World出力】バイナリファイルをイメージファイルに書き込み、実行!

hello worldのバイナリを描きたいところだが、m1 macでoktetを使うのはだるいと判断。
そこで、hex fiendを使うことにした。
brewでインストールできる。

brew install hex-fiend

hex-fiendコマンドを実行すると、アプリが起動する(デスクトップ画面からGUIで開いてもOK)

hex-fiend

書籍29Pのバイナリを書くのは厳しそうなので、既存のファイルを持ってきてバイナリエディタで確認するようにする。
https://github.com/uchan-nos/mikanos-build/blob/master/day01/bin/hello.efi
にファイルがあるので、ダウンロード

// mikanos-devに持ってくる
mv ~/Downloads/hello.efi .

hello.efiのまま進めてしまうとなぜかコンピュータ起動時にStart PXE Over IPv4と表示されてしまうので、hello.efiをリネーム。
https://ymzkmtfm.hatenablog.com/entry/2021/03/06/150359

mv hello.efi BOOTX64.EFI

あとは、書籍P35のエミュレータ通りにコマンドを実行していく。

qemu-img create -f raw disk.img 200M
mkfs.fat -n 'MIKAN OS' -s 2 -f 2 -R 32 -F 32 disk.img
mkdir -p mnt
sudo mount -o loop disk.img mnt
sudo mkdir -p mnt/EFI/BOOT
sudo cp BOOTX64.EFI mnt/EFI/BOOT/BOOTX64.EFI
sudo umount mnt

これで、起動イメージの生成は完了。
次にqemuを使ってエミュレータを使ってコンピュータを起動するのだが、書籍のコマンドの尻尾に-vnc :0オプションをつけることを忘れずに。

qemu-system-x86_64 -drive if=pflash,file=$HOME/osbook/devenv/OVMF_CODE.fd -drive if=pflash,file=$HOME/osbook/devenv/OVMF_VARS.fd -hda disk.img -vnc :0

そうすると、ブラウザからvncを使ってqemuで起動したコンピュータにアクセスできるので、ブラウザでhttp://localhost:6080にアクセスし、接続すると...

done!
おめでとう!

まきぞうまきぞう

16進数と2新数を区別するためにそれぞれ

  • 16進数: 0x~~
  • 2進数: 0b~~
    と表現するんだな。

例えば、
0x01(1)とか0x11(17)とか
0b01(1)とか0b11(3)とか

まきぞうまきぞう

コンピュータに電源が入った後の流れ

  1. CPUがBIOSの実行を始める
  2. BIOSが周辺機器やコンピュータ本体を初期化する
  3. BIOSがストレージを探索する
  4. ストレージの中で実行ファイルを見つけた場合、その実行ファイルをメインメモリに読み出す
  5. BIOSの実行を中止し、読み出した実行ファイルを実行する。
BIOSとは?

コンピュータの電源を入れてから最初に実行されるプログラム。
Basic Input Output Systemの略。

コンピュータのメモリは電源が途切れると消える。
何もない状態でCPUが実行可能になっても、実行対象の命令が存在せず、動作しなくなってしまう。

なので、電源が途切れても内容が消えない命令メモリ(ROM)にBIOSを書き込んでおき、CPUが最初に命令を実行できるようになっている。

今回はBIOSがBOOTX64.EFIを読み込むことでコンピュータが起動したので、Hello Worldと出力された。

ここで、BIOSによって読み込まれる実行ファイルのことをUEFIアプリと呼ぶ。

まきぞうまきぞう

C言語でHello Worldしてみる。

バイナリエディタでUEFIアプリを書くのは至難の業。
なので、プログラミング言語を使って実装していく。
devcontainerを使えば、C言語の実行環境を整えなくても良いので便利。

clangコマンドでcファイルをコンパイル。

clang -target x86_64-pc-win32-coff -mno-red-zone -fno-stack-protector -fshort-wchar -Wall -c hello.c

lld-linkコマンドでPE形式の実行可能ファイルを作る。

lld-link /subsystem:efi_application /entry:EfiMain /out:hello.efi hello.o
まきぞうまきぞう

C++コンパイラオプションについて

フリースタンディング: OSがなくても動作する環境
ホスト: OS上で動くプログラムのための環境

まきぞうまきぞう

C++ファイルをコンパイルし、オブジェクトファイル(main.o)を生成する。

clang++ -O2 -Wall -g --target=x86_64-elf -ffreestanding -mno-red-zone -fno-exceptions -fno-rtti -std=c++17 -c main.cpp

オブジェクトファイルから実行可能ファイル(kernel.elf)を生成する。

ld.lld --entry KernelMain -z norelro --image-base 0x100000 --static -o kernel.elf main.o
まきぞうまきぞう

書籍のC言語ファイルを読んでてわからなかったところまとめ

C言語を全く経験していないマンなので、手を動かしていきながら学びつつOS作っていく。

*ってなんなん?

ポインタ変数が示すアドレスにおけるデータの値を参照する演算子。

&ってなんなん?

変数のアドレスにアクセスする演算子。

*と&をおさらいすると。。。

#include <stdio.h>

int main(void)
{
  int data;

  // int型のポインタ変数pointerを宣言
  // int型の変数のメモリアドレスが格納される
  int *pointer;

  // int型の変数dataのメモリアドレスをpointerに代入
  pointer = &data;

  data = 100;

  // pointerはそのまま出力するとメモリアドレスが表示される
  printf("address of pointer = %p\n", pointer);

  // *演算子を使うことでポインタが示すメモリアドレスにアクセスできる。
  printf("data of pointer = %d\n", *pointer);

  return 0;
}

結果

address of pointer = 0x16ee9b0e8
data of pointer = 100

【値なの?アドレスなの?と迷う人へ】ポインタとアドレス見分け表

アドレス
int data;と宣言 data &data
int *pointer;と宣言 *pointer pointer
int型の変数に*をつけるとどうなるの?

コンパイラエラーが発生する。

  printf("data = %d\n", *data);
// オペランド '*' はポインターである必要がありますが、型 "int" が指定されています
ポインタ変数に&をつけるとどうなるの?

コンパイル時にワーニングが発生する。
実際の開発時にはlinterとかでエラー発生させるようにしたいね。

  printf("pointer = %d\n", &pointer);
$ gcc main.c
main.c:22:28: warning: format specifies type 'int' but the argument has type 'int **' [-Wformat]
  printf("pointer = %d\n", &pointer);

.ってなんなん?

構造体が示すフィールドにアクセスする演算子。

#include <stdio.h>

struct user
{
  char name[8];
  int age;
};

int main(void)
{
  struct user hirokei = {"hirokei", 21};

  printf("Name: %s\n", hirokei.name);
  printf("Age: %d\n", hirokei.age);

  return 0;
}
Name: hirokei
Age: 21

->ってなんなん?

ポインタ変数が指す構造体のフィールドへアクセスする演算子。
元々(*構造体).フィールドの形でも表現できるが、可読性のためにアロー演算子は積極的に使っていきたい。

#include <stdio.h>

struct user
{
  char name[8];
  int age;
  int *height;
};

int main(void)
{
  int height = 180;
  struct user hirokei = {"hirokei", 21, &height};

  struct user *pointerHirokei = &hirokei;

  printf("pointerHirokei.Name: %s\n", (*pointerHirokei).name);
  printf("pointerHirokei.Name use arrow: %s\n", pointerHirokei->name);

  printf("pointerHirokei.Height: %d\n", *(*pointerHirokei).height);
  printf("pointerHirokei.Height use arrow: %d\n", *pointerHirokei->height);

  return 0;
}

結果

pointerHirokei.Name: hirokei
pointerHirokei.Name use arrow: hirokei
pointerHirokei.Height: 180
pointerHirokei.Height use arrow: 180

このページが参考になった。

まきぞうまきぞう

C言語の習得に時間がかかりすぎてる & もうすでに書籍の内容が単語から理解できていないので、1章からやり直す。

C言語でhello worldにおけるhello.cのメモ

typedef

typedefは既存のデータ型に新しい名前を付与する宣言。

typedef unsigned short CHAR16;

この場合、CHAR16型はunsigned shortという型を指し、符号なし整数を表現する。

unsignedは修飾子で、符号を気にしない(負の値を扱わない)という意味である。

shortは整数を扱うデータ型で、特に、使用するバイトが2バイト(16ビット)の整数である。

longは8バイトの整数を扱うデータ型。

https://www.sejuku.net/blog/25690

typedef unsigned long long EFI_STATUS;

long longが二つ続く意味は、long long型の整数を示し、これはlong型である8バイトよりも大きな整数を扱うときに使う型である。

typedef void *EFI_HANDLE;

これは、voidのポインタ型に新しい名前、EFI_HANDLEを付与する記述。

voidのポインタ型って?

汎用ポインタ、またはジェネリックポインタと呼ばれる。

任意の方のデータへのポインタとして使用できる。

特定の方を持たないメモリ領域を指すために使用される。

EFIシステムにおいてさまざまなオブジェクトへの参照として使用される。

構造体の宣言について

struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL;

struct宣言なのに、フィールドが宣言されていない。。。
これは、C言語における不完全型というものらしい。
構造体の詳細を定義せずにそのポインタを使用できるようにするために用いられる。

構造体の定義(どんなフィールドを持つかとか)は後で行われることを示す。

まきぞうまきぞう
build.py...
 : error 000E: File/directory not found in workspace
                /workspaces/os-self-made/edk2/MikanLoaderPkg/MikanLoaderPkg.dsc (Please give file in absolute path or relative to WORKSPACE)

上記のエラーはln -sコマンドにてシンボリックリンクが正しく貼れていないのが原因だった。
lnコマンドの引数に入れたパスがあっているか確認すると、大体間違ってる。