自作OS(OS5)のUEFI+x86_64対応でやったこと/やっていること(そして-fPIEの謎挙動)

少し早いですが、この記事は「自作OS Advent Calendar 2017」の12/3(日)の記事です。


自作OS(OS5GitHub)の「UEFI + x86_64」対応で

  • やったこと(masterブランチへマージ済の内容)
  • やっていること(作業ブランチで作業中の内容)、そして-fPIEの謎挙動

を紹介します。

OS5について

OS5x86_32のQEMU(qemu-system-i386)を想定したフルスクラッチの自作OSです。CUIのみですが、ブートローダー・カーネル・ユーザーランド(アプリケーション群)を備え、すべて含めて3000行程度のOSです。
自作OS Advent Calendar 2016」の12/13(火)の記事でOS5を紹介しました


OS5については、今年、"Ohgami's Commentary on OS5"という「全コード + コメンタリー」の同人誌を作り、技術書典やオープンソースカンファレンス等で頒布しました。
PDF版は公開していますので、興味があれば見てもらえるとうれしいです。


その後、"UEFI + x86_64"の自機で動作させたく、「まずは下回りから」ということで、「UEFI」の勉強をしていました。
OS5で十分なブートローダーを作る程度であれば理解できてきたので、11月頃からOS5の「UEFI + x86_64対応」を行っています。

やったこと(masterブランチへマージ済の内容)

主にブートローダーのUEFI+x86_64対応です。


3f0b05dがHEADである現時点で、カーネル・ユーザーランド(アプリケーション群)をRAMへロードし、カーネルの先頭アドレスへジャンプするといった基本的な事はできます。


Makefileも修正済なので、OS5のソースディレクトリ直下で

$ make fs

を実行すると、UEFI+x86_64対応のブートローダーとブートローダーの動作確認程度のカーネルx86_32のユーザーランド(x86_64対応未着手)の各バイナリが生成されます。


そして、

$ make run64

を実行すると、QEMU上で実行します。QEMUは"qemu-system-x86_64"コマンドを使用します。
また、QEMUにはUEFIファームウェアが入っていないので、加えてOVMFというオープンソースUEFIファームウェアを使用します。Debian等APTが使用できる環境ならば、"qemu-system-x86"と"ovmf"パッケージをインストールしておいてください。


"make fs"ではfsディレクトリへ各バイナリを生成します。fsディレクトリはEFIシステムパーティションの構成になっているため、USBフラッシュメモリの第1パーティション(FATフォーマット済)へ

$ sudo cp -r fs/* /mnt/to/usb/

のようにコピーすれば、USBフラッシュメモリから起動することで実機で動作させることもできます。(ただし、Lenovo Thinkpad以外未確認)


なお、fsディレクトリの構成は以下の通りです。

fs
├── EFI
│   └── BOOT
│       └── BOOTX64.EFI  ← ブートローダー
├── kernel.bin  ← カーネル
└── apps.img    ← ユーザーランド

ブートローダーはユーザーランド(apps.img)が無い場合はロードをスキップする様に作っています。
そのため、kernel.binという名前で置いておけば任意のファイルをブートローダーにロード・実行させることができます。
ただし、ブートローダーはロードしたカーネルの先頭アドレスへジャンプするので、リンク時にはテキストセクションをバイナリ先頭へ配置し、なおかつエントリ関数をテキストセクション先頭に配置する必要があります。


現在のOS5カーネルでは以下のリンカスクリプトを使用しています。

■ os5/kernel/sys_64.ld

OUTPUT_FORMAT("binary");

SECTIONS
{
	.text	: {*(.text)}
	.rodata	: {
		*(.strings)
		*(.rodata)
		*(.rodata.*)
	}
	.data	: {*(.data)}
	.bss	: {*(.bss)}
}


そして、Makefileではオブジェクトファイルのリンク順でカーネル初期化関数がテキストファイル先頭に配置されるようにしています。

■ os5/kernel/Makefile

・・・省略・・・
ifneq ($(x86_64),true)
CFLAGS += -m32
else
CFLAGS += -fPIE    ← (*1)
endif
LDFLAGS = -Map System.map -s -x
ifneq ($(x86_64),true)
LDFLAGS += -m elf_i386 -T sys.ld
OBJS = sys.o cpu.o intr.o excp.o memory.o sched.o fs.o task.o	\
		syscall.o lock.o timer.o console_io.o queue.o	\
		common.o debug.o init.o kern_task_init.o
else
LDFLAGS += -T sys_64.ld
OBJS = init_64.o fb.o    ← "init_64.o"を先頭に
endif

kernel_64.bin: $(OBJS)
	ld $(LDFLAGS) -o $@ $+

kernel.bin: $(OBJS)
	ld $(LDFLAGS) -o $@ $+

%.o: %.S
	gcc $(CFLAGS) -o $@ $<
%.o: %.c
	gcc $(CFLAGS) -o $@ $<

・・・省略・・・


(*1)について、PIEはPosition-Independent Executableの略で位置独立実行形式という意味です。
ブートローダーは、カーネル・ユーザーランドを配置可能なメモリ領域を検索し、見つけた場所へ配置します。
カーネルの実行バイナリがメモリ領域のどこに配置されても良いように、このオプションをつけています。


その後、ブートローダーは以下のようにカーネルへのジャンプを行います。


現状、カーネルのエントリ関数は、ブートローダーの動作確認程度で、以下の通りです。

■ os5/kernel/init_64.c

#include <efi.h>
#include <fb.h>

int kern_init(struct EFI_SYSTEM_TABLE *st __attribute__ ((unused)),
	      struct fb *_fb)
{
	unsigned int x, y;

	fb_init(_fb);

	for (y = 0; y < fb.vr; y++)
		for (x = 0; x < fb.hr; x++)
			draw_px(x, y, (x + y) % 256, y % 256, x % 256);

	while (1);

	return 0;
}


実行すると↓の絵が出ます。

やっていること(作業ブランチで作業中の内容)

作業ブランチはwip/support_uefi_x86_64_2です。(ブランチは移動するのでタグを打っておきました。)


masterブランチでせっかく対応したPIEなのですが、謎の挙動があり、作業ブランチではPIE対応を外しています。


問題は、-fPIEオプションをつけた場合に生成されるアセンブラが間違っているように見受けられる、というものです。
加えて、-fPIEオプションを付けずともPC(RIP)相対なアセンブラが生成され、実行にも今の所、問題無い、ということもあります。



"-fPIE"オプションをつけた場合の問題(謎の挙動)を以降で説明します。
(ただし、正直、コンパイラが間違うなんて事は考えにくく、自分の認識がどこか間違っているのではないかと思ってはいます。)

問題

別ソースファイル(別オブジェクトファイル)で定義されているグローバル変数へ、extern宣言でアクセスする際、-fPIEを付けてコンパイルすると間違ったアセンブラを生成しているように見える。

確認コード
  • main.c
extern unsigned int foo;
int main(void)
{
	volatile unsigned int bar = foo;
	while (1);
	return 0;
}
  • foo.c
unsigned int foo = 0xbeefcafe;
確認手順

1. main.cとfoo.cをkernel/ディレクトリへ追加

2. 追加したファイルをビルドする様、kernel/Makefileを変更
■ kernel/Makefile(13〜16行目)

else
LDFLAGS += -T sys_64.ld
OBJS = main.o foo.o  # 変更
endif

3. ビルド、実行

$ make run64

4. QEMUモニター[*2]で実行中のアセンブラレジスタ値等を確認


※ kernel/MakefileのCFLAGSの-fPIEあり/なしそれぞれで3.と4.を行いました。


[*2] 2.6 QEMU Monitor - QEMU version 2.10.92 User Documentation

確認結果

【-fPIEあり】

  • kernel/System.mapの抜粋
.data           0x0000000000000050        0x4
 *(.data)
 .data          0x0000000000000050        0x0 main.o
 .data          0x0000000000000050        0x4 foo.o
                0x0000000000000050                foo
  • QEMUモニターでの確認結果
(qemu) xp/6i 0x100000
0x0000000000100000:  push   %rbp
0x0000000000100001:  mov    %rsp,%rbp
0x0000000000100004:  mov    0x45(%rip),%rax        # 0x100050
0x000000000010000b:  mov    (%rax),%eax
0x000000000010000d:  mov    %eax,-0x4(%rbp)
0x0000000000100010:  jmp    0x100010
(qemu) info registers  <= (*1)
RAX=0000000000000000 RBX=0000000007a43f18 RCX=0000000000400000
RDX=0000000007a43f18
...
(qemu) xp/gx 0x100050  <= (*2)
0000000000100050: 0x00000003beefcafe
(qemu) xp/wx 0x3beefcafe  <= (*3)
00000003beefcafe: 0x00000000
  • 説明

0x100000はmain.cとfoo.cから生成した実行バイナリ(kernel.bin)をブートローダーがロードしたアドレスです。


まず、main.cの

volatile unsigned int bar = foo;

に該当するQEMUモニターで確認したアセンブラは、

0x000000000010000d:  mov    %eax,-0x4(%rbp)

ですので、グローバル変数fooの内容0xbeefcafeは、%eaxへ格納されていなければならないです。


しかし、レジスタ値を見てみると(*1)、RAXは全て0になっています。(EAXはRAXの下位32ビット)


%eaxへ値を格納している処理は以下の箇所です。

0x0000000000100004:  mov    0x45(%rip),%rax        # 0x100050
0x000000000010000b:  mov    (%rax),%eax


ここで、

0x0000000000100004:  mov    0x45(%rip),%rax        # 0x100050

は、"0x45(%rip)"のPC(%rip)相対アドレス計算結果のアドレス(0x100050)ではなく、アドレスが指す先を%raxへ格納します。


0x100050の内容を64ビット幅で確認すると(*2)、0x00000003beefcafe という値が格納されており、この値が %rax へ格納されます。


この後、

0x000000000010000b:  mov    (%rax),%eax

で、%rax(0x00000003beefcafe)が指す先の内容を%eaxへ格納しています。


0x3beefcafe の内容を見てみると(*3)、0が書かれており、
その結果、%eaxへは0が格納されてしまいます。


謎に思うのが、

0x0000000000100004:  mov    0x45(%rip),%rax        # 0x100050

の箇所です。
0x100050はグローバル変数fooのアドレスなので、

lea    0x45(%rip),%rax

であれば%raxに0x100050が格納され、次の

0x000000000010000b:  mov    (%rax),%eax

で、アドレス"0x100050(foo)"に格納されている内容が%eaxへ格納されるため、
無事変数fooの内容を%eaxへロードできるのですが、
なぜコンパイラは"lea"命令ではなく"mov"命令をしようしているのか、謎です。


【-fPIEなし】

  • kernel/System.mapの抜粋
.data           0x0000000000000048        0x4
 *(.data)
 .data          0x0000000000000048        0x0 main.o
 .data          0x0000000000000048        0x4 foo.o
                0x0000000000000048                foo
  • QEMUモニターでの確認結果
(qemu) xp/5i 0x100000
0x0000000000100000:  push   %rbp
0x0000000000100001:  mov    %rsp,%rbp
0x0000000000100004:  mov    0x3e(%rip),%eax        # 0x100048
0x000000000010000a:  mov    %eax,-0x4(%rbp)
0x000000000010000d:  jmp    0x10000d
(qemu) info registers
RAX=00000000beefcafe RBX=0000000007a43f18 RCX=0000000000400000
RDX=0000000007a43f18
(qemu) xp/wx 0x100048
0000000000100048: 0xbeefcafe
  • 説明

"-fPIE"を外していますが、
> 0x0000000000100004: mov 0x3e(%rip),%eax # 0x100048
のようにPC相対で値をロードしています。(こういうものなのか。。?)


この場合、"0x3e(%rip)"のアドレス計算結果0x100048が指す先を%eaxへ
格納しており、%eaxへfooの値 0xbeefcafe が格納されます。

補足

kernel/Makefileではld時に-pieの指定が無いですが、
gccの-fPIEとldの-pieを共に指定した場合も上記の"【-fPIEあり】"の結果と同じでした。