Linux Insides : カーネル起動プロセス part4

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が設定されていない場合は、以下のようにdsss、そして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_DS0x18(グローバルディスクリプタテーブルにあるデータセグメントのインデックス)であることを忘れないでください。KEEP_SEGMENTSが設定されていない場合は1fラベルにジャンプするか、__BOOT_DSフラグが設定されている場合のみ、これと併せてセグメントレジスタをアップデートします。

前回のパートを読んだ方は、arch/x86/boot/pmjump.S内のセグメントレジスタがアップデートされているのを覚えているでしょう。ではなぜ、再度設定する必要があるのでしょうか。実際、Linuxカーネルは32ビットブートプロトコルを持っています。ですから、ブートローダがコントロールからカーネルへ移行した直後に実行される最初の関数は、startup_32となります。

KEEP_SEGMENTSフラグをチェックし、正しい値をセグメントレジスタに置いたら、次は実行するために配置した場所とコンパイルした場所の差を計算します(セクションの最初には. = 0setup.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のアドレスを差し引きます。

そうすると、ebp0x100000)に配置した場所にアドレスが入ります。

次に、スタックをセットアップし、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の値に追加します(ここでは、ebp0x100000を配置したアドレスが含まれていることを思い出してください)。最後に、eax値をexpに置きます。これで、正しいスタックポインタが得られます。

次はCPUの検証です。CPUがlong modeSSEをサポートしていることをチェックする必要があります。

    call    verify_cpu
    testl   %eax, %eax
    jnz no_longmode

cpuid命令に対していくつかの呼び出しを含んでいるarch/x86/kernel/verify_cpu.Sからverify_cpu関数を呼び出すだけです。cpuidはプロセッサに関する情報を入手するために使われる命令です。今回の場合は、これでロングモードとSSEのサポートをチェックし、成功すれば0を、失敗すれば1eaxレジスタに返します。

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を置き、ebxz_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_RELOCATABLEyである場合のコードを理解していきましょう。

まず、ebp値をebxに置きます(ebpは、読み込んだアドレスが含まれていることを思い出してください)。そして、カーネルセットアップヘッダのkernel_alignmentフィールドをeaxレジスタに配置します。kernel_alignmentは、カーネルで必要とされるアライメントの物理アドレスです。次に、前のケース(カーネルが再配置できない場合)と同様のことを行いますが、アラインユニットとして、kernel_alignmentフィールドの値を、CONFIG_PHYSICAL_ALIGNLOAD_PHYSICAL_ADDRの代わりにベースアドレスとしてebx(読み込んだアドレス)を使います。

アドレスを計算したら、LOAD_PHYSICAL_ADDRで比較し、これに再度z_extract_offsetを追加するか、もし計算されたアドレスが必要以下の値であれば、ebxLOAD_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 = 1CS.D = 0.とします。

GDTを読み込んだ後は、PAE(物理アドレス拡張)モードを有効にしないといけません。それには、以下のようにcr4レジスタの値をeaxに置いて、5ビット目を設定してから、再度cr4の中に読み込みます。

    movl    %cr4, %eax
    orl $X86_CR4_PAE, %eax
    movl    %eax, %cr4

ここまで来れば、64ビットモードに移行する準備はほとんど終わりです。最後のステップはページテーブルの作成ですが、その前にロングモードについて少し説明しましょう。

ロングモード

ロングモードはx86_64プロセッサ用のネイティブモードです。まずはx86_64x86の違いについて見てみましょう。

ロングモードでは、以下のような機能がサポートされています。

*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には、カーネルを再配置して展開するためのアドレスが含まれています)オフセットのpgtableediレジスタに置きます。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)

ここでは、オフセットのpgtableebxに格納されたアドレスを取得し、それをediに置いています。次に、このアドレスを0x1007でオフセットしてeaxレジスタに置きます。0x1007は4096バイト(PML4のサイズ) + 7(PML4エントリーフラグのPRESENT+RW+USER)です。そして、eaxediに置きます。この処理の後、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の記事は以上です。

質問やご意見がありましたら、TwitterEメールでメ
ッセージを送信していただくか、こちらからissueを作成してください。

次のパートでは、カーネルの展開などについて説明します。

英語は私の第一言語ではないことをご承知おきください。誤りを見つけた方は
linux-internalsに、プルリクエストを送ってください。


リンク