プログラミング
stinkarm をより少なく臭わせる、あるいはもっと臭わせるか?
Making stinkarm stink less, or more? (xnacly.me)
要約
この記事は、Rustで書かれたARMv7ユーザーモードエミュレータ「stinkarm」の改善点について論じています。以前のバージョンでは複雑だったメモリ変換処理を、4ギガバイトのスラブメモリを割り当ててゲストプロセス領域をマッピングし、その領域へのポインタをゲストに渡すという、よりシンプルで効率的な方法に置き換えました。また、不正なアドレスへのアクセスを防ぐための境界チェックを強化し、デコードルールをコンパイル時定数リストとして定義することで、命令デコードの柔軟性を向上させています。
全文翻訳
stinkarm をより少なく臭わせる、あるいはもっと臭わせるか?
2026年8月16日
2323語 11分読書
タグ: Arm, Rust
しばらくぶりですが、約半年前、ユーザーモードのARMv7エミュレータを一から実装した記事を書きました。つまり、以下を実装しました。
ARMASM
1 .section .rodata
2 msg:
3 .asciz "Hello, world!\n"
4
5 .section .text
6 .global _start
7 _start:
8 ldr r0, =1
9 ldr r1, =msg
10 mov r2, #14
11 mov r7, #4
12 svc #0
13
14 mov r0, #0
15 mov r7, #1
16 svc #0
またはリストとして:
ELF(32) の解析、検証、解釈
ARMv7命令のごく一部 (わずか3つ) のデコード
これらの命令の実行、条件付きのものまで 🤓
ゲストからホストへのメモリアクセス変換
システムコールフォワーディング (ARMv7からx86へ)
システムコールサンドボックス化 (制限されたシステムコールサブセットのみ) およびシステムコール実行の拒否
以前の記事では詳細に触れていないので、「Building a Minimal Viable Armv7 Emulator from Scratch」を読むことをお勧めします。これは、約3ヶ月ぶりの執筆意欲が湧いた最初の記事です :O)。これは部分的にアップデートであり、ARMv7-Aのデコードとエミュレーションに関する私の考え、そして少しのデブログでもあります。
過度に複雑なホストからゲストへのメモリ変換
最初の記事で、lobste.rs の ~aengelke がコメントをいくつか寄せてくれました。最も共鳴したのは次の点でした。
[...] メモリの間接参照は非常に非効率的であるように見えます。64ビットシステムで32ビットプラットフォームをエミュレートする場合、単に4ギガバイトの領域をmmapすれば、変換は単一の加算になります。そうでなければ、最近翻訳されたアドレス領域の小さなハッシュテーブルを持つことで、より高価な検索を回避できます — メモリアクセスは非常に高い局所性を持っています。マッピングの数は通常少ないため、ソートされた配列に対するバイナリ検索はBツリーよりも簡単です。
そこで、実装を少し改善しようと考えました。まず、複雑な割り当て領域ベースの追跡を、ゲストのためにメモリに4ギガのスラブを割り当て、そこにプロセス領域をマッピングし、その領域へのポインタをゲストに渡すことで置き換えます。
以前は、メモリ変換は次のように機能していました。
ゲストの開始アドレスからホストセグメントへのバイナリツリーマップを使用します。
RUST
1 struct MappedSegment {
2 host_ptr: *mut u8,
3 len: u32,
4 }
5
6 pub struct Mem {
7 maps: BTreeMap<u32, MappedSegment>,
8 }
領域の引き渡し、特にELFセグメントを開始アドレスでマッピングするように求められた場合、map_region が呼び出されます。
RUST
1 // stinkarm::elf::pheader::Pheader::map 内:
2 3 // ゲストメモリテーブルにマッピングを記録し、CPUがゲストvaddrをホストポインタに変換できるようにする
4 guest_mem.map_region(self.vaddr, len, segment_ptr);
5
6 // stinkarm::mem::Mem 内:
7
8 pub fn map_region(&mut self, guest_addr: u32, len: u32, host_ptr: *mut u8) {
9 self.maps
10 .insert(guest_addr, MappedSegment { host_ptr, len });
11 }
CPUは命令を取得する必要があるため、translate を呼び出す read_u32 があります。
RUST
1 /// ゲストアドレスを書き込みおよび読み取り可能なホストアドレスに変換する
2 pub fn translate(&self, guest_addr: u32) -> Option<*mut u8> {
3 // guest_addr 以下の最大のキーを見つける。
4 let (&base, seg) = self.maps.range(..=guest_addr).next_back()?;
5 if guest_addr < base.wrapping_add(seg.len) {
6 let offset = guest_addr.wrapping_sub(base);
7 Some(unsafe { seg.host_ptr.add(offset as usize) })
8 } else {
9 None
10 }
11 }
12
13 pub fn read_u32(&self, guest_addr: u32) -> Option<u32> {
14 let ptr = self.translate(guest_addr)?;
15 unsafe { Some(u32::from_le(*(ptr as *const u32)))
16 }
17 }
18
19 // stinkarm::cpu::Cpu 内:
20
21 pub fn step(&mut self) -> Result<bool, err::Err> {
22 let Some(word) = self.mem.read_u32(self.pc()) else {
23 return Ok(false);
24 };
25
26 // [...]
27 }
もちろん、これは完全に不要な作業です。範囲を歩き回って各マッピング/割り当て/領域を追跡する必要はありません。R/Wインタラクション要求が範囲内にあることを確認するだけで十分です。したがって、新しい実装は次のようになります。
ポインタとサイズを取ります。
RUST
1 pub struct Mem {
2 ptr: NonNull<u8>,
3 len: usize,
4 }
ELFセグメントをマッピングするように求められた場合、stinkarm::mem::Mem::map_region が呼び出されます。
RUST
1 // stinkarm::elf::pheader::Pheader::map 内:
2 guest_mem.map_region(self.vaddr, file_slice)?;
3
4 // stinkarm::mem::Mem 内:
5
6 pub fn map_region(&mut self, guest_addr: u32, data: &[u8]) -> Result<(), String> {
7 let dst = self
8 .get_slice_mut(guest_addr, data.len())
9 .ok_or_else(|| format!("guest region out of bounds at {guest_addr:#010x}"))?;
10 dst.copy_from_slice(data);
11 Ok(())
12 }
CPUがデコードのためにdwordを要求するときは、以前と同様に stinkarm::mem::Mem::read32 を呼び出しますが、今回は境界チェック付きです。
RUST
1 pub fn read_u32(&self, guest_addr: u32) -> Option<u32> {
2 let bytes = self.get_slice(guest_addr, 4)?;
3 Some(u32::from_le_bytes(bytes.try_into().unwrap()))
4 }
5
6 fn get_slice(&self, guest_addr: u32, len: usize) -> Option<&[u8]> {
7 if !self.in_bounds(guest_addr, len) {
8 return None;
9 }
10
11 Some(unsafe { std::slice::from_raw_parts(self.ptr.as_ptr().add(guest_addr as usize), len) })
12 }
既存の実装の強化
また、(write.2 と exit.2 のシステムコール、ldr、mov、svc の小さな表面積であっても) 信頼されていないゲストアドレスをホストメモリアクセスに変換できる多くのものがあることに気づきました。
write.2 に渡されるアドレスの有効性をチェックすることでこれを防ぎます。これは、stinkarm::mem::Mem で translate_range 内の in_bounds 呼び出しを使用して、ゲストアドレスをホストメモリ空間に変換する際に行います。
RUST
1 const NULL_PAGE_SIZE: u32 = 0x1000;
2
3 impl Mem {
4 fn in_bounds(&self, guest_addr: u32, len: usize) -> bool {
5 if guest_addr < NULL_PAGE_SIZE {
6 return false;
7 }
8
9 let start = guest_addr as usize;
10 let Some(end) = start.checked_add(len) else {
11 return false;
12 };
13
14 end <= self.len
15 }
16
17 pub fn translate_range(&self, guest_addr: u32, len: usize) -> Option<*mut u8> {
18 if !self.in_bounds(guest_addr, len) {
19 return None;
20 }
21
22 Some(self.ptr.as_ptr().wrapping_add(guest_addr as usize))
23 }
24 }
NULLポインタへの書き込み、ゲストメモリ外への書き込み、0x0へのELFセグメントのロードを正しくキャッチすることを確認するための複数のテストを追加しました。
ARMASM
1 .section .rodata
2 msg:
3 .ascii "ignored"
4
5 .section .text
6 .global _start
7 _start:
8 mov r0, #1
9 mov r1, #0
10 mov r2, #7
11 mov r7, #4
12 svc #0
13
14 mov r0, #0
15 mov r7, #1
16 svc #0
ARMASM
1 .section .rodata
2 msg:
3 .ascii "ignored"
4
5 .section .text
6 .global _start
7 _start:
8 mov r0, #1
9 ldr r1, =0x08000000
10 mov r2, #7
11 mov r7, #4
12 svc #0
13
14 mov r0, #0
15 mov r7, #1
16 svc #0
DSLか否か
以前は、すべてのオペコードとそのフィールドをハードコードしてRust表現にデコードしていましたが、現在はオペコードのみがデコードの対象です。これは、コンパイル時の定数リストのパターンを使用して実現されます。
RUST
1 const DECODE_RULES: &[ArmRule] = &[
2 arm_rule!(Svc {
3 bits(27..24 = 0b1111),
4 }),
5 arm_rule!(Branch {
6 bits(27..25 = 0b101),
7 }),
8 // LDR リテラル: `ldr Rt, [pc, #imm12]`。
9 arm_rule!(LdrLiteral {
10 bits(27..26 = 0b01), // load/store クラス
11 bit(24 = 1), // P: pre-indexed address
12 bit(23 = 1), // U: positive offset を加算
13 bit(22 = 0), // B: word 転送、byte ではない
14 bit(21 = 0), // W: writeback なし
15 bit(20 = 1), // L: load、store ではない
16 bits(19..16 = 15), // Rn: ベースレジスタは pc/r15
17 }),
18 // MOV 即値: オペコード 1101 のデータ処理即値。
19 arm_rule!(MovImm {
20 bits(27..25 = 0b001),
21 bits(24..21 = Op::Mov as u32),
22 }),
23];
ARMv7命令エンコーディングに興味がある場合は、ARM® Architecture Reference Manual ARMv7-A and ARMv7-R edition をお勧めします。
マクロ自体はビットパターンを構築し、それを単純なANDビット命令で使用して検出できます。
RUST
1 macro_rules! arm_rule {
2 ($kind:ident { $($field:ident($($args:tt)*)),* $(,)? }) => {
3 ArmRule {
4 kind: InstructionKind::$kind,
5 mask: 0 $(| arm_mask!($field($($args)*)))*,
6 value: 0 $(| arm_value!($field($($args)*)))*,
7 }
8 };
9}
10
11 macro_rules! arm_mask {
12 (bit($bit:literal = $value:expr)) => {
13 1u32 << $bit
14 };
15 (bits($high:literal .. $low:literal = $value:expr)) => {
16 ((1u32 << ($high - $low + 1)) - 1) << $low
17 };