April 17, 2021

C言語でHello, World!を書いてUEFI完全に理解した

ゼロからの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


  1. gccでは、ターゲットごとにコンパイラを準備する必要がある。ビルド済みのgccが配布されていることもあるが、開発ホストがARMでターゲットがx64というのは特殊だと思われるので(逆のケースは典型的である)、自力でビルドすることを覚悟していた。(gccのビルドは大変に重い処理である) ↩︎

  2. 文字符号化何もわからん(本当にわからん) ↩︎

  3. 関数ポインタ宣言、本当に苦手である(いまだにスッと読めない) ↩︎

  4. このメタファが使えなくなる日も、そう遠くない。(若い人は見たことがないかもしれない?) ↩︎

  5. コンパイラ仕組みについては、それだけで1冊の本が書けてしまうほどなので、ここでは詳しく触れない。 ↩︎

  6. 「ベースとしている」は少々語弊があるが、いい表現が思いつかないので勘弁してほしい。正確にはclangはLLVMのコンパイラフロントエンドである。 ↩︎

  7. LLVMの仕組みはここでは本質ではないので割愛する(なにより私も詳しいわけではない。インターネットには素晴らしい教材がたくさんあるのでググられたし) ↩︎

  8. これは厳密には不正確な表現である(少なくともx64に関しては)。コンパイラが生成した機械語は、CPUのなかで更にマイクロコードに変換され、それがCPUによって実行される。詳しくはググ(r y ↩︎

  9. ここに書かれている内容は、ターゲットとするソフトウェア環境によって変化してくるので、あくまで今回の課題に限定した説明である。(特に引数の扱いは呼び出し規則によって異なるので要注意) ↩︎

  10. ここでは、説明を簡単にするために、スタックについてあえて触れていない。(今回の範囲では、スタックを意識することがないため) ↩︎

  11. 実際にはsourceには具体的な数値を設定することもできる(即値)。この例では即値が出てこないので、説明していない。 ↩︎

  12. レジスタ間接アドレッシングと呼ばれるアドレス指定手法である。数字で指定するオフセットはディスプレースメントと呼ばれる。 ↩︎

  13. 引数の数が増えると、レジスタだけではなくメモリにも格納される。詳しくはMicrosoftのx64呼び出し規則を参照のこと ↩︎

  14. 単純にアドレスを足し算しても合わない部分があるが、これはメモリアラインメントの影響である(メモリアドレスの区切りの良い場所にデータを配置するために、データを格納するアドレスに一定の制約を課すこと) ↩︎

  15. 「完全に理解した」 ↩︎

© Gaku Nakagawa 2021