LinuxをQEMUで動かす(Armv8-A中心)
2023-03-26
uRamdiskの作成方法について追記
最近、低レイヤー開発のため、Linuxをいろいろな方法でQEMU上で動かす必要があり、苦戦することも多かったのでメモ代わりにまとめます。
自分はArmv8-A環境で動かすことが多いので、そちらを前提とした方法を中心に書いていきますが、他のアーキテクチャでも使える部分は多いかと思います。
とりあえず、動くことは確認していますが、最適な方法であるかは自信がない部分も多いので、もし改善の指摘とかあればコメントしていただけると幸いです。
細かいツールの使い方は書いていくときりがないので省略します。
Linuxをビルドする
自前のLinuxをQEMU用にビルドするにはLinuxのソースにすでに用意されているconfigを使ってしまうのが楽です。
arch/arm64/configsの中にすでに用意されたconfigが存在している場合、make <configファイル名>で現在の.configに必要な設定をマージすることができます。
が、本家のLinuxのツリーにはデフォルトのdefconfigとvirt.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 menuconfigでDEBUG_KERNELとDEBUG_INFOを有効化して、RELOCATABLEを無効化するといいでしょう
(参考: https://www.hiroom2.com/2014/01/15/qemu上のlinuxカーネルをgdbでデバッグする/)
あとはビルドするだけです。-jでビルドを並列化しないととても時間がかかるのできちんとつけましょう
make -j$(nproc)
QEMUでLinuxカーネルを起動する
Ubuntuであればqemu-system-armをaptで入れれば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はlsやipなどの基本的なLinuxで使うCLIツールをコンパクトに提供してくれるツールです。
以下からソースコードをダウンロードして手元でビルドするといいでしょう。
Linuxをビルドするのと同様にARCHとCROSS_COMPILEを指定して、make menuconfigで設定を調整してからmake installコマンドを叩くと./_install以下にbinやsbinといった必要なディレクトリを生成して、そこにコマンド用のバイナリもつくってくれます。
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が渡されているかを確認したい場合は-machineでdumpdtb=<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切り替え前の起動の挙動も追うことができます
参考
Discussion