让 stinkarm 减少臭味还是增加臭味?

Hacker News Top 工具

摘要

关于改进一个最小化 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 模拟的极限

Hacker News Top

这篇博客文章探讨了如何通过在非 RISC-V 机器上使用提前重编译器来加速 RISC-V 执行,该重编译器通过尾调用连接基本块,并利用 Clang 的 preserve_none 调用约定,作为 Axiom 的 OpenVM 项目的一部分。

PS3 模拟现在在 ARM 上运行很快

Lobsters Hottest

RPCS3 的 ARM 移植版现在运行速度快了 60%,功耗降低了 25%,这是因为修复了一个忙等待定时器 bug、用 ARM ISB 替换了 x86 pause,并重写了 LLVM 代码生成。这些改进源于由一台廉价 Android 掌上测试设备推动的低层 ARM 优化。

优化 LLVM 的 bump 分配器

Lobsters Hottest

这篇博客文章详细介绍了对 LLVM 的 BumpPtrAllocator 进行的三项近期优化,通过移除冗余对齐、空指针检查以及每次分配的记账开销来减少快速路径开销,从而提升了 Clang、lld 及其他 LLVM 组件的性能。

Windows堆栈限制检查回顾,后续

The Old New Thing (Raymond Chen)

Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。