学习了关于复古游戏机模拟器的实现方法后,我就有了一个思考,为什么我见到的大部分模拟器都是单线程实现的(这里单线程指的是对CPU、MMU、中断、DMA、定时器等硬件的模拟都塞在一个线程里面)(也有可能是我孤陋寡闻,我只研究过比较古老的游戏机模拟器,没有研究像3DS和Switch这种较新的模拟器)?
虽然大部分复古游戏机的性能都很弱,现在的个人电脑的CPU和旗舰手机的SOC的单核性能来模拟这些游戏机绰绰有余,但是对于低成本的嵌入式SOC,情况就不一样了,某些嵌入式SOC虽然单核性能弱,但也是多核架构的,如果能充分利用多核的性能,那也是能够流畅运行的(理论上)。
更何况,受到工艺的限制,目前摩尔定律所描述的单核主频的提升已经举步维艰了,将来模拟性能更强的多核游戏机,模拟器的性能会更加受限。
那么多线程实现模拟器的挑战在哪里?
我搜索过很多资料,但没有一个确切的答案。有说多线程同步状态太麻烦的,有说多线程性能不好的,但我觉得这些都不是本质的原因。
无论是单核的GB、GBA还是双核的NDS,它们的硬件可以说都是并行地运行的,在硬件层面上对于内存的读写操作本质上就是没有并发安全一说,数据竞争问题那是软件需要解决的问题。按这个道理来说,多线程模拟多个硬件似乎问题不大?
经过我一段时间以来,闲来无事就稍微思考一下的习惯,我认为是以下原因,以及解决办法。
首先,很多复古游戏机模拟器都使用的解释执行,类似rboy、mGBA等,melonds支持JIT,但也是后面才加的。解释运行的好处是跨平台很方便,毕竟使用代码模拟opcode。但这里会有一个问题,比如rboy里面一段LD BC, d16
的操作:
let v = self.fetchword();
self.reg.setbc(v);
这里模拟一个简单的指令就需要两个步骤,更别说这些步骤可能编译出来更多的真实机器的汇编指令。我们要想模拟复古游戏机的单个指令的原子性,就必须保证解释执行这个指令的原子性。
要怎么保证解释执行指令的原子性?加锁?用channel的方式?
要知道就算是古老的Gameboy,他的CPU主频只有4194304 Hz,但通过加锁的方式来保证原子性,那一秒钟就会有4194304次加解锁操作,要是用channel的方式就会有4194304次内存复制的操作,可以实现(我试过),但是性能会非常差,掉帧掉出天际了。
那还有别的办法吗?
我突然想到一个思路是放弃解释执行,改成用JIT,但前提是JIT能够将游戏机的指令一比一翻译成目标机器的指令,这样就可以保证模拟指令的原子性了。但是JIT有个缺点,就是跨平台性很差,需要针对每种真实机器的CPU指令集都做一遍翻译。