自作OS開発におけるTips集 〜liumOSの場合〜

これは、自作OS Advent Calendar 2019 の7番目の素数日の記事です。(遅れてごめんなさい!)

はじめに

github.com

liumOSは、2018年の中頃から私が一人で開発している自作OSです。これまでにいくつかの自作OSを作っては壊し続けてきましたが、今回が3作目になります。(蛇足ですが、前作はchnosという名前で、2010-2012年に主に開発していたようです。)

今回は、このliumOS自体の紹介ではなく、開発をしてゆく中で色々と工夫したポイントを紹介したいと思います。

ビルドの依存関係を自動生成する

プロジェクトの規模が大きくなってくると、全ファイルを毎回ビルドしていては時間がかかるようになってきます。 liumOSプロジェクトでは、C++のソース(.cc)とヘッダ(.h)ファイルが合計80個程度あり、これらを毎回ビルドするのはCPU時間の無駄です。 そこで、分割コンパイルにより、変更のないファイルを再度ビルドしないようにする方法がよくとられますが、ソースファイルには変更がなくても、それがincludeしているヘッダファイルに変更があった場合は、やはり再度ビルドする必要があります。そのため、各ソースファイルがどのヘッダをincludeしているのかをMakefileに記述する必要があるのですが、これは人間のやるべきことではありません!ファイル一つ一つの依存関係を列挙するのは面倒ですし、依存関係が変わった際に整合性をとれる保証もありません。

ということで、面倒なことはコンパイラにやらせましょう。

詳細はclangのドキュメントなどをみてもらうことにして、以下ではliumOSの場合を説明します。(ソースはこちら。)

まずは、Makefileにこのようなルールを書いておきます。

%.o.d : %.c Makefile
    @$(LLVM_CC) $(CXXFLAGS_LOADER) -MD -MF $@ -c -o $*.o $*.c >/dev/null 2>&1
    @echo 'DEP $@'

%.o.d : %.cc Makefile
    @$(LLVM_CXX) $(CXXFLAGS_LOADER) -MD -MF $@ -c -o $*.o $*.cc >/dev/null 2>&1
    @echo 'DEP $@'

%.o.d : %.S Makefile
    @touch $@ # generate dummy
    @echo 'DEP $@'

...

ここで、LLVM_CC/CXXはCコンパイラへのパス、CXX_FLAGS_LOADRERは、ローダ用のCFLAGSが入っています。オブジェクトファイルを生成する各ルールのパラメータを少し変えてオブジェクトファイルの名前.dというファイルを生成するルールを書いています。アセンブリソース.Sなど、依存するヘッダがないものはダミーを生成しておきます。

そして、さらに以下のような記述をMakefile最後に書いておきます。

-include $(LOADER_DEPS)
-include $(KERNEL_DEPS)

このLOADER_DEPSには、上で説明した生成規則で生成される*.dのファイルが列挙されています。(acpi.o.d apic.o.d asm.o.d inthandler.o.d ... のような感じです。)

実際には、このような感じでソースコードのリストから生成しています。

COMMON_SRCS= \
             acpi.cc apic.cc asm.S inthandler.S \
             ...

LOADER_SRCS= $(COMMON_SRCS) \
             efimain.cc \
             ...

KERNEL_SRCS= $(COMMON_SRCS) \
             command.cc \
             ...

LOADER_OBJS= $(addsuffix .o, $(basename $(LOADER_SRCS)))
LOADER_DEPS= $(addsuffix .o.d, $(basename $(LOADER_SRCS)))
KERNEL_OBJS= $(addsuffix .elf.o, $(basename $(KERNEL_SRCS)))
KERNEL_DEPS= $(addsuffix .elf.d, $(basename $(KERNEL_SRCS)))

Makeは、このincludeディレクティブをみつけると、C言語のincludeと同じようにそのファイルの内容を書かれた場所に展開しようとするのですが、もしそのファイルが存在しなかった場合、これまで読み込んだ生成規則を適用して、読み込むべきファイルを生成しようとしてくれます。これにより、対象のソースファイルが更新された場合や、依存関係を記したファイルが存在しない場合は、自動的それをmakeしてくれるわけです。

生成される.dファイルの中身は、このような感じになっています。

$ cat acpi.o.d
acpi.o: acpi.cc liumos.h acpi.h apic.h generic.h \
  /usr/local/Cellar/llvm/9.0.0_1/lib/clang/9.0.0/include/stddef.h \
  /usr/local/Cellar/llvm/9.0.0_1/lib/clang/9.0.0/include/__stddef_max_align_t.h \
  third_party_root/include/stdint.h \
  third_party_root/include/machine/_default_types.h \
  third_party_root/include/sys/features.h \
  third_party_root/include/_newlib_version.h \
  third_party_root/include/limits.h third_party_root/include/newlib.h \
  third_party_root/include/sys/cdefs.h \
  third_party_root/include/sys/syslimits.h \
  third_party_root/include/sys/config.h \
  third_party_root/include/machine/ieeefp.h \
  third_party_root/include/sys/_intsup.h \
  third_party_root/include/sys/_stdint.h immintrin.h loader_support.h \
  guid.h asm.h console.h efi.h efi_file.h elf.h \
  third_party_root/include/elf.h gdt.h githash.h hpet.h interrupt.h \
  ring_buffer.h scheduler.h process.h execution_context.h \
  kernel_virtual_heap_allocator.h paging.h stl.h phys_page_allocator.h \
  sys_constant.h pmem.h keyboard.h keyid.h serial.h sheet.h \
  sheet_painter.h text_box.h

ここに書かれた生成規則の依存関係をみて、Makeは実際にacpi.oを生成するかどうか決定してくれます。わーい、これでCPU時間を節約できましたね!

macOSLinuxのどちらでもビルドしたい!

liumOSは、macOSLinuxのどちらでもビルドできるようになっています。これは主に、LLVMツールチェーンが異なるプラットフォーム向けのクロスビルドにデフォルトで対応してくれているおかげなのですが、ツールチェーン自体は対応していても、周辺のライブラリとの兼ね合いで困難な点がいくつかあったので、少し説明したいと思います。

まず、macOS標準のclangは、Appleが少し手を加えているようで、なんとx86_64-pc-win32-coffx86_64-unknown-none-elfなどのターゲット指定に対応していないという悲しい事実があります。また、liumOSではEDK2やgnu-efiなどのUEFI開発環境を用いずに、clang+lldのみでOSのローダを生成しているのですが、macOSにはなんとlldが入っていません!(その代わり、ld64というリンカが入っているようです。)ですから、Homebrew経由でふつうのLLVMツールチェーンを入れる必要があります。

これでツールチェーン自体は揃ったのですが、環境ごとにコンパイラを切り替えなければなりません。そこで、liumOSでは以下のMakefileで、該当するアーキテクチャ向けのツールチェーン情報を取得しています。

$ cat common.mk 
THIS_DIR:=$(dir $(abspath $(lastword $(MAKEFILE_LIST))))

OSNAME=${shell uname -s}

ifeq ($(OSNAME),Darwin)
$(THIS_DIR)cc_cache.gen.mk : $(THIS_DIR)scripts/gen_tool_defs_linux.sh
    @ $(THIS_DIR)scripts/gen_tool_defs_macos.sh > $@
else
$(THIS_DIR)cc_cache.gen.mk : $(THIS_DIR)scripts/gen_tool_defs_linux.sh
    @ $(THIS_DIR)scripts/gen_tool_defs_linux.sh > $@
endif

include $(THIS_DIR)cc_cache.gen.mk
CLANG_SYSTEM_INC_PATH=$(shell $(THIS_DIR)./scripts/get_clang_builtin_include_dir.sh $(LLVM_CXX))

ここで、なぜ直接シェルを叩いてツールチェーンの情報を変数に入れずに、cc_cache.gen.mkなるファイルを生成して読み込んでいるかというと、

$ cat scripts/gen_tool_defs_macos.sh 
LLVM_PREFIX=`brew --prefix llvm`
echo "LLVM_CC:=${LLVM_PREFIX}/bin/clang"
echo "LLVM_CXX:=${LLVM_PREFIX}/bin/clang++"
echo "LLVM_LLD_LINK:=${LLVM_PREFIX}/bin/lld-link"
echo "LLVM_LD_LLD:=${LLVM_PREFIX}/bin/ld.lld"
echo "LLVM_AR:=${LLVM_PREFIX}/bin/llvm-ar"
echo "LLVM_RANLIB:=${LLVM_PREFIX}/bin/llvm-ranlib"

このスクリプト中のbrew --prefix llvmがちょっと重くて、

$ time brew --prefix llvm
/usr/local/opt/llvm

real    0m0.723s
user    0m0.406s
sys 0m0.303s

毎回実行するコストが大きいためです。ビルドするたびに待たされるのは困りますよね?

というわけで、無事各ツールのパスは手に入ったわけですが、もう一つ引っ掛かりどころがあります。それは、stdint.hなどの、コンパイラが提供するヘッダファイルのパスについてです。

C標準ライブラリのうち、stdint.hなどの一部のヘッダは、コンパイラの実装に依存するため、標準ライブラリではなくコンパイラによって提供されます。このヘッダは通常であれば、デフォルトのインクルードパスに含まれているため、気にする必要はないのですが、私たちはOSを書きたいので、-nostdlibinc -nostdlibを設定してしまっています。そうすると、コンパイラが提供するヘッダファイル、つまりはコンパイラさえあれば使えるはずのヘッダファイルが見えなくなってしまい、困ってしまいます。(newlibなども、コンパイラが提供するstdint.hに依存しているため、なんとかしないといけません。)

では、この「コンパイラが提供するヘッダファイル」はどこにあるのかというと…

the default location to look for builtin headers is in a path $(dirname /path/to/tool)/../lib/clang/3.3/include relative to the tool binary. https://clang.llvm.org/docs/LibTooling.html#libtooling-builtin-includes

ということで、わかりづらいのですが、ツールチェインのバイナリが置かれているパスから相対パス../lib/clang/<clangのバージョン>/include にあるようです。

…clangのバージョンがいるの、つらい…。

ということで、シェルスクリプトでよしなにやります。

$ cat scripts/get_clang_builtin_include_dir.sh
#!/bin/bash
if [ "$(uname)" == 'Darwin' ] || [ "$(expr substr $(uname -s) 1 5)" == 'Linux' ]; then
    # macOS, Linux
    version=`$1 --version | head -1 | sed 's/^.*[^0-9] \([0-9]*\.[0-9]*\.[0-9]*\).*$/\1/'`
    basepath="$(dirname $(dirname $(which $1)))"
    echo ${basepath}/lib/clang/${version}/include
else
    echo "Your platform ($(uname -a)) is not supported."
    exit 1
fi

ここで、第一引数にはclangへのパスが入ってきていることを想定しています(上記のcommon.mkを参照)。このようなスクリプトを、以下のように実行すれば、めでたく組み込みヘッダファイルのインクルードパスを得ることができます。

$ ./scripts/get_clang_builtin_include_dir.sh /usr/local/opt/llvm/bin/clang
/usr/local/opt/llvm/lib/clang/9.0.0/include

これを適宜コンパイラに指定してあげれば、無事にstdint.hなどが使えるようになるはずです!

というわけで、「ツールチェーンのパス」と「組み込みヘッダファイルのパス」に気をつければ、macOSLinuxでクロスビルドをすることはそんなに難しくありません。皆さんもぜひお試しください!

まとめ

自作OSは、一人もしくは少人数で作っている場合がほとんどだとは思いますが、その割にソースコードの規模が大きくなりがちです。そのような状況下で、効率よく、ストレスをためずに開発をするためには、今回紹介したような開発環境としての工夫も大事になってきます。 この記事をきっかけに、みなさんの自作OS開発が少しでも効率化・汎用化されれば幸いです。 年末、そして来年も、自作OSを楽しんでいきましょう!