🐷

LinuxをQEMUで動かす(Armv8-A中心)

2023/03/20に公開

2023-03-26
uRamdiskの作成方法について追記

最近、低レイヤー開発のため、Linuxをいろいろな方法でQEMU上で動かす必要があり、苦戦することも多かったのでメモ代わりにまとめます。
自分はArmv8-A環境で動かすことが多いので、そちらを前提とした方法を中心に書いていきますが、他のアーキテクチャでも使える部分は多いかと思います。

とりあえず、動くことは確認していますが、最適な方法であるかは自信がない部分も多いので、もし改善の指摘とかあればコメントしていただけると幸いです。
細かいツールの使い方は書いていくときりがないので省略します。

Linuxをビルドする

自前のLinuxをQEMU用にビルドするにはLinuxのソースにすでに用意されているconfigを使ってしまうのが楽です。
arch/arm64/configsの中にすでに用意されたconfigが存在している場合、make <configファイル名>で現在の.configに必要な設定をマージすることができます。
が、本家のLinuxのツリーにはデフォルトのdefconfigvirt.configというものしかないので、Rust for Linuxのほうで使われていたqemu-busybox-min.configファイルを自分は使っています。
内容としては以下のようになっています。

CONFIG_PCI_HOST_GENERIC=y

CONFIG_SERIAL_AMBA_PL011=y
CONFIG_SERIAL_AMBA_PL011_CONSOLE=y

CONFIG_GPIOLIB=y
CONFIG_GPIO_PL061=y

CONFIG_KEYBOARD_GPIO=y

CONFIG_CMDLINE="console=ttyAMA0 nokaslr rdinit=/sbin/init"

このconfigファイルをつくってから

export ARCH=arm64
export CROSS_COMPILE=aarch64-linux-gnu-
make defconfig #まだ.configファイルがない場合
make qemu-busybox-min.config

とすれば、QEMU用のbuild configができます。

Linuxカーネルをgdbを使ってデバッグしたい場合は、さらにmake menuconfigDEBUG_KERNELDEBUG_INFOを有効化して、RELOCATABLEを無効化するといいでしょう
(参考: https://www.hiroom2.com/2014/01/15/qemu上のlinuxカーネルをgdbでデバッグする/)

あとはビルドするだけです。-jでビルドを並列化しないととても時間がかかるのできちんとつけましょう

make -j$(nproc)

QEMUでLinuxカーネルを起動する

Ubuntuであればqemu-system-armaptで入れればqemu-system-aarch64でArmv8-Aの仮想環境で実行できます。
基本は

qemu-system-aarch64 \
    -machine virt \
    -cpu cortex-a72 \
    -m 1G \
    -nographic \
    -kernel "$IMAGE_PATH" \
    -initrd "$INITRD_PATH"

という形です。$IMAGE_PATHにはLinuxカーネルをmakeしてできたarch/arm64/boot/Imageへのパスを、$INITRD_PATHにはinit ramdiskを指定します。
init ramdiskは起動時に最初にRAM上に展開されるファイルシステムで、initプロセスが始まる前のセットアップを行います。
フルのLinuxディストリビューションを用意するよりは、init ramdiskをBusyBoxを使って構築するのが一番コンパクトな方法となると思います。

BusyBoxでinit ramdisk構築

BusyBoxはlsipなどの基本的なLinuxで使うCLIツールをコンパクトに提供してくれるツールです。
以下からソースコードをダウンロードして手元でビルドするといいでしょう。

https://busybox.net/downloads/

Linuxをビルドするのと同様にARCHCROSS_COMPILEを指定して、make menuconfigで設定を調整してからmake installコマンドを叩くと./_install以下にbinsbinといった必要なディレクトリを生成して、そこにコマンド用のバイナリもつくってくれます。

menuconfigでいじっておくべきは、CONFIG_STATICです。これにより各バイナリがビルドされるときに動的リンクではなく静的リンクによって生成されるため、ライブラリを配置する手間を省くことができます。
CONFIG_PREFIXを変更するとインストール先を変えることもできます。

QEMUに渡すinitramfsとするにはcpioというツールで先程make installでBusyBoxをインストールしたディレクトリをアーカイブにする必要があります。
インストールしたディレクトリに移動して以下のコマンドでnew ASCIIというフォーマットでアーカイブします。

find . | cpio -H newc -ov -F ../initramfs.cpio 

これを、さらにgzipで圧縮します。

cd ../
gzip initramfs.cpio

こうすると、initramfs.cpio.gzというファイルができるので、これを先程の$INITRD_PATHに渡してあげることになります。

ただし、このまま起動すると以下のようになってしまい、シェルが起動できません。

[    0.575247] Run /sbin/init as init process
can't run '/etc/init.d/rcS': No such file or directory

can't open /dev/tty2: No such file or directory
can't open /dev/tty3: No such file or directory
can't open /dev/tty4: No such file or directory
can't open /dev/tty3: No such file or directory
can't open /dev/tty2: No such file or directory
can't open /dev/tty4: No such file or directory
can't open /dev/tty3: No such file or directory
can't open /dev/tty2: No such file or directory
can't open /dev/tty4: No such file or directory
can't open /dev/tty3: No such file or directory
can't open /dev/tty2: No such file or directory
can't open /dev/tty4: No such file or directory
can't open /dev/tty3: No such file or directory
can't open /dev/tty2: No such file or directory
can't open /dev/tty4: No such file or directory
can't open /dev/tty3: No such file or directory
can't open /dev/tty2: No such file or directory
can't open /dev/tty4: No such file or directory
can't open /dev/tty3: No such file or directory
can't open /dev/tty2: No such file or directory

これを解決するにはinitramfsに予めいくつかディレクトリやファイルをつくっておき、etc/init.d/rcSという/sbin/initから呼び出されるスクリプトをつくっておく必要があります。

まず、BusyBoxをインストールしたディレクトリで以下のようにして追加でディレクトリとデバイスノードをつくります。

mkdir proc sys dev run etc dev/pts etc/init.d
sudo mknod -m 666 dev/null c 1 3
sudo mknod -m 600 dev/console c 5 1

さらにetc/init.d/rcSを以下の内容でつくります

#!/bin/shz

mount -v --bind /dev /dev
mount -v --bind /dev/pts /dev/pts
mount -vt proc proc /proc
mount -vt sysfs sysfs /sys
mount -vt tmpfs tmpfs /run
/sbin/mdev -s

これをchmod +xで実行権限を付与して、再度上記と同じ手順でアーカイブをつくりなおして実行すると今度はシェルが立ち上がり、コマンドを実行することができます。

自分でビルドしたU-Bootをつかってカーネルを起動する

U-Bootを自分でビルドしてそれを経由してカーネルを起動させることもできます。Arm系のボードはよくブートローダーとしてU-Bootが使われるので、より実際の環境に近い形でテストすることができます。

U-BootをGitレポジトリからクローンして

git clone https://source.denx.de/u-boot/u-boot.git

適当なtagにチェックアウトします。今回はv2022.10を使います。

U-Bootもカーネル同様にconfigを設定する必要がありますが、qemu用のdefconfigが用意されているので、そのまま使います。

export CROSS_COMPILE=aarch64-linux-gnu-
make qemu_arm64_defconfig
make -j$(nproc)

次にinitramfsをmkimageというツールを使ってU-Bootが認識できる形式に加工する必要があります。
このツールはU-Bootの中に入っています。

 ./u-boot/tools/mkimage -A arm64 -O linux -T ramdisk -d initramfs.cpio.gz uRamdisk 

ビルドできたu-boot.bin-biosオプションで起動します。
カーネルのイメージとuRamdiskはオプションとしてではなく、適当なフォルダをに入れておき、これを仮想デバイスとして接続することで認識させます。
環境変数$DIRECTORYにそのディレクトリへのパスを設定しておき以下のように実行しましょう

qemu-system-aarch64 \
    -bios u-boot.bin
    -machine virt \
    -cpu cortex-a72 \
    -m 1G \
    -nographic \
    -drive file=fat:rw:"$DIRECTORY",format=raw,if=none,media=disk,id=drive0 \
    -device virtio-blk,drive=drive0

こうすると、U-Bootが起動し、自動で起動コマンドが実行される前に適当なキーを押すと、U-Bootのシェルが起動してコマンドを入力できます。
以下のようにするとBusyboxのシェルが起動するでしょう

=> load virtio 0 $kernel_addr_r Image
4518400 bytes read in 22 ms (195.9 MiB/s)
=> load virtio 0 $ramdisk_addr_r uRamdisk
1168723 bytes read in 24 ms (46.4 MiB/s)
=> booti $kernel_addr_r $ramdisk_addr_r $fdt_addr

$fdt_addrはDTBが存在しているメモリアドレスで、U-Bootがデフォルトで定義している変数です。
QEMUは渡すオプションによりデバイスの構成が変わるので、DTBは自動生成されメモリに展開されているため、自分でわざわざDTBを用意して渡す必要はありません。
実際どのようなDTBが渡されているかを確認したい場合は-machinedumpdtb=<file>とすると<file>で指定したファイルにDTBを出力してくれます。
ダンプしたら即座に終了してQEMUは立ち上がりません。

qemu-system-aarch64 \
    -bios u-boot.bin
    -machine virt,dumpdtb= \
    -cpu cortex-a72 \
    -m 1G \
    -nographic \
    -drive file=fat:rw:"$DIRECTORY",format=raw,if=none,media=disk,id=drive0 \
    -device virtio-blk,drive=drive0

Linuxの動作をGDBで追う

QEMUをリモートターゲットとしてGDBに接続することも可能です。
-gdbによって特定ポートからGDBの接続を受け付けるよう指定します。また-Sオプションをつけることにより、デバッガが接続されまでプログラムがスタートしないように調整するといいでしょう

qemu-system-aarch64 \
    -bios u-boot.bin
    -machine virt \
    -cpu cortex-a72 \
    -m 1G \
    -nographic \
    -drive file=fat:rw:"$DIRECTORY",format=raw,if=none,media=disk,id=drive0 \
    -device virtio-blk,drive=drive0 \
    -gdb tcp::3333 \
    -S

GDBはmultiarchのものを使い、Linuxビルド時に生成されたvmlinuxを読み込ませればいいです。
その後、target remote :<port>とすればいいです。
Ubuntuであればこのようにしてできます。

$ gdb-multiarch vmlinux
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from vmlinux...
(gdb) target remote :3333
Remote debugging using :3333
0x0000000000000000 in ?? ()
(gdb)

しかし、このままcontinueなどでプログラムをスタートさせても、特に起動直後の動きを追うのは難しいです。
これはvmlinuxにかかれているシンボルのアドレスがMMU有効後のもので、それ以前はカーネルは別のアドレスにいるからです。
これを解決するにはシンボルファイルをカーネルを配置したアドレスをベースにGDBに読み込み直させる必要があります。

aarch64-linux-gnu-objdump -h vmlinux

とすると、各セクションがどのように配置されているかがわかります。
自分がコンパイルしたカーネルだと以下のようになっていました。

vmlinux:     file format elf64-littleaarch64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .head.text    00010000  ffff800008000000  ffff800008000000  00010000  2**16
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .text         00f88910  ffff800008010000  ffff800008010000  00020000  2**16
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .rodata       0085dc40  ffff800008fa0000  ffff800008fa0000  00fb0000  2**12
                  CONTENTS, ALLOC, LOAD, DATA
  3 .pci_fixup    00002a70  ffff8000097fdc40  ffff8000097fdc40  0180dc40  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 __ksymtab     0000fbdc  ffff8000098006b0  ffff8000098006b0  018106b0  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  5 __ksymtab_gpl 00015abc  ffff80000981028c  ffff80000981028c  0182028c  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  6 __ksymtab_strings 0003d2c6  ffff800009825d48  ffff800009825d48  01835d48  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
...

このうち注目すべきは.textとついているセクションです。
U-Bootで起動する場合、まず.head.textの先頭アドレスへジャンプして起動を開始します。
なので最低限.text.head.textの情報だけ読み込ませましょう。

U-Bootでは$kernel_addr_rにカーネルを配置していましたが、printenvでこの変数の中身を確認すると自分の環境では0x40400000であることがわかりました。
これらの情報から、.head.textはこの0x40400000に配置され、.textはobjdumpの結果より.head.textから0x10000だけ離れたところから始まることがわかるので、0x40410000に配置されるということになります。
GDBにこの位置でのシンボル情報として追加しましょう

(gdb) add-symbol-file vmlinux 0x40410000 -s .head.text 0x40400000

これで0x40400000にブレークポイントを仕掛ければMMU切り替え前の起動の挙動も追うことができます

参考

https://zenn.dev/miwarin/articles/d2fb1532185891

https://stackoverflow.com/questions/70676679/how-should-i-apply-add-symbol-file-command-during-u-boot-linux-boot-debug)

Discussion