Teakra 子系统的 AHBM 外部内存总线接口解析:melonDS 中 DSP 与 DSi/3DS 主存之间的数据通路

原创2026-10-03 10:02:30321 阅读
文章标签:虚拟化图形学桌面应用

Teakra 子系统的 AHBM 外部内存总线接口解析:melonDS 中 DSP 与 DSi/3DS 主存之间的数据通路

meloneDS 是开源的 Nintendo DS/DSi 模拟器,其源码中内嵌的 src/teakra/ 目录实现了一个完整的 TeakLite-II(TML)数字信号处理器(DSP)模拟核心。AHBM(Advanced High-performance Bus Memory,主机总线内存接口)是这颗 DSP 与外部内存(DSi/3DS 主内存,例如 3DS 的 FCRAM)之间的关键通路:DSP 内部无法直接寻址主存,必须通过 AHBM 以突发(burst)方式批量搬运数据。本文以 ahbm.md 为骨架,结合 ahbm.cpp、ahbm.h、mmio.cpp 与 dma.cpp 的源码实现,完整讲解 AHBM 的 MMIO 寄存器布局、通道配置、突发队列机制、地址对齐与字节序处理,以及它与 DMA 引擎的协作方式。读完本文,你将能够理解并正确配置 AHBM 通道,把 DSP 内存与外部主存之间的任意字节宽度、任意突发长度的数据传输串起来。

一、AHBM 在 Teakra 子系统中的定位

在 Teakra 外设总览 中,AHBM 被定义为"用于访问外部内存(DSi/3DS 主内存)的接口"。它与周边外设的分工如下:

  • AHBM:访问外部主内存的突发式总线接口(本文主题);
  • APBP:DSP 与 CPU(DSi/3DS 中的 ARM)通信的邮箱接口;
  • DMA:在 DSP 内存与外部内存之间搬运大块数据的引擎,当 SRC_SPACE/DST_SPACE 取值为 7 时,实际的数据读写全部落在 AHBM 上;
  • MMIO:所有外设寄存器的统一 I/O 端口映射。

从 teakra.cpp 可以看到 AHBM 在核心对象图中的装配关系:

Ahbm ahbm;
Dma dma{shared_memory, ahbm};
MMIORegion mmio{miu, icu, apbp_from_cpu, apbp_from_dsp, timer, dma, ahbm, btdmp};

即 Dma 持有一个 Ahbm& 引用,MMIORegion 同时持有两者引用;MMIO 寄存器读写会转发到 Ahbm 的成员函数,而 DMA 引擎在搬运外部内存数据时通过 Ahbm 的读写方法完成。因此 AHBM 处于"DMA 引擎 ↔ 外部主存"这条链路的中间层。

二、MMIO 寄存器布局:一个状态寄存器 + 三个通道配置块

AHBM 在 MMIO 空间占用从 0x00E0 开始的一段区域,共 3 个通道(N = 0、1、2)。原文档 ahbm.md 给出的布局如下:

Status register
+-----------#---+---+---+---#---+---+---+---#---+---+---+---#---+---+---+---#
|+0x00E0    |   |   |   |   |   |   |   |   |   |   |   |   |   |RNE|   |   |
+-----------#---+---+---+---#---+---+---+---#---+---+---+---#---+---+---+---#
Applications wait for all bits to be 0 before connecting AHBM to DMA
RNE: 1 when the burst queue is not empty

Channel config (N = 0, 1, 2)
+-----------#---+---+---+---#---+---+---+---#---+---+---+---#---+---+---+---#
|+0x00E2+N*6|       -       |   |   |   |   |   | - | TYPE  |   | BURST | R |
+-----------#---+---+---+---#---+---+---+---#---+---+---+---#---+---+---+---#
|+0x00E4+N*6|           -           | E | W |   |   |   |   |   |   |   |   |
+-----------#---+---+---+---#---+---+---+---#---+---+---+---#---+---+---+---#
|+0x00E6+N*6|               -               | D7| D6| D5| D4| D3| D2| D1| D0|
+-----------#---+---+---+---#---+---+---+---#---+---+---+---#---+---+---+---#
R: Applications set this to 1 if BURST is non-zero
BURST: burst type
 - 0: x1
 - 1: x4
 - 2: x8
 - 3: ?
TYPE: data type
 - 0: 8 bit
 - 1: 16 bit
 - 2: 32 bit
 - 3: ?
W: Transfer direction
 - 0: read from external memory
 - 1: write to external memory
E: Applications always set this.
D0..D7: Connects to DMA channel 0..7 if set to one

逐寄存器拆解:

偏移 名称 位域 说明
0x00E0 状态寄存器 bit 0 RNE RNE = 1 表示突发队列非空;应用必须等待所有位归零后才允许把 AHBM 接入 DMA
0x00E2 + N*6 通道 N 配置 1 bit 0 R、bit 1–2 BURST、bit 4–5 TYPE 突发类型与数据类型;R 在 BURST 非零时必须由应用置 1
0x00E4 + N*6 通道 N 配置 2 bit 8 E、bit 9 W W 为传输方向,E 应用恒置 1
0x00E6 + N*6 通道 N 配置 3 bit 0–7 D0..D7 位图掩码:置 1 的位把本 AHBM 通道绑定到对应编号的 DMA 通道(0–7)

字段取值语义(照录原文档并给出模拟器侧实现依据):

  • BURST(突发类型):0 = x1(单次传输)、1 = x4(4 单元突发)、2 = x8(8 单元突发)、3 = ?(未定义/未知)。
  • TYPE(数据类型):0 = 8 bit、1 = 16 bit、2 = 32 bit、3 = ?(未定义)。
  • W(传输方向):0 = 从外部内存读入 DSP、1 = 从 DSP 写入外部内存。
  • E:应用总是置 1(模拟器中的"始终启用"语义)。
  • D0..D7:按位连接到 DMA 通道 0..7;某一位为 1 即表示该 AHBM 通道服务对应 DMA 通道。

在 mmio.cpp 中,这套布局被映射为可绑定的 MMIO 单元,与头文件中的访问器一一对应:

// AHBM
impl->cells[0x0E0].set = NoSet("AHBM::BusyFlag");
impl->cells[0x0E0].get = std::bind(&Ahbm::GetBusyFlag, &ahbm);
for (u16 i = 0; i < 3; ++i) {
    impl->cells[0x0E2 + i * 6] = Cell::BitFieldCell({
        BitFieldSlot{1, 2, std::bind(&Ahbm::SetBurstSize, &ahbm, i, _1),
                     std::bind(&Ahbm::GetBurstSize, &ahbm, i)},
        BitFieldSlot{4, 2, std::bind(&Ahbm::SetUnitSize, &ahbm, i, _1),
                     std::bind(&Ahbm::GetUnitSize, &ahbm, i)},
    });
    impl->cells[0x0E4 + i * 6] = Cell::BitFieldCell({
        BitFieldSlot{8, 1, std::bind(&Ahbm::SetDirection, &ahbm, i, _1),
                     std::bind(&Ahbm::GetDirection, &ahbm, i)},
    });
    impl->cells[0x0E6 + i * 6].set = std::bind(&Ahbm::SetDmaChannel, &ahbm, i, _1);
    impl->cells[0x0E6 + i * 6].get = std::bind(&Ahbm::GetDmaChannel, &ahbm, i);
}

可以看到:

  • 0x00E0 是只读状态寄存器,写入被 NoSet 拒绝,读出值来自 Ahbm::GetBusyFlag()(对应头文件中的 busy_flag 成员);
  • 0x00E2 + i*6 的 bit 1–2 对应 BURST,bit 4–5 对应 TYPE;
  • 0x00E4 + i*6 的 bit 8 对应 E(位域槽在模拟器中实现为直通,bit 9 对应 W);
  • 0x00E6 + i*6 是完整的 16 位寄存器,直接整体绑定 SetDmaChannel/GetDmaChannel。

三、通道模型与配置访问器(源码印证)

ahbm.h 用枚举把文档中的三个字段值固化为类型安全的形式:

enum class UnitSize : u16 {
    U8 = 0, U16 = 1, U32 = 2,
};
enum class BurstSize : u16 {
    X1 = 0, X4 = 1, X8 = 2,
};
enum class Direction : u16 {
    Read = 0, Write = 1,
};

注意:文档中 TYPE 的 3: ? 与 BURST 的 3: ? 在枚举中没有对应成员,属于"未定义/未使用"取值,模拟器会将其视为非法并打印警告(见下文 GetBurstSize() 的 default 分支)。每个通道的内部状态如下(ahbm.h):

u16 busy_flag = 0;
struct Channel {
    UnitSize unit_size = UnitSize::U8;
    BurstSize burst_size = BurstSize::X1;
    Direction direction = Direction::Read;
    u16 dma_channel = 0;

    std::queue<u32> burst_queue;
    u32 write_burst_start = 0;
    unsigned GetBurstSize();
};
std::array<Channel, 3> channels;
  • 默认状态为 U8 / X1 / Read,对应文档中 TYPE=0、BURST=0、W=0 的复位值;
  • 每个通道拥有一个 std::queue<u32> burst_queue 作为突发队列,这正是状态寄存器 RNE 位所反映的对象;
  • write_burst_start 记录一次写突发在外部内存中的起始地址,用于突发完成时按地址顺序回写。

Channel::GetBurstSize() 把 BurstSize 枚举翻译成实际的单元个数(ahbm.cpp):

case Ahbm::BurstSize::X1: return 1;
case Ahbm::BurstSize::X4: return 4;
case Ahbm::BurstSize::X8: return 8;
default:
    std::printf("Unknown burst size %04X\n", static_cast<u16>(burst_size));
    return 1;

即 x1/x4/x8 对应突发长度 1/4/8 个数据单元;未知取值按 x1 保守处理并输出诊断。

四、突发队列机制:读突发与写突发的完整生命周期

4.1 读方向(外部内存 → DSP)

Read32(channel, address) 是读突发的核心(ahbm.cpp),逻辑如下:

  1. 校验方向:若通道 direction != Read,打印 "Wrong direction!";
  2. 若突发队列为空,则从当前地址开始,按 GetBurstSize() 的长度连续读取,把每个单元结果压入 burst_queue:
    • U8:read_external8(current),若 current 为奇数地址则 value <<= 8;
    • U16:地址先对齐到 current & 0xFFFFFFFE,再 read_external16;
    • U32:地址先对齐到 current & 0xFFFFFFFC,再 read_external32;
  3. 从队列头部弹出一次读取的结果并返回。

也就是说,一次 Read32 调用在队列为空时会预取整个突发,随后对 Read16/Read32 的连续调用只是从队列中依次消费;队列非空时 RNE 位为 1。Read16(channel, address) 是 Read32 的低层封装:偶数地址取低 16 位,奇数地址取高 16 位(ahbm.cpp)。

值得注意的源码注释:U8 模式下奇数地址左移 8 位、Write32 中奇数地址右移 16 位等行为在代码里均标注为 // this weird behaviour is hwtested,即这些奇特的字节序处理是基于真实硬件实测得出的,模拟时不可想当然地去掉。

4.2 写方向(DSP → 外部内存)

WriteInternal(channel, address, value)(ahbm.cpp)按以下顺序执行:

  1. 校验方向:direction != Write 时打印 "Wrong direction!";
  2. 若突发队列为空,记录 write_burst_start = address(突发首地址);
  3. 把值压入队列;当队列长度达到 GetBurstSize() 时,从 write_burst_start 开始逐单元写回外部内存:
    • U8:奇数地址取高 8 位、偶数地址取低 8 位写入(同样标注为 hwtested);
    • U16:地址对 & 0xFFFFFFFE 对齐后写 16 位,若发生错位则退化为写 8 位;
    • U32:按 & 0xFFFFFFFC 对齐,根据对齐程度拆分写 32/16/8 位。

Write16/Write32 的区别在于:Write32 在地址为奇数时会先把值右移 16 位再进入内部流程(ahbm.cpp)。

4.3 忙碌标志与复位

busy_flag 通过 GetBusyFlag() 暴露给状态寄存器;Reset() 将 busy_flag 清零并重置所有通道(ahbm.cpp)。状态机保存/恢复通过 DoSavestate(段名 "TKah")序列化,含队列长度、队列元素与 write_burst_start,保证即时存档/读档时突发状态不丢失(ahbm.cpp)。

五、与 DMA 引擎的协作:GetChannelForDma 的绑定关系

原文档指出 D0..D7 按位绑定 DMA 通道 0..7。在 DMA 引擎启动时,dma.cpp 调用:

channels[channel].ahbm_channel = ahbm.GetChannelForDma(channel);

而 GetChannelForDma(dma_channel)(ahbm.cpp)遍历 3 个 AHBM 通道,检查 (channels[c].dma_channel >> dma_channel) & 1,返回第一个命中的 AHBM 通道编号;找不到时打印 "Could not find AHBM channel for DMA channel %04X" 并回退到通道 0:

for (u16 channel = 0; channel < channels.size(); ++channel) {
    if ((channels[channel].dma_channel >> dma_channel) & 1) {
        return channel;
    }
}
std::printf("Could not find AHBM channel for DMA channel %04X\n", dma_channel);
return 0;

随后 DMA 的 Channel::Tick 在 src_space == 7(读方向)或 dst_space == 7(写方向)时,把数据读写转发到 Ahbm(dma.cpp):

case 7:
    value = parent.ahbm.Read32(ahbm_channel, current_src);
    break;
...
case 7:
    parent.ahbm.Write32(ahbm_channel, current_dst, value);
    break;

16 位模式下则调用 Read16/Write16。这样,dma.md 中描述的"内存空间指定为 7 时从外部内存传输"就与 AHBM 的突发机制接上了。

5.1 外部内存的地址语义

dma.md 补充了一个重要细节:外部内存(如 3DS FCRAM)按 8 位寻址,而 DSP 内部内存按 16 位字寻址。因此:

  • 传给 AHBM 的外部内存地址就是主存的物理地址原样;
  • STEP 值原样累加到地址上:STEP = 1 对外部内存意味着步进 8 位,而对 DSP 内存则步进 16 位;
  • 外部内存地址还有额外的对齐要求,该对齐处理由 AHBM 内部完成——这正是 Read32/WriteInternal 中 & 0xFFFFFFFE、& 0xFFFFFFFC 掩码的用途。

六、外部内存回调:模拟器如何接入主存

AHBM 本身不直接持有主存数据,而是通过回调函数桥接到 melonDS 的主内存子系统。teakra.cpp 展示了接线方式:

impl->shared_memory.SetExternalMemoryCallback(callback.read16, callback.write16);
impl->ahbm.SetExternalMemoryCallback(callback.read8, callback.write8,
                                     callback.read16, callback.write16,
                                     callback.read32, callback.write32);

ahbm.h 中的 SetExternalMemoryCallback 接受 6 个 std::function:8/16/32 位的读与写各一个。这解释了为何 ahbm.md 的状态寄存器要求"应用等待所有位归零后才把 AHBM 接入 DMA"——突发队列中还残留着未消费的预取数据时,直接切换方向会破坏读写序列的一致性。

另外,Teakra 对外也直接暴露了以"通道 0"进行裸读写的接口(teakra.cpp 中的 GetUnitSize/GetDirection/GetDmaChannel/Read16/Write16/Read32/Write32),供宿主在 DMA 之外按需直接访问外部内存。

七、实操要点速查

综合文档与源码,配置一次 AHBM 传输的推荐流程是:

  1. 等待状态寄存器 0x00E0 的 RNE 位清零,确保突发队列空闲(文档:all bits to 0);
  2. 写 0x00E2 + N*6:设置 BURST(0/1/2)与 TYPE(0/1/2),并按文档要求置 R(BURST 非零时);
  3. 写 0x00E4 + N*6:置 W(0 读 / 1 写)与 E(恒 1);
  4. 写 0x00E6 + N*6:按位图设置 D0..D7,把它绑定到目标 DMA 通道;
  5. 由 DMA 引擎(SRC_SPACE/DST_SPACE = 7)发起传输,AHBM 会在 Tick 中通过回调读写外部内存,突发队列按 BURST 长度批量工作。

典型取值参考:

场景 TYPE BURST W E
从 FCRAM 读 32 位突发 2 (32 bit) 2 (x8) 0 1
向 FCRAM 写 16 位突发 1 (16 bit) 1 (x4) 1 1
单字 8 位读写 0 (8 bit) 0 (x1) 0/1 1

注意事项:

  • BURST = 3 与 TYPE = 3 在源码枚举中无对应成员,模拟器按未知取值处理并打印警告,实际固件不应使用;
  • 方向错误(读通道上写、写通道上读)会被源码检测并打印 "Wrong direction!",但不会中止模拟,属于调试辅助;
  • 外部内存地址存在对齐/字节序的"实测怪癖"(奇数地址移位、U16/U32 写退化),这些行为是硬件实测确定的,配置时应与真实 DSP 固件行为保持一致;
  • 涉及存档时,DoSavestate 会完整保存突发队列,保证模拟可重入。

八、相关文档与源码索引

内容 路径
AHBM 原始文档(MMIO 布局) src/teakra/src/ahbm.md
AHBM 头文件(通道模型、枚举、回调接口) src/teakra/src/ahbm.h
AHBM 实现(突发队列、读写逻辑、通道绑定) src/teakra/src/ahbm.cpp
MMIO 注册表(AHBM 寄存器绑定) src/teakra/src/mmio.cpp
DMA 引擎文档(AHBM 空间语义) src/teakra/src/dma.md
DMA 实现(空间 7 转发到 AHBM) src/teakra/src/dma.cpp
Teakra 主入口(回调接线、裸读写接口) src/teakra/src/teakra.cpp
Teakra 外设总览 src/teakra/src/README.md

AHBM 是理解 Teakra DSP 如何访问 DSi/3DS 主内存的必经之路:它以 3 个可配置通道、1 个只读忙碌状态寄存器承载突发式读写,通过 D0..D7 位图与 8 路 DMA 通道建立绑定,最终由外部回调把读写请求落到 melonDS 的主存模型上。掌握本文的寄存器语义与队列机制,即可在仿真调试中准确解释 DSP 固件的内存访问行为。

登录后查看全文
melonDS