Hyper-V on Windows 11 に Arch Linux をインストールする
Arch Linux on Distrod on WSL2にいろいろと限界を感じ始めたので、Hyper-V上にArch Linux環境を作ることにしました。 手順をメモします。
Hyper-VとかLinuxとかよく分かっていないので、雰囲気でやっていきます。
Hyper-V 設定
Hyper-V - ArchWikiの手順に従いながら進めていく。
仮想ネットワークの設定
今回は内部スイッチを利用する。
Windows 10 Fall Creators Update (バージョン 1803) 以降では、仮想マシンに NAT スイッチ (Default Switch) が組み込まれており、特段の設定なしでインターネットに接続できます。
とのことなので、特別な設定は不要のようだ。
仮想マシンの作成

まず、Hyper-Vマネージャーの右サイドバーの「新規」→「仮想マシン」を選択。 出てきたダイアログに値を入力しながら進めていく。

名前と場所は適当に指定する。 今回は名前を "thyme"、場所を D ドライブ配下にした。

UEFIベースのファームウェアを使うため、「第2世代」を選択。

起動メモリはひとまず1024MBを指定。 「動的メモリ」を有効にして、必要に応じて追加のメモリが割り当てられるようにする (便利!)。

前述の通り内部スイッチを利用するため「Default Switch」を選択。

仮想ハードディスクを新たに作成して接続します。 ディスクは容量可変で後からサイズ変更可能なため、容量はデフォルトの127GBのままとした。

Arch Linux JP Project - ダウンロード よりダウンロードしたISOイメージを指定。

最後に内容を確認して、「完了」を押すと、仮想マシンが作成され仮想マシンマネージャーの画面に戻る。
仮想マシンの設定
続けて、仮想マシンを設定していく。

仮想マシンマネージャーで作成した仮想マシンを選択すると右サイドバーに表示される「設定」をクリックする。

「ハードウェア」→「セキュリティ」を選択する。 Arch Wikiの注意書きに従い、セキュアブートを無効化する。

「ハードウェア」→「メモリ」を選択する。 動的メモリの最大割り当て量 (最大RAM) が1048576MB (1TB!) というとんでもない値になっている。 仮想マシンが暴走した場合などに問題が起きるかもしれないため、マシンの搭載メモリ量の50%ほどを指定しておく。 今回は32768(32GB)を指定した。
※後ほどrustupでRustをインストールしようとしたところメモリ不足で失敗すたため、RAMと最小RAMを2048MBに変更した。

「ハードウェア」→「プロセッサ」を選択する。 「仮想プロセッサの数」が1になっているので、適切な値に設定する。
仮想マシンで重い処理をする場合、搭載されているプロセッサコアの半分を割り当てると良いでしょう。
とのこと。ここで言うプロセッサコア数は論理プロセッサ数のことだと思われるが、今回はホストマシンの物理プロセッサ数の半分の8を設定した。

「自動チェックポイントを使用する」はオフにした。
その他の設定はデフォルトのままにした。 VMの運用方法に合わせて「自動開始アクション」や「自動停止アクション」の設定をすると良いだろう。
設定完了したら、「OK」をクリックする。
仮想マシンの起動
仮想マシンマネージャーの右サイドバーの「接続」をクリックして、仮想マシン接続を表示する。

「起動」を押すと仮想マシンが起動される。

少し待つとGRUBの画面が表示される。
"Arch Linux install medium (x86_64, UEFI)" を選択し、先に進む。 何もキーを押さずに放置していれば、自動で選択されるようだ。

少し待つとコンソールが表示される。 インターネット接続できるか、以下コマンドで確認する。
# ping archlinux.org
Arch Linuxのインストールの準備
インストールガイド - ArchWiki に従って作業を進める。
コンソールのキーボードレイアウトの設定
US配列のキーボードを利用しているため、省略。
起動モードの確認
UEFIモードで起動しているか確認する。 以下コマンドでディレクトリの内容が表示されれば、UEFIで起動していると確認できるそうだ。
# ls /sys/firmware/efi/efivars
インターネットへの接続
以下コマンドでネットワークインタフェースが認識・有効化されていることを確認する。
# ip link

認識されているようだ。
システムクロックの更新
NTPを有効化してシステムクロックを正しくする。
# timedatectl set-ntp true # timedatectl status

正しい時刻になっていればOK。
パーティションの作成
ディスクに割り当てられたブロックデバイスを確認する。
# fdisk -l

/dev/sda
が割り当てられたようだ。
このデバイス上にパーティションを作成する。
今回はルートディレクトリのパーティションと、UEFIで起動するためのEFIシステムパーティションの2つを作成する。 UEFIを使うのでパーティションテーブルはGPTを使用する。
まずgdiskコマンドを起動する。
# gdisk /dev/sda

gdisk を起動するとパーティションテーブルのスキャン結果の後にプロンプトが表示され、コマンド入力を求められる。

?
を入力すると、利用可能なコマンドの一覧が表示される。

n
(add a new partition) を入力すると、新たにパーティションを作成するためのパラメータが聞かれるので、順番に以下を入力する。
- Partition number: 空欄 (デフォルト値: 1)
- First sector: 空欄 (デフォルト値: 2048)
- Last sector:
512M
- Hex code or GUID:
ef00
(EFIシステムパーティションを意味する値)
続いて、ルートディレクトリのパーティションを設定する。
再び n
を入力する。
パラメータ設定は以下の通り。
- Partition number: 空欄 (デフォルト値: 2)
- First sector: 空欄 (デフォルト値: EFIパーティションの直後のセクター)
- Last sector: 空欄 (デフォルト値: ドライブ全体の最終セクター)
- Hex code or GUID: 空欄 (デフォルト値:
8300
= Linux ファイルシステムを意味する値)

p
(print the partition table) を入力して、設定値に問題がないか確認する。

問題がなければ w
(write table to disk and exit) を入力して、パーティション情報をディスクに書き込む。
書き込みを実行しても良いか最終確認で聞かれるので、 Y
を入力する。
処理が完了すると gdisk コマンドが終了し、シェルのプロンプトが表示される。
パーティションのフォーマット
作成したパーティションをフォーマットする。
EFIシステムパーティション (/dev/sda1
) はFAT32でフォーマットする。
ルートディレクトリのパーティション (/dev/sda2
) はお好みのファイルシステムでフォーマットすればよい。
今回はext4でフォーマットした。
# mkfs.fat -F32 /dev/sda1 # mkfs.ext4 /dev/sda2
mkfs.fat
はすぐに終わるが、mkfs.ext4
は少し時間がかかる。
ファイルシステムのマウント
ルートディレクトリのパーティションを /mnt
にマウントし、EFIシステムパーティションを/mnt/boot
にマウントする。
# mount /dev/sda2 /mnt # mount --mkdir /dev/sda1 /mnt/boot
Arch Linuxのインストール
引き続き、インストールガイド - ArchWiki に従って作業を進める。
ミラーの選択
/etc/pacman.d/mirrorlist
の中身を確認し、作業環境から近いミラーサーバーがリストの中で上の方に来るよう並べ替える。
今回はJPドメインのミラーサーバーが一覧になかったため、デフォルトの設定のままとした。
archlinux-keyringの更新
作業実施時点 (2022/7/30) ではこの後のpacstrap実行が invalid or corrupted package pacstrap
というエラーで失敗してしまうため、対処として行った。
# pacman -Sy archlinux-keyring
pacman -Syu
ですべてのパッケージを更新しようとしたところディスク容量不足で失敗してしまったため、archlinux-keyringのみ更新している。
必須パッケージのインストール
pacstrap
を実行して /mnt
以下に必要パッケージをインストールする。
この後の作業で必要になるテキストエディタ や 等の必要パッケージもインストールしておく。dhcpcd
# pacstrap /mnt base linux linux-firmware vim
※dhcpcdをやめ、 systemd-networkd に移行したため、削除
パッケージのインストールが正常に終了していることをきちんと確認すること。
システムの設定
更に引き続き、インストールガイド - ArchWiki に従って作業を進める。
fstabの設定
genfstab
コマンドを使ってfstab
を設定する。
# genfstab -U /mnt >> /mnt/etc/fstab
chroot
arch-chroot
コマンドを使って、/mnt
配下にインストールしたシステムにchrootする。
# arch-chroot /mnt
タイムゾーン設定
タイムゾーンをJSTに設定し、hwclock
コマンドを使って/etc/adjtime
を生成する。
# ln -sf /usr/share/zoneinfo/Asis/Tokyo /etc/localtime # hwclock --systohc # date
date
コマンドの実行結果がJSTになっていればOK。
ローカリゼーション
/etc/locale.gen
の内容を編集して、en_US.UTF-8 UTF-8
とja_JP.UTF-8 UTF-8
の行をコメントを外す。
その後locale-gen
コマンドを使いロケールを生成する。
# vim /etc/locale.gen ... # locale-gen
ロケールが生成されたら、/etc/locale.conf
を作成し、LANG
環境変数を設定する。
# echo "LANG=en_US.UTF-8" > /etc/locale.conf ...
ネットワーク設定
/etc/hostname
ファイルを作成し、ホスト名を書き込む。
# echo "thyme" > /etc/hostname
今回は thyme
と名付けた。
再起動に備えて、ネットワーク機能を有効化しておく。
# systemctl enable systemd-networkd.service
# /etc/systemd/network/20-wired.network [Match] Name=eth0 [Network] DHCP=yes
rootパスワード設定
passwd
コマンドでrootユーザーのパスワードを設定する。
設定しておかないと、再起動後ログインできなくなってしまう。
ブートローダーの設定
今回はsystemd-bootを利用することにした。
systemd-boot - ArchWiki を参考に設定していく。
EFIブートマネージャのインストール
bootctl
コマンドを使ってEFIブートマネージャをインストールする。
# bootctl --path=/boot install
成功すると/boot/EFI
等のファイルが作成される。
ローダー設定
/boot/loader/loader.conf
を以下のように書き換える。
default arch timeout 3 editor no
ローダーエントリの追加
ブートローダーのエントリを追加する。 まず、サンプルのブートローダーエントリファイルをコピーして編集する。
# cp /usr/share/systemd/bootctl/arch.conf /boot/loader/entries/ # vim /boot/loader/entries/arch.conf
以下のように編集する。
* options
の行のroot=PARTUUID=XXXX
のXXXX
をPARTUUIDに置き換える
* PARTUUIDの値は blkid -s PARTUUID -o value /dev/sda2
で取得できる
* options
の行のrootfstype=XXXX
のXXXX
をファイルシステム種別に置き換える
* 今回はext4
bootctl list
を実行すると、登録されたローダーエントリを確認できる。
再起動
exit
コマンドまたはCtrl-d入力によりchroot環境より抜け、reboot
コマンドでマシンを再起動する。
成功すればログイン画面が表示されるはず。
ブート後の設定
まずはroot
ユーザーでログインする。
一般ユーザの追加
ユーザーとグループ - ArchWiki を参考に作業を進める。
まず、wheelグループに属したユーザーを作成し、ログインパスワードを設定する。
# useradd -m -G wheel -s /bin/bash nksm # passwd nksm
今回はnksm
というユーザー名のユーザーを作成した。
次に、sudo
が使えるよう設定する。
まず、sudo
をインストールする。
# pacman -S sudo
続いて、 /etc/sudoers
を編集してwheel
グループに属するユーザーがsudo
を使えるようにする。
安全のため、visudo
で編集する。
# EDITOR=vim visudo
以下の行のコメントを解除する。
%wheel ALL=(ALL:ALL) ALL
ここまで編集したら一旦root
ユーザーのセッションからログアウト後、作成したユーザーのアカウントでログインしsudo
が使えることを確認する。
manページのインストール
この後の設定作業で参照する機会も多いので、manを使えるようにしておく。
$ sudo pacman -S man-db
SSH設定
Hyper-Vのコンソールからの作業は大変なので、SSHでログインできるようにする。
まず、openssh
をインストールする。
$ sudo pacman -S openssh
次に、SSHデーモンの設定を編集する。
$ EDITOR=vim sudoedit /etc/ssh/sshd_config
今回は内部ネットワークにしか接続しないことから、デフォルト設定のままにしている。
最後に、SSHデーモンを有効化し、起動する。
$ sudo systemctl enable --now sshd.service
ホストマシンからsshログインできることを確認する。
仮想マシンのIPアドレスはip addr
で調べておく。
$ ssh nksm@<仮想マシンのIP>
ssh-copy-id
を実行するなどして、公開鍵でログインできるようにしておくと良い。
ネットワーク設定を変更する
Hyper-Vのデフォルトの仮想スイッチ"Default Switch"ではVM起動の度に異なるサブネット/IPアドレスが割り振られるため、外部からSSHログインするためには毎回IPアドレスを調べる必要がある。 これでは不便なのでネットワーク設定を変更する。
今回は外部ネットワークに直接接続させ、仮想マシンのIPアドレスは外部ネットワーク上のDHCPサーバーにより自動割り当てさせることとする。

まず、Hyper-Vマネージャーの右サイドバーの「仮想スイッチマネージャー」をクリックする。

仮想スイッチマネージャーの「新しい仮想ネットワークスイッチ」で「外部」を選択し、「仮想スイッチの作成」をクリックする。

仮想スイッチのプロパティで適当な名前を設定し、「OK」をクリックしスイッチを作成する。 このとき、ネットワークが一瞬切断される旨の警告ダイアログが表示される。

仮想スイッチ作成後、仮想マシン設定の「ネットワークアダプター」で、「仮想スイッチ」を先ほど作成したスイッチ (今回は External Switch) に変更し、「OK」ボタンを押す。
少し待つと、外部ネットワーク上のDHCPサービスにより仮想マシンにIPアドレスが割り当てられる。
ホスト名で名前解決できるようにする
mDNSの仕組みを使うと、ローカルネットワーク上のホストについて ホスト名.local
で名前解決できるようになる。
mDNSを有効にするためには、systemd-resolvedを有効化すれば良い。
$ sudo systemctl enable --now systemd-resolved.service
これでホスト環境から ssh <ホスト名>.local
で仮想マシンへログインできるようになる。
Hyper-V Integration Servicesを導入する
Hyper-Vの動的メモリ機能等を有効にするため、Hyper-V Integration Servicesをインストールし、起動・有効化する。
$ sudo pacman -S hyperv $ systemctl enable --now hv_fcopy_daemon.service hv_kvp_daemon.service hv_vss_daemon.service
AURヘルパーを入れる
AURからのパッケージインストールを簡単にするため、AURヘルパーを入れる。 今回はparuを導入する。
GitHubのREADMEの手順に従いインストール。 途中rustを入れろと言われるので、入れる。
$ sudo pacman -S --needed base-devel $ sudo pacman -S git $ git clone https://aur.archlinux.org/paru.git $ cd paru $ makepkg -si
paruの新バージョンがリリースされた時に自動アップデートできるよう、AURからparu-bin
をインストールし、paru
を上書きする。
$ paru -S paru-bin
systemd-boot更新時にbootctl updateを自動実行する
systemd-bootが更新された場合、EFIブートマネージャを更新できる。
更新のためには bootctl update
を実行する必要がある。
作業を自動化するためにAURよりpacmanのhookをインストールする。
$ paru -S systemd-boot-pacman-hook
NTPの有効化
timedatectlで有効化する
$ sudo timedatectl set-ntp true $ timedatectl Local time: Sat 2022-07-30 19:13:32 JST Universal time: Sat 2022-07-30 10:13:32 UTC RTC time: Sat 2022-07-30 10:13:32 Time zone: Asia/Tokyo (JST, +0900) System clock synchronized: yes NTP service: active RTC in local TZ: no
NTP serviceがactiveになった。
swap の有効化
ひとまず4GBのswapfileを用意する。
スワップ - ArchWikiを参考に設定する。
$ sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=progress $ sudo chmod 600 /swapfile $ sudo mkswap -U clear /swapfile Setting up swapspace version 1, size = 4 GiB (4294963200 bytes) no label, UUID=00000000-0000-0000-0000-000000000000 $ sudo swapon /swapfile
自動的にswapfileが有効化されるよう、/etc/fstab
に以下を追加する。
/swapfile none swap defaults 0 0
以降、随時追加予定
ARK のマルチプレイサーバーを立てる (Docker on CentOS 8 Stream)
ARK: Survival Evolved が無料 "だった" ので入手しました。 マルチプレイのためには自分でサーバーを起動する必要があったので、VPS 上に構築した時の作業手順メモします。
なお、VPS のスペックがショボすぎたために、動作確認まではできず... 手順メモとして残しておきます。
VPS 環境
さくらのVPS 1GBプラン
swapfile を有効にする
ARK サーバーは 6GB 以上メモリがないと起動失敗するようです (起動時チェックにひっかかる?)。
実際のメモリ消費量はもっと少ないためか (?) swapfile
を設定してメモリ容量が十分なように見せかければ動作するらしいです。
$ free -h total used free shared buff/cache available Mem: 964Mi 115Mi 321Mi 43Mi 527Mi 663Mi Swap: 4.0Gi 51Mi 3.9Gi $ sudo fallocate -l 6GiB /swapfile $ sudo chmod 600 /swapfile $ sudo swapon /swapfile $ free -h total used free shared buff/cache available Mem: 964Mi 118Mi 313Mi 43Mi 532Mi 660Mi Swap: 9Gi 51Mi 9.9Gi
元々4GiBのswapが設定されていましたが、念のための追い swap 6GiB です。
OS 起動時に自動的に swap 領域が設定されるよう、 /etc/fstab
に以下を追加します。
/swapfile swap swap defaults 0 0
Docker をインストールする
ARK サーバーを動かすためには公開されている Docker イメージを使うのが簡単です。 まずは Docker をインストールします。
$ sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo $ sudo dnf install docker-ce docker-ce-cli containerd.io docker-compose-plugin $ sudo usermod -aG docker $(id -un) $ sudo systemctl enable --now docker.service $ sudo systemctl enable --now docker.socket
ARK サーバーを構築する
/srv/docker/ark-server
に必要なファイル一式を置くこととします。
$ sudo mkdir /srv/docker $ sudo chown root:docker /srv/docker $ sudo chmod 774 /srv/docker $ cd /srv/docker $ mkdir ark-server $ cd ark-server
以下内容の /srv/docker/ark-server/docker-compose.yml
を作成します。
Dockerイメージ の README 記載の docker-compose.yml
とほとんど同じですが、
volume の作成先を ${HOME}
ではなくカレントディレクトリにしています。
version: '3' services: server: restart: always container_name: ark_server image: hermsi/ark-server:latest volumes: - ./ark-server:/app - ./ark-server-backups:/home/steam/ARK-Backups environment: - SESSION_NAME=${SESSION_NAME} - SERVER_MAP=${SERVER_MAP} - SERVER_PASSWORD=${SERVER_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - MAX_PLAYERS=${MAX_PLAYERS} - UPDATE_ON_START=${UPDATE_ON_START} - BACKUP_ON_STOP=${BACKUP_ON_STOP} - PRE_UPDATE_BACKUP=${PRE_UPDATE_BACKUP} - WARN_ON_STOP=${WARN_ON_STOP} ports: # Port for connections from ARK game client - "7777:7777/udp" # Raw UDP socket port (always Game client port +1) - "7778:7778/udp" # RCON management port - "27020:27020/tcp" # Steam's server-list port - "27015:27015/udp" networks: - default
設定値は /srv/docker/ark-server/.env
に記載します。
SESSION_NAME=... SERVER_MAP=TheIsland SERVER_PASSWORD=... ADMIN_PASSWORD=... MAX_PLAYERS=5 UPDATE_ON_START=false BACKUP_ON_STOP=false PRE_UPDATE_BACKUP=true WARN_ON_STOP=true
準備が出来たら起動します。
$ docker compose up -d $ docker compose logs -f ...
初回起動時は多数のファイルをダウンロードするために時間がかかるため、忍耐強く待ちます。
firewall を設定しポートを公開する
ARK サーバーのポートをインターネットに公開するため、 firewalld
を設定します。
まずは、 ARK サーバーをサービスとして定義してやります。
$ sudoedit /etc/firewalld/services/ark-server.xml
内容は以下です。
<?xml version="1.0" encoding="utf-8"?> <service> <short>ARK</short> <description>ARK Survival Evolved</description> <port protocol="udp" port="27015"/> <port protocol="udp" port="7777"/> <port protocol="udp" port="7778"/> </service>
上記で定義したサービスの利用するポートを開放します。
$ sudo firewall-cmd --reload $ sudo firewall-cmd --permanent --zone=public --add-service=ark-server $ sudo firewall-cmd --reload
Steam クライアントに登録する
Steam の表示→サーバーで、上記で立ち上げたサーバーのIP (or FQDN) を登録します。 構築がうまくいっていれば、ARKが起動できるはず。
参考サイト
「ゼロからのOS自作入門」を Rust でやる (第17章)
いつの間にかこのシリーズも10記事を超えていました。 本記事で11記事目です。 難所をいくつも越え、なんとか最終章まで続けられそうな気がしてきました。 頑張っていきましょう。
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第14章~第16章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第17章
FATファイルシステムを扱えるようにする章です。 この章ではFATファイルシステムのルートディレクトリのファイル一覧を出力できるようにします。
FAT ファイルシステムをカーネルから参照できるようにする (day17a)
「ゼロからのOS自作入門」ではファイルシステムの実装にあたり、ブロックデバイスからの読み書きはサポートしていません。 代わりに、メモリの一部分をブロックデバイスと見なし、メモリ上にファイルシステムを構築します。
C++ 版実装では UEFI のブロックデバイス読み取り機能を使い、 OS のブートイメージの先頭部分をメモリ上にコピーすることでメモリ上にファイルシステムを構築していました。
Rust 版実装では、ブートローダーは bootloader
クレートを利用しているため UEFI に手を入れるのは面倒です (bootloader
クレートを fork しないといけない)。
今回は、build.rs
で必要なファイルを含んだFATイメージファイルを作成し、バイナリデータとしてカーネル本体にリンクするようにしました。
カーネルのバイナリサイズが16MiB増加しますが、許容範囲でしょう。
build.rs
の処理の流れは以下の通りです。
- FAT ファイルシステムのイメージファイルを作成
llvm-objcopy
で上記イメージファイルの内容を含むオブジェクトファイルを作成 (_binary_fs_fat_start
というシンボルでファイルシステムにアクセスできるようにする)llvm-ar
で上記オブジェクトファイルを含む静的ライブラリを作成し、成果物にリンクするよう cargo に指示する
当初は16Mi要素のバイト配列を定義した Rust ソースコードを出力し、カーネル側ソースコードから include!()
する方式を試していましたが、カーネルのコンパイルが終わらなくなってしまったため、静的ライブラリをリンクする方式に変更しました。
FAT ファイルシステムの作成には fatfs
クレート を利用しました。
「ゼロからのOS自作入門」で紹介されていた mkfs.fat
や mount.
を使う方法と異なり、root 権限が不要になるのが良いですね。
ルートディレクトリのファイルを一覧する (day17b)
前節で追加したファイルシステムからルートディレクトリのファイル一覧を取得し表示するコマンド ls
を追加します。
C++版と同じように実装すれば良いかと思いきや、結構引っかかりました。 というのも、「ゼロからのOS自作入門」ではファイルシステムが FAT32 であることを前提としたいたのですが、 今回 Rust 版でカーネルにリンクされたファイルシステムは FAT16 であったため、構造体メンバのアクセス方法などが大きく異なっていたためです。 FAT の仕様上 FAT32 にできるのはボリュームサイズが 32MiB 以上の場合のみで、今回作成した 16MiB のファイルシステムは FAT12 か FAT16 にせざるを得なかったようです。
仕方がないので、 FAT12/FAT16/FAT32 に対応できるようにしました。 実装にあたり、以下のサイトを大いに参考にさせて頂きました。
ファイルシステムの詳細はできるだけ fat
モジュールに閉じるようにして、 terminal
モジュールからは詳細をあまり意識しなくて良いようにしようとしています。
まだ抽象化は十分ではない感じなので、これから機能を追加しながら綺麗にしていけたらなあと思います。
まとめ
比較的短い章でしたが、 FAT12/16 でのファイルアクセス方法を調べながらコーディングする必要がありなかなか大変でした。 次章はついにアプリケーションが実行できるようになります。 ユーザーランドのプログラムも rust で書きたいものですが、果たしてうまくいくのでしょうか? 次章もお楽しみに。
「ゼロからのOS自作入門」を Rust でやる (第14章~第16章)
ブログの更新は間が空いてしまいましたが、OSの移植じたいは細々と続けていました。 今回はまとめて3章分です。
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第13章) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第17章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第14章
第13章に引き続きプリエンプティブマルチタスクを実装する章です。 第14章ではタスクのスリープや優先度を実装します。
タスクのスリープ (day14a)
タスクのスリープを実装します。また、 s
キー、 w
キーの入力でタスクBをスリープ状態にしたり実行状態にしたりします。
実装は C++ 版とおおよそ同じです。
Rust 版固有の作り込みとして、コンテキストスイッチ時の Arc
の取り扱いがあります。
static TASK_MANAGER: OnceCell<Mutex<TaskManager>> = OnceCell::uninit(); #[derive(Debug)] struct TaskManager { tasks: BTreeMap<TaskId, Arc<Task>>, wake_queue: VecDeque<TaskId>, } impl SwitchTask { fn switch(self) { assert!(Arc::strong_count(&self.next_task) > 1); assert!(Arc::strong_count(&self.current_task) > 1); unsafe { let next_task_ptr = Arc::as_ptr(&self.next_task); let current_task_ptr = Arc::as_ptr(&self.current_task); drop(self.next_task); drop(self.current_task); #[allow(clippy::unwrap_used)] let next_task = next_task_ptr.as_ref().unwrap(); #[allow(clippy::unwrap_used)] let current_task = current_task_ptr.as_ref().unwrap(); Task::switch(next_task, current_task) } } }
Task
構造体は TaskManager
構造体の tasks
フィールドに Arc<Task>
という形で格納されています。
TaskManager
構造体は複数のコンテキストからアクセスするために static
変数 TASK_MANAGER
に保持されており、アクセスするためには Mutex
ロックの取得が必要です。
このため、コンテキストスイッチ時にロックを取得したままだと次回のコンテキストスイッチ時にロックを取得しようとしてデッドロックになってしまうため、以下のような順序で処理するようにしました。
TASK_MANAGER
のロックを取得- 現在実行中のタスクと次にコンテキストスイッチするタスクの
Arc<Task>
のクローンを取得 TASK_MANAGER
のロックを解除- コンテキストスイッチ対象の
Arc<Task>
から*const Task
を取得し、Arc<Task>
は drop する - コンテキストスイッチを実行
4 で *const Task
を取得しているのは、 Arc<Task>
の drop
(参照カウントのデクリメント) をコンテキストスイッチ前に行いたいためです。
4~5 の時点では TaskManager
が Arc<Task>
を保持しているため、ポインタの指す先の領域が解放されることはないはずです。
イベント発生時にタスクを起床させる & アイドル時にタスクをスリープさせる (day14b)
イベント待ち状態になったタスクをスリープ状態にする & 他タスクに対してイベント通知をする際に当該タスクを起床状態にするという節です。 これにより、OS の応答性が向上します。
C++ 版ではタスク間でイベントをやりとりするためのイベントキュー関連処理でタスクの起床/スリープを行っていました。 Rust 版では async-await の仕組みを採用しているため、 async-await の waker の仕組みと連携できると良さそうです。
まずは、新たに生成されたタスクで async-await が使えるよう、タスクごとにランタイム (executor) を持つようにしました。
従来は Task::new
の引数にタスクのエントリーポイントとなる関数ポインタを渡していました。
修正後は impl Future<Output=()>
を渡すようにしています。
エントリポイントは全タスクで共通化し、第一引数 rax
に格納されたポインタから Box<EntryPointArg>
を復元し、その中に含まれる executor の run
メソッドを実行することで CoTask
が実行できるようになりました。
次に、 async-wait の waker をタスクの wake/sleep に対応させます。
waker が CoTask
を起床させる際に、当該 CoTask
が所属するタスクも起床させるようにしました。
また、各タスクの Executor
で処理可能な CoTask
が存在しなくなったら自タスクをスリープさせるようにしました。
これだけでイベント発生時のタスク起床 & アイドル時のタスクスリープが実現できます。
簡単ですね!
タスクに優先度をつける (day14c)
個々のタスクに優先度 (レベル) を設け、優先度の高いタスクが優先的に実装されるようにします。
C++版とは少し実装方法が異なりますが、簡単に実装することができました。
アイドルタスクを追加 (day14d)
すべてのタスクがスリープ状態になった時にCPU消費を抑えられるよう、 hlt
を実行するだけの低優先度タスクであるアイドルタスクを追加します。
バグ修正
Rust 版のタスク優先度設定にはバグがあり、高優先度のタスクが実行可能状態の場合にタイマー割り込み契機のコンテキストスイッチ要求が発生すると、低優先度のタスクにスイッチしてしまうという問題がありましたので修正しました。
もうひとつのバグとして、割り込みコンテキストでのコンテキストスイッチ時にメモリ獲得を行ってしまう可能性がありました。 タスクの spawn 時に必要な領域獲得を行うようにして、割り込みコンテキストではメモリ獲得が行われないようにしました。
第15章
端末を実装する章です。
ウインドウ描画をメインタスクで行うようにする (day15a)
Rust 実装では元々描画処理はメインタスクで行うようにしていたため、この節の対応は不要でした。
アクティブウインドウの追加 (day15b)
アクティブウインドウの概念を導入し、タイトルバーの色を変えたり、キー入力イベントの送信先タスクを限定したりします。
まずは、ウインドウ描画処理を共通化します。
具体的には、タイトルバーや枠のあるウインドウを意味する FramedWindow
構造体を追加しました。
C++ 版では ToplevelWindow
という名前でしたが、あまりしっくり来ない名前だったので FramedWindow
としています。
また、C++版では ToplevelWindow
は Window
を継承していましたが、Rust 版では継承ではなくコンポジションでコードの再利用を実現しています (Rust 版では Layer
が Window
を保持しないため Window
型とのサブタイピング関係が不要であるという事情もあります)。
次にアクティブウインドウを管理する ActiveLayer
構造体を追加します。
だいたいC++版と同じ実装です。
続いて、ウインドウの状態に応じてタイトルバーの色を変更します。
ウインドウごとにイベント通知用のキューを保持するようにし、 ActiveLayer
が各 Window
の active/inactive を変更した時に、当該キュー経由でイベント通知するようにしました。
更にこのキューを利用して、キーボード入力イベントはアクティブウインドウに送信するようにしました。
これにて最初の節はおしまいです。
ターミナルの追加 (day15c)
ターミナルのウインドウを追加します。
C++版とだいたい同じです。
描画速度の高速化 (day15d)
従来はウインドウの一部に更新があるとウインドウ全体を再描画していました。 再描画時に必要な範囲だけ描画するよう描画範囲を指定することで描画処理を高速化します。
C++ 版ではターミナルのカーソル点滅の処理のみ描画範囲指定して高速化していました。 描画が必要な範囲は Window に描画する側のコードで計算する必要があります。 Rust 版では描画処理において再描画が必要な範囲も記憶するようにすることで、描画する側のコードで特殊な考慮をする必要がなくなり、カーソルの点滅以外のすべての描画処理で高速化の恩恵を受けられます。
バグ修正
第15章まで実装したあたりで、並列処理関連のバグ (デッドロックやクラッシュ) が高頻度で発生するようになってしまったため、関連バグを修正しました。
デッドロックが発生した原因は、スピンロックとタスクの優先度の実装方法にあります。 スピンロックは、ロック取得に失敗した場合タスクをスリープさせるのではなく、無限ループで他のタスクがロックを解放するのを待ちます。 また、現時点では高優先度のタスクが実行可能状態で存在する限り、低優先度のタスクは実行されません。 結果として、以下のようなデッドロックが発生する可能性があります。
- 低優先度のタスクがスピンロックを取得する
- 低優先度のタスクがロックを解放する前に高優先度のタスクにコンテキストスイッチする
- 高優先度のタスクが当該ロックを取得しようとすると、いつまで経ってもロックが取得できず待ち続けてしまう
この現象を回避するため、ロック取得失敗時にタスクをスリープさせるような Mutex を実装しました。 (スケジューラーのロジックを変更するのでも良いのですが、 Mutex はいずれにせよ必要になるため実装しました)
Mutex ロック取得失敗時、タスクIDを Mutex の保持するキューに追加します。 Mutex ロック取得に成功したタスクは、アンロック時にこのキューに含まれるタスクIDのタスクを起床させます。 (上記コミットのソースにはレースコンディションがあったため、後のコミットで修正しています。)
Mutex の実装に SegQue
を利用したため、 allocator の排他制御に Mutex は利用できなくなってしまいました。
このため、メモリ割り当て処理中は割り込みを抑止するようにしました。
Mutex のキューにヒープの領域ではなくスタックの領域を利用するなどすれば allocator の排他制御にも Mutex が利用できるかもしれないですね。
また、複数タスクからロックが取得される可能性がある箇所については Mutex を利用するようにしました。
ここまでの修正でデッドロックやパニックの発生頻度はかなり低下しました。
ArrayQueue
関連処理が wait-free でないため同様のデッドロックが発生する可能性は残っているのですが、ひとまずはこれでヨシとしました。
ついでに、割り込みコンテキストから allocator が呼び出されたことを検知できるようアサーションも追加しています。
並列処理が関連すると問題のあるコードがたまたま動いてしまうことが多く、バグがあっても再現性がなくて原因調査が大変だったりするので、アサーションを入れるに越したことはないでしょう。
第16章
ターミナル上での入力やコマンド実行を実装する章です。 全体的にC++実装と同じで難しいことはあまりないので、コメントは省略します。
github.com github.com github.com github.com github.com github.com
まとめ
プリエンプティブマルチタスクと協調的マルチタスク (async-await) をうまく連携させることができました。 Rustらしい実装ができたのではないかと思います。 また、並列処理に関するバグも修正でき、動作の安定度も上がっています。
次章ではファイルシステムを取り扱います。どうなることか。お楽しみに。
「ゼロからのOS自作入門」を Rust でやる (第13章)
13章もなかなかに難産でした。 マルチスレッドプログラミングは難しい...
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第10章~第12章) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第14章~第16章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第13章の前に
第13章に取り組む前にいくつかバグ修正をしました。
unaligned memory access を修正
デバッグモードでビルドしたOSを起動したところ、 debug_assert!()
で異常終了してしまいました。
原因は、XSDTのエントリ (u64
) が 8byte 境界に揃えられていなかったためです (4byteずれていた)。
x86_64 は unaligned なメモリアクセスも可能なので修正前のプログラムでも動いてしまうのですが、
デバッグビルドを動作させられないのも困るので修正しました。
修正自体は簡単で、ポインタの指す先を一度 [u8; 4]
として読み込んだ後、 u64::from_le_bytes
で u64
へと変換しているだけです。
オーバーフローの修正
同じくデバッグモードで検出したバグです。 タイマーの初期化時に整数のオーバーフローを検出していました。
let lapic_timer_freq = elapsed * 10;
上記が問題のあった処理です。
上記の演算結果が u32::MAX
を越えてしまうためオーバーフローが発生していました。
オーバーフローが発生しないよう修正しました。
第13章
プリエンプティブマルチタスクを実装する章です。
コンテキストスイッチの実装 (day13a)
コンテキストスイッチを実装します。
コンテキストスイッチのためにはアセンブリでの実装が必要です。 Rust のインラインアセンブラを使いたかったので、 naked function を使ってみました (関数のプロローグ・エピローグを生成させないため)。
コンテキストスイッチを実装しましたが、この節の段階では協調的マルチタスクの実装であること、また、C++版と異なりウインドウへの画像描画ではなくコンソールへの文字列出力しかしていないため、この段階では特に難しいことはありませんでした。
Makefile + Cargo から Cargo への以降
本筋とは関係ないのですが、 Makefile を利用するのをやめ、 Cargo だけで OS をビルド & 実行できるようにしました。
また、ユニットテストも実行できるようにしました。
saibos のブートローダーである bootloader
クレートに example が追加されたので、それを参考に実装しました。
ユニットテストが書けるようになると複雑なロジックもある程度安心して書くことができますね。
ログのリフォーム
ログ関連コードを整理しました。
具体的な修正内容は以下です。
- ログをシリアルポート経由で QEMU を起動した端末にも出力する
- ログ出力関数呼び出し元のファイル名、行番号をログに出力する
- ログレベルに Trace を追加し、 USB ドライバ関連ログのレベルを落とした
Layer
と Window
が描画バッファを共有していたのをやめる
従来処理では Layer
が Arc<Mutex<Window>>
を所有し、描画処理時はロックを取得していました。
このような構造では描画スレッドと Window
関連処理のスレッドが同時に Window
にアクセスしようとした場合、片方のスレッドが次のコンテキストスイッチ発生まで長時間待たされてしまいます。
描画スレッドが待ち状態になってしまうと、他の Window
の描画も行われなくなるため問題です。
この問題を解消するため、 Layer
が Window
を所有しなくなるように修正しました。
従来処理では layer_manager
の CoTask
に描画を依頼するために DrawLayer
イベントを送信していましたが、同時に描画するデータを含むバッファも送信するようにしました。
描画完了後バッファを元の CoTask
に oneshot チャンネル経由で返却します。
これにより、画面描画処理時に Mutex
ロックを取得する必要がなくなります。
サブタスクからウインドウを描画する
従来はメインタスクからのみウインドウの更新をしていましたが、サブタスクでもウインドウの描画を更新するようにしました。 前節までの準備が実を結びましたね。
メインタスクとサブタスクが同時に oneshot チャンネルのロックを取得する場合があったため、ロック待ち時に panic するのではなく、コンテキストスイッチ発生までスピンロックで待ち続けるようにしました。
定期的なコンテキストスイッチ (day13b)
タイマー契機で複数のタスク間でコンテキストスイッチを発生させるようにしました。 プリエンプティブマルチタスクです!
実行してみると、動作が非常に遅いです。
タスクBではウインドウの描画を更新する度に oneshot
チャンネルからバッファを受信するのですが、このときメインタスク側の処理が実行されるまで待ち続けてしまうため、タスクBのコンテキストではほとんど処理が進まずフリーズして見えることが原因のようです。
描画処理ではロックを使わないようにしたのですが、それだけではだめで、待ち時間をなくさなければならないようですね。 なんてこった...
トリプルバッファの導入
タスクが待たされてしまう問題に対してどうしたものかと思い悩みいろいろ調べてみたところ、どうやらトリプルバッファというものが利用できそうということが分かりましたので、実装してみました。
トリプルバッファにはいろいろな流儀があるようなのですが、ここでは以下のような仕組みを実装しています。
in_progress
,ready
,present
の3種類のバッファを用意する- 描画内容生成元タスク (producer) は
in_progress
バッファを所有する - 画面への描画処理タスク (consumer) は
present
バッファを所有する - producer は描画処理完了後、
in_progress
バッファとready
をスワップする (アトミック操作) - consumer は画面への描画開始時、
ready
バッファとpresent
バッファを比較し、ready
バッファの内容の方が新しい場合、 両者をスワップする (アトミック操作)
この仕組みにより、producer (consumer) は常時 in_progress
バッファ (present
バッファ) にアクセス可能 (=待ち時間がなし) になります。
トリプルバッファのアルゴリズムは 以下を参考にしました。
値が一致しない場合に値を差し替えるアトミック操作 (compare and swap の逆?) の実装は以下を参考にしました。
ロックフリーアルゴリズムは頭の体操みたいで楽しいのですが、難しいですね...
また、例によってアトミック操作のオーダーについては自信が持てなかったので、複数スレッドによりアクセスされる領域の操作は SeqCst
にしています。
トリプルバッファの実装にあたり、デバッグのためにユニットテストが非常に役立ちました。
トリプルバッファを使ってウインドウを描画する
前の節で用意したトリプルバッファでウインドウを描画するようにしました。
合わせてコードの整理も行っています (Window
生成にビルダーパターンを使うようにした)。
タスクBが動作している間は画面の描画は更新されませんが、画面描画は高速化されました。いい感じですね。
なお、タスクBが動作している間に大量のイベントがキューイングされるため、 layer_manager
の CoTask
のキューのサイズを大きくしています。
まとめ
プリエンプティブマルチタスクの仕組みを実装しました。 トリプルバッファを導入するなど C++ とは実装が大きく乖離したため、結構大変な章でした。 まだ性能はイマイチなのですが、次章以降で改善していきましょう。
「ゼロからのOS自作入門」を Rust でやる (第10章~第12章)
今回はまとめて3章です。
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第9章) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第13章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第10章
ウィンドウを表示して操作できるようにする章です。 一気にGUIっぽくなりますね。
マウスカーソルが画面外に飛び出すのを修正 (day10a)
マウスカーソルを画面端に動かすと画面端から飛び出してしまうのを修正しました。
C++版では画面から飛び出したマウスが反対側の端から現れるようになっていました。 Rust 版では描画時に座標の範囲チェックを行っているためそのような動作にはなっていませんでしたが、 マウスが画面端から移動して隠れてしまうようにはなっていたため修正しました。
メインウインドウを追加 (day10b)
メインウインドウを追加します。
メインウインドウ処理専用の CoTask
を作成し、表示するようにしています。
高速カウンタを追加 (day10c)
イベントループのループ毎にカウントアップするカウンタを作成し、値をメインウインドウに表示します。
C++版とは構造が異なりイベントループは async/await の executor として実装されているため、ループ内に簡単にカウントアップ処理を追加することはできません。
このため、一度イベントループに制御を戻した後即復帰するような Future
である Yield
を作成し main_window
のイベント処理中で利用するようにしました。
これにより、 main_window
の CoTask
からイベントループに一旦制御を戻せるようになります。
制御を戻した回数をカウントすることでカウンターの代替としました。
描画範囲の制限による高速化 (day10d)
従来処理では画面の一部分が変更された場合でも画面全体を再描画していました。 これを更新があったウインドウの範囲のみ再描画するように変更し、画面描画を高速化します。
実装方針はC++版と同じです。
Window
と WindowDrawer
を統合する
Window
と WindowDrawer
はそれぞれ別の構造体として定義していましたが、両者を統合し、 Window
が Draw
を実装するようにしました。
コードがシンプルになりました。
Mutex::with_lock
を追加
各 CoTask
のイベントループ内で一時的に Window
のロックを取得し、描画完了後アンロックするという処理が何度も登場しています。ロック & アンロックの区間を制御するため以下のようにブロックが必要なのですが、コードが読みづらく感じたため、引数のクロージャにロックを取った値を渡す Mutex::with_lock
を用意しました。
// 既存処理 let mut window = ...; { let mut window = window.lock(); window.fill_rect(...); window.fill_rect(...); } // 改造後処理 window.with_lock(|window| { window.fill_rect(...); window.fill_rect(...); });
後者の方がロック区間が明確になって良いかなーという気持ちです。
バックバッファによりちらつきを解消する (day10e)
これまでは画面の描画時に直接フレームバッファに描画していました。 このため、描画途中の状態が画面に表示されるため、マウスカーソルなどがちらついて表示されることがありました。 これを解消するため、バックバッファをというバッファを導入します。 各ウインドウの描画時に直接フレームバッファに描画するのではなく、一旦バックバッファにすべてのウインドウを描画し、 完了後にバックバッファの内容をフレームバッファにコピーするという実装へと変更します。 これによりちらつきが完全に解消します。
ウインドウをドラッグできるようにする (day10f)
ウインドウをドラッグすることで移動できるようにします。
mouse
の CoTask
でマウスのボタン押下を検知できるようにし、それに応じてウインドウをドラッグできるようにします。
マウスカーソルの下にあるウインドウの LayerId
を取得するため、 LayerManager
へ問い合わせるようなインタフェースを用意しています。
これまでの LayerManager
関連処理と異なり、 LayerManager
側関数からの戻り値を呼び出し元へ返す必要がありますが、mouse
と layer_manager
はそれぞれ異なる CoTask
で動作しているため、通常の関数のように値を渡すことはできません。
CoTask
間を跨がって値をやりとりするためにはチャンネルが利用可能ですが、これまでに作成したチャンネルは何度も繰り返して値を送信するためのものであり、関数の戻り値といった値を一度だけ渡すような使い方には向いていません。
このため、 oneshot
というチャンネルを作成し使うようにしています。
マウスのドラッグ関連処理を layer
の CoTask
へと移動する
先ほどの節でマウスのドラッグ処理を実装したばかりですが、今後このようなマウスからの入力に応じてウインドウを制御するような処理が増えてくると、 mouse
と layer
の CoTask
間でのやりとりが増えることとなり、効率が悪いですし、なによりもプログラミングがめんどくさいです。
このため、 mouse
の CoTask
はマウスボタンの押下有無等を判定するだけとし、 layer
の CoTask
でドラッグ等の処理を行うようにしました。
CoTask
間の役割分担が明確になって良い感じですね。
メインウインドウだけをドラッグ可能にする (day10g)
従来の実装では Window
により実装されているすべての要素がドラッグ可能だったため、コンソールやデスクトップの背景もドラッグ可能になってしまっていました。
main_window
だけドラッグできるように改造します。
Layer
に draggable
というメンバーを追加し、当該メンバが true
の場合のみドラッグ処理を実行するようにします。
先の節で layer
の CoTask
にドラッグ関係の処理を移動したことで、簡単に実装することができました。
(mouse
の CoTask
で各レイヤのドラッグ可否を取得しようとすると、layer
の CoTask
とのメッセージやりとりを増やす必要があるため)
第11章
Local ACPI によるタイマーを実装する章です。
ソースコードの整理 (day11a)
ソースコードのモジュール構造を整理する節です。 Rust版では最初からモジュール構造を整理していたため、特に何も行っていません。
タイマー割り込み (day11b)
Local ACPI によるタイマー割り込みを実装します。
実装は xHC の割り込みとほとんど同じですが、割り込みの発生有無だけが分かれば良い xHC と異なり、割り込みが発生した回数が重要なため、割り込み発生回数を AtomicU64
でカウントするようにしています。
タイマー間隔の短縮とタイマーマネージャーの追加 (day11c)
タイマーの設定により割り込み間隔を短縮するのと、タイマーを管理する TimerManager
を追加します。
複数のタイマーへ対応する (day11d)
プログラムの複数箇所で同時にタイマーによる待ち合わせができるようにします。
前の節で追加した TimerManager
に、タイマーの登録とタイムアウトの通知機能を実装します。
timer
の CoTask
では ACPI タイマーの割り込みと他の CoTask
からのタイマー登録依頼という異なるキューからの二種類のイベントを処理しないといけないため、 futures_util::select_biased
マクロを利用しています。 select
マクロは std
が必要ですが、 select_biased
は std
不要なのでフリースタンディング環境でも利用できます。
なお、タイマー割り込みで使うかと思い動的に CoTask
を spawn
できるようにする仕組みを Executor
に追加しましたが、結局使いませんでした。
今後利用出来る場面があるかと思い、実装はそのままにしています。
RSDP を取得する (day11e)
正確な時刻が分かる ACPI PM タイマーを利用するための準備の節です。
Rust 実装で利用しているブートローダーである bootloader
クレートでは、起動時のパラメータとして RSDP へのポインタが渡されるため、カーネル側の実装は特に難しいことはありませんでした (例によって RSDP の物理アドレスが仮想アドレスにマッピングされていなかったため、ページテーブルの書き換えは行っていますが)。
問題があったのは bootloader
側の実装でした。
具体的には、 ACPI v1 と v2 の両方の RSDP が存在する場合に、 ACPI v1 側の RSDP をカーネルへ渡す場合があるためです。
これは、UEFI のブートローダー実装で ACPI v1 と ACPI v2 の RSDP のうち、先に見つかった方をカーネルへ渡すようになっているためです。
筆者のQEMU環境では必ず ACPI v1 の RSDP が渡されるようでした。 ACPI PM タイマー利用のためには ACPI v2 の RSDP が必要なのでこれでは困ってしまいます。
ひとまず、 bootloader
にパッチを当て、 ACPI v1 の RSDP は無視するようにしました。
また、 bootloader
の GitHub リポジトリに issue を立てました。
作者の方にも反応頂いたので、そのうち解決されるといいなー。
第12章
ACPI PM タイマを使えるようにするのと、キーボードからの入力に対応する章です。
FADT を検索する (day12a)
前の節で検索した RSDP をたどって XSDT を取得、そこから更に FADT を検索するという節です。
だいたい C++ 実装と同じですね。 Rust のイテレーターではメソッドチェーンで検索処理を簡潔に書けるのが良いですね。
ACPI PM タイマーによりタイマー間隔を補正する (day12b)
正確な時間が分かるタイマーである ACPI PM タイマーにより、周期が不明なタイマーである Local APIC タイマーの周期を測定します。
これもまた C++ 実装と同じです。 余談ですが、筆者環境だとどうも時間が正しく計れていないような気がしています。 1秒間隔のはずが、3秒間隔くらいになっています。 筆者は WSL2 上で QEMU を動作させているのですが、 WSL2 では (まだ) Nested VM がサポートされていないため、 QEMU の動作が遅いことが原因なのでしょうか。
キーボードからの入力を処理する (day12c)
キー入力を受け取って画面に出力する節です。
C++ 実装と同じで特に書くことがありません...
修飾キーを処理する (day12d)
Ctrl や Shift などの修飾キーを処理できるようにします。
C++ と同じですね。
テキストボックスを表示する (day12e)
テキストボックスを含むウインドウを作成し、キー入力に応じてテキストボックス内に文字を表示します。
テキストボックスのウインドウを独立した CoTask
として実装しています。
また、キーボード入力を処理する keyboard
CoTask
からは mpsc::Sender
経由でキー入力イベントをテキストボックスの CoTask
へ直接送信するようにしています。
将来的には送信先 CoTask
を動的に切り替える処理が必要になるでしょうが、とりあえずはこのような簡単な実装にしておきます。
点滅するカーソルを描画する (day12f)
テキストボックスに入力位置を示すカーソルを描画します。
0.5秒ごとにタイマーイベントを発生させ、イベント契機ごとにカーソルの描画、削除を繰り返します。
定期的に実行されるタイマーである timer::interval
を追加し、簡単に利用できるようにしています。
まとめ
C++ 実装とだいたい同じで書くことがだんだん無くなってきましたが、メモ代わりにブログ記事は残しておこうかとは思っています。
次章はついにプリエンプティブマルチタスクの実装です。Rust でうまく実装できるのか。楽しみですね。
「ゼロからのOS自作入門」を Rust でやる (第9章)
改造量の多い章だと時間がかかってしまいますが、ゆるゆると続けております。
- シリーズ最初の記事: 「ゼロからのOS自作入門」を Rust でやる (第1章~第4章) - gifnksmの雑多なメモ
- 前回: 「ゼロからのOS自作入門」を Rust でやる (第7章) - gifnksmの雑多なメモ
- 次回: 「ゼロからのOS自作入門」を Rust でやる (第10章~第12章) - gifnksmの雑多なメモ
- 関連記事一覧: ゼロからのOS自作入門 カテゴリーの記事一覧 - gifnksmの雑多なメモ
第9章
マウスカーソルを動かすと背景やコンソールの文字が消えてしまう問題を解決するために、重ね合わせ処理 (レイヤー) を導入する章です。
重ね合わせの実装 (day09a)
以下を実装しました。
- マウスカーソル、デスクトップ背景など個別の描画要素を意味する
Window
構造体と、Window
への描画機能を有するWindowDrawer
構造体の追加 - 重ね合わせの個々の階層を意味する
Layer
構造体 Layer
の重ね合わせ順序や Frame Buffer への描画を制御するLayerManger
構造体Console
構造体の動作変更 (LayerManager
の初期化までは直接フレームバッファに描画、初期化後はWindow
に描画しフレームバッファへの描画はLayerManager
が行う)
実施していることは C++ 版と同様なのですが、実装の詳細が異なります。 具体的には以下の差異があります。
- C++ 版:
LayerManager
をグローバル変数として定義し各 Window の更新処理から直接メソッドを実行 - Rust 版: 各
Window
の更新処理およびLayerManager
の処理はそれぞれ専用のCoTask
で実施。Window
の更新処理により画面の再描画が必要になった場合、LayerManager
のCoTask
へとイベントを通知し、それを受けたLayerManager
のタスクが描画を行う
上記を実現するために、 sync::mpsc::{Sender, Receiver}
のような async/await 対応したキューを作成しました。
(マウスカーソル移動の時に作成したものを共通的に使える構造体として定義しなおしました)
tokio の mpsc キュー の実装は複雑なのでこのような構造体を定義するのは難しいと思っていたのですが、機能を絞れば簡単に実装することができました。
例えば、今回作成したキューでは tokio の Receiver
or Sender
の drop 後に send
/recv
を Err
で復帰させる機能などは実装していません。
EmergencyConsole
の追加
重ね合わせ処理の導入により LayerManager
初期化後は、 Console
に書き込んだ文字列の描画は LayerManager
の CoTask
が行うようになりました。
Console
へ書き込んだ文字列が画面に表示されるためには async/await のランタイム (Executor
) や各 CoTask
が正常に動作している必要があります。
通常の処理で Console
を使う限りは問題ないのですが、パニックハンドラーや例外ハンドラーの中で文字列を出力したい場合、これでは問題があります。
ハンドラー実行以降はプログラムの実行が停止してしまいランタイムも動作しないため画面に文字列が描画されないためです。
この問題に対処するため、パニックハンドラーや例外ハンドラーからの文字列表示のため EmergencyConsole
を導入しました。
Console
を改造しても良かったのですが、パニックハンドラーや例外ハンドラーから複雑な処理を行うと正しく動作しない可能性があったため、シンプルな別構造体として追加しました。
EmergencyConsole
ではフレームバッファを利用して画面描画するのですが、ハンドラー呼出し時の状況によってはフレームバッファのロックが取られており、普通にロックをとるとデッドロックしてしまう可能性があります。
これに対処するため、 EmergencyConsole
からロック取得する場合は、既存のロックを強制的にアンロックした上でロックを取得するようにしています。
タイマーの実装 (day09b)
性能測定のために Local APIC タイマーを使えるようにする節です。
実装については特にコメントはないです。
シャドウバッファの追加 (day09c)
従来処理ではフレームバッファへの描画時に毎回 Color
構造体からフレームバッファの byte 配列フォーマットへの pixel 毎に変換していました。画面サイズが大きいと、この処理の負荷は大きくなります。
これを改善するため、フレームバッファと同じフォーマットでデータを保持するシャドウバッファを各 Window
に持たせ、 フレームバッファへの描画時はこのシャドウバッファの内容をコピー (memcpy
) するような方式へと変更しました。
これにより Color
構造体から byte 配列フォーマットへの変換が描画の度に毎回行われることがなくなり、性能が改善されます。
C++ 版とは異なり、 ShadowBuffer
という専用構造体を用意しています (この後のコミットで変更されますが)。
コンソールのスクロール速度を測定する (day09d)
コンソールのスクロール速度を測定するためタイマーを設定しています。
C++ 版とは異なり、実際の描画処理は LayerManager
の CoTask
で行われるので、時間測定処理も当該 CoTask
に追加しています。
また、どの CoTask
からの描画依頼かを区別するため、 CoTask
間でやりとりするメッセージに描画時間測定対象か否かを意味するフラグを追加しています。
ShadowBuffer
と FrameBuffer
の実装共通化
先の節で ShawdorBuffer
は FrameBuffer
は別の構造体として実装していました。
両者共画面の描画を行うという点は共通で、描画対象が Vec<u8>
か FrameBuffer
かが違うだけです。
実装共通化のため Vec<u8>
と FrameBuffer
を抽象化する Buffer
トレイトを設け、 Buffer
トレイトを実装した型に対して描画する BufferDrawer
という構造体を導入しました。
コンソールの性能改善 (day09e)
コンソールのスクロール時、コンソールの描画範囲全体に対して文字の再描画を行っていました。
文字の描画のためには各文字の字形に応じてドット単位で描画する必要があり、非常に時間がかかります。
これを、描画範囲全体を上方向に移動させることで単純な memcpy
で済むようにし処理を高速化しました。
まとめ
重ね合わせ処理を実装し、マウスカーソルを動かしても背景が消えることがなくなりました。 また、重ね合わせ処理の性能改善により、マウスカーソルの描画自体も高速になった気がします。
また、 C++ 実装もなかなかに Rust らしい実装へと変更できているのではないでしょうか。 この調子で次章も進んでいきたいです。