とある事情で06/30までに「ゼロからのOS自作入門」を読み終えたいと思うようになりました。
自律のため毎日勉強したことを投稿しようと思うのでよろしくお願いします。
QEMUモニタ
QEMU を動作させている間であれば、ユーザ側からの操作を受け付けるためのモニタコンソールが起動されます。モニタコンソール内ではコマンドを入力して実行することができますので、ここからリムーバブルメディアの変更やスクリーンショットの採取、オーディオのキャプチャなど、仮想マシンに対するさまざまな制御を行うことができます。
https://www.belbel.or.jp/opensuse-manuals_ja/cha-qemu-monitor.html
-
コマンドはここらへんに一覧としてある
-
仮想メモリダンプ(メモリのアドレス付近の値を表示)するコマンド↓
x / 書式 アドレス
アドレス で指定したアドレスを開始点として、 書式 文字列に従って仮想メモリダンプを出力します。このとき、 書式 には カウント, 形式, サイズ をそれぞれ指定します:
カウント パラメータには表示すべき項目数を指定します。
形式 には x (16 進数), d (符号付き 10 進数), u (符号無し 10 進数), o (8 進数), c (char 型) or i (アセンブラインストラクション) のいずれかを指定します。
サイズ パラメータには b (8 ビット), h (16 ビット), w (32 ビット) ,g (64 ビット) のいずれかを指定します。 x86 の場合、 i で h や w を指定することで、 16 ビットと 32 ビットのインストラクションサイズを選択することができます。
-
(qemu) info registers
ででたRIPのアドレスでメモリダンプしてみる(前回自分でUEFIアプリケーションを作ったので、本とは異なる)。
(qemu) x /4xb 0x3e665249
000000003e665249: 0xeb 0xfe 0x55 0x41
(qemu) x /2i 0x3e665249
0x000000003e665249: jmp 0x3e665249
0x000000003e66524b: push %rbp
なるほど確かに自分のアドレスにジャンプしている。
レジスタ
(qemu) info registers
RAX=0000000000000000 RBX=0000000000000000 RCX=000000003fb7b1c0 RDX=0000000000000002
RSI=000000003fec93d0 RDI=000000003e7741c0 RBP=000000003e7741c8 RSP=000000003feaca50
R8 =00000000000000af R9 =0000000000000288 R10=0000000000000050 R11=0000000000000000
R12=000000003e774418 R13=000000003effef18 R14=8000000000000002 R15=0000000000000131
RIP=000000003e665249 RFL=00000202 [-------] CPL=0 II=0 A20=1 SMM=0 HLT=0
ES =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
CS =0038 0000000000000000 ffffffff 00af9a00 DPL=0 CS64 [-R-]
SS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
DS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
FS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
GS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
LDT=0000 0000000000000000 0000ffff 00008200 DPL=0 LDT
TR =0000 0000000000000000 0000ffff 00008b00 DPL=0 TSS64-busy
GDT= 000000003fbee698 00000047
IDT= 000000003f306018 00000fff
CR0=80010033 CR2=0000000000000000 CR3=000000003fc01000 CR4=00000668
DR0=0000000000000000 DR1=0000000000000000 DR2=0000000000000000 DR3=0000000000000000
DR6=00000000ffff0ff0 DR7=0000000000000400
EFER=0000000000000500
FCW=037f FSW=0000 [ST=0] FTW=00 MXCSR=00001f80
FPR0=0000000000000000 0000 FPR1=0000000000000000 0000
FPR2=0000000000000000 0000 FPR3=0000000000000000 0000
FPR4=0000000000000000 0000 FPR5=0000000000000000 0000
FPR6=0000000000000000 0000 FPR7=0000000000000000 0000
XMM00=00000000000000000000000000000000 XMM01=00000000000000000000000000000000
XMM02=00000000000000000000000000000000 XMM03=00000000000000000000000000000000
XMM04=00000000000000000000000000000000 XMM05=00000000000000000000000000000000
XMM06=00000000000000000000000000000000 XMM07=00000000000000000000000000000000
XMM08=00000000000000000000000000000000 XMM09=00000000000000000000000000000000
XMM10=00000000000000000000000000000000 XMM11=00000000000000000000000000000000
XMM12=00000000000000000000000000000000 XMM13=00000000000000000000000000000000
XMM14=00000000000000000000000000000000 XMM15=00000000000000000000000000000000
- このRAXからR15が汎用レジスタで、多分それ以外が特殊レジスタ
- たしかに16桁なので64 bit (8 B)
- アセンブリ言語というのを使えば、人間でも機械語をかけるようになる
アセンブリ言語(アセンブリげんご、英: assembly language)とは、コンピュータなどのプログラミング言語の1種で、原則として機械語の命令に1対1で対応した、人間に理解しやすい文字列や記号で記述される。いわゆる高水準言語に対して低水準言語と分類される[1]
プロセッサが直接実行できる言語は、機械語である。しかし機械語は2進数の羅列なので、人間には極めて理解しにくい。そこで機械語を直接書くのではなく、ニーモニックと呼ぶ命令語でプログラムを記述することで、人間により分かりやすくしたものがアセンブリ言語である。ただしアセンブリ言語の意味は 後述のように個々のプロセッサに依存し、プロセッサを介したハードウェア制御も含むため多くの場合は同一プロセッサを用いていてもソフトウェアの互換性は限定的である。
アセンブリ言語 - Wikipedia
- 命令をオペコード (operation code, opcode) 、操作される対象をオペランド(被演算子)という
初めてのカーネル(osbook_day03a)
-
ブートローダをUEFIアプリケーションで作って、カーネルはELFバイナリとして作る
-
初めてのカーネルは無限ループするもの
-
C++ではマングリングというものがあるがCにはない
-
C++のファイルに
extern "C"
をつけてコンパイルすると、マングリングが不要なものについてはしなくなるためCプログラムとのリンクが成功する(https://www.koikikukan.com/archives/2017/07/04-000300.php) -
Cプログラムの中でアセンブリ言語の命令を使うときには
__asm__()
を使う -
ここでは
__asm__("hlt")
とする -
hlt
はCPUを止めるが割り込みがあったら再開させる命令 -
(読書&コードにコメントをつけた)
-
osbook_day03aを実行してRIPでの命令を見ると、たしかに本の通りループになっているみたい
// #@@range_begin(call_kernel)
UINT64 entry_addr = *(UINT64 *)(kernel_base_addr + 24);
typedef void EntryPointType(void);
EntryPointType* entry_point = (EntryPointType*)entry_addr;
entry_point();
// #@@range_end(call_kernel)
これでカーネルが起動する理由がわからなかったので、Discordで質問したところ、お答えしていただきました。
最初このentry_point()
はカーネルのベースアドレスを格納しているだけのように見えました。しかしCでは関数のポインタ(関数ポインタ)を呼び出すと、そのメモリに展開された関数が呼び出されたのと同じになるらしいです。
#include <stdio.h>
void f(){ // なんらかの関数
printf("Hello\n");
}
int main(){
void* f_addr = &f; // 関数fのアドレスを取得
void (*g)() = f_addr; // 関数ポインタを宣言、fのアドレスで初期化
g(); // 関数ポインタを呼び出すとf()を呼び出したのと同じになるみたい
// f(); // 上の行はこれと同じ
return 0;
}
$ gcc test.c && ./a.out
Hello
おそらくブートローダはカーネルを実行するのに次のような手順を踏んでいると理解しました。
-
kernel.elf
をメモリに展開 - メモリに展開された
KernelMain()
のベースアドレスを取得 - そのベースアドレスを元に、関数ポインタを利用して間接的に
KernelMain()
を実行
たぶん直接KernelMain()
を実行することができない?からこのように起動しているのだと思います。
ブートローダからピクセルを描く(osbook_day03b)
- フレームバッファ(ピクセルに描画するための値を敷き詰めたメモリ領域)
UINT8 *frame_buffer = (UINT8 *)gop->Mode->FrameBufferBase; //frame buffer base address
for (UINTN i = 0; i < gop->Mode->FrameBufferSize; ++i){
frame_buffer[i] = 255; // all white
}
-
というところ、
frame_buffer
はポインタ(に見える)のにframe_buffer[i] = 255;
としていいのな? -
とりあえず本にあるように白い画面を描画できた。
カーネルからピクセルを描く(osbook_day03c)
-
前章ではブートローダからピクセルを白塗りしていたが、それをカーネルでやる
-
カーメルのソースコードにそれを追加、
frame_buffer_base
とframe_buffer_size
は引数で渡るようにした -
ブートローダの
Main.c
にもカーメルにそれらの引数を渡すように設定した -
カーネルの
main.cpp
で<cstdin>
を使うのに、用意されていたスクリプトでパスを通した -
ビルドしなおして起動すると、一瞬白く塗りつぶされた画面が映って(ブートローダ)、その直後画面がしましま模様になった(カーネル)
エラー処理をしよう(osbook_03d)
- OSは安定して動いて欲しいプログラムなのでエラー処理は必須
- ブートローダの中で動作に失敗したら、カーネルの起動を中断することをやる
写経をしてわかったことは、
-
while (1) __asm__("hlt");
を関数Halt()
にまとめた -
EFI_STATUS
型の変数status
を用意して、いろんな関数の中でstatus = file->Open()
とする - エラーならばそれらの関数は
status
を返す -
EfiMain()
内で、それらの関数を使い、エラーならば文言とEFI_STATUS(status)
をprintしてHalt()
ということらしい。(printのところで\n
がないものがあったけど大丈夫かな?)
- 写経はしたけど、うまくいくか確認の仕方はないのかな
ポインタのキャスト
- ポインタとは「指し示す先のアドレスと型を組み合わせたもの」(引用しました。大事だなと思います)
- ポイントへキャストすることで、整数の配列を、同じ場所にstructureがあるかのように読み書きできる
- この方法を使うならアライメント(メモリアドレスの制約)に気をつけて、本来は本にあるようにきちんと書いた方がいい。(https://ja.wikipedia.org/wiki/%E3%83%87%E3%83%BC%E3%82%BF%E6%A7%8B%E9%80%A0%E3%82%A2%E3%83%A9%E3%82%A4%E3%83%A1%E3%83%B3%E3%83%88)
ポインタ入門(2):ポインタとアセンブリ言語
- p. 82コンパイルとあるが、乗っているアセンブラのファイルはどうやって見るのか?
追記
g++
などのコンパイラで-S
オプションを使うとアセンブリ言語のファイル(.s
)が出力される。
.sは、asmコンパイラの出力に使用されます。 (gcc -S foo.cは、デフォルトのファイル名がfoo.sのasm出力を生成します)
https://www.it-swarm-ja.com/ja/file/%E3%82%A2%E3%82%BB%E3%83%B3%E3%83%96%E3%83%AA%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%EF%BC%9Aa-s-asm%E3%81%AE%E9%81%95%E3%81%84/1056242888/
.s
ファイルは.asm
ファイルと同じとみなして良い?
#include <iostream>
void foo(){
int i = 42;
int* p = &i;
int r1 = *p;
*p = 1;
int r2 = i;
uintptr_t addr = reinterpret_cast<uintptr_t>(p);
int* q = reinterpret_cast<int*>(addr);
}
このファイル(foo.cpp
)を
g++ -S foo.cpp # used g++ on macOS. Using clang++ may work too.
でコンパイルすると、以下の内容のfoo.s
が出力された。
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 11, 0 sdk_version 11, 3
.globl __Z3foov ## -- Begin function _Z3foov
.p2align 4, 0x90
__Z3foov: ## @_Z3foov
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl $42, -4(%rbp)
leaq -4(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl (%rax), %ecx
movl %ecx, -20(%rbp)
movq -16(%rbp), %rax
movl $1, (%rax)
movl -4(%rbp), %ecx
movl %ecx, -24(%rbp)
movq -16(%rbp), %rax
movq %rax, -32(%rbp)
movq -32(%rbp), %rax
movq %rax, -40(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
これは本にあるアセンブリファイルとは異なるが、たぶんCPUのアーキテクチャが違うなどの違いがあるらしく、あまり気にしないことにした。大体は本と同じになっているっぽい。
用語
https://qiita.com/tobira-code/items/75d3034aed8bb9828981
こちらの記事がわかりやすかった。
スタック(stack)
- メインメモリ上のデータ保存領域
- プログラム実行中の一時的なデータ置き場
- データを入れることをpush, 取り出すことをpopという
- LIFO(Last In First Out)方式で、popするときは直近pushされたデータが取り出される
- 山積みの干し草に、上から積んで、上から取るイメージ
SS(スタックセグメント)レジスタ
- スタックの配置されるメモリ領域を指定するレジスタ
- 値はどうなっているのか? 検索したが定義は見つからなかった。スタックセグメントの最初のアドレスなのか、最後のアドレスを保持しているのか?
- (レジスタなので、もちろんCPUの中にある)
- 保持する値はOSによって決まる
RSP(スタックポインタ)レジスタ
スタック領域の中で次にデータを書き込むべき番地を保持する
CPUがスタックへの書き出しを指示するとスタックポインタの値は書き込んだデータの長さの分だけ増え、読み出しを指示すると読み出されたデータの長さだけ減る (https://e-words.jp/w/%E3%82%B9%E3%82%BF%E3%83%83%E3%82%AF%E3%83%9D%E3%82%A4%E3%83%B3%E3%82%BF.html#:~:text=%E3%82%B9%E3%82%BF%E3%83%83%E3%82%AF%E3%83%9D%E3%82%A4%E3%83%B3%E3%82%BF%E3%81%A8%E3%81%AF%E3%80%81%E3%83%9E%E3%82%A4%E3%82%AF%E3%83%AD,%E4%BF%9D%E6%8C%81%E3%81%99%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AE%E3%82%82%E3%81%AE%E3%80%82)
- (解釈)rspの値は、スタック領域にデータをpushするとその分だけ減り、popするとその分だけ増える
- イメージ:「下に行くほどアドレスが増えていくメモリの絵」をイメージすると、データをpushする(積む)と先頭のアドレスは減り、popする(積んでいたものを上からとる)と先頭のアドレスは増えるのが理解できる。
スタックポインタは、スタックの最上段のアドレスを保持するレジスタで、スタック内で最後に参照されたアドレスを保持しています。(https://www.ap-siken.com/kakomon/24_aki/q10.html)
espは現在使用しているスタックの一番上を指します。(https://kataware.hatenablog.jp/entry/2017/12/02/224444)
- 64-bitだとRSPで、32-bitだとESPなど、アーキテクチャによって名前は変わる(http://ext-web.edu.sgu.ac.jp/koike/CA14/assembler_content.html)
RBP(ベースポインタ)レジスタ
ベースポインタは、平たく言うと「今実行中の関数が使用しているスタック領域の底」です。底だからベースね。
このベースポインタは当然各関数ごとに変わるわけなので、関数の最初と最後で値を変更する処理が書かれています。
(https://note.com/nekotricolor/n/n2a247c808275)
動作
Function prologue
関数が始まる時には2行の操作が行われる。
__Z3foov:
push rbp
mov rbp, rsp
- 呼び出している関数のベースポインタの値(
rbp
)をスタックに積んでおく(メモリの絵をイメージするとrspは上に成長している) -
rbp
を現在のrsp
の値に更新する
Function epilogue
関数が終わるときの操作
感想、疑問
-
毎日1章と行っていたが、二日も開いてしまった。やっと13%読み終わった。こりゃ6/30までに読み切るの無理かもなぁ
-
本で出てくるコードブロックなどは、シンタックスハイライトされていると分かりやすいと思った。
-
mapkeyってなんだっけ
-
なぜカーネルファイルは
0x100000
番地に配置する設定にしているんだ? -
だんだんわかってきて楽しくなってきたな。
-
わからないことは多いけど、一つ一つ勉強して習得するのが好きだなぁ
-
最後のアセンブリ言語のところはもっと詳しく書いてほしかった
参考、使ったリンク
-
MikanOS ソースコード:https://github.com/uchan-nos/mikanos
-
MikanOS 開発環境:https://github.com/uchan-nos/mikanos-build
-
osdev-jp:https://osdev.jp/
-
著者(uchan)のTwitter:@uchan_nos
-
- SlackやDiscordでサポート、勉強会(2021年3月27日から)を行っているらしい! 途中からかもだけど参加してみよっと。