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に、プルリクエストを送ってください。