背景
(詳しい人には釈迦に説法な内容なので読み飛ばしてください。)
自作OSや自作VMMをやっている際に問題になるのが、ブートストラップをどうするかです。通常U-BootやUEFIなどのブートローダからOSのローダが起動してKernelを展開する、もしくはKernelが直接起動するようになっています。
MMUを持つCPUで動く汎用Kernelは0xFFFF...から始まるいわゆるHigh Memory Addressで動くことが大半で、このページテーブルはKernelの起動初期かローダが作成します。そのため、Kernelのアドレッシングは(ASLRなどありますが)静的でも動きますが、このページテーブルができる前は物理アドレスで関数や変数のアドレスを指定します。
組み込み系などあらかじめ動く環境が決まっている環境は、ローダが置かれるアドレスをリンカスクリプトで指定してビルドすればバイナリにアドレスが適切に埋め込まれます。一方で汎用OSでは動作環境によってRAMが存在しているアドレスが異なり、ロードされて初めて決定することも多いです。
そのため、特にローダはどのアドレスにも配置されても実行できるようにする必要があります。そしてローダの中でメモリマップを把握してKernelで使用するメモリ領域を決定しロードしていきます。
コードレベルでどのアドレスにロードされても動くようにするには固定のアドレスを持たないなどの対策で実現できますが、実際の機械語で固定アドレスを使用しない様にするには手間がかかります。グローバル変数や関数などのアドレスを持っておかないと参照できません。
最近のCPUアーキテクチャではPC相対というアドレッシングモードがサポートされています。これはアドレスを指定する際に現在実行している命令のPC(Program Counter)からの相対値をオペランドとして機械語に埋め込んでおき、実行時にCPUがPCの値を元に実際のアドレスを決定します。実現にはいくつか手段がありますがx86_64やArm、RISC-Vが対応しています。(x86(32bit)はこの方式をサポートしておらず、関数呼び出しでスタックからアドレスを取り出すなどします。)このようなPC相対アドレスを使用してどのアドレスに配置されても動作するようにしたプログラムをPIC(Position Independent Code)/PIE(Position Independent Executable)といいます。
PC相対があるから全て解決かというとそうではありません。関数ポインタを保持しているグローバル変数などは、実行開始時から有効なアドレスを保持していなければなりません。正確には分割コンパイルなどで、いつ参照されるかコンパイラからは判断できず、かつ初期化コードを自動的に挿入できない言語仕様のためです。これらは relaセクションに実行時にベースアドレスを書き込む場所が列挙して保存されていて、実行前にローダやCRTが適切に設定します。
RustでフラットバイナリでPIEがしたい
自作OS(https://github.com/PG-MANA/Methylenix)をRISC-V向けにポートしていて、その際にロードをどうするかを考えていました。x86_64はGRUB2からMultiboot経由で立ち上げていてELFを適切に配置してくれます(そろそろどうにかしたい)。AArch64はUEFIから立ち上げることを前提にLoaderを作って、KernelのELFを読み込んでページテーブルを作成するようにしています。
RISC-Vでどうするか考えたのですが、U-Bootから立ち上がるようにしたいため、ELF形式かPE形式(bootefi)でローダを作ることを考えました。ELF形式ではELFのプログラムヘッダに書かれているアドレスにロードしてしまいます(https://github.com/u-boot/u-boot/blob/v2026.01/lib/elf.c#L100)。これでは動的に読み込むアドレスを決定できません。U-BootはUEFIアプリケーションを(完全ではないものの)動かせるようになっていて、UEFIの仕様に従い動的再配置が行えるようになっています。RISC-Vでもサポートされているようですが、RustでRISC-V向けUEFIアプリケーションを作る方法がまだない(多分target.jsonを書けばいける)のと、PE形式に頼るのが負けた気分になったので断念しました。
というわけで、残るはFlat Binary、すなわちメモリに展開して一切の前処理を行わずに先頭アドレスにジャンプして実行する方式を取ることにしました。これはLinux KernelのImageなどでも採用されています。(AArch64のImageはPEファイルとしても機能するようになっています。)
出力をFlat Binaryにするには、リンカスクリプトで、OUTPUT_FORMAT(binary)を指定するとできます。
また、PIEな機械語を生成するために以下のような.cargo/config.tomlを書きます。
[target.'cfg(all(target_arch = "riscv64", target_os = "none"))']
rustflags = [
"-C", "code-model=medium",
"-C", "relocation-model=pie",
"-C", "no-redzone=yes",
"-C", "panic=abort",
"-C", "link-arg=-Tloader.ld",
]これでできたLoaderのバイナリをU-Bootで実行します。$kernel_addr_rには環境ごとに違うアドレスが入っています。
fatload nvme 0 $kernel_addr_r loader
go $kernel_addr_r結果はまあ…コケるわけです。(コケたときの例外情報を取り忘れました…)
原因追求
今回コケた命令のアドレスは0x84004364でした。0x84000000は$kernel_addr_rの値なので、コケた命令は0x4364ということになります。デバッグのために出力形式をELFに戻して、objdumpでみてみます。
00000000000042e6 <core::fmt::write>:
42e6: 7159 addi sp, sp, -0x70
42e8: f486 sd ra, 0x68(sp)
42ea: f0a2 sd s0, 0x60(sp)
42ec: eca6 sd s1, 0x58(sp)
42ee: e8ca sd s2, 0x50(sp)
42f0: e4ce sd s3, 0x48(sp)
42f2: e0d2 sd s4, 0x40(sp)
42f4: fc56 sd s5, 0x38(sp)
42f6: f85a sd s6, 0x30(sp)
42f8: f45e sd s7, 0x28(sp)
42fa: f062 sd s8, 0x20(sp)
42fc: ec66 sd s9, 0x18(sp)
42fe: 89b6 mv s3, a3
4300: 892e mv s2, a1
4302: 0016f593 andi a1, a3, 0x1
4306: 8a2a mv s4, a0
4308: 14059763 bnez a1, 0x4456 <core::fmt::write+0x170>
430c: 00064403 lbu s0, 0x0(a2)
4310: 16040763 beqz s0, 0x447e <core::fmt::write+0x198>
4314: 4c01 li s8, 0x0
4316: 01893a83 ld s5, 0x18(s2)
431a: 08000b13 li s6, 0x80
431e: 0c000b93 li s7, 0xc0
4322: 60000cb7 lui s9, 0x60000
4326: 020c8c93 addi s9, s9, 0x20
432a: a025 j 0x4352 <core::fmt::write+0x6c>
432c: 004c1513 slli a0, s8, 0x4
4330: 00a985b3 add a1, s3, a0
4334: 6188 ld a0, 0x0(a1)
4336: 6590 ld a2, 0x8(a1)
4338: e052 sd s4, 0x0(sp)
433a: e44a sd s2, 0x8(sp)
433c: e866 sd s9, 0x10(sp)
433e: 858a mv a1, sp
4340: 9602 jalr a2
4342: 14051063 bnez a0, 0x4482 <core::fmt::write+0x19c>
4346: 0c05 addi s8, s8, 0x1
4348: 0004c403 lbu s0, 0x0(s1)
434c: 8626 mv a2, s1
434e: 12040863 beqz s0, 0x447e <core::fmt::write+0x198>
4352: 03841513 slli a0, s0, 0x38
4356: 00160493 addi s1, a2, 0x1
435a: 00054a63 bltz a0, 0x436e <core::fmt::write+0x88>
435e: 8552 mv a0, s4
4360: 85a6 mv a1, s1
4362: 8622 mv a2, s0
4364: 9a82 jalr s5jalr s5は戻り先をリンクレジスタに格納してレジスタの値にジャンプする命令なので、s5レジスタの値が壊れていると予想します。関数の先頭に向かってたどるとld s5, 0x18(s2)があり、s2レジスタの指す先から0x18バイト先からロードしているようです。そして戻っていくとmv s2, a1とあるように、このレジスタは引数として渡されているようです。
core::fmt::writeを呼んでいるのは自作したprint関数です。
0000000000002ba6 <kernel_loader::print::print>:
2ba6: 86ae mv a3, a1
0000000000002ba8 <.Lpcrel_hi26>:
2ba8: 00b71597 auipc a1, 0xb71
2bac: f4058593 addi a1, a1, -0xc0
2bb0: 6198 ld a4, 0x0(a1)
2bb2: c739 beqz a4, 0x2c00 <.Lpcrel_hi27+0x40>
2bb4: 862a mv a2, a0
2bb6: 0016f513 andi a0, a3, 0x1
2bba: e919 bnez a0, 0x2bd0 <.Lpcrel_hi27+0x10>
2bbc: 00858513 addi a0, a1, 0x8
0000000000002bc0 <.Lpcrel_hi27>:
2bc0: 00002597 auipc a1, 0x2
2bc4: 31058593 addi a1, a1, 0x310
2bc8: 00001317 auipc t1, 0x1
2bcc: 71e30067 jr 0x71e(t1) <core::fmt::write>下からa1を追いかけるとすぐ上にauipc命令とaddi命令があります。auipc命令は現在のPCとオペランドを12ビットシフトした値を加算してレジスタに格納する命令です。つまりここ、「objdump上」ではa1 = 0x2bc0 + (0x2 << 12) = 0x4bc0ということになります。これがPC相対アドレッシングで、実際の動作環境によって格納される値は変わります。先程動作させた環境では0x84002bc0 + (0x2 << 12)になるはずです。objdumpでは0ベースで表示しているので、先程の計算結果になります。次のaddi命令は加算命令で、a1 = a1 + 0x310 = 0x4bc0 + 0x310 = 0x4ed0が実行結果になります。
さて、このアドレスをreadelfなどで参照すると匿名変数になっていました。
182: 0000000000004ed0 48 OBJECT LOCAL HIDDEN 2 anon.8ebadb4c8da7aa972c6ef32b2da398b1.26.llvm.17646884786561777633これでは埒が明かないので、Rustにアセンブリを吐かせる方法を使って、アセンブリを出して比較します。
_RNvNtCs4S0rFkNLftr_13kernel_loader5print5print:
mv a3, a1
.Lpcrel_hi51:
auipc a1, %pcrel_hi(.L_MergedGlobals)
addi a1, a1, %pcrel_lo(.Lpcrel_hi51)
ld a4, 0(a1)
beqz a4, .LBB10_8
mv a2, a0
andi a0, a3, 1
bnez a0, .LBB10_3
addi a0, a1, 8
.Lpcrel_hi52:
auipc a1, %pcrel_hi(.Lanon.07d1e03e94b7c3c3fc7ae6cf24a924da.76)
addi a1, a1, %pcrel_lo(.Lpcrel_hi52)
tail _RNvNtCs3pK5phuYJXT_4core3fmt5write表現が少し異なりますが、.Lanon.07d1e03e94b7c3c3fc7ae6cf24a924da.76のアドレスを取ろうとしているようです。これもアセンブリから探します。
.section .rodata..Lanon.07d1e03e94b7c3c3fc7ae6cf24a924da.76,"a",@progbits
.p2align 3, 0x0
.Lanon.07d1e03e94b7c3c3fc7ae6cf24a924da.76:
.asciz "\000\000\000\000\000\000\000\000\b\000\000\000\000\000\000\000\b\000\000\000\000\000\000"
.quad _RNvXNtCs4S0rFkNLftr_13kernel_loader5printNtB2_10SerialPortNtNtCs3pK5phuYJXT_4core3fmt5Write9write_str
.quad _RNvYNtNtCs4S0rFkNLftr_13kernel_loader5print10SerialPortNtNtCs3pK5phuYJXT_4core3fmt5Write10write_charB6_
.quad _RNvYNtNtCs4S0rFkNLftr_13kernel_loader5print10SerialPortNtNtCs3pK5phuYJXT_4core3fmt5Write9write_fmtB6_
.size .Lanon.07d1e03e94b7c3c3fc7ae6cf24a924da.76, 48名前修飾で見づらいですが、ASCIIZでほとんどヌル文字の文字列と、3つのアドレスが書かれているようです。そして、計算すると、0x18(a1)は.quad _RNvXNtCs4S0rFkNLftr_...write_strを指しています。これはfmt::write関数からコールバックされる文字列の出力関数で、traitの関数(C++でいうところの純粋仮想関数)になっています。
このポインタ(グローバル変数)には、リンカスクリプトを元に計算したアドレスが入っていそうですが、実行時にベースアドレスに応じた初期化はされていなさそうです。
ここで、インターネットを漁っていると我らがhikalium先生が同じ問題に引っかかっていました。https://hikalium.hatenablog.jp/entry/2022/12/19/011722
時間切れ
というわけで追いかけていたら.data.rel.roの沼にはまったので今日はここまで。明日もやるかも。おやすみなさい…。
https://hikalium.hatenablog.jp/entry/2022/12/19/011722
沼にハマって寝てました。私は知っています…これはやらないやつで、「MANAさん後はよろしくぅ…」とスゥッと消えていくやつです。
周辺の記事を探してもやっぱりやってなかったので、続きをすることにします。
記事にある通り、.data.rel.roセクションにこのグローバル変数は置かれるようです。objdumpでダンプします。
Contents of section .data:
0010 00000000 00000000 08000000 00000000 ................
0020 08000000 00000000 0e360000 00000000 .........6......
0030 b08f0000 00000000 1c000000 00000000 ................
0040 fc000000 11000000 b08f0000 00000000 ................
0050 1c000000 00000000 ec000000 17000000 ................
0060 0d8f0000 00000000 24000000 00000000 ........$.......
0070 49000000 09000000 28980000 00000000 I.......(.......
0080 0d000000 00000000 2f000000 11000000 ......../.......
0090 28980000 00000000 0d000000 00000000 (...............
00a0 3e000000 0d000000 28980000 00000000 >.......(.......
00b0 0d000000 00000000 46000000 12000000 ........F.......
00c0 28980000 00000000 0d000000 00000000 (...............
00d0 52000000 0a000000 28980000 00000000 R.......(.......
00e0 0d000000 00000000 58000000 12000000 ........X.......
00f0 00000000 00000000 08000000 00000000 ................
0100 08000000 00000000 dc350000 00000000 .........5......
...しばらく眺めてみましたが、構造がわかりません。Elf64_Rel構造体の配列でもElf64_Rela構造体の配列でもなさそうです。0x08が定期的に現れますが、ポインタの個数を表しているわけでも無さそうです。
ここで、アドレスをあぶり出すために、リンカスクリプトの最初に. = 0xFF0000;を追加して、ベースアドレスを変更してみます。ビルド後再度ダンプします。
ff0010 00000000 00000000 08000000 00000000 ................
ff0020 08000000 00000000 0e36ff00 00000000 .........6......
ff0030 b08fff00 00000000 1c000000 00000000 ................
ff0040 fc000000 11000000 b08fff00 00000000 ................
ff0050 1c000000 00000000 ec000000 17000000 ................
ff0060 0d8fff00 00000000 24000000 00000000 ........$.......
ff0070 49000000 09000000 2898ff00 00000000 I.......(.......
ff0080 0d000000 00000000 2f000000 11000000 ......../.......
ff0090 2898ff00 00000000 0d000000 00000000 (...............
ff00a0 3e000000 0d000000 2898ff00 00000000 >.......(.......
ff00b0 0d000000 00000000 46000000 12000000 ........F.......
ff00c0 2898ff00 00000000 0d000000 00000000 (...............
ff00d0 52000000 0a000000 2898ff00 00000000 R.......(.......
ff00e0 0d000000 00000000 58000000 12000000 ........X.......
ff00f0 00000000 00000000 08000000 00000000 ................
ff0100 08000000 00000000 dc35ff00 00000000 .........5......0e360000が0e36ff00に変化したようにポインタのところだけ変化しました。やはり、「Null or ポインタ、小さな値、小さな値〜大きな値、ポインタ、….」連続でできているようです。
さて、この構造が何かを調べていたところ、traitのvtableであることがわかりました。https://qnighy.hatenablog.com/entry/2017/03/18/070000
なるほど、drop関数ポインタ、型サイズ、型アライメント、関数ポインタの順で並んでいたのようで、辻褄が合います。
Vtableのリストから順当にポインタを取り出して、ロードされたアドレスになるように適宜調節すれば良さそうですが、関数ポインタが配列長がない可変長配列のため、先頭から順に辿っていっても構造が把握できません。
しばらく唸っていましたが、うまくアドレスを取り出す方法を思いつきませんでした。
解決策
ここでふと、アドレスの部分をあぶり出す際に. = 0xFF0000;を追加したことを思い出しました。0xFFがついていればポインタと判断したわけですが、これをコードベースでやればいいのです。
思えば、PC/AT互換機のBIOSだってHDDやFDDの先頭512バイトを読んで最後の2バイトが0xAA55だったら先頭アドレスに飛ぶということを繰り返して起動していました。(MBRとFAT)
これにあやかって、ベースアドレスを0xAA550000にしておき、起動時に.data.rel.roを片っ端から舐めて0xAA55が含まれるアドレスを修正していけばよいのではないでしょうか。きれいな解法ではないですが、まあブートストラップということで許してもらいましょう。
それではリンカスクリプトを以下のようにします。
OUTPUT_FORMAT(binary)
/* OUTPUT_FORMAT(elf64-littleriscv) */
ENTRY(_start)
SECTIONS {
/* This is the magic number, please see `_start` also */
. = 0xAA550000;
.start : {
*(.start)
}
. = ALIGN(8);
__REL_START = .;
.data : {
KEEP(*(.data.rel*))
}
. = ALIGN(8);
.got : {
KEEP(*(.got))
}
__REL_END = .;
.text : {
*(.text .text.*)
}
.data : {
*(.data)
}
.rodata : {
*(.rodata .rodata.*)
}
.bss : {
KEEP(*(.bss .bss.*))
}
/DISCARD/ : {
*(.comment .comment.*)
*(.eh_frame .eh_frame.*)
*(.eh_frame_hdr .eh_frame_hdr.*)
}
}
全メモリを舐めると流石に問題があるので、関数ポインタがある領域を__REL_STARTと__REL_ENDというリンカ定数で指定します。ここではGlobal Offset Tableも生成されていたため、これも対象とします。
リンカスクリプトにあるように、最初には.startセクションが来るようにします。セクションには_start関数が来るようにします。これはアセンブリで記述して、暗黙のグローバル変数参照がないようにします。
#[unsafe(naked)]
#[unsafe(no_mangle)]
#[unsafe(link_section = ".start")]
/// `a0` and `a1` must be reserved.
extern "C" fn _start() {
core::arch::naked_asm!("
.extern __REL_START, __REL_END
// This must be the first instruction
auipc t0, 0
lla t1, __REL_START
lla t2, __REL_END
li t3, 0xAA55
slli t4, t3, 16
1:
ld t5, (t1)
srli t6, t5, 16
bne t6, t3, 2f
// t5 == 0x0000_0000_AA55_xxxx
xor t5, t5, t4 // t5 ^= 0xAA55_0000
or t5, t5, t0 // t5 |= t0(base_address)
sd t5, (t1)
2:
addi t1, t1, 8
bne t1, t2, 1b
3:
j {main}", main = sym crate::main);
}フルアセンブリですが、やっている内容は至ってシンプルです。
最初にレジスタに値をセットしていきます。
- t0 : PCの値、ここではすなわちベースアドレスが格納されます
- t1 :
__REL_STARTの値をPC相対で読み込み格納します、ここではauipc命令とaddi命令に展開されます - t2 :
__REL_ENDの値が格納されます - t3 :
0xAA55が格納されます - t4: t3 を16ビット左シフトした値、すなわち
0xAA550000が格納されます
ラベル1から3までの間でt1を8バイトずつ増やしながら、値の確認と更新を行います。読み出した値を16ビット右シフトして0xAA55と比較し、同じだった場合は関数ポインタだったと判断します。0xAA55はマーカのため、これをXORで除去した後、ベースアドレスを加算します。結果を書き戻した後次のループに移行します。終わったら、Rustで書いたmain関数に飛んでいきます。
動作確認
さて、こんな雑な実装で動くのでしょうか。
## Starting application at 0x84000000 ...
Boot Loader version 0.1.0
Loader range: [ 0x84000000 ~ 0x84B74000]
Stack range: [ 0xFEEE2000 ~ 0xFEEE7000]
RAM: [ 0x80000000 ~ 0x100000000]
Reserved: [ 0x80100000 ~ 0x80140000]
Reserved: [ 0x80140000 ~ 0x80160000]
Allocatable: [ 0x80000000 ~ 0x80100000]
Allocatable: [ 0x80160000 ~ 0x84000000]
Allocatable: [ 0x84B74000 ~ 0xFEEE2000]
Allocatable: [ 0xFEEE7000 ~ 0x100000000]
Load the kernel...なんと動いてしまいました。動いたときは思わず笑いが出てしまいました。
疑わしいので、読み込む先を$ramdisk_addr_r(試した環境では0x8C300000)に変更して、「ビルドし直さずに」再度実行します。
## Starting application at 0x8C300000 ...
Boot Loader version 0.1.0
Loader range: [ 0x8C300000 ~ 0x8CE74000]
Stack range: [ 0xFEEE4000 ~ 0xFEEE9000]
RAM: [ 0x80000000 ~ 0x100000000]
Reserved: [ 0x80100000 ~ 0x80140000]
Reserved: [ 0x80140000 ~ 0x80160000]
Allocatable: [ 0x80000000 ~ 0x80100000]
Allocatable: [ 0x80160000 ~ 0x8C300000]
Allocatable: [ 0x8CE74000 ~ 0xFEEE4000]
Allocatable: [ 0xFEEE9000 ~ 0x100000000]
Load the kernel...やっぱり動いちゃうらしいです。Loader rangeが変わっていることにも注目です。
というわけで、世の中はやっぱり泥臭いハックで回ってしまうということでした。
追記
上はQEMUで実験してたわけなんですが、なんと実機でも動いちゃったんですよね。
## Starting application at 0x40200000 ...
Boot Loader version 0.1.0
Loader range: [ 0x40202000 ~ 0xEB2C6000]
Stack range: [ 0xCC502000 ~ 0xCC506000]
RAM: [ 0x40000000 ~ 0x140000000]
Reserved: [ 0x40000000 ~ 0x40080000]
Allocatable: [ 0x40080000 ~ 0x40202000]
Allocatable: [ 0xEB2C6000 ~ 0x140000000]
Load the kernel...
Physical Address | Virtual Address | Memory Size | File Size | File Offset | Alignment | Read | Write | Exec
0x0000000040080000 | 0xFFFFFFE000000000 | 0x00000AAF18 | 0x00000AAF18 | 0x0000001000 | 0x0000001000 | true | false | true
0x000000004012B000 | 0xFFFFFFE0000AB000 | 0x00000105C0 | 0x00000105C0 | 0x00000AC000 | 0x0000001000 | true | true | false
0x000000004013C000 | 0xFFFFFFE0000BC000 | 0x0000014F7A | 0x0000014F7A | 0x00000BD000 | 0x0000001000 | true | false | false
0x0000000040151000 | 0xFFFFFFE0000D1000 | 0x0000000010 | 0x0000000010 | 0x00000D2000 | 0x0000001000 | true | true | false
0x0000000040152000 | 0xFFFFFFE0000D2000 | 0x0000006AF8 | 0x0000000000 | 0x00000D3000 | 0x0000001000 | true | true | false
Kernel's entry point: 0xFFFFFFE000025CE6
Dump the initial page table for the kernel
VA: 0x40202000 => PA: 0x40202000, R: true, W: true, E: true, U:false
... 0xCC400000 0xCC400000 (fin)
VA: 0xCC502000 => PA: 0xCC502000, R: true, W: true, E: true, U:false
... 0xCC506000 0xCC506000 (fin)
VA: 0xCC600000 => PA: 0xCC600000, R: true, W: true, E: true, U:false
... 0xEB2C6000 0xEB2C6000 (fin)
VA: 0xFFFFFF8000000000 => PA: 0x0, R: true, W: true, E:false, U:false
... 0xFFFFFFC000000000 0x4000000000 (fin)
VA: 0xFFFFFFE000000000 => PA: 0x40080000, R:false, W:false, E: true, U:false
... 0xFFFFFFE0000AB000 0x4012B000 (fin)
VA: 0xFFFFFFE0000AB000 => PA: 0x4012C000, R: true, W: true, E:false, U:false
... 0xFFFFFFE0000BB000 0x4013C000 (fin)
VA: 0xFFFFFFE0000BB000 => PA: 0x4013D000, R: true, W:false, E:false, U:false
... 0xFFFFFFE0000CF000 0x40151000 (fin)
VA: 0xFFFFFFE0000CF000 => PA: 0x40152000, R: true, W: true, E:false, U:false
... 0xFFFFFFE0000D6000 0x40159000 (fin)
Jump to the kernel...