64ビットモードへの移行
Kernel booting processもパート4になりました。4回目の今回は、プロテクトモードでの最初の一歩についてご紹介します。CPUがサポートするロングモード、SSE(ストリーミングSIMD拡張命令)、ページング方式、そしてページテーブルの初期化やロングモードへの移行のお話しです。
注:このパートでは、アセンブリ言語のソースコードが頻出しますので、知識がない方は、事前に参考書を読むなどして理解を深めておいてください。
前回のパートでは、arch/x86/boot/pmjump.S内にある32ビットのエントリーポイントにジャンプするところで終了しました。
jmpl *%eax
eaxレジスタには、32ビットのエントリーポイントのアドレスが含まれていたことを思い出してください。この点は、Linuxカーネルのx86ブートプロトコルから読み込むことができます。
When using bzImage, the protected-mode kernel was relocated to 0x100000
訳:bzImageを使うとき、保護モードのカーネルは0x100000に再配置されました
これが正しいか確認します。32ビットエントリーポイントでのレジスタ値を見てみましょう。
eax 0x100000 1048576 ecx 0x0 0 edx 0x0 0 ebx 0x0 0 esp 0x1ff5c 0x1ff5c ebp 0x0 0x0 esi 0x14470 83056 edi 0x0 0 eip 0x100000 0x100000 eflags 0x46 [ PF ZF ] cs 0x10 16 ss 0x18 24 ds 0x18 24 es 0x18 24 fs 0x18 24 gs 0x18 24
ここで、csレジスタに0x10(前回のパートで説明したグローバルディスクリプタテーブルにある2つ目のインデックスです)が、eipレジスタに0x100000、そしてコードセグメントを含む全てのセグメントのベースアドレスにゼロが含まれているのが分かります。つまり、ブートプロトコルにあるように、0:0x100000もしくは0x100000といった物理アドレスを取得することができます。では、32ビットエントリーポイントから始めていきましょう。
32ビットエントリーポイント
32ビットエントリーポイントの定義は、arch/x86/boot/compressed/head_64.Sから見つけることができます。
__HEAD
.code32
ENTRY(startup_32)
....
....
....
ENDPROC(startup_32)
まず、なぜcompressedディレクトリなのでしょう? 実はbzimageは、gzip圧縮されたvmlinux + header + kernel setup codeです。カーネルのセットアップコードは、これまでの全てのパートにも出てきました。head_64.Sの主な目的は、ロングモードに入る準備、ロングモードへ移行、そしてカーネルを展開することです。カーネルの展開と併せて全てのステップをこのパートで説明していきます。
なお、arch/x86/boot/compressedディレクトリには、2つのファイルがあることに注意してください。
- head_32.S
- head_64.S
ここでは、x86_64のLinuxカーネルを学ぶので、head_64.Sだけを使用します。head_32.Sは、今回のケースではコンパイルしていません。では、arch/x86/boot/compressed/Makefileを見ていきましょう。以下のターゲットがあります。
vmlinux-objs-y := $(obj)/vmlinux.lds $(obj)/head_$(BITS).o $(obj)/misc.o \
$(obj)/string.o $(obj)/cmdline.o \
$(obj)/piggy.o $(obj)/cpuflags.o
$(obj)/head_$(BITS).oに注目してください。これは、head_{32,64}.oのコンパイルが$(BITS)値に左右されることを示しています。他のMakefileにも同様のコードがあります – arch/x86/kernel/Makefile。
ifeq ($(CONFIG_X86_32),y)
BITS := 32
...
...
else
...
...
BITS := 64
endif
どこから始めればいいのか分かったので、早速実践してみましょう。
必要に応じてセグメントを再読み込みする
上の項目で記載したようにarch/x86/boot/compressed/head_64.Sから始めます。まず、startup_32の定義の前を見ます。
__HEAD
.code32
ENTRY(startup_32)
__HEADは、include/linux/init.hで定義されており、以下のようになっています。
#define __HEAD .section ".head.text","ax"
このセクションについては、arch/x86/boot/compressed/vmlinux.lds.Sリンカスクリプトから見つけられます。
SECTIONS
{
. = 0;
.head.text : {
_head = . ;
HEAD_TEXT
_ehead = . ;
}
ここで、. = 0;に注目してください。.は、ロケーションカウンタと呼ばれるリンカの特殊変数です。これに割り当てられる値は、セグメントのオフセットからのオフセットとなります。ここでは0を割り当てていますが、コメントには以下のように書かれています。
Be careful parts of head_64.S assume startup_32 is at address 0.
訳:Startup_32の一部は、head_64.Sがアドレス0(ゼロ)であると見なすことに注意する
これで、今どこにいるのかがわかります。。次にstartup_32関数を見ていきましょう。
startup_32の最初にDFフラグを消去するためのcld命令があります。この後、stosbといった文字列オペレーションや他のものがesiまたはediインデックスレジスタをインクリメントします。
次にloadflagsからKEEP_SEGMENTSフラグのチェックを見ます。arch/x86/boot/head.S(ここではCAN_USE_HEAPフラグをチェックしました)内にあったloadflagsを覚えていますか? ここでは、KEEP_SEGMENTSフラグをチェックする必要があります。このフラグの説明は、linuxブートプロトコルに掲載されています。
Bit 6 (write): KEEP_SEGMENTS
Protocol: 2.07+
- ゼロであれば、32ビットエントリーポイントでセグメントレジスタを再読み込みする。
- 1であれば、32ビットエントリーポイントでセグメントレジスタを再読み込みしない。
%cs %ds %ss %esは全て0(ゼロ)のベースと併せてフラットセグメント(または、それぞれの環境に同等)に設定すると見なす。
KEEP_SEGMENTSが設定されていない場合は、以下のようにds、ss、そしてesレジスタをベース0(ゼロ)のフラットセグメントに設定する必要があります。
testb $(1 << 6), BP_loadflags(%esi)
jnz 1f
cli
movl $(__BOOT_DS), %eax
movl %eax, %ds
movl %eax, %es
movl %eax, %ss
__BOOT_DSが0x18(グローバルディスクリプタテーブルにあるデータセグメントのインデックス)であることを忘れないでください。KEEP_SEGMENTSが設定されていない場合は1fラベルにジャンプするか、__BOOT_DSフラグが設定されている場合のみ、これと併せてセグメントレジスタをアップデートします。
前回のパートを読んだ方は、arch/x86/boot/pmjump.S内のセグメントレジスタがアップデートされているのを覚えているでしょう。ではなぜ、再度設定する必要があるのでしょうか。実際、Linuxカーネルは32ビットブートプロトコルを持っています。ですから、ブートローダがコントロールからカーネルへ移行した直後に実行される最初の関数は、startup_32となります。
KEEP_SEGMENTSフラグをチェックし、正しい値をセグメントレジスタに置いたら、次は実行するために配置した場所とコンパイルした場所の差を計算します(セクションの最初には. = 0がsetup.ld.Sに含まれていることを思い出してください)。
leal (BP_scratch+4)(%esi), %esp
call 1f
1: popl %ebp
subl $1b, %ebp
ここでは、esiレジスタにboot_params構造のアドレスが含まれており、boot_paramsにはオフセットである0x1e4と併せて特殊フィールドであるscratchが含まれています。scratchフィールドのアドレス + 4バイトを取得し、これをespレジスタに置きます(これらの計算のスタックとして使用します)。その後、コール命令とそのオペランドである1fラベルを見ることができます。さて、callとはどういう意味でしょうか。これは、ebp値をスタック、esp値、そして関数の引数にプッシュし、最後にあるアドレスに返します。この後、スタックからのリターンアドレスをebpレジスタにポップし(ebpにはリターンアドレスが含まれます)、先のラベル1のアドレスを差し引きます。
そうすると、ebp(0x100000)に配置した場所にアドレスが入ります。
次に、スタックをセットアップし、CPUがロングモードとSSEをサポートしていることを検証したいと思います。
スタックのセットアップとCPUの検証
カーネルを展開するための新しいスタックをセットアップするアセンブリ言語のコードを見ていきます。
movl $boot_stack_end, %eax
addl %ebp, %eax
movl %eax, %esp
boots_stack_endは、.bssセクションにあります。この定義は、head_64.Sの最後にあります。
.bss
.balign 4
boot_heap:
.fill BOOT_HEAP_SIZE, 1, 0
boot_stack:
.fill BOOT_STACK_SIZE, 1, 0
boot_stack_end:
まずは、boot_stack_endのアドレスをeaxレジスタに置き、それをebpの値に追加します(ここでは、ebpに0x100000を配置したアドレスが含まれていることを思い出してください)。最後に、eax値をexpに置きます。これで、正しいスタックポインタが得られます。
次はCPUの検証です。CPUがlong modeとSSEをサポートしていることをチェックする必要があります。
call verify_cpu
testl %eax, %eax
jnz no_longmode
cpuid命令に対していくつかの呼び出しを含んでいるarch/x86/kernel/verify_cpu.Sからverify_cpu関数を呼び出すだけです。cpuidはプロセッサに関する情報を入手するために使われる命令です。今回の場合は、これでロングモードとSSEのサポートをチェックし、成功すれば0を、失敗すれば1をeaxレジスタに返します。
eaxがゼロでなければ、no_longmodeラベルにジャンプします。これによってhlt命令がCPUを停止させ、どんなハードウェア割り込みもができなくなります。
no_longmode:
1:
hlt
jmp 1b
スタックのセットアップ、CPUの検証ができたので、次のステップに進みましょう。
再配置されたアドレスの計算
ここでは、展開が必要だった場合の再配置されたアドレスの計算について説明していきます。アセンブリ言語のコードは以下のようになります。
#ifdef CONFIG_RELOCATABLE
movl %ebp, %ebx
movl BP_kernel_alignment(%esi), %eax
decl %eax
addl %eax, %ebx
notl %eax
andl %eax, %ebx
cmpl $LOAD_PHYSICAL_ADDR, %ebx
jge 1f
#endif
movl $LOAD_PHYSICAL_ADDR, %ebx
1:
addl $z_extract_offset, %ebx
まずは、CONFIG_RELOCATABLEマクロに注目してください。この設定オプションは、arch/x86/Kconfigで定義されており、以下の説明が記載されています。
これは、再配置情報を維持するカーネルイメージをビルドするので、デフォルトの1メガバイトに加えて、どこにでも配置することができます。 注:もし、CONFIG_RELOCATABLE=yであれば、カーネルは読み込まれたアドレスから起動し、コンパイル時の物理アドレス(CONFIG_PHYSICAL_START)は、最小値のロケーションとして使われます。
簡単に言うと、このコードはカーネルを移動させるためのアドレスの計算になります。カーネルは展開のために、カーネルが再配置可能、もしくはbzimageがLOAD_PHYSICAL_ADDRの上で展開するのであればebxレジスタにそれを置きます。
コードを見てみましょう。カーネルの設定ファイルにCONFIG_RELOCATABLE=nがあれば、ebxレジスタにLOAD_PHYSICAL_ADDRを置き、ebxにz_extract_offsetを追加します。今はebxがゼロなので、これにはz_extract_offsetが含まれます。では、これら2つの値を理解していきましょう。
LOAD_PHYSICAL_ADDRは、arch/x86/include/asm/boot.hで定義されたマクロです。以下のようになります。
#define LOAD_PHYSICAL_ADDR ((CONFIG_PHYSICAL_START \
+ (CONFIG_PHYSICAL_ALIGN - 1)) \
& ~(CONFIG_PHYSICAL_ALIGN - 1))
ここでは、アラインされたアドレスを計算します。これは、カーネルが読み込まれた(0x100000もしくは、この場合では1メガバイト)アドレスです。PHYSICAL_ALIGNは、カーネルがアラインされるべきアライメント値です。その値の領域は、x86_64であれば0x200000から0x1000000となります。デフォルト値では、LOAD_PHYSICAL_ADDRで2メガバイトを得ることができます。
>>> 0x100000 + (0x200000 - 1) & ~(0x200000 - 1) 2097152
アライメントユニットを抽出したら、2メガバイトにz_extract_offset(この場合は、0xe5c000となります)を追加します。最終的に、17154048バイトのオフセットを得ることができます。z_extract_offseは、arch/x86/boot/compressed/piggy.Sにあります。このファイルは、mkpiggyプログラムによってコンパイル時に生成されます。
では、CONFIG_RELOCATABLEがyである場合のコードを理解していきましょう。
まず、ebp値をebxに置きます(ebpは、読み込んだアドレスが含まれていることを思い出してください)。そして、カーネルセットアップヘッダのkernel_alignmentフィールドをeaxレジスタに配置します。kernel_alignmentは、カーネルで必要とされるアライメントの物理アドレスです。次に、前のケース(カーネルが再配置できない場合)と同様のことを行いますが、アラインユニットとして、kernel_alignmentフィールドの値を、CONFIG_PHYSICAL_ALIGNとLOAD_PHYSICAL_ADDRの代わりにベースアドレスとしてebx(読み込んだアドレス)を使います。
アドレスを計算したら、LOAD_PHYSICAL_ADDRで比較し、これに再度z_extract_offsetを追加するか、もし計算されたアドレスが必要以下の値であれば、ebxにLOAD_PHYSICAL_ADDRを置きます。
これらの計算を行うことで、読み込んだアドレスを含むebpと、展開のために移動させられたカーネルのアドレスがあるebxが得られます。
ロングモードに入る前の準備
64ビットモードに移行する前の最後の準備です。まずは、グローバルディスクリプタをアップデートする必要があります。
leal gdt(%ebp), %eax
movl %eax, gdt+2(%ebp)
lgdt gdt(%ebp)
ここでは、ebpからのアドレスをgdtでオフセットしたものをeaxレジスタに置きます。次に、そのアドレスをgdt+2でオフセットしたものをebpに置き、lgdt命令でグローバルディスクリプタテーブルを読み込みます。
グローバルディスクリプタテーブルの定義は以下の通りです。
.data
gdt:
.word gdt_end - gdt
.long gdt
.word 0
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00af9a000000ffff /* __KERNEL_CS */
.quad 0x00cf92000000ffff /* __KERNEL_DS */
.quad 0x0080890000000000 /* TS descriptor */
.quad 0x0000000000000000 /* TS continued */
これは.dataセクションと同じファイル内に定義されており、中には5つのディスクリプタが含まれています。nullディスクリプタ、カーネルコードセグメント用のディスクリプタ、カーネルデータセグメント用のディスクリプタ、そして2つのタスクディスクリプタです。GDTは前のパートと同様の方法で読み込みますが、64ビットモードで実行するため、ディスクリプタのビットはCS.L = 1、CS.D = 0.とします。
GDTを読み込んだ後は、PAE(物理アドレス拡張)モードを有効にしないといけません。それには、以下のようにcr4レジスタの値をeaxに置いて、5ビット目を設定してから、再度cr4の中に読み込みます。
movl %cr4, %eax
orl $X86_CR4_PAE, %eax
movl %eax, %cr4
ここまで来れば、64ビットモードに移行する準備はほとんど終わりです。最後のステップはページテーブルの作成ですが、その前にロングモードについて少し説明しましょう。
ロングモード
ロングモードはx86_64プロセッサ用のネイティブモードです。まずはx86_64とx86の違いについて見てみましょう。
ロングモードでは、以下のような機能がサポートされています。
*r8からr15までの8つの新しい汎用レジスタと全ての汎用レジスタは64ビットです。
* RIP(64ビットの命令ポインタ)
* ロングモード(新しいオペレーティングモード)
* 64ビットのアドレスとオペランド
* RIP相対アドレス指定(次のパートで例を紹介する予定です)
ロングモードはレガシーのプロテクトモードを拡張したもので、以下の2つのサブモードから構成されます。
- 64ビットモード
- 互換モード
64ビットモードに切り替えるには、次の処理が必要です。
- PAEの有効化(上述の通り、既に有効にしてあります)
- ページテーブルの作成と、
cr3レジスタへのトップレベルページテーブルのアドレスの読み込み EFER.LMEの有効化- ページングの有効化
既にcr4レジスタ内でPAEビットを設定しているので、PAEは有効になっています。次はページングについて説明します。
初期ページテーブルの初期化
64ビットモードに移行する前に、ページテーブルを作成する必要があります。では、初期の4Gブートページテーブルの作成について見ていきましょう。
注:ここでは仮想メモリの理論については説明しません。詳細を知りたい方は、本ページの一番下にあるリンクをご覧ください。
Linuxカーネルは4レベルのページングを使用しているので、通常は以下の6つのページテーブルを作成します。
- PML4テーブル1つ
- PDPテーブル1つ
- ページディレクトリテーブル4つ
では、実装を見ていきましょう。まずはメモリ内のページテーブル用のバッファをクリアします。各テーブルは4096バイトなので、24キロバイトのバッファが必要です。
leal pgtable(%ebx), %edi
xorl %eax, %eax
movl $((4096*6)/4), %ecx
rep stosl
ebxに保存されているアドレスと(ebxには、カーネルを再配置して展開するためのアドレスが含まれています)オフセットのpgtableをediレジスタに置きます。pgtableは以下のような内容で、head_64.Sの最後に定義されています。
.section ".pgtable","a",@nobits
.balign 4096
pgtable:
.fill 6*4096, 1, 0
これは.pgtableセクションにあり、サイズは24キロバイトです。アドレスをediに置いた後は、eaxレジスタをゼロアウトし、rep stosl命令でバッファにゼロを書き込みます。
これで、以下のようにするとトップレベルページテーブルPML4が作成できます。
leal pgtable + 0(%ebx), %edi
leal 0x1007 (%edi), %eax
movl %eax, 0(%edi)
ここでは、オフセットのpgtableとebxに格納されたアドレスを取得し、それをediに置いています。次に、このアドレスを0x1007でオフセットしてeaxレジスタに置きます。0x1007は4096バイト(PML4のサイズ) + 7(PML4エントリーフラグのPRESENT+RW+USER)です。そして、eaxをediに置きます。この処理の後、ediにはPRESENT+RW+USERフラグの設定された最初のページ・ディレクトリ・ポインタ・エントリーのアドレスが格納されている状態になります。
次のステップでは、ページ・ディレクトリ・ポインタ・テーブル内に、4つのページディレクトリを作成します。ここでは、最初のエントリーには0x7のフラグが、その他のエントリーには0x8のフラグが設定されます。
leal pgtable + 0x1000(%ebx), %edi
leal 0x1007(%edi), %eax
movl $4, %ecx
1: movl %eax, 0x00(%edi)
addl $0x00001000, %eax
addl $8, %edi
decl %ecx
jnz 1b
ページ・ディレクトリ・ポインタ・テーブルのベースアドレスをediに、最初のページ・ディレクトリ・ポインタ・エントリーのアドレスをeaxに置きます。そしてecxレジスタに4を置くと、これは以下のループ内のカウンタになり、最初のページ・ディレクトリ・ポインタ・テーブルのアドレスをediレジスタに書き込みます。
これでediには、0x7フラグが設定された最初のページ・ディレクトリ・ポインタ・エントリーのアドレスが入ります。そして、以下の0x8フラグが設定された以下のページ・ディレクトリ・ポインタ・エントリーのアドレスを計算して、そのアドレスをediに書き込みます。
次のステップでは、2メガバイトを使って2048ページ・テーブル・エントリーを作成します。
leal pgtable + 0x2000(%ebx), %edi
movl $0x00000183, %eax
movl $2048, %ecx
1: movl %eax, 0(%edi)
addl $0x00200000, %eax
addl $8, %edi
decl %ecx
jnz 1b
ここでは前の例とほぼ同じ処理をしています。1つ違うのは、最初のエントリーにはフラグ$0x00000183、つまりPRESENT + WRITE + MBZが、他のエントリーには0x8が設定されていることです。これで、2メガバイトの2048ページが完成します。
作成した初期ページテーブルは、4ギガバイトのメモリをマッピングするので、cr3コントロールレジスタにあるハイレベルページテーブルPML4のアドレスを格納できます。
leal pgtable(%ebx), %eax
movl %eax, %cr3
必要な処理は全て完了したので、これでロングモードに移行できます。
ロングモードへの移行
まずはMSRにあるEFER.LMEフラグを、0xC0000080に設定する必要があります。
movl $MSR_EFER, %ecx
rdmsr
btsl $_EFER_LME, %eax
wrmsr
ここではMSR_EFERフラグ(arch/x86/include/uapi/asm/msr-index.hで定義されています)をecxレジスタに格納し、rdmsr命令を呼び出して、MSRレジスタを読み込みます。rdmsrが実行されると、ecx値に応じて、edx:eaxにあるデータが結果として得られます。btsl命令でEFER_LMEビットをチェックし、wrmsr命令でeaxのデータをMSRレジスタに書き込みます。
次のステップでは、カーネルセグメントコードのアドレスをスタック(GDTの中に定義されています)にプッシュして、startup_64ルーチンのアドレスをeaxに格納します。
pushl $__KERNEL_CS
leal startup_64(%ebp), %eax
その次は、このアドレスをスタックにプッシュして、cr0レジスタにあるPGビットとPEビットを設定して、ページングを有効にします。
movl $(X86_CR0_PG | X86_CR0_PE), %eax
movl %eax, %cr0
そして、以下の呼び出しをします。
lret
前のステップでは、startup_64関数のアドレスをスタックにプッシュしました。そして、このlret命令の後は、CPUがそのアドレスを抽出して、そこにジャンプします。
これらのステップを全て終えると、最終的に64ビットモードに移行できます。
.code64
.org 0x200
ENTRY(startup_64)
....
....
....
これでおしまいです!
まとめ
カーネル起動処理 パート4の記事は以上です。
質問やご意見がありましたら、TwitterやEメールでメ
ッセージを送信していただくか、こちらからissueを作成してください。
次のパートでは、カーネルの展開などについて説明します。
英語は私の第一言語ではないことをご承知おきください。誤りを見つけた方は
linux-internalsに、プルリクエストを送ってください。