4、第一个仿真脚本:使用Avatar2仿真一个简单的ARM Cortex-M3固件,理解基本流程

好,我们正式开始动手了。

前面几章我们把Avatar2的环境搭好,也聊了聊它的设计哲学。说实话,光看文档和理论,总觉得隔着一层纱。我个人的习惯是,不管三七二十一,先跑通一个最简单的例子再说。哪怕只是点个灯,也能让你对整个框架有个直观的感受。

这一章,我们就来写第一个仿真脚本。目标很简单:用Avatar2仿真一个ARM Cortex-M3的固件。这个固件本身没什么复杂的逻辑,就是读写几个寄存器,然后进入死循环。但通过这个流程,你能把Avatar2的核心工作流摸清楚——创建架构、加载固件、启动仿真、观察状态。

4.1 准备工作:一个最小的Cortex-M3固件

首先,我们需要一个目标固件。这里我准备了一个非常简单的例子,用汇编写的,直接操作内存地址。

为什么用汇编?因为Cortex-M3启动后,第一条指令就是从0x00000004地址取PC指针,然后开始执行。用C语言的话,编译器会帮你做很多初始化工作,反而把核心逻辑藏起来了。我们做逆向的,就是要看最底层的东西。

; minimal_cm3.s
; 一个最简单的Cortex-M3固件
; 功能:向0x40000000地址写入0xDEADBEEF,然后死循环

.syntax unified
.cpu cortex-m3
.thumb

.global _start
.section .text

_start:
    ; 设置栈指针(随便给个地址)
    ldr sp, =0x20001000

    ; 向0x40000000写入0xDEADBEEF
    ldr r0, =0x40000000
    ldr r1, =0xDEADBEEF
    str r1, [r0]

    ; 死循环
loop:
    b loop

.section .isr_vector
    .word 0x20001000    ; 初始栈指针
    .word _start        ; 复位向量

编译成二进制文件:

arm-none-eabi-as -mcpu=cortex-m3 -mthumb minimal_cm3.s -o minimal_cm3.o
arm-none-eabi-ld -Ttext=0x00000000 -o minimal_cm3.elf minimal_cm3.o
arm-none-eabi-objcopy -O binary minimal_cm3.elf minimal_cm3.bin

嗯,这里要注意一点:链接脚本里指定了起始地址为0x00000000,这是Cortex-M3的默认启动地址。如果你用别的地址,记得调整。

4.2 编写第一个Avatar2仿真脚本

现在,我们来写Python脚本。这个脚本会完成以下几件事:

  1. 创建一个Avatar2仿真引擎实例
  2. 添加一个ARM Cortex-M3架构
  3. 创建一个内存映射,把固件加载进去
  4. 设置仿真器的起始状态
  5. 运行几条指令,观察结果

直接上代码:

#!/usr/bin/env python3
# first_avatar_script.py

import logging
from avatar2 import *

# 配置日志,方便调试
logging.basicConfig(level=logging.INFO)

# 1. 创建Avatar实例
avatar = Avatar(arch=ARM_CORTEX_M3, output_directory='/tmp/avatar_output')

# 2. 添加一个Cortex-M3架构的target
target = avatar.add_target(
    name='cm3_target',
    arch=ARM_CORTEX_M3,
    # 这里我们使用QEMU作为后端仿真器
    # 因为QEMU对Cortex-M3支持得比较好
    backend=QEMU,
    # 指定CPU型号
    cpu_model='cortex-m3'
)

# 3. 创建内存映射
#    先创建一个ROM区域,存放固件
rom = avatar.add_memory(
    name='rom',
    size=0x1000,        # 4KB
    address=0x00000000,
    permissions='r-x',  # 读+执行
    file='minimal_cm3.bin'  # 加载固件文件
)

#    再创建一个RAM区域,用于栈和变量
ram = avatar.add_memory(
    name='ram',
    size=0x1000,        # 4KB
    address=0x20000000,
    permissions='rw-'   # 读+写
)

# 4. 初始化仿真器
avatar.init_targets()

# 5. 设置初始状态
#    对于Cortex-M3,复位后PC从0x00000004读取
#    但Avatar2/QEMU会自动处理向量表,所以我们直接设置PC
target.set_register('pc', 0x00000008)  # 跳过向量表,直接到代码

# 6. 运行几步,观察效果
print("=== 开始仿真 ===")

# 先读取初始的寄存器状态
r0_init = target.get_register('r0')
r1_init = target.get_register('r1')
pc_init = target.get_register('pc')
print(f"初始状态: PC=0x{pc_init:08X}, R0=0x{r0_init:08X}, R1=0x{r1_init:08X}")

# 单步执行一条指令
target.step()
pc_after_step1 = target.get_register('pc')
print(f"执行一步后: PC=0x{pc_after_step1:08X}")

# 再执行一步(加载地址到R0)
target.step()
r0_after = target.get_register('r0')
print(f"执行两步后: R0=0x{r0_after:08X} (应该为0x40000000)")

# 再执行一步(加载立即数到R1)
target.step()
r1_after = target.get_register('r1')
print(f"执行三步后: R1=0x{r1_after:08X} (应该为0xDEADBEEF)")

# 再执行一步(STR指令,写入内存)
target.step()
# 读取内存地址0x40000000的值
mem_value = target.read_memory(0x40000000, 4)
print(f"执行四步后: 内存[0x40000000] = 0x{int.from_bytes(mem_value, 'little'):08X} (应该为0xDEADBEEF)")

# 7. 清理
avatar.shutdown()
print("=== 仿真结束 ===")

4.3 运行脚本,看看效果

把上面的脚本保存为 first_avatar_script.py,然后运行:

python3 first_avatar_script.py

如果一切顺利,你会看到类似这样的输出:

=== 开始仿真 ===
初始状态: PC=0x00000008, R0=0x00000000, R1=0x00000000
执行一步后: PC=0x0000000A
执行两步后: R0=0x40000000 (应该为0x40000000)
执行三步后: R1=0xDEADBEEF (应该为0xDEADBEEF)
执行四步后: 内存[0x40000000] = 0xDEADBEEF (应该为0xDEADBEEF)
=== 仿真结束 ===

看到这个输出,说明你的第一个Avatar2仿真脚本跑通了!

我个人第一次跑通的时候,说实话挺激动的。虽然只是写了个内存,但你能感觉到,这个框架真的在模拟一个完整的CPU,指令一条条在执行,状态在变化。这种感觉,跟看文档是完全不一样的。

4.4 核心流程拆解

我们来拆解一下这个脚本背后的逻辑。说白了,Avatar2的仿真流程就四步:

Avatar2仿真核心四步曲

  1. 创建引擎:Avatar(arch=...) —— 确定你要仿真的架构
  2. 添加目标:add_target() —— 指定后端仿真器(QEMU、Unicorn、PyPanda等)
  3. 构建内存:add_memory() —— 定义地址空间,加载固件
  4. 运行与交互:step()、get_register()、read_memory() —— 控制执行,观察状态

这四步,几乎覆盖了所有Avatar2仿真场景。不管你是仿真一个简单的MCU,还是一个复杂的SoC,流程都是这样。只是后面的内存映射会更复杂,交互会更频繁。

这里我画了一张流程图,帮你把整个逻辑串起来:

Avatar2 仿真核心流程 1. 创建引擎 Avatar(arch=ARM_CORTEX_M3) 2. 添加目标 add_target(backend=QEMU) 3. 构建内存 add_memory(file='firmware.bin') 4. 运行与交互 step() / get_register() / read_memory() 循环执行 核心思想:先搭骨架(引擎+目标),再填血肉(内存),最后驱动它跑起来 所有复杂的仿真场景,都是在这个四步框架上扩展出来的

4.5 避坑指南:我踩过的几个坑

第一次跑这个脚本,你可能会遇到一些问题。我把自己踩过的坑列出来,你遇到了可以直接对照:

常见问题与解决方案

  • QEMU后端找不到:确保你安装了 qemu-system-arm。Avatar2默认会调用系统的QEMU。如果没装,会报错说找不到可执行文件。
  • 固件加载地址不对:Cortex-M3的向量表必须从0x00000000开始。如果你把固件加载到其他地址,PC会从错误的位置取值,导致仿真直接崩溃。
  • 权限设置错误:代码段要设置 permissions='r-x',RAM要设置 permissions='rw-'。如果代码段没有执行权限,QEMU会报段错误。
  • 寄存器名字写错:ARM的寄存器名字是 r0r1pcsp,注意大小写。我一开始写成 R0,结果报错说找不到寄存器。

调试小技巧

如果你不确定仿真器当前的状态,可以多打印一些信息。比如:

# 打印所有通用寄存器的值
for reg in ['r0', 'r1', 'r2', 'r3', 'r4', 'pc', 'sp', 'lr']:
    val = target.get_register(reg)
    print(f"{reg} = 0x{val:08X}")

这样能帮你快速定位问题出在哪一步。

4.6 小结:你学到了什么

这一章,我们亲手写了一个Avatar2仿真脚本。你学会了:

  • 如何创建Avatar2引擎实例
  • 如何添加一个Cortex-M3目标,并指定QEMU作为后端
  • 如何创建内存映射,加载固件文件
  • 如何单步执行指令,读取寄存器和内存

说白了,这就是Avatar2的“Hello World”。虽然简单,但五脏俱全。后面的章节,我们会在这个基础上,加入外设仿真、动态分析、甚至多核调试。但核心的骨架,就是这四步。

嗯,我建议你把这段代码多跑几遍,改改参数试试。比如换个固件,或者试试读取不同的内存地址。只有亲手折腾过,才能真正理解每个API的作用。


公众号:蓝海资料掘金营,微信deep3321