ゼロからのOS自作入門を題材にRaspberry Pi 4 + ArchLinux ARM + iPadな環境でのんびり手を動かしています。記事へのインデックスはこちら
みかん本1.9節「C言語でハローワールド」の内容に取り組む。 ここでは、前記事で作成した、バイナリ直打ちのhello worldプログラムと同等のものを、C言語で作成する。
みかん本では、この課題はあらかじめ準備されているソースコードをビルドして実行するハンズオンになっているが、それに加えて、ソースコードリーディング、アセンブラコードの確認も行った。それについは後半で述べる。
コンパイラとリンカのセットアップ
ARMな環境でC言語のソースコードからx64で動作するバイナリを生成するためには、クロスコンパイル環境が必要である。クロスコンパイル環境の準備は手間がかかると思っていたが1、みかん本で用いるclangはデフォルトでクロスコンパイルの機能を持っていることがわかった。ということで、基本的に本の記述どおりに進めることができる。
ArchLinux ARMにはデフォルトでclangやlld(リンカ)がインストールされていないので、pacman
でインストールする。
$ sudo pacman -S clang lld
コンパイルとリンク
GitHubで公開されているソースコードをダウンロードしてきて、本の手順通りにコンパイルとリンクを行う。
~/osbook/hello_c $ clang -target x86_64-pc-win32-coff \
-mno-red-zone \
-fno-stack-protector \
-fshort-wchar -Wall -c \
hello.c
~/osbook/hello_c $ lld-link /subsystem:efi_application \
/entry:EfiMain \
/out:hello.efi \
hello.o
~/osbook/hello_c $
正しくリンクが終わるとhello.efi
が生成されるので、これをqemuで実行する。前回と同様にHello, World!が表示される。
ソースコードを眺めてみる
みかん本ではhello.c
全体の解説はされていないので、読んでみる。なお私はUEFIの仕様を詳しく知らないので、あくまで雰囲気読みである。
typedef unsigned short CHAR16;
typedef unsigned long long EFI_STATUS;
typedef void *EFI_HANDLE;
struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL;
typedef EFI_STATUS (*EFI_TEXT_STRING)(
struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *This,
CHAR16 *String);
typedef struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL {
void *dummy;
EFI_TEXT_STRING OutputString;
} EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL;
typedef struct {
char dummy[52];
EFI_HANDLE ConsoleOutHandle;
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut;
} EFI_SYSTEM_TABLE;
EFI_STATUS EfiMain(EFI_HANDLE ImageHandle,
EFI_SYSTEM_TABLE *SystemTable) {
SystemTable->ConOut->OutputString(SystemTable->ConOut, L"Hello, world!\n");
while (1);
return 0;
}
typedef unsigned short CHAR16;
typedef unsigned long long EFI_STATUS;
typedef void *EFI_HANDLE;
基本的なデータ型の宣言である。CHAR16
は文字のデータ型の定義のようだ。UEFIでは文字符号化にUnicodeを使っているそうなので、2byte長なのだろうか(でもUnicodeって2byteだけじゃないよね?2)。EFI_STATUS
はプログラム中での使われ方からみて、関数の戻り値のようだ。EFI_HANDLE
はvoidな関数ポインタであること、HANDLE
という名前から、何かしらの操作を行う関数ポインタを表現するものに見える。
struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL;
typedef EFI_STATUS (*EFI_TEXT_STRING)(
struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *This,
CHAR16 *String);
EFI_TEXT_STRING
という名前の関数ポインタの宣言である3。この関数は引数としてstruct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
のポインタ、先に定義したCHAR16
のデータへのポインタを取る。これはUEFI Firmwareに実装されているAPIのインタフェースを定義していると思われる。
1行目に書かれているstruct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL;
は不完全な構造体の宣言である。これは構造体の中身を定義せずに、ポインタだけを利用可能にするものだ。後ほど実態が定義される。このような形になっているのは、struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
と関数ポインタEFI_TEXT_STRING
が、それぞれの定義の中に相互参照を持っているためである。
typedef struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL {
void *dummy;
EFI_TEXT_STRING OutputString;
} EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL;
これは先に定義した関数ポインタであるEFI_TEXT_STRING
をメンバに持つ関数ポインタの集合体(関数ポインタテーブル)のようだ。先頭の*dummy
はおそらくOutputString
のアドレスを合わせるための埋め草と推測される。今回はEFI_TEXT_STRING
のみを呼ぶので、他のAPIの関数ポインタについては定義されていないようだ。
先述した、不完全に宣言された_EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
はここでメンバが定義され、typedef
により、EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
という型名が設定される。
typedef struct {
char dummy[52];
EFI_HANDLE ConsoleOutHandle;
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut;
} EFI_SYSTEM_TABLE;
これは先に定義した関数ポインタテーブルEFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
を更にラッピングする関数ポインタテーブルのようだ。名前とEfiMain
に引数として渡されているので、これがUEFI FirmwareのAPIにアクセスするためのルートテーブルなのだろう。ConsoleOutHandle
はこのプログラムの中では使われていないので、用途不明。*ConOut
というメンバに、EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
へのポインタが入る。
EFI_STATUS EfiMain(EFI_HANDLE ImageHandle,
EFI_SYSTEM_TABLE *SystemTable) {
SystemTable->ConOut->OutputString(SystemTable->ConOut, L"Hello, world!\n");
while (1);
return 0;
}
ここが文字列を出力する処理である。UEFI Firmwareから渡されるEFI_SYSTEM_TABLE
のポインタを辿って、最終的に文字列を出力するAPIが呼ばれている。
まとめると、このソースコードでは、以下のことを行っているようだ。
- 基本的なデータ型の定義(使うものだけ)
- UEFIのAPIのうち、Hello, world!の出力に必要な最小限のAPIインタフェース、関数ポインタテーブルの定義
- UEFI Firmwareから降ってくる関数ポインタテーブルへのポインタを辿って実際にAPIをコールする
この処理は、タウンページ(職業別電話帳)で必要な電話番号を探して、電話することに例えられる4。*SystemTable
で示された場所には、電話帳が置いてあって、ConOut
, OutputString
と手繰っていくことで、最終的に実行すべき処理のエントリポイントにたどり着くことができる。ポインタを順番に辿って行くのは、職業名からページを調べて、実際に電話番号が掲載されているページにたどり着くのに似ている。
アセンブラコードで確認
推測が正しいか、コンパイルの中間生成物であるアセンブラコードを確認して、実際にどのような処理が行われるのかを確認する。
コンパイルと一口に言っても、処理の中身は複数の段階に分かれていて、様々な中間生成物を経て、最終的な生成物が出力される5。通常のコンパイルコマンドでは、中間生成物は残らないが、コンパイラに適切なオプションを渡すことで、その内容を確認することができる。
今回の課題で用いているclangはLLVMというコンパイラ基盤をベースに作られている6。clangはC言語を特定のマシンアーキテクチャに依存しない中間言語(LLVM-IR)に変換し、それをLLVMに渡す。LLVMはそれに様々な処理を加えて7、ターゲットアーキテクチャのアセンブラコードに変換し、最終的にCPUが直接理解できる機械語8に変換する。
clangでは、-S
オプションを渡すことで、ターゲットアーキテクチャのアセンブラコードを出力することができる。処理が完了すると、hello.s
というファイルが出力される。
~/osbook/hello_c $ clang -target x86_64-pc-win32-coff \
-mno-red-zone \
-fno-stack-protector \
-fshort-wchar -Wall -c \
-S \
hello.c
hello.s
の内容を以下に示す。
.text
.def @feat.00;
.scl 3;
.type 0;
.endef
.globl @feat.00
.set @feat.00, 0
.file "hello.c"
.def EfiMain;
.scl 2;
.type 32;
.endef
.globl EfiMain # -- Begin function EfiMain
.p2align 4, 0x90
EfiMain: # @EfiMain
.seh_proc EfiMain
# %bb.0:
subq $56, %rsp
.seh_stackalloc 56
.seh_endprologue
movq %rdx, 48(%rsp)
movq %rcx, 40(%rsp)
movq 48(%rsp), %rax
movq 64(%rax), %rax
movq 8(%rax), %rax
movq 48(%rsp), %rcx
movq 64(%rcx), %rcx
leaq "??_C@_1BO@NCAKGBFB@?$AAH?$AAe?$AAl?$AAl?$AAo?$AA?0?$AA?5?$AAw?$AAo?$AAr?$AAl?$AAd?$AA?$CB?$AA?6?$AA?$AA@"(%rip), %rdx
callq *%rax
.LBB0_1: # =>This Inner Loop Header: Depth=1
jmp .LBB0_1
.seh_handlerdata
.text
.seh_endproc
# -- End function
.section .rdata,"dr",discard,"??_C@_1BO@NCAKGBFB@?$AAH?$AAe?$AAl?$AAl?$AAo?$AA?0?$AA?5?$AAw?$AAo?$AAr?$AAl?$AAd?$AA?$CB?$AA?6?$AA?$AA@"
.globl "??_C@_1BO@NCAKGBFB@?$AAH?$AAe?$AAl?$AAl?$AAo?$AA?0?$AA?5?$AAw?$AAo?$AAr?$AAl?$AAd?$AA?$CB?$AA?6?$AA?$AA@" # @"??_C@_1BO@NCAKGBFB@?$AAH?$AAe?$AAl?$AAl?$AAo?$AA?0?$AA?5?$AAw?$AAo?$AAr?$AAl?$AAd?$AA?$CB?$AA?6?$AA?$AA@"
.p2align 1
"??_C@_1BO@NCAKGBFB@?$AAH?$AAe?$AAl?$AAl?$AAo?$AA?0?$AA?5?$AAw?$AAo?$AAr?$AAl?$AAd?$AA?$CB?$AA?6?$AA?$AA@":
.short 72 # 0x48
.short 101 # 0x65
.short 108 # 0x6c
.short 108 # 0x6c
.short 111 # 0x6f
.short 44 # 0x2c
.short 32 # 0x20
.short 119 # 0x77
.short 111 # 0x6f
.short 114 # 0x72
.short 108 # 0x6c
.short 100 # 0x64
.short 33 # 0x21
.short 10 # 0xa
.short 0 # 0x0
.addrsig
x64アセンブラの読み方を解説するのは主旨ではないので、ここでは部分的に見て行く。重要なのは以下の部分である。このコードはC言語のプログラムのEfiMain
に対応する。
movq %rdx, 48(%rsp)
movq %rcx, 40(%rsp)
movq 48(%rsp), %rax
movq 64(%rax), %rax
movq 8(%rax), %rax
movq 48(%rsp), %rcx
movq 64(%rcx), %rcx
leaq "??_C@_1BO@NCAKGBFB@?$AAH?$AAe?$AAl?$AAl?$AAo?$AA?0?$AA?5?$AAw?$AAo?$AAr?$AAl?$AAd?$AA?$CB?$AA?6?$AA?$AA@"(%rip), %rdx
callq *%rax
.LBB0_1: # =>This Inner Loop Header: Depth=1
jmp .LBB0_1
以下に、このコードを読むことに限定したx64アセンブラの読み方を示す910。
- それぞれの行がCPUへの命令に対応する。
hoge fuga, piyo
の形式で書かれている行は、ある場所にあるデータ(source)に対して何らかの処理を行い、指定の場所(destination)にデータを格納する処理を表現している11。この場合、hogeが命令の種類、fugaがsource、piyoがdestinationである。hoge piyo
の形式で書かれているのは、piyoが示すデータを引数に何らかの処理を行うものである。- プロセッサにはレジスタと呼ばれるデータの入れ物がある。
%rsp
などの%
が接頭辞についているシンボルは、そのレジスタを表している。 %
がついていないsourceもしくはdestinationはメモリアドレスを示している。- 48(%rsp)のように、数字とカッコで括ったレジスタの表現は、レジスタに格納された数値を基準とし、数字の分だけオフセットを取ったメモリアドレスを表している12。この例の場合は、%rspに格納されているアドレスに48バイト加算したメモリアドレスを表している。
- movqはデータをsourceからdestinationに移す命令である
- callqは指定された場所にあるデータをメモリアドレスとして解釈し、そのアドレスへ処理を移す命令である。これは関数呼び出しと対応する。
- leaqはsourceが示すアドレスそのものを、destinationに格納する。
- jmpは指定のアドレスの命令に移動するものである。
- C言語における関数への引数は、レジスタおよびメモリに置かれる。ここでは、引数の左から%rcx, %rdxの順番で格納される13。
このアセンブラコードは4つの部分にわけることができる。
movq %rdx, 48(%rsp)
movq %rcx, 40(%rsp)
先述したとおり、EfiMain
が呼び出された時点で、%rcx
には第一引数である、ImageHandle
、%rdx
には第二引数である*SystemTable
が格納されている。この2つの命令で、その2つの引数を、メモリ上のアドレスに格納している。
movq 48(%rsp), %rax
movq 64(%rax), %rax
movq 8(%rax), %rax
この3つの命令は以下のことを行っている。
48(%rsp)
に格納された*SystemTable
が示すアドレスを%rax
に格納%rax
に64byte加算したアドレスにあるデータを%rax
に格納%rax
に8byte加算したアドレスにあるデータを%rax
に格納
これはC言語のソースファイルにおけるOutputString
のプログラムが置かれているメモリアドレスの計算である(電話帳を手繰る処理)。それぞれの構造体でのデータ配置と照らし合わせると、メモリアドレス演算の数値が大体で一致することがわかる14。
movq 48(%rsp), %rcx
movq 64(%rcx), %rcx
leaq "??_C@_1BO@NCAKGBFB@?$AAH?$AAe?$AAl?$AAl?$AAo?$AA?0?$AA?5?$AAw?$AAo?$AAr?$AAl?$AAd?$AA?$CB?$AA?6?$AA?$AA@"(%rip), %rdx
ここでのmovq
も%rcx
に格納された引数を処理している。前述したOutputString
のメモリアドレスの計算と同様に、SystemTable->ConOut
が示すEFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
構造体のアドレスを%rcx
に格納している。leaqは"Hello, world!“の文字列が配置されているメモリアドレスを%rdx
に格納している。
後述するが、ここで格納した%rcx
、%rdx
のデータは、次の関数呼び出しの引数として利用される。
callq *%rax
.LBB0_1:
jmp .LBB0_1
callq
で%rax
に格納されたアドレスに処理を遷移させる。%rax
には、OutputString
の関数のアドレスが格納されているので、UEFIのAPIに処理が遷移する。関数の引数は、%rcx
、%rdx
に格納されているデータが用いられる。
呼び出した処理から復帰すると、jmp
命令によって処理を遷移する。移動先は、このjmp命令そのものなので、無限ループとなる。(C言語ソースコードでのwhile(1)
に相当する)
おわりに
みかん本 1.9節「C言語でハローワールド」をハンズオンし、そのソースコードリーディングでUEFIアプリケーションでのAPI呼び出しの手順を確認した。また、アセンブリコードを確認して、実際にどのような処理がCPUで実行されるのかを確認した。
このコードリーディングでUEFIでのAPI呼び出しのイメージが掴めた。これでUEFIが「完全に理解できた」15。
gccでは、ターゲットごとにコンパイラを準備する必要がある。ビルド済みのgccが配布されていることもあるが、開発ホストがARMでターゲットがx64というのは特殊だと思われるので(逆のケースは典型的である)、自力でビルドすることを覚悟していた。(gccのビルドは大変に重い処理である) ↩︎
文字符号化何もわからん(本当にわからん) ↩︎
関数ポインタ宣言、本当に苦手である(いまだにスッと読めない) ↩︎
このメタファが使えなくなる日も、そう遠くない。(若い人は見たことがないかもしれない?) ↩︎
コンパイラ仕組みについては、それだけで1冊の本が書けてしまうほどなので、ここでは詳しく触れない。 ↩︎
「ベースとしている」は少々語弊があるが、いい表現が思いつかないので勘弁してほしい。正確にはclangはLLVMのコンパイラフロントエンドである。 ↩︎
LLVMの仕組みはここでは本質ではないので割愛する(なにより私も詳しいわけではない。インターネットには素晴らしい教材がたくさんあるのでググられたし) ↩︎
これは厳密には不正確な表現である(少なくともx64に関しては)。コンパイラが生成した機械語は、CPUのなかで更にマイクロコードに変換され、それがCPUによって実行される。詳しくはググ(r y ↩︎
ここに書かれている内容は、ターゲットとするソフトウェア環境によって変化してくるので、あくまで今回の課題に限定した説明である。(特に引数の扱いは呼び出し規則によって異なるので要注意) ↩︎
ここでは、説明を簡単にするために、スタックについてあえて触れていない。(今回の範囲では、スタックを意識することがないため) ↩︎
実際にはsourceには具体的な数値を設定することもできる(即値)。この例では即値が出てこないので、説明していない。 ↩︎
レジスタ間接アドレッシングと呼ばれるアドレス指定手法である。数字で指定するオフセットはディスプレースメントと呼ばれる。 ↩︎
引数の数が増えると、レジスタだけではなくメモリにも格納される。詳しくはMicrosoftのx64呼び出し規則を参照のこと ↩︎
単純にアドレスを足し算しても合わない部分があるが、これはメモリアラインメントの影響である(メモリアドレスの区切りの良い場所にデータを配置するために、データを格納するアドレスに一定の制約を課すこと) ↩︎