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