让 stinkarm 减少臭味还是增加臭味?
摘要
关于改进一个最小化 ARMv7 模拟器的更新,重点优化内存翻译,从复杂的 B-tree 映射转向简化的 4 GiB slab 方法以提高效率。
暂无内容
查看缓存全文
缓存时间: 2026/08/21 16:30
# 让stinkarm少臭一点,还是更臭?来源:https://xnacly.me/posts/2026/making-stinkarm-stink-less/
橡子、armv7-a和stinkarm1(https://xnacly.me/posts/2026/making-stinkarm-stink-less/#fn:1)
时隔已久,大约半年前,我撰写了一篇关于从零开始实现一个用户空间ARMv7模拟器的文章,其中我实现了:
ARMASM
```
1 .section .rodata
2msg:
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)
- 系统调用沙箱化(仅允许有限的系统调用子集)并拒绝执行未经授权的系统调用
请务必阅读《从零构建一个最小可行的ARMv7模拟器》(https://xnacly.me/posts/2025/building-a-minimal-viable-armv7-emulator/),因为本文不像前一篇那样深入细节(这是我三个月来第一次有足够动力写作!)。本文部分内容是更新,部分是我对解码和模拟armv7-a的思考,也带有一些开发日志的性质。
## 过于复杂的主机到客机内存转换
在上一篇文章中,~aengelke在lobste.rs(https://lobste.rs/s/bv3570/building_minimal_viable_armv7_emulator)上发表了一些评论,其中最引人共鸣的一条是:
> [...]
> Mem的间接引用看起来效率相当低。当在64位系统上模拟32位平台时,只需为客机分配一个4 GiB的内存区域,内存转换就变成了简单的加法运算。否则,维护一个最近转换过的地址区域的小型哈希表,可以避免更昂贵的搜索——内存访问具有非常高的局部性。映射的数量通常很少,因此对排序数组进行二分查找比使用B树更简单。
> [...]
因此,现在我想,为什么不改进一下我的实现呢?首先,将基于复杂分配区域的跟踪方式,替换为仅为客机在内存中分配一个4GB的内存块,将进程区域映射到那里,然后向客机提供指向该区域的指针。
所以,之前的内存转换工作原理如下:
1. 使用一个二叉树映射,将客机起始地址映射到其主机段:
RUST
```
1struct MappedSegment {
2 host_ptr: *mut u8,
3 len: u32,
4}
5
6pub struct Mem {
7 maps: BTreeMap,
8}
```
2. 当请求分配区域时,特别是在映射ELF段并指定起始地址时,调用`map_region`:
RUST
```
1// in stinkarm::elf::pheader::Pheader::map:
2
3// record mapping in guest memory table, so CPU can translate guest vaddr to host pointer
4guest_mem.map_region(self.vaddr, len, segment_ptr);
5
6// in stinkarm::mem::Mem:
7
8pub 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}
```
3. 由于CPU需要获取指令,因此存在`read_u32`,它调用`translate`:
RUST
```
1/// 将客机地址转换为可读写的主机地址
2pub 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
13pub fn read_u32(&self, guest_addr: u32) -> Option {
14 let ptr = self.translate(guest_addr)?;
15 unsafe { Some(u32::from_le(*(ptr as *const u32))) }
16}
17
18
19// in stinkarm::cpu::Cpu:
20
21pub fn step(&mut self) -> Result {
22 let Some(word) = self.mem.read_u32(self.pc()) else {
23 return Ok(false);
24 };
25
26 // [...]
27}
```
当然,这完全是不必要的工作。我们不需要通过遍历范围来跟踪每个映射/分配/区域,我们只需要确保读/写交互请求在边界内即可。因此,新的实现如下:
1. 只需要一个指针和一个大小:
RUST
```
1pub struct Mem {
2 ptr: NonNull,
3 len: usize,
4}
```
2. 当被要求映射ELF段时,调用`stinkarm::mem::Mem::map_region`:
RUST
```
1// in stinkarm::elf::pheader::Pheader::map:
2guest_mem.map_region(self.vaddr, file_slice)?;
3
4// in stinkarm::mem::Mem:
5
6pub 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}
```
3. 当CPU请求一个双字进行解码时,它通过调用`stinkarm::mem::Mem::read_u32`来执行,就像以前一样,只是这次带有边界检查:
RUST
```
1pub fn read_u32(&self, guest_addr: u32) -> Option {
2 let bytes = self.get_slice(guest_addr, 4)?;
3 Some(u32::from_le_bytes(bytes.try_into().unwrap()))
4}
5
6fn 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`指令),也可能允许将不受信任的客机地址转换为主机内存访问。我们通过在`stinkarm::mem::Mem`中将客机地址转换到主机内存空间时,在`translate_range`调用内部使用`in_bounds`调用来检查传递给`write.2`的地址的有效性,从而防止这种情况:
RUST
```
1const NULL_PAGE_SIZE: u32 = 0x1000;
2
3impl 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}
```
我添加了多个测试,以确保我能正确捕获写入空指针、写入超出客机内存范围以及在0x0地址加载ELF段的情况:
ARMASM
```
1 .section .rodata
2msg:
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
2msg:
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
```
1const 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 literal: `ldr Rt, [pc, #imm12]`。
9 arm_rule!(LdrLiteral {10 bits(27..26 = 0b01), // load/store 类
11 bit(24 = 1), // P:前索引寻址
12 bit(23 = 1), // U:加上正偏移量
13 bit(22 = 0), // B:字传输,非字节
14 bit(21 = 0), // W:不回写
15 bit(20 = 1), // L:加载,非存储
16 bits(19..16 = 15), // Rn:基址寄存器是pc/r15
17 }),18 // MOV immediate:数据处理立即数,操作码为 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》(https://documentation-service.arm.com/static/5f8daeb7f86e16515cdb8c4e)
该宏本身构建了一个位模式,然后可以使用简单的AND位指令来检测:
RUST
```
1macro_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
11macro_rules! arm_mask {12 (bit($bit:literal = $value:expr)) => {13 1u32 << $bit14 };15 (bits($high:literal .. $low:literal = $value:expr)) => {16 ((1u32 << ($high - $low + 1)) - 1) << $low17 };18}
19
20macro_rules! arm_value {21 (bit($bit:literal = $value:expr)) => {22 ($value as u32) << $bit23 };24 (bits($high:literal .. $low:literal = $value:expr)) => {25 ($value as u32) << $low26 };27}
```
因此,`MovImm`的定义会生成(`Op::mov`定义为`0b1101`):
RUST
```
1ArmRule {2 kind: InstructionKind::MovImm,3 mask: 04 | ((1u32 << (27 - 25 + 1)) - 1) << 255 | ((1u32 << (24 - 21 + 1)) - 1) << 21,6 value: 07 | (0b001 as u32) << 258 | ((Op::Mov as u32) as u32) << 21,9}
10
11impl ArmRule {12 fn matches(&self, word: u32) -> bool {13 (word & self.mask) == self.value14 }15}
```
然后对每个32位字迭代所有规则进行解码:
RUST
```
1pub fn decode_word(word: u32) -> Decoded {2 let cond = bits(word, 31, 28) as u8;3 let kind = DECODE_RULES4 .iter()5 .find(|rule| rule.matches(word))6 .map(|rule| rule.kind)7 .unwrap_or(InstructionKind::Unknown);8
9 Decoded {10 cond,11 kind,12 raw: word,13 }14}
```
我知道使用Trie树或类似结构可能会更快,但这方法易读、易懂且易于维护。
## 仅按需完全解码
在引入指令DSL之前,我为所有指令在所有时间解码了所有必要的值,这意味着即使短指令不匹配,也会进行解码,只是作为尝试确定指令类型的副产品。DSL允许只解码操作码,并让CPU仅通过`decoder::{decode_word,bit,bits}`和`decoder::{sign_extend,rotated_imm}`解码其所需的内容,其中bit和bits允许对字进行部分访问,decode_word返回操作码、条件码和原始字本身,供CPU模拟进一步处理:
RUST
```
1/// 取指-解码-执行 步骤,仅在执行退出svc时返回false
2pub fn step(&mut self) -> Result {3 // [...] 获取字
4
5 let Decoded { kind, cond, raw } = decoder::decode_word(word);6
7 // [...]
8
9 match kind {10 InstructionKind::MovImm => {11 let rd = decoder::bits(raw, 15, 12) as usize;12 let imm12 = decoder::bits(raw, 11, 0);13 // [...]14 }15 // [...]16 InstructionKind::LdrLiteral => {17 let rd = decoder::bits(raw, 15, 12) as usize;18 let imm12 = decoder::bits(raw, 11, 0);19
20 // [...]21 }22 }23}
```
位和位访问是显而易见的,sign_extend和rotated_imm可能不那么直观:
RUST
```
1pub fn bits(word: u32, high: u8, low: u8) -> u32 {2 debug_assert!(high < 32);3 debug_assert!(low <= high);4 let width = high - low + 1;5 (word >> low) & ((1 << width) - 1)6}
7
8pub fn bit(word: u32, bit: u8) -> bool {9 bits(word, bit, bit) != 010}
11
12pub fn sign_extend(value: u32, bits: u32) -> i32 {13 debug_assert!((1..=32).contains(&bits));14
15 let shift = 32 - bits;16 ((value << shift) as i32) >> shift17}
18
19pub fn rotated_imm(imm12: u32) -> u32 {20 let rotate = ((imm12 >> 8) & 0b1111) * 2;21 (imm12 & 0xff).rotate_right(rotate)22}
```
## 支持 B 和 BL
B和BL是ARMv7指令集的无条件分支指令:
- 无条件分支:
ARMASM
```
1.text
2 .global _start
3_start:
4 mov r0, #0
5 b 1f
6 mov r0, #1 @ must NOT execute
71:
8 mov r7, #1
9 svc #0
```
- 带链接的无条件分支:
ARMASM
```
1.text
2 .global _start
3_start:
4 mov r0, #0
5 bl foo
6 mov r7, #1
7 svc #0
8foo:
9 mov r0, #42
10 mov r7, #1
11 svc #0
```
B和BL编码了:
1. 条件码,参见《ARMv7 Condition code suffixes》(https://support.arm.com/documentation/den0042/0100/Unified-Assembly-Language-Instructions/Instruction-set-basics/Conditional-execution?lang=en#md260-conditional-execution__tbl_cond_code_suffixes)
2. 指令组(`101`)
3. 是否带链接分支(`L`)
4. 目标偏移量(imm24)
其位结构如下:
TEXT
```
1 31 30 29 28 27 26 25 24 23 .. 0
2|cond |1 0 1 |L | imm24 |
```
这意味着它相当容易实现,如下所示。L指示模拟器将返回地址保存到**L**ink**R**寄存器(LR),否则我们只需解码imm24,将其左移2位,然后符号扩展到32位,将其加到程序计数器上,就这样:
RUST
```
1InstructionKind::Branch => {2 let l = decoder::bit(raw, 24); // BL
3 if l {4 // 将返回地址保存到 LR (实际是下一条指令地址)
5 self.r[14] = self.instr_addr().wrapping_add(4);6 }
7
8 let imm24 = decoder::bits(raw, 23, 0);9 let imm26 = imm24 << 2;10 let imm32 = decoder::sign_extend(imm26, 26);11
12 self.r[15] = self.arm_pc().wrapping_add(imm32 as u32);13}
```
## 测试“框架”
为了测试所有加固工作和我打算支持的每个新指令,我添加了一些工具,具体来说是srun,它是一个小型stinkarm包装器,用于构建、链接和执行汇编或C文件:
TEXT
```
1Build, link, and execute an ARM assembly or C file with stinkarm
2
3Usage: srun [OPTIONS] [-- ...]
4
5Arguments: 6 ARM assembly or C file to run
7 [EMULATOR_ARGS]... Extra arguments passed to stinkarm before the generated ELF path
8
9Options:10 --text-addr Guest address used as the linker text address [default: 0x8000]11 --out-dir Directory for generated object and ELF files [default: target/srun]12 --dump-asm Print the linked ARM disassembly before running the emulator13 -h, --help Print help
```
例如,以前我需要先汇编`examples/branch.S`文件,然后调用stinkarm,现在我可以直接:
SHELL
```
1cargo run --bin srun
2 # arguments for srun
3 \ -- examples/helloWorld.S
4 # arguments for stink arm, log instructions and syscalls
5 \ -- -linstructions -lsyscalls
```
TEXT
```
1[ 0.538ms] MovImm 1110 E3A00001
2[ 0.543ms] LdrLiteral 1110 E59F1014
3[ 0.545ms] MovImm 1110 E3A0200E
4[ 0.548ms] MovImm 1110 E3A07004
5[ 0.551ms] Svc 1110 EF000000
665174 write(fd=1, buf=0x8024, len=14) [sandbox]
7Hello, world!
8=14
9[ 0.567ms] MovImm 1110 E3A00000
10[ 0.570ms] MovImm 1110 E3A07001
11[ 0.573ms] Svc 1110 EF000000
1265174 exit(code=0) [sandbox]
13=0
```
所以,就是这样,现在请欣赏我猛击一台Claude服务器机架的画面:claude
相似文章
突破 RISC-V 模拟的极限
这篇博客文章探讨了如何通过在非 RISC-V 机器上使用提前重编译器来加速 RISC-V 执行,该重编译器通过尾调用连接基本块,并利用 Clang 的 preserve_none 调用约定,作为 Axiom 的 OpenVM 项目的一部分。
x86仿真器团队曾遇到一段代码糟糕到他们在仿真过程中直接修复
一个关于Windows x86仿真器团队的故事:他们遇到一个程序,其初始化循环完全展开了64KB(65,536条指令),于是添加了特殊优化,将其替换为一个紧凑循环。
PS3 模拟现在在 ARM 上运行很快
RPCS3 的 ARM 移植版现在运行速度快了 60%,功耗降低了 25%,这是因为修复了一个忙等待定时器 bug、用 ARM ISB 替换了 x86 pause,并重写了 LLVM 代码生成。这些改进源于由一台廉价 Android 掌上测试设备推动的低层 ARM 优化。
优化 LLVM 的 bump 分配器
这篇博客文章详细介绍了对 LLVM 的 BumpPtrAllocator 进行的三项近期优化,通过移除冗余对齐、空指针检查以及每次分配的记账开销来减少快速路径开销,从而提升了 Clang、lld 及其他 LLVM 组件的性能。
Windows堆栈限制检查回顾,后续
Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。