ブートローダからカーネルまで
これまでの私の ブログ投稿を読まれた方はご存じかと思いますが、しばらく前から低水準言語を使うようになりました。Linux用x86_64アセンブリ言語プログラミングについても書いています。また、同時にLinuxのソースコードにも触れるようになりました。下層がどのように機能しているのか、コンピュータでプログラムがどのように実行されるのか、どのようにメモリに配置されるのか、カーネルがどのように処理や記憶をするのか、下層でネットワークスタックがどのように動くのかなどなど、多くのことを理解しようと意欲が湧いています。これをきっかけに、x86_64版Linuxカーネルについてシリーズを書いてみようと思いました。
私はプロのカーネルプログラマではないことと、仕事でもカーネルのコードを書いていないことをご了承ください。個人的な趣味です。私は下層で何が起きているのかとても興味があり、単に好きなのです。読んでいて私が勘違いしている点、ご質問やご意見がありましたら、ツイッター(0xAX)やメールでお知らせいただくか、もしくはGithubでIssueを作成してください。助かります。全ての投稿は linux-insides からアクセスできますので、ぜひ読んでみて下さい。また、私の英文が間違っていたり内容に問題があったりした場合は、お気軽にご連絡ください。
これは正式なドキュメントではありません。あくまでも学習のためや知識共有のためのものですのでご注意ください。
必要な知識
- Cコードの理解
- アセンブリコード(AT&T記法)の理解
ツールについて学び始めている人のために、本稿やそれ以降の投稿の中で説明を入れるようにします。簡単な前置きはここまでにして、本題のカーネルと下層のものに入りましょう。
本稿で紹介するコードはLinuxカーネル3.18のものです。変更が生じた場合は、その都度更新します。
魔法の電源ボタン。次に何が起きるのか。
本連載はLinuxカーネルについてのシリーズなのですが、カーネルコードからは始めません(少なくともこの段落では)。ノートパソコンやデスクトップは魔法の電源ボタンを押すと起動します。マザーボードが電源回路に信号を送り、コンピュータに適量の電力が供給されます。マザーボードが電気を流して大丈夫という信号を受けると、CPUが起動します。CPUによってレジスタに残されたデータはリセットされ、所定の値が設定されます。
80386 や後継のCPUでは、コンピュータがリセットされると次の所定のデータがCPUレジスタに定義されています。
IP 0xfff0 CS selector 0xf000 CS base 0xffff0000
リアルモードでプロセッサは動作を始めます。少し戻ってこの動作モードのセグメント方式を理解しましょう。リアルモードは、8086を始め、最新のIntel64ビットCPUまでのx86互換のプロセッサに導入されています。8086プロセッサには20ビットアドレスバスがあります。つまり、0-2^20バイト(1メガバイト)のアドレス空間を利用できます。しかし16ビットのレジスタしかなく、16ビットのレジスタが使用できるアドレスは最大で2^16 , または0xffff(64KB)までです。セグメント方式は、アドレス空間すべてを利用するために用いられる方法です。全てのメモリは65535バイトまたは64キロバイトの固定長の小さなセグメントに分けられます。16ビットレジスタでは、64キロバイト以上のメモリ位置にアクセスできないので、別の方法でアクセスします。アドレスは、前半のセグメントアドレス部分とオフセットアドレスの2つの部分で構成しています。メモリ内の物理アドレスを割り出すには、セグメントアドレスに16をかけ、オフセットアドレス部分を足します。
PhysicalAddress = Segment * 16 + Offset
例えば、CS:IP
が0x2000:0x0010
の場合、物理アドレスは次のようになります。
>>> hex((0x2000 << 4) + 0x0010) '0x20010'
しかし、セグメント部分とオフセット部分を両方最大にした場合、つまり0xffff:0xffff
の場合は次のようになります。
>>> hex((0xffff << 4) + 0xffff) '0x10ffef'
つまり、最初の1メガバイトよりも65519バイトオーバーしていることになります。リアルモードでアクセスできるのは最大で1メガバイトのため、A20ラインが無効になっていると0x10ffef
は0x00ffef
になります。
リアルモードとメモリアドレスが分かったところで、リセット後のレジスタの値について説明しましょう。
CS
レジスタは、見えるセグメントセレクタと隠れたベースアドレスの2つの部分で構成されています。CS
ベースとIP
の値は既知なので、論理アドレスは次にようになります。
0xffff0000:0xfff0
ベースアドレスをEIPレジスタの値に足して、アドレスバスの最初の部分が生成されます。
>>> 0xffff0000 + 0xfff0 '0xfffffff0'
その結果、0xfffffff0
ができ、4GB-16byteになります。このポイントをリセットベクタと呼びます。このメモリ配置には、リセット後にCPUが最初に実行するプログラムが置かれています。これには、JMP命令が含まれ、通常はBIOSのエントリポイントを指しています。例えば、corebootのソースコードを見ると、次のように書かれています。
.section ".reset" .code16 .globl reset_vector reset_vector: .byte 0xe9 .int _start - ( . + 2 ) ...
JMP命令のオペコードである0xe9と、その宛先アドレスである_start - ( . + 2)
があります。また、reset
セクションが16バイトで0xfffffff0
から始まることが分かります。
SECTIONS { _ROMTOP = 0xfffffff0; . = _ROMTOP; .reset . : { *(.reset) . = 15 ; BYTE(0x00); } }
ここでBIOSが実行されます。ハードウェアの初期化とチェックを行い、ブートできるデバイスを探します。ブート順位はBIOS設定に保存されており、カーネルがどのデバイスを使用して起動するのかを操作します。ハードドライブから起動しようとする場合、BIOSはブートセクタを探そうとします。ハードディスクがMBRのパーティションレイアウトによってパーティション分割されている場合、ブートセクタは最初のセクター(512バイト)の最初の446バイトに置かれています。最初のセクターの最後2バイトは0x55
と0xaa
で、BIOSにこのデバイスが起動可能であることを知らせます。例えば次のようになります。
; ; Note: this example is written in Intel Assembly syntax ; [BITS 16] [ORG 0x7c00] boot: mov al, '!' mov ah, 0x0e mov bh, 0x00 mov bl, 0x07 int 0x10 jmp $ times 510-($-$$) db 0 db 0x55 db 0xaa
これを実行します。
nasm -f bin boot.nasm && qemu-system-x86\_64 boot
上のコードがQEMUにディスクイメージとして作成したboot
バイナリコードを使用するよう命令します。上のアセンブリコードによって生成されるバイナリコードはブートセクタの要件(はじめが0x7c00
に設定され、マジックシーケンスで終点を指定)を満たしているので、QEMUはそのバイナリコードをディスクイメージのMBRセクタとして扱います。
次のとおりです。
この例では、16ビットリアルモードでコードが実行され、メモリの0x7c00から始まります。実行が始まると、0x10 が呼び出され、!
マークが出力されます。残りの510バイトを0で埋め、2つのマジックバイト0xaa
と0x55
で終わります。
上のバイナリダンプはobjdump
で見ることができます。
nasm -f bin boot.nasm objdump -D -b binary -mi386 -Maddr16,data16,intel boot
実際のブートセクタの場合、この続きは多くの0や感嘆府ではなく、起動処理とパーティションテーブルになります。これ以降はBIOSではなくブートローダが操作します。
注:上でも書いたようにCPUはリアルモードで動作します。リアルモードでは、メモリ内の物理アドレスを次のように計算します。
PhysicalAddress = Segment * 16 + Offset
前述したように、16ビットの汎用レジスタしかなく、16ビットレジスタの最大値は0xffff
のため、最大値を取ると次のようになります。
>>> hex((0xffff * 16) + 0xffff) '0x10ffef'
0x10ffef
は、1MB + 64KB - 16b
と同じになります。しかし、8086プロセッサは、リアルモードが搭載された初めてのプロセッサであり、A20ラインが有効です。また、2^20 = 1048576
は1MBなので、実際に使用可能なメモリは1メガバイトとなっています。
一般的なリアルモードでのメモリマップは次のとおりです。
0x00000000 - 0x000003FF - Real Mode Interrupt Vector Table 0x00000400 - 0x000004FF - BIOS Data Area 0x00000500 - 0x00007BFF - Unused 0x00007C00 - 0x00007DFF - Our Bootloader 0x00007E00 - 0x0009FFFF - Unused 0x000A0000 - 0x000BFFFF - Video RAM (VRAM) Memory 0x000B0000 - 0x000B7777 - Monochrome Video Memory 0x000B8000 - 0x000BFFFF - Color Video Memory 0x000C0000 - 0x000C7FFF - Video ROM BIOS 0x000C8000 - 0x000EFFFF - BIOS Shadow Area 0x000F0000 - 0x000FFFFF - System BIOS
本稿の最初の部分でも書きましたが、CPUが実行する最初の処理は0xFFFFFFF0
アドレスに配置されています。これは、0xFFFFF
(1メガバイト)よりはるかに大きい領域です。CPUはどのようにしてこのリアルモードでアクセスするのでしょうか。これはcorebootドキュメントに記載されています。
0xFFFE_0000 - 0xFFFF_FFFF: 128 kilobyte ROM mapped into address space
実行時、BIOSはRAMではなくROMに置かれています。
ブートローダ
GRUB 2やsyslinuxのような、Linux
を起動させることができるブートローダは数多くあります。Linuxカーネルは、Linuxサポートを実行するためのブートローダに必要な条件を指定するブートプロトコルを持っています。ここではGRUB 2について取り上げます。
BIOSはブートデバイスを選んで、ブートセクタコードに対する制御を伝達し、boot.imgから実行を開始します。このコードは、利用可能な空間が限られているため非常に単純であり、GRUB 2のコアイメージの位置へジャンプするためのポインタを含んでいます。コアイメージはdiskboot.imgで始まりますが、これは通常、最初のパーティションの前の、使用されていないスペースにある最初のセクタの直後に格納されます。上記のコードは残りのコアイメージをメモリにロードしますが、それにはGRUB 2のカーネルとファイルシステムを取り扱うためのドライバを含んでいます。残りのコアイメージをロードした後に、grub_mainを実行します。
grub_main
は、コンソールの初期化、モジュールのためのベースアドレスの取得、ルートデバイスの設定、GRUB設定ファイルの搭載/パース、モジュールのロードなどを行います。実行の最後には、grub_main
がGRUBを通常モードへ移動させます。(grub-core/normal/main.c
からの)grub_normal_execute
が最後の準備を完了させ、オペレーティングシステムを選択するためのメニューを表示します。GRUBメニューを選択する際に、grub_menu_execute_entry
が起動し、GRUBboot
コマンドを実行して、選択したオペレーティングシステムが作動します。
カーネルのブートプロトコルを見て分かるように、ブートローダはカーネルのセットアップヘッダを読み込み、いくつかのフィールドを満たさなければいけません。そしてそれは、カーネルの設定コードのオフセット0x01f1
から始まります。カーネルヘッダarch/x86/boot/header.Sは次のようにスタートします。
.globl hdr hdr: setup_sects: .byte 0 root_flags: .word ROOT_RDONLY syssize: .long 0 ram_size: .word 0 vid_mode: .word SVGA_MODE root_dev: .word 0 boot_flag: .word 0xAA55
ブートローダは、これと、(この例のようなLinuxブートプロトコルのwrite
でマークされている)残りのヘッダを、コマンドラインや計算から導き出される値で満たす必要があります。カーネルのセットアップヘッダの全てのフィールドの記述や説明についてはここれは触れません。後で、カーネルが使用する時に説明します。ブートプロトコルで全てのフィールドの記述を見つけることができます。
カーネルのブートプロトコルを見て分かるように、メモリマップはカーネルをロードした後、次のようになるでしょう。
| Protected-mode kernel | 100000 +------------------------+ | I/O memory hole | 0A0000 +------------------------+ | Reserved for BIOS | Leave as much as possible unused ~ ~ | Command line | (Can also be below the X+10000 mark) X+10000 +------------------------+ | Stack/heap | For use by the kernel real-mode code. X+08000 +------------------------+ | Kernel setup | The kernel real-mode code. | Kernel boot sector | The kernel legacy boot sector. X +------------------------+ | Boot loader | <- Boot sector entry point 0x7C00 001000 +------------------------+ | Reserved for MBR/BIOS | 000800 +------------------------+ | Typically used by MBR | 000600 +------------------------+ | BIOS use only | 000000 +------------------------+
そこでブートローダがカーネルに対する制御を転送したときに、以下で開始されます。
0x1000 + X + sizeof(KernelBootSector) + 1
X
がカーネルのブートセクタが搭載されている位置を示します。この場合は、X
が0x10000
で、メモリダンプに見て取れます。
ブートロードはLinuxカーネルをメモリへロードし、ヘッダのフィールドを満たし、そこへジャンプします。今は、カーネルの設定コードへ直接移動することができます。
カーネルセットアップを開始する。
ついにカーネルまでたどり着きました。しかし、このままでは、カーネルは起動しません。まず、カーネルとメモリ管理、プロセス管理などの設定が必要になります。カーネルのセットアップの実行は_startでarch/x86/boot/header.Sから開始します。いくつか命令が前にあって、ひと目見ると、少し違和感を覚えるかもしれません。
ひと昔前は、Linuxカーネルは自前のブートローダを持っていました。ですが、今は実行すると次のようになります。
qemu-system-x86\_64 vmlinuz-3.18-generic
次のような結果が見られるはずです。
実際は(画像の上にある)MZからheader.S
が開始され、PEヘッダに続いて、エラーメッセージが表示されます。
#ifdef CONFIG_EFI_STUB # "MZ", MS-DOS header .byte 0x4d .byte 0x5a #endif ... ... ... pe_header: .ascii "PE" .word 0
これにはUEFIモードでOSを起動することが必要です。今すぐにこれが稼働するかどうかを確かめることはしませんが、次の章の中の1つで見ていきましょう。
実際のカーネルセットアップのエントリポイントはこちらです。
// header.S line 292 .globl _start _start:
ブートローダ(grub2など)はこのポイント(MZ
からオフセット0x200
)を知っています。header.S
がエラーメッセージが表示される.bstext
セクションから始まっているにも関わらず、このエントリポイントへ直接ジャンプします。
// // arch/x86/boot/setup.ld // . = 0; // current position .bstext : { *(.bstext) } // put .bstext section to position 0 .bsdata : { *(.bsdata) }
カーネルセットアップのエントリポイントはこちらです。
.globl _start _start: .byte 0xeb .byte start_of_setup-1f 1: // // rest of the header //
ここではstart_of_setup-1f
のポイントに対するjmp
命令オペコード0xeb
が見て取れます。Nf
表記が意味するところは、2f
が次のローカル2:
ラベルを表しているということです。この場合、ジャンプした直後に行くのがラベル1
です。そこには残りのセットアップヘッダも含まれます。セットアップヘッダのすぐ後に、start_of_setup
ラベルで開始される.entrytext
を見られます。
実際にはこれが(もちろんジャンプ命令を除いて)最初に実行するコードです。カーネルセットアップがブートローダから制御された後に、最初のjmp
命令がカーネルのリアルモードの開始からオフセット0x200.
(最初は512バイト)に格納されます。これは次のLinuxのカーネルブートプロトコルとgrub2のソースコードを見て分かります。
state.gs = state.fs = state.es = state.ds = state.ss = segment; state.cs = segment + 0x20;
カーネルセットアップが始まった後、セグメントレジスタが以下の値を持つことを意味します。
gs = fs = es = ds = ss = 0x1000 cs = 0x1020
この場合は、カーネルが0x10000
に置かれます。
start_of_setup
にジャンプした後は、以下の作業が必要になります。
それでは実装を見てみましょう。
セグメントレジスタのアライメント
まず、セグメントレジスタのds
とes
が同じアドレスを指すようにし、cli
命令を実行して割り込みに応答しないようにします。
movw %ds, %ax movw %ax, %es cli
前述したとおり、GRUB 2はカーネルの設定コードをアドレス0x10000
に、cs
を0x1020
にロードします。なぜなら、実行はファイルの冒頭からではなく、以下のコードから開始されるからです。
_start: .byte 0xeb .byte start_of_setup-1f
jump
は4d 5aから512バイト離れたオフセットにあります。また、他の全てのセグメントレジスタと同じように、cs
を0x1020
から0x10000
までアラインする必要があります。それが終わったらスタックを設定します。
pushw %ds pushw $6f lretw
ds
の値をスタックにプッシュし、続けてラベル6のアドレスもスタックにプッシュすると、lretw
命令が実行されます。lretw
を呼び出すと、ラベル6
のアドレスがIP(instruction pointer)レジスタの中にロードされ、ds
の値を備えたcs
もロードされます。それが完了すると、ds
とcs
は同じ値を持つようになります。
スタックの設定
実際、ほぼ全ての設定コードが、リアルモードでC言語の開発環境を作る準備となります。次のステップではss
レジスタの値をチェックし、もしss
が間違っている場合は正しいスタックを設定します。
movw %ss, %dx cmpw %ax, %dx movw %sp, %dx je 2f
これは、異なる3つのシナリオを導くことが可能です。
ss
が有効値0x10000を持つ(cs
を除く全てのセグメントレジスタと同様)ss
は無効で、CAN_USE_HEAP
フラッグが設定されている(下記参照)ss
は無効で、CAN_USE_HEAP
フラッグが設定されていない(下記参照)
それでは、これら3つのシナリオを全て見てみましょう。
1. ss
は正しいアドレス(0x10000)を持つ。この場合は、ラベル2へと進みます。
2: andw $~3, %dx jnz 3f movw $0xfffc, %dx 3: movw %ax, %ss movzwl %dx, %esp sti
ここで、dx
(ブートローダによって与えられるsp
を含みます)が4バイトにアラインされ、ゼロになっているかどうか確認できます。ゼロの場合は0xfffc
(最大セグメントサイズの64KBより前で4バイトにアラインされたアドレス)をdx
内に置きます。ゼロでない場合は、引き続きブートローダに与えられたsp
(私の例では0xf7f4)を使います。正しいセグメントアドレス0x10000
を格納しているss
にax
の値を置いた後で、正しいsp
の値を設定します。これで正しいスタックが設定できました。
2. 2つ目のシナリオでは(ss
!=ds
)となります。まず、_end(設定コードの最後のアドレス)の値をdx
に置き、loadflags
のヘッダフィールドをtestb
命令を使ってチェックし、ヒープ領域を使えるかどうかを確認します。loadflagsは、以下のように定義されるビットマスクヘッダです。
#define LOADED_HIGH (1<<0) #define QUIET_FLAG (1<<5) #define KEEP_SEGMENTS (1<<6) #define CAN_USE_HEAP (1<<7)
また、ブートプロトコルを読むと、以下のように書かれています。
Field name: loadflags This field is a bitmask. Bit 7 (write): CAN_USE_HEAP Set this bit to 1 to indicate that the value entered in the heap_end_ptr is valid. If this field is clear, some setup code functionality will be disabled.
(訳: heap_end_ptrに入力された値が有効であることを示すために、このビットを1にしてください。もしこのフィールドがからの場合、いくつかのセットアップコードの機能は無効になります。)
CAN_USE_HEAP
のビットが設定された場合は、_end
を指すdx
にheap_end_ptr
を置き、そこにSTACK_SIZE
(最小スタックのサイズは512バイト)を加えます。これ以降、dx
がキャリーでない場合(キャリーでなければ、dx = _end + 512となる)、これの前のケースと同じようにラベル2
にジャンプし、正しいスタックを作ります。
3. CAN_USE_HEAP
が設定されていない場合は、_end
から_end + STACK_SIZE
まで、最小スタックを使うだけです。
BSSの設定
メインのCコードにジャンプする前に、あと2つステップが残っています。それはBSS領域の設定と”マジック”シグネチャの確認です。それでは、まずシグネチャの確認から始めましょう。
cmpl $0x5a5aaa55, setup_sig jne setup_bad
これは単純に、setup_sigをマジックナンバー0x5a5aaa55
と比べているだけです。この2つが等しくなければ、Fatal errorが表示されます。
マジックナンバーと等しくなれば、正しいセグメントレジスタとスタックを設定できたことが分かり、あとはBSSセクションさえ設定すればCコードにジャンプすることができます。
BSSセクションは、静的にアロケートされた初期化されていないデータを保存するために使われます。Linuxでは以下のコードを使い、このメモリ領域が必ず最初は空になるように設定します。
movw $__bss_start, %di movw $_end+3, %cx xorl %eax, %eax subw %di, %cx shrw $2, %cx rep; stosl
最初に__bss_startのアドレスがdi
に移動し、次に_end + 3
(+3は4バイトにアラインされている)のアドレスがcx
に移動します。eax
レジスタは消去され(xor
命令を使います)、BSSセクションのサイズ(cx-di
)が計算されてcx
の中に置かれます。それから、cx
は4(=語長)で除算され、stosl
命令を繰り返しdi
が指すアドレスにeax
の値(ゼロ)を格納して、di
は自動的に4ずつ増加します(これはcx
がゼロになるまで続きます)。このコードの実際の効果は、__bss_start to _end
から始まり、メモリ内にある全ての語を介してゼロが書き出されるということです。
mainへジャンプする
これで準備完了です。スタックもBSSもそろったので、C言語の関数main()
にジャンプできます。
calll main
main()
関数はarch/x86/boot/main.c.にあります。この働きについては、パート2で説明しましょう。
まとめ
これでLinux kernel internalsについて書いた本稿のパート1が終わります。ご質問やご意見がありましたら、ツイッター(0xAX)やメールでお知らせいただくか、もしくはGithubでIssueを作成してください。本稿のパート2では、Linuxカーネルの設定で実行する最初のCコード、memset
、memcpy
、earlyprintk
の実装といったメモリルーチンの実装、そし初期のコンソールの初期化など、他にも多くを説明していく予定です。