Teakra 子系统的 AHBM 外部内存总线接口解析:melonDS 中 DSP 与 DSi/3DS 主存之间的数据通路
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),逻辑如下:
- 校验方向:若通道
direction != Read,打印"Wrong direction!"; - 若突发队列为空,则从当前地址开始,按
GetBurstSize()的长度连续读取,把每个单元结果压入burst_queue:U8:read_external8(current),若current为奇数地址则value <<= 8;U16:地址先对齐到current & 0xFFFFFFFE,再read_external16;U32:地址先对齐到current & 0xFFFFFFFC,再read_external32;
- 从队列头部弹出一次读取的结果并返回。
也就是说,一次 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)按以下顺序执行:
- 校验方向:
direction != Write时打印"Wrong direction!"; - 若突发队列为空,记录
write_burst_start = address(突发首地址); - 把值压入队列;当队列长度达到
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 传输的推荐流程是:
- 等待状态寄存器
0x00E0的RNE位清零,确保突发队列空闲(文档:all bits to 0); - 写
0x00E2 + N*6:设置BURST(0/1/2)与TYPE(0/1/2),并按文档要求置R(BURST 非零时); - 写
0x00E4 + N*6:置W(0 读 / 1 写)与E(恒 1); - 写
0x00E6 + N*6:按位图设置D0..D7,把它绑定到目标 DMA 通道; - 由 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 固件的内存访问行为。