从零开始的nes模拟器-[7] 总线
参考:
- NES 模拟器开发教程 01 - NES 系统结构
- Nintendo Entertainment System Documentation.pdf
- How To Write a Computer Emulator
- NES基本原理(二)CPU
包含模块
前面在讲cpu的时候有提到总线的概念,这里我详细讲解总线的具体功能和实现代码。
首先,我们要回顾一下Nes的硬件布局。
这里实际上还缺少了DMA (详见PPU精灵渲染的博文)
上面图中两条绿色的线就是 Nes 的 “总线” , 一条总线是 cpu 的 , 一条是 ppu 的。
我们之前在实现ppu代码的时候,已经把ppu的总线以及总线上对应的模块信息集成到 ppu 类里面了。
所以这个博文主要讲的是实现 cpu 的总线。
因为只是实现cpu的总线,所以我们只要根据图上的模块组装,然后按照cpu的内存布局写对应接口就好。
代码
总线包含的模块 , 以及cpu和其它设备交流的寄存器
class Bus
{
.....
public: // Devices on Main Bus
// The 6502 derived processor
cpu_6502 cpu;
// The 2C02 Picture Processing Unit
cpu_2C02 ppu;
// The Cartridge or "GamePak"
std::shared_ptr<Cartridge> cart;
// 2KB of RAM
uint8_t cpuRam[2048];
// Controllers 手柄之类的
uint8_t controller[2];
// 这个暂时还没有实现... 先占个位
apu_2A03 apu;
private:
// Internal cache of controller state
uint8_t controller_state[2];
private:
// DMA 相关, 详见ppu 精灵渲染那个博客
uint8_t dma_page = 0x00;
uint8_t dma_addr = 0x00;
uint8_t dma_data = 0x00;
bool dma_dummy = true;
bool dma_transfer = false;
...
};
实际上上面很多模块和寄存器前面的博客都出现过的。
初始化模块
加一些接口来初始化ppu和卡带
class bus{
public: // 系统接口
// 将卡带智能指针对象连接到内部总线
void insertCartridge(const std::shared_ptr<Cartridge> &cartridge);
// 重置系统
void reset();
}
初始化ppu和整体的变量
void Bus::insertCartridge(const std::shared_ptr<Cartridge> &cartridge)
{
// 插入卡带,
// 将卡带连接到bus和ppu
this->cart = cartridge;
ppu.ConnectCartridge(cartridge);
}
void Bus::reset()
{
cart->reset();
cpu.reset();
ppu.reset();
nSystemClockCounter = 0;
dma_page = 0x00;
dma_addr = 0x00;
dma_data = 0x00;
dma_dummy = true;
dma_transfer = false;
}
cpu读写通道
添加两个函数让cpu可以通过这两个函数来和各个模块交流。可以说,这两个函数是整个总线类的精髓。
class bus{
public:
// 总线中负责cpu的读写
void cpuWrite(uint16_t addr, uint8_t data);
uint8_t cpuRead(uint16_t addr, bool bReadOnly = false);
}
这里加一个参数bool bReadOnly ,是为了调试用的
这两个函数的实现是参照内存布局实现的
void Bus::cpuWrite(uint16_t addr, uint8_t data)
{
if (cart->cpuWrite(addr, data))
{
// cartridge进行首次判断
// cartridge可以根據需要將內容映射到其他任何地址
// 通过这种方式,可以扩展硬件外设,比如枪。
}
else if (addr >= 0x0000 && addr <= 0x1FFF)
{
// 系统内存地址
// 只有一共8kb,但是只有2kb可以用
// 其他地址是2kb的镜像,使用&0x07ff运算,只使用2kb
cpuRam[addr & 0x07FF] = data;
}
else if (addr >= 0x2000 && addr <= 0x3FFF)
{
// PPU地址
// ppu只有8个寄存器,在0x2000 -
// 0x3fff这个范围内,都是这8个寄存器的重复镜像数据
// 所以直接使用&0x0007获取正确地址即可
ppu.cpuWrite(addr & 0x0007, data);
}
else if ((addr >= 0x4000 && addr <= 0x4013) || addr == 0x4015 || addr == 0x4017)
{
// APU地址
// apu的几个寄存器地址 0x4000 - 0x4013 0x4015 0x4017
apu.cpuWrite(addr, data);
}
else if (addr == 0x4014)
{
// 0x4014地址是DMA传送开始标志
// 当此地址被写入数据时,说明发生DMA传送
// CPU周期将停止,将OAM寄存器中的数据传送到PPU中,64个精灵,直到传送结束
dma_page = data;
dma_addr = 0x00;
dma_transfer = true;
}
else if (addr >= 0x4016 && addr <= 0x4017)
{
// 控制寄存器地址
// 0x4016 - 0x4017是控制寄存器地址,cpu通过读取此地址中的数据
// 获取控制命令
controller_state[addr & 0x0001] = controller[addr & 0x0001];
}
}
uint8_t Bus::cpuRead(uint16_t addr, bool bReadOnly)
{
uint8_t data = 0x00;
if (cart->cpuRead(addr, data))
{
// Cartridge 地址
}
else if (addr >= 0x0000 && addr <= 0x1FFF)
{
// 系统内存地址RAM, 每2kb都是镜像 2kb = 0x07FF
data = cpuRam[addr & 0x07FF];
}
else if (addr >= 0x2000 && addr <= 0x3FFF)
{
// PPU地址 每8个字节都是镜像数据
data = ppu.cpuRead(addr & 0x0007, bReadOnly);
}
else if (addr == 0x4015)
{
// APU读寄存器
data = apu.cpuRead(addr);
}
else if (addr >= 0x4016 && addr <= 0x4017)
{
// 读取控制命令数据
data = (controller_state[addr & 0x0001] & 0x80) > 0;
controller_state[addr & 0x0001] <<= 1;
}
return data;
}
时钟
cpu总线可以同时访问 ppu 和 cpu ,所以可以顺便在这个类里面组织时序。
class bus{
private:
// A count of how many clocks have passed
uint32_t nSystemClockCounter = 0;
public:
// Clocks the system - a single whole systme tick
void clock();
}
时序里面的各个部分前面的博客都已经提及过了,这里只不过是再整体展示一下。
bool Bus::clock()
{
// ppu时钟通常用来决定整个模拟器速度
ppu.clock();
// APU时钟
apu.clock();
// CPU的时钟周期比PPU和APU的慢3倍,也就是说,PPU和APU每执行3次,CPU执行一次
// 这是NES模拟器中规定的
// 使用nSystemClockCounter来记录ppu的时钟周期. 除3取余即可得到cpu时钟
if (nSystemClockCounter % 3 == 0)
{
// 判断是否发生DMA传送(将OAM数据传送到PPU中)
if (dma_transfer)
{
// TODO(tiansongyu): 可能存在cpu的周期bug
// 这里虽然在cpu的周期中,但实际cpu不进行clock,
// 此时DMA占用cpu周期
// 通常占用513或514个周期
// (等待写入完成时为 1 个等待状态周期,如果在奇数 CPU 周期中为 + 1,则为
// 256 个交替读 / 写周期。) dma_dummy的默认是true
if (dma_dummy)
{
// 执行周期必须是偶数,需要等待一个cpu周期
if (nSystemClockCounter % 2 == 1)
{
// DMA开始传送
dma_dummy = false;
}
}
else
{
if (nSystemClockCounter % 2 == 0)
{
// 偶数周期从cpu中读取数据,
// dma_page存储着 0x(dma_page)xx数据
// xx通常从0开始 ,0xFF结束,
// 64个精灵,每个精灵4个字节,所以是256个字节,也就是0xFF大小
// dma_page是地址的高位,dma_addr是地址的低位
// dma_data即为从cpu中读取的数据
dma_data = cpuRead(dma_page << 8 | dma_addr);
}
else
{
// 奇数周期,将数据写到PPU中
ppu.pOAM[dma_addr] = dma_data;
// 需要增加dma的地址偏移
dma_addr++;
// dma_addr是一个字节的数据
// 当dma_addr大于0xFF时,会产生溢出
// dma_addr = 0 说明此时DMA传送停止
// 将标志寄存器更改
if (dma_addr == 0x00)
{
dma_transfer = false;
dma_dummy = true;
}
}
}
}
else
{
// 没有发生DMA传送时,CPU时钟正常进行。
cpu.clock();
}
}
补充
总线应该还包含apu模块的内容,等我把apu实现了再填坑