4. 内存操作:读写目标内存(read_memory/write_memory),理解地址空间与页表

内存操作,说白了就是直接跟目标设备的“脑子”打交道。你想想看,一个嵌入式设备跑起来,所有变量、寄存器、缓冲区,最终都映射到某一段物理地址上。能读能写这些地址,就等于拿到了设备的控制权。

我个人习惯把内存操作比作“开锁”。read_memory 是窥探锁芯里的结构,write_memory 则是直接拨动弹子。在 avatar2 里,这两个接口就是你的万能钥匙。但别高兴太早——地址空间和页表这层“防盗门”,才是真正考验功底的地方。

4.1 为什么不能直接读写物理地址?

很多刚接触 avatar2 的朋友会问:“我明明知道外设寄存器的物理地址,为什么读出来全是 0?”

嗯,这里要注意。现代嵌入式系统(尤其是带 MMU 的)都有一层虚拟地址到物理地址的映射。CPU 看到的地址是虚拟的,MMU 通过页表把它翻译成物理地址。avatar2 的 read_memory 默认走的是虚拟地址空间。如果你直接塞一个物理地址进去,它可能根本不在当前页表里。

我曾经在一个 ARM Cortex-A9 的项目上调试 UART 寄存器,折腾了半小时才发现是地址空间没切换对。那感觉,就像拿着钥匙去开别人家的门。

4.2 read_memory / write_memory 的基本用法

先看最基础的调用方式。avatar2 的 read_memorywrite_memory 都接受三个核心参数:起始地址、数据长度、访问宽度。

# 读取 0x1000 地址开始的 4 个字节,按字(4字节)对齐读取
data = avatar.read_memory(0x1000, 4, raw=True)

# 写入 0x2000 地址,写入 2 个字节,半字宽度
avatar.write_memory(0x2000, 2, b'\xAA\xBB', raw=True)

这里 raw=True 表示返回原始字节流。如果你想要解析后的整数列表,可以设 raw=False。我个人习惯在调试外设寄存器时用 raw=True,然后自己按位解析——这样更可控。

4.3 地址空间:虚拟 vs 物理

avatar2 里有一个关键概念叫 地址空间(Address Space)。默认情况下,read_memory 操作的是虚拟地址空间。但你可以通过 address_space 参数显式指定。

地址空间类型 说明 典型场景
virtual CPU 当前看到的虚拟地址 调试应用程序、读取栈数据
physical 物理内存地址 访问外设寄存器、DMA 缓冲区
raw 绕过 MMU,直接访问总线 调试 bootloader 早期阶段
注意: 在 MMU 开启后,物理地址访问可能被缓存或写缓冲影响。如果你发现写入后读回来不对,试试先 invalidate cache。

4.4 页表:MMU 的翻译官

页表是 MMU 用来把虚拟地址翻译成物理地址的查找表。avatar2 提供了 get_pteset_pte 接口来操作页表项。但说实话,我很少直接动页表——除非你在做内核调试或者外设内存映射。

更实用的做法是:先通过 read_memory 读取页表所在的物理内存,然后手动解析页表项。比如在 ARMv7-A 上,一级页表项的结构是这样的:

# 读取一级页表基地址(TTBR0)
ttbr0 = avatar.read_register("TTBR0")

# 读取页表项(每个表项 4 字节)
pte_addr = ttbr0 + (virt_addr >> 20) * 4
pte = avatar.read_memory(pte_addr, 4, raw=True, address_space="physical")

# 解析页表项(简化版)
if pte[0] & 0x03 == 0x02:  # 段映射
    phys_base = (int.from_bytes(pte, 'little') & 0xFFF00000)
    print(f"段映射:虚拟 0x{virt_addr:08X} -> 物理 0x{phys_base:08X}")

你看,本质上还是 read_memorywrite_memory 的组合拳。页表本身也是内存,只不过它的内容决定了其他内存的访问方式。

4.5 实战:遍历进程页表

我记得有一次调试一个 Linux 内核模块,需要验证某个用户态进程的虚拟地址映射是否正确。用 avatar2 的 read_memory 配合页表解析,可以手动走一遍 MMU 的翻译流程。

def walk_page_table(avatar, pgd_base, vaddr):
    """手动遍历页表,返回物理地址"""
    # 一级页表索引
    l1_idx = (vaddr >> 20) & 0xFFF
    l1_entry = avatar.read_memory(
        pgd_base + l1_idx * 4, 4, raw=True, address_space="physical"
    )
    entry_val = int.from_bytes(l1_entry, 'little')
    
    if entry_val & 0x03 == 0x02:  # 段
        return (entry_val & 0xFFF00000) + (vaddr & 0x000FFFFF)
    elif entry_val & 0x03 == 0x01:  # 页表
        l2_base = entry_val & 0xFFFFFC00
        l2_idx = (vaddr >> 12) & 0xFF
        l2_entry = avatar.read_memory(
            l2_base + l2_idx * 4, 4, raw=True, address_space="physical"
        )
        l2_val = int.from_bytes(l2_entry, 'little')
        return (l2_val & 0xFFFFF000) + (vaddr & 0x00000FFF)
    else:
        return None  # 无效映射

这段代码虽然简单,但能帮你理解 MMU 到底在背后做了什么。你想想看,每次 CPU 访问一个地址,硬件都在毫秒级内完成这样的查表操作——是不是挺神奇的?

4.6 避坑指南:缓存一致性与对齐

我曾经在一个项目里用 write_memory 修改了某段物理内存,然后发现 CPU 读到的还是旧数据。原因很简单:D-Cache 没刷。

在带 MMU 的系统中,write_memory 写入物理地址时,如果对应的虚拟地址被缓存了,CPU 可能读到的是缓存里的旧副本。解决办法是:

  • 写入后执行一次 clean_and_invalidate_dcache(如果 avatar2 的 target 支持)
  • 或者直接操作非缓存(non-cacheable)的内存区域
  • 再或者,用 address_space="raw" 绕过缓存
小技巧: 调试外设寄存器时,尽量用 address_space="physical" 并配合单次读取(不要用 burst 模式)。外设寄存器往往有副作用,连续读可能改变状态。

4.7 知识体系总览

下面这张图概括了本章的核心逻辑:从 avatar2 的 API 到底层硬件,每一层都在做什么。

内存操作知识体系 avatar2 API 层 read_memory / write_memory 地址空间选择 virtual | physical | raw 决定走不走 MMU 翻译 MMU 与页表 一级/二级页表遍历 get_pte / 手动解析 物理内存 / 外设寄存器 常见陷阱 • 缓存一致性(D-Cache) • 外设寄存器的副作用 • 对齐要求(4字节/2字节) • 物理地址被 MMU 拦截 • 页表项权限检查 • 写缓冲导致的延迟 避坑口诀 先查地址空间 再刷缓存 最后读回来验证

4.8 写在最后

内存操作看起来就是读读写写,但背后牵扯到地址空间、页表、缓存一致性这些底层机制。我个人建议:刚开始用 avatar2 时,先在一个关闭 MMU 的裸机环境里练手,把 read_memorywrite_memory 用熟。等你能闭着眼读写任意物理地址了,再打开 MMU 去挑战页表遍历。

嗯,今天就聊到这儿。记住一句话:能读能写,不代表你理解了;能解释为什么读出来是这个值,才算真懂了。

核心要点回顾:
  • read_memory / write_memory 默认走虚拟地址空间
  • 通过 address_space 参数切换物理/原始访问
  • 页表本质也是内存,可以用同样的 API 读取
  • 缓存一致性是调试外设时最常见的坑

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