Dockerを使ったOS開発

こんにちは．[だいみょーじん](https://twitter.com/Egh2Deywos)です．
この記事は，[自作OS Advent Calendar 2021](https://adventar.org/calendars/6581) 25日目の記事です．

対象読者：OS自作をやっていたり，興味がある人．

# 背景

OS自作のみならず，ソフトウェア開発全般において最も難しく，かつ面倒なのは「Hello, World!」するまでの開発環境構築だと思います．
PCを買い替えたりすると，また同様の環境を構築する手間がかかるし，PCを買い替える頃には以前のPCで環境構築したときの手順なんて頭から吹っ飛んでしまっているわけで，またいろいろ調べる羽目になるのです．
その点Dockerはとても便利です．
一度Dockerfileに環境構築の手順をプログラムとして記述してしまえば，Dockerさえあればいつでもどこでも同一の開発環境を再現できるのです．
この記事では，私がDockerを使ってOS開発環境を構築した以下の手順を紹介します．

1. とりあえずDockerfileを書こう
1. Makefileで自動化しよう
1. GitHub用の秘密鍵を配置して`git push`できるようにしよう
1. Docker上でQEMUを動かそう
1. GDBでデバッグしよう

# とりあえずDockerfileを書こう

以下のように開発環境の構築手順を記述します．
（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/Dockerfile)）

```Dockerfile:Dockerfile
FROM alpine
RUN apk update

# gcc, git, ld, make, etc.
RUN apk add --no-cache alpine-sdk
# git setting
RUN git config --global pull.rebase false
# debugger
RUN apk add --no-cache gdb
# emulator
RUN apk add --no-cache qemu-system-i386
# ssh
RUN apk add --no-cache openssh
RUN mkdir ~/.ssh
# editor
RUN apk add --no-cache vim
# set time zone UTC+9 (Japan)
RUN apk add --no-cache tzdata
RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
RUN apk del tzdata

# clone the repository
WORKDIR ~
RUN git clone https://github.com/TaiseiIto/hariboslinux.git

# make the OS image file
WORKDIR hariboslinux
RUN make

# gdb setting
RUN echo add-auto-load-safe-path `pwd`/gdb/.gdbinit > ~/.gdbinit

# gdb real mode disassemble
RUN wget https://raw.githubusercontent.com/qemu/qemu/master/gdb-xml/i386-32bit.xml -P gdb

# VNC port
EXPOSE 5900
```

上から順番に説明します．
`FROM alpine`は今回構築する開発環境の元となるDockerイメージとしてAlpine Linuxを指定しています．
（Alpine Linuxが軽量でよいという話を聞いたのでこれを選択したわけですが，どうやら`FROM scratch`とすると本当にまっさらな状態から環境を構築できることを知ったので，いずれ`FROM scratch`に移行するかもです．）
それに続いて`RUN`コマンドが続いています．
これらは`FROM`コマンドで生成されたAlpine Linux上でコマンドを実行し，環境を構築していくコマンドです．
`apk`はAlpine Linuxのパッケージ管理システムです．
Debian系のaptやRedHat系のyumに相当するものですね．
`RUN apk update`でこれからインストールするパッケージの参照先を更新します．
`RUN apk add --no-cache alpine-sdk`でコンパイラのgcc，バージョン管理システムのgit，リンカのld，ビルド自動化のmakeなどをまとめてインストールします．
不要なキャッシュを残さないようにして軽量化するため，`--no-cache`オプションを指定しています．
`RUN git config --global pull.rebase false`は`git pull`の動作に関する設定で，単純にローカルのHEADの先にリモートのHEADがある場合はローカルのHEADをリモートのHEADまで移動させ，両HEADがそれ以前のコミットから分岐している場合はmergeコミットを生成するようにしています．
以下，デバッガのGDB，エミュレータのQEMU，`git push`の通信で使用するSSH，エディタのvimをインストールします．
その後，`RUN apk add --no-cache tzdata`で開発環境の時計を日本標準時に設定するため一時的にタイムゾーンデータベースtzdataをインストールし，`RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime`で日本標準時に設定したら`RUN apk del tzdata`で用済みになったtzdataを削除します．
続いて`WORKDIR ~`でホームディレクトリに移動し，`RUN git clone https://github.com/TaiseiIto/hariboslinux.git`で自作OSのリポジトリを配置します．
`WORKDIR hariboslinux`で配置したリポジトリに移動し，`RUN make`で自作OSのビルドまで行います．
残りの3行については後の項目で説明するので今は飛ばします．

# Makefileで自動化しよう

面倒なDockerコマンドたちをまとめています．

* Dockerfileを実行してDockerイメージを生成する`docker-build`
* Dockerコンテナのシェルに入る`docker-login`
* Dockerコンテナを削除する`docker-remove-container`
* Dockerイメージを削除する`docker-remove-image`
* DockerイメージからDockerコンテナを生成して実行する`docker-run`
* Dockerコンテナを起動する`docker-start`
* Dockerコンテナを終了する`docker-stop`
* Dockerコンテナ上で生成された自作OSのイメージファイルをホストにダウンロードする`download-image`

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/Makefile)）

```makefile:Makefileの一部
# docker
DOCKER = docker
DOCKER_IMAGE_NAME = hariboslinux
DOCKER_IMAGE_TAG = latest
DOCKER_CONTAINER_NAME = hariboslinux
DOCKER_CONTAINER_SHELL = /bin/sh
DOCKER_VNC_PORT_OPTION = -p $(VNC_PORT):$(VNC_PORT)

docker-build:
	$(DOCKER) build --no-cache -t $(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG) .

docker-login:
	$(DOCKER) attach $(DOCKER_CONTAINER_NAME)

docker-remove-container:
	$(DOCKER) rm $(DOCKER_CONTAINER_NAME)

docker-remove-image:
	$(DOCKER) rmi $(DOCKER_IMAGE_NAME)

docker-run:
	$(DOCKER) run --name $(DOCKER_CONTAINER_NAME) $(DOCKER_VNC_PORT_OPTION) -i -t $(DOCKER_IMAGE_NAME)

docker-start:
	$(DOCKER) start $(DOCKER_CONTAINER_NAME)

docker-stop:
	$(DOCKER) stop $(DOCKER_CONTAINER_NAME)

download-image:
	$(DOCKER) cp $(DOCKER_CONTAINER_NAME):/~/hariboslinux/$(IMAGE_FILE) .
```

# GitHub用の秘密鍵を配置して`git push`できるようにしよう

GitHubへのpushをSSHで行う場合，あなたがGitHubに登録した公開鍵に対応する秘密鍵をコンテナに配置する必要があります．

<font color="#ff0000">注意事項！！！
当たり前ですが，秘密鍵の扱いには十分に注意してください．
間違っても秘密鍵をパブリックリポジトリに公開しないこと！
間違っても秘密鍵を含むDockerイメージをDocker Hubに公開しないこと！
とにかく秘密鍵は絶対に公開しないこと！
この記事を読んで問題が発生した場合私は一切の責任を負いません．
</font>

私は，予めコンテナ上の決められた場所（/~/hariboslinux/ssh/github）に秘密鍵を配置し，以下のシェルスクリプトを実行することで`git push`できるようにしてあります．

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/git/gitconfig.sh)）

```sh:gitconfig.sh
#!/bin/sh

# This program gives the push access right.
# Only the developer can execute it.
# Put the private key "/~/hariboslinux/ssh/github" before the execution.

currentdir=$(pwd)
cd $(dirname $0)
echo -n "Your GitHub email:"
read email
echo -n "Your GitHub name:"
read name
git config --global user.email $email
git config --global user.name $name
git remote set-url origin git@github.com:TaiseiIto/hariboslinux.git
cat ../ssh/config >> ~/.ssh/config
chmod 600 ../ssh/github
cd $currentdir
```

まずGitHubアカウントのメールアドレスとユーザ名を入力させ，`git config`コマンドでアカウント情報を設定します．
次に，`git remote set-url origin git@github.com:TaiseiIto/hariboslinux.git`でGitHubとの通信プロトコルをHTTPSからSSHに変更します．
次に，`cat ../ssh/config >> ~/.ssh/config`でGitHubとの通信で使用する秘密鍵の場所をSSHの設定ファイルに追記します．
（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/ssh/config)）

```:~/.ssh/config
Host github github.com
	HostName github.com
	IdentityFile /~/hariboslinux/ssh/github
	User git
```

最後に，`chmod 600 ../ssh/github`で秘密鍵に対する権限を設定すれば，Dockerコンテナ上のローカルリポジトリからSSHで`git push`できるようになります．

# Docker上でQEMUを動かそう

DockerfileでQEMUをインストールしたので，QEMU自体は普通に動きます．
問題は，X Window SystemなどのGUI環境が入っていないDocker環境でどうやってQEMUのGUIを表示するかです．
方法はいろいろあると思いますが，私はリモートデスクトップソフトのVNC（Virtual Network Computing）を使用しました．
以下に構成図を示します．

![VNC.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/f8ea9994-8592-99ce-40f2-69dffbc46356.png)


まず，操作者はVNCクライアントを通して自作OSを操作することになります．
操作者によるマウス操作，キーボード操作のDockerコンテナへの送信およびDockerコンテナからの自作OSの出力画面の受信は，ホストOS上の5900番ポートを通してVNCクライアントとDockerコンテナがRFBプロトコルで通信することで実現しています．
Dockerfileに`EXPOSE 5900`と記述して5900番ポートをホストOSに公開し，`docker run`コマンドでDockerコンテナを生成する時にオプション`-p`でDockerコンテナの5900番ポートとホストOSの5900番ポートを結びつけます．
こうしてDockerコンテナが自身の5900番ポートをホストOSの5900番ポートにリダイレクトすることにより，あたかもVNCクライアントとDockerコンテナ上のQEMUが直接通信しているように動作します．

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/Dockerfile)）

```dockerfile:Dockerfileの一部
# VNC port
EXPOSE 5900
```

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/Makefile)）

```makefile:Makefileの一部
VNC_PORT = 5900

# docker
DOCKER = docker
DOCKER_IMAGE_NAME = hariboslinux
DOCKER_IMAGE_TAG = latest
DOCKER_CONTAINER_NAME = hariboslinux
DOCKER_CONTAINER_SHELL = /bin/sh
DOCKER_VNC_PORT_OPTION = -p $(VNC_PORT):$(VNC_PORT)

docker-run:
	$(DOCKER) run --name $(DOCKER_CONTAINER_NAME) $(DOCKER_VNC_PORT_OPTION) -i -t $(DOCKER_IMAGE_NAME)
```

QEMUは，RFB通信で受信したマウス操作，キーボード操作を自作OSに対するマウス割り込み，キーボード割り込みに変換します．
自作OSはVRAMに書き込むことで画面を出力し，QEMUがその画面をRFB通信でDockerコンテナを通してVNCクライアントに送信することで，画面表示を実現します．

QEMU起動時にオプション`-vnc :0`で任意のIPアドレスとのRFB通信を受け入れるようにします．
また，VNCとは無関係ですがQEMU起動時にオプション`-serial stdio`で自作OSのRS-232通信とDockerコンテナの標準入出力を結びつけ，さらにそれをパイプラインで`tee`コマンドに流すことで通信内容を`serialout.txt`というファイルに保存しています．
あとはQEMUを終了する`make stop`コマンドも作りました．

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/Makefile)）

```makefile:Makefileに記述したQEMUの起動と終了
# emulator
# emulate intel 386 processor
EMULATOR = qemu-system-i386
EMULATOR_BOOT_OPTION = -boot order=a
# debug by gdb through tcp:2159
EMULATOR_DEBUG_OPTION = -S -gdb tcp::$(DEBUG_PORT)
# a raw image of the floppy disk
EMULATOR_DRIVE_OPTION = -drive file=$(IMAGE_FILE),format=raw,if=floppy
# memory size assigned for the emulator 
EMULATOR_MEMORY_OPTION = -m 4G
# serial console
EMULATOR_SERIAL_OPTION = -serial stdio
EMULATOR_SERIAL_OUT = serialout.txt
# VESA VBE 2.0
EMULATOR_VIDEO_OPTION = -vga std
# virtual network computing for all ip address
EMULATOR_VNC_OPTION = -vnc :0

# run the OS on QEMU
run: $(IMAGE_FILE) stop
	$(EMULATOR) $(EMULATOR_BOOT_OPTION) $(EMULATOR_DRIVE_OPTION) $(EMULATOR_MEMORY_OPTION) $(EMULATOR_SERIAL_OPTION) $(EMULATOR_VIDEO_OPTION) $(EMULATOR_VNC_OPTION) | tee $(EMULATOR_SERIAL_OUT) &

# stop QEMU
stop:
	for i in $$(ps ax | grep $(EMULATOR) | grep -v grep | awk '{print $$1}'); do kill $$i; done
```

# GDBでデバッグしよう

Dockerとはあまり関係ないかもですが，一応開発環境の重要な部分として自作OSをGDBでデバッグする方法も紹介しておきます．
GDBとQEMUが2159番ポート（たぶん他のポートでもいいんだろうけど2159番ポートがそれっぽかったのでこのポートにしている）で通信することでQEMU上で動作する自作OSをデバッグできます．

### QEMU側の設定

QEMU起動時にオプション`-gdb`を渡すことで，処理を進めずに指定したポートでGDBを待機する状態で起動してくれます．
以下のMakefileでは，コマンド`make debug`で，QEMUをGDB待機状態で起動したのち，GDBを起動しています．

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/Makefile)）

```makefile:Makefileの一部
DEBUG_PORT = 2159

# emulator
# emulate intel 386 processor
EMULATOR = qemu-system-i386
EMULATOR_BOOT_OPTION = -boot order=a
# debug by gdb through tcp:2159
EMULATOR_DEBUG_OPTION = -S -gdb tcp::$(DEBUG_PORT)
# a raw image of the floppy disk
EMULATOR_DRIVE_OPTION = -drive file=$(IMAGE_FILE),format=raw,if=floppy
# memory size assigned for the emulator 
EMULATOR_MEMORY_OPTION = -m 4G
# serial console
EMULATOR_SERIAL_OPTION = -serial stdio
EMULATOR_SERIAL_OUT = serialout.txt
# VESA VBE 2.0
EMULATOR_VIDEO_OPTION = -vga std
# virtual network computing for all ip address
EMULATOR_VNC_OPTION = -vnc :0

# debug the operating system onQEMU by gdb
debug: $(IMAGE_FILE) stop
	($(EMULATOR) $(EMULATOR_BOOT_OPTION) $(EMULATOR_DRIVE_OPTION) $(EMULATOR_MEMORY_OPTION) $(EMULATOR_SERIAL_OPTION) $(EMULATOR_VIDEO_OPTION) $(EMULATOR_VNC_OPTION) $(EMULATOR_DEBUG_OPTION) &) && \
	make -C gdb
```

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/gdb/Makefile)）

```makefile:GDBを起動するMakefile
DEBUGGER = gdb

all:
	$(DEBUGGER)
```

### GDB側の設定

DockerfileでGDBの設定ファイルをホームディレクトリ直下に配置しています．

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/Dockerfile)）

```dockerfile:Dockerfileの一部
# gdb setting
RUN echo add-auto-load-safe-path `pwd`/gdb/.gdbinit > ~/.gdbinit
```

（[GitHub上のソースファイル](https://github.com/TaiseiIto/hariboslinux/blob/main/gdb/.gdbinit)）

```:.gdbinit
# tcp port
target remote localhost:2159

# real mode
# set tdesc filename target.xml

# go to entry point of kernel.bin
break *0x7c00
continue
delete 1
```

.gdbinitにはGDB起動直後に自動実行したいことを記述します．
まず，`target remote localhost:2159`で，GDBとQEMUを2159番ポートで接続します．
次の`set tdesc filename target.xml`は自作OSがリアルモードで動作している部分をデバッグするときに用いる設定ですが，プロテクトモード移行後をデバッグする際には不要なのでコメントアウトしています．
リアルモードデバッグに関しては[以前書いた記事](https://qiita.com/Egh2Deywos/items/a8ad9b96139a4f34bb7e)を参照してください．
その後，`break *0x7c00`で0x7c00にブレークポイントを配置し，`continue`でQEMUを動作させ，0x7c00に到達したところで一時停止します．
最後に`delete 1`で0x7c00のブレークポイントを削除します．
.gdbinitにこれを記述することでGDB起動時にマスターブートレコード実行直前まで自動的に進めてくれるので，あとは頑張ってデバッグするだけです．

# まとめ

* Dockerを使うことで，環境構築が簡単になった．
* Makefileで面倒なDockerコマンドを簡略化した．
* Dockerコンテナに自分のGitHub用の秘密鍵を配置して`git push`できるようにした．
* VNCを使ってDockerコンテナ上のQEMUで動く自作OSを操作できるようにした．
* GDBでデバッグできるようにした．