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_memory 和 write_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 早期阶段 |
4.4 页表:MMU 的翻译官
页表是 MMU 用来把虚拟地址翻译成物理地址的查找表。avatar2 提供了 get_pte 和 set_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_memory 和 write_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 到底层硬件,每一层都在做什么。
4.8 写在最后
内存操作看起来就是读读写写,但背后牵扯到地址空间、页表、缓存一致性这些底层机制。我个人建议:刚开始用 avatar2 时,先在一个关闭 MMU 的裸机环境里练手,把 read_memory 和 write_memory 用熟。等你能闭着眼读写任意物理地址了,再打开 MMU 去挑战页表遍历。
嗯,今天就聊到这儿。记住一句话:能读能写,不代表你理解了;能解释为什么读出来是这个值,才算真懂了。
read_memory/write_memory默认走虚拟地址空间- 通过
address_space参数切换物理/原始访问 - 页表本质也是内存,可以用同样的 API 读取
- 缓存一致性是调试外设时最常见的坑
公众号:蓝海资料掘金营,微信deep3321