POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
0xAX

本記事は、原著者の許諾のもとに翻訳・掲載しております。

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.

訳:Startup32の一部は、head64.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 は、カーネルがアラインされるべきアライメント値です。その値の領域は、x8664であれば 0x200000 から 0x1000000 となります。デフォルト値では、 ` LOADPHYSICAL_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ビットモードに移行する準備はほとんど終わりです。最後のステップはページテーブルの作成ですが、その前にロングモードについて少し説明しましょう。

ロングモード

ロングモードはx8664プロセッサ用のネイティブモードです。まずは ` x8664 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 には、カーネルを再配置して展開するためのアドレスが含まれています)オフセットの 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 に、プルリクエストを送ってください。


リンク

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。