前言
前段時間無意中瀏覽到了描述FC(Family Computer)游戲的一些工作原理的博客,瞬間勾起了兒時對小霸王游戲機如癡如醉的過往,看到網上從以前游戲卡帶中導出來的游戲:超級瑪麗、魂斗羅等才幾十k大小,大的也不過幾百k,極少數(shù)超過1M的,而這點空間現(xiàn)在的一張普通質量的圖片可能都存不下。所以想到了不如自己實現(xiàn)一個FC模擬器,一探它背后神秘的魔法。即是重新追憶一下那些逝去的時光,又是對計算基礎知識很好的一個實踐。然現(xiàn)實很骨感,網上能找得到的相關硬件資料太少,資料比較全面的就是NesDev,但是全英文,而且很多東西過于詳細,花了很長時間讀可能還找不到重點在哪里,除非做過類似的東西否則很難就此上手。所以寫篇博客記錄一下整個實現(xiàn)的流程。
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
總覽
所謂模擬器,實際就是我們在軟件層面來模擬硬件的工作,也就是說實現(xiàn)一個FC程序的執(zhí)行環(huán)境。要實現(xiàn)這么一個模擬器,首先就是要清楚FC主要由哪些硬件構成以及各個硬件是如何協(xié)作的,反正現(xiàn)代的計算機基本都是基于馮洛伊曼體系啦。與我們一般的電腦一樣,主要就是CPU、內存、顯卡、輸入設備、輸出設備幾部分。具體到FC,以前游戲機都是插卡的,所以卡帶也要算一部分。接下來描述一下大致的過程,主機通電后
- 卡帶的加載
游戲的數(shù)據主要包括圖像和程序,所以第一步首先就是根據固定的頭部解析nes文件(卡帶硬件導出的),拿到相關的數(shù)據后,就可以開始將程序和圖像分別裝載到主內存和顯存,而CPU主要就是和主內存打交道,包括其它的硬件也都是通過CPU內部的IO寄存器映射到主內存,IO寄存器可以當作是CPU與外部設備通過總線連接的端點。當然具體實現(xiàn)時,直接通過操作具體的某個內存地址來實現(xiàn)與其它硬件的通信就可以了。
- 卡帶的加載
- CPU運行程序代碼
CPU(Center Processing Unit)對計算機而言,始終是最核心的硬件,其它組件的運行都是通過它來帶動的。對于FC,通電重置后會觸發(fā)一個RESET中斷,也就是會將CPU的指令指針寄存器PC(Program Counter)跳轉到RESET中斷存儲的地址,因為PC總是存儲的程序下一條要執(zhí)行指令的內存地址嘛,所以程序也就從這里開始執(zhí)行了。執(zhí)行的過程也就是[取指令]->[指令譯碼]->[取操作數(shù)]->[計算]。
- CPU運行程序代碼
- PPU開始讀取圖像數(shù)據并進渲染
圖像處理單元PPU(Picture Processing Unit)也就是我們常說的顯卡,主要就是用來處理圖形的渲染、窗口的顯示,一般顯卡會有自己單獨的一塊內存,主要用來存儲圖像以及相關的信息。對于FC,PPU會定期從顯存中抓取游戲背景、精靈的數(shù)據,并渲染到窗口上。這里說的定期,實際上就是需要與CPU的時鐘周期進行同步啦,這樣才能保證獲取到的數(shù)據是正確的。一般來說,PPU需要有比CPU更快的執(zhí)行速度,CPU除了執(zhí)行指令,還有對各個硬件進行協(xié)調的功能,對于PPU而言,也是通過幾個IO寄存器來進行的,一般CPU執(zhí)行一次,屏幕至少要抓取幾個像素點。
- PPU開始讀取圖像數(shù)據并進渲染
-
輸入與輸出
輸入設備主要包括手柄、光槍之類的,和現(xiàn)在的計算機不太一樣,我們用鍵盤輸入時一般都是通過中斷來通知CPU,鍵盤的中斷處理程序接著再根據輸入的掃描碼翻譯成鍵盤上對應按鍵值。對于FC而言,是程序通過代碼定期(一般是在一幀繪制完成后)按手柄按鍵順序從內存映射的IO寄存器中讀取輸入的值。輸出主要就是包括屏幕的像素點以及聲音。
各個組件的協(xié)作見上圖,接下來就依次介紹一下各個部以及一些額外的擴展。
-
游戲卡帶
這部分需要讀取nes文件并按部就班的解析和存儲相關的信息,nes文件實際就是FC平臺的可執(zhí)行文件,與Windows上的PE(常見的.exe)、Linux上面的ELF可執(zhí)行文件一樣,都不是純二進制程序,額外包含了一些固定的頭部信息。這是平臺所規(guī)定的,需要從中解析出實際的程序才能放到CPU上面執(zhí)行。現(xiàn)在需要關注的信息
- Mapper Id
FC卡帶上自帶的額外擴展的芯片Mapper的id,后面再詳細介紹。
- Mapper Id
- CHR-ROM/VROM
即Charater-ROM, 這部分就是存儲的游戲中所需要用到的圖像信息,或者換個說法,也就是常說的字體庫。程序包那么小的體積,存圖片肯定是不現(xiàn)實的,它是存儲游戲中背景和精靈需要引用的圖案的點陣,需要裝載到顯存。
- CHR-ROM/VROM
- PRG-ROM
即Program-ROM,這部分存儲的就是游戲程序編譯后的二進制代碼,需要裝載到主內存。
- PRG-ROM
- Mirroring-Type
鏡像類型,主要是決定了程序運行過程顯存中存儲的背景渲染信息的那部分內存是如何規(guī)劃,在PPU部分再詳細解釋。
- Mirroring-Type
知道大概需要哪些東西了,就可以先定義一個獲取該文件信息相關的接口了。
public interface INesLoader {
// 獲取16KB PRG(程序)數(shù)據的頁數(shù)
int getPRGPageCount();
// 獲取8KB CHR(圖像)數(shù)據的頁數(shù)
int getCHRPageCount();
// 通過索引獲取對應的PRG數(shù)據塊
byte[] getPRGPageByIndex(int index);
// 通過索引獲取對應的CHR數(shù)據塊
byte[] getCHRPageByIndex(int index);
// 獲取屏幕的鏡像類型
int getMirroringType();
int getMapperId();
String getFileMD5();
}
具體文件格式可以看看這篇博客
https://zhuanlan.zhihu.com/p/44035613
CPU
FC使用的是2A03 CPU,主要在6502 CPU的基礎上擴展了對音頻處理(pAPU)的支持,所以CPU使用的其實仍是6502的匯編指令集。前面說過,模擬器主要模擬的是硬件,是FC程序的執(zhí)行環(huán)境。FC程序也是直接使用的6502匯編進行的編程,不過它本身用什么也不重要,關鍵點在于編譯后的程序最后是在什么硬件上運行的,因為編譯后的都是二進制,我們需要解決的是把這些二進制機器碼對應到CPU所支持的指令集,這樣程序才能正常在該CPU上運行。(這里需要與Win上面的PE、Linux的ELF可執(zhí)行文件區(qū)別開的是,Win和Linux上面編譯后的程序雖然都是CPU可識別的機器碼,但它們畢竟是運行在操作系統(tǒng)上,程序運行需要的資源的分配與管理、相應的系統(tǒng)調用都依賴該操作系統(tǒng)的內核,所以即使最終都是同一套機器碼、在同樣的硬件上面運行,也很難做到跨平臺,重點在于這些程序需要另一套程序(內核)來進行管理),而FC的程序不需要額外的管家,直接在硬件上面裸奔,所以直接將程序裝載到主內存就可以跑了。因為要模擬CPU和內存,所以基本的思路就是對二進制的FC程序進行解釋執(zhí)行就可以了。接著先看看CPU直接訪問的主內存各部分是怎么劃分的
主內存布局

RAM
實際就是程序運行期間可以完全供自己操作的內存,不過前面1kb(0-0x200)也是有固定用途的,ZeroPage指內存的第一頁,臨時存放一些數(shù)據,CPU可以用來快速尋址和執(zhí)行;棧就是用來存放計算時需要臨時保存的一些值,或者子程序(函數(shù))調用和觸發(fā)中斷時需要將PC的下一條要執(zhí)行指令的地址、狀態(tài)寄存器等信息保存在棧中,等待執(zhí)行完后再恢復現(xiàn)場; RAM(0x0200-0x0800)就是沒有固定用途可任意操作的了。而0x0800-0x2000內存地址實際都是前面0-0x800的鏡像,也就是說訪問地址0x800實際是訪問到了地址0,以此類推。所以可以看到,供程序自由發(fā)揮的也就2KB(0-0x0800)。
I/O Regesters
0x2000-0x4020主要包含了PPU、APU(Audio Processing Unit)、手柄等輸入設備的IO寄存器的內存映射,直接對映射的內存地址進行讀寫就可實現(xiàn)對這些設備的控制以及狀態(tài)信息的獲取??梢钥吹剑?x4020個字節(jié)對所有程序的內存都是這樣規(guī)劃的。
Expansion ROM 與 SRAM
Expansion ROM留作卡帶程序的擴展空間;SRAM(Save RAM)主要用來給某些存在存檔的游戲預留的空間,這兩部分暫時都不用管。
PRG-ROM
游戲卡帶那部分提過,0x8000-0xFFFF這32KB空間用來存儲游戲程序代碼。
關于CPU,還有幾點需要了解的。
- 之前圖已經給出了,2A03 CPU擁有16位的地址總線 ,可尋址的范圍是2字節(jié),即0x0000-0xFFFF,主內存總共空間大小為64KB,默認字節(jié)序采用小端序;而數(shù)據和控制總線都是8位的,所以具體操作內存的時候實際都是以字節(jié)為單位進行的。
- 實現(xiàn)CPU首先需要實現(xiàn)CPU的寄存器,寄存器主要包括PC(Program Counter)寄存器、SP(Stack Poninter)寄存器、A(Accumulator)累加器、X和Y索引寄存器以及處理器狀態(tài)寄存器。棧指針寄存器就是始終指向當前棧頂?shù)奈恢?。CPU指令實現(xiàn)的過程中也需要對狀態(tài)寄存器的標志位進行對應的改變。
- 尋址模式 ,也就是說看匯編指令(機器碼)使用什么樣的方式尋址,一共尋址模式包含10來種,也都是和機器碼一起已經定義好的,至于程序使用哪種方式尋址程序開發(fā)人員自己發(fā)揮。所以具體實現(xiàn)時可以先完成各個尋址模式,然后再看各個指令的機器碼分別對應哪種就行了。不管哪種尋址模式,最終目的都是拿到內存地址最終的數(shù)值,進行運算。這部分也純粹是一個體力活,前面說過具體操作都以字節(jié)為單位,指令的機器碼也是1字節(jié),所以最多也只能有256個指令。實際6502CPU的指令只有幾十個,剩下的要么是組合了不同的尋址模式(同一指令,尋址模式不同對應的機器碼也不同),要么是留作擴展。另外,指令包括官方指令和非官方指令,暫時實現(xiàn)官方記載的指令就可以支持大部分游戲了。根據文檔,指令的排列還是有一定規(guī)律的,所以最好參照已有的模擬器代碼來。測試可以寫單元測試,可以用專門的測試Rom,倒比較好看執(zhí)行結果。
- 時鐘周期,既然模擬CPU,必須得控制好它的時鐘周期。2A03CPU的主頻才1.78MHz,要知道現(xiàn)在的CPU基本都是GHz起步了,這差了成百上千倍了,要是不加以控制,那么游戲里面指令執(zhí)行的速度就會快到飛起,那樣根本沒法玩。所以運行過程中需要計算一下主頻,首頁要清楚的是主頻(也稱CPS, Cycle Per Second)實際是指CPU每秒度過的時鐘周期數(shù),一般一條指令需要1-幾個時鐘周期不等,看看以下的公式
平均每個時鐘周期花費的時間 = 1 / 每秒度過的時鐘周期數(shù)
程序運行時間 = CPU指令總的時鐘周期數(shù) * 每個周期花費的時間
所以要算出當前的主頻
主頻 = CPU指令總的時鐘周期數(shù) / 程序運行時間
而CPU指令的周期數(shù)和程序運行的時間都是運行過程中需要進行統(tǒng)計的, 算出當前的主頻后,直接一個While循環(huán),當前的大于目標主頻就直接sleep(),先空閑一段時間,接著計算。對于程序的主循環(huán)直接這樣
long time = System.nanoTime();
while (true) {
cpu.execute();
long timeDiff = System.nanoTime() - time;
cps = cpu.getCycle() * 1e9 / timeDiff;
while (cps > Emulator.TARGET_CPS) {
sleep(1);
cps = cpu.getCycle() * 1e9 / (System.nanoTime() - time);
}
}
看到這里,應該也有了想法,一般的模擬器都有幾倍加速的,其實加大下目標主頻就行了,反正怎么調還是比現(xiàn)代的CPU速度慢得多。可以看看CPU執(zhí)行的偽代碼
public long execute() {
int opcode = mainMemory.readByte(register.getPC());
increasePC();
switch (opcode) {
case 0: return brk();
case 1: xxx;
case 2: xxx;
}
}
就是從內存讀取操作碼,然后對應到指令,如果指令還需要操作數(shù),就繼續(xù)在PC指向的地址取值,并移動PC指針到下一個地址。至于內存,因為多個地方要用到,所以也可以抽象出一個接口來。
public interface IMemory {
// 從地址address讀取1字節(jié)數(shù)據
int readByte(int address);
// 寫1字節(jié)數(shù)據到地址address
void writeByte(int address, int value);
int getSize();
}
總的來說,CPU沒有那么多彎彎繞繞,具體的指令無非就是一些基本的運算以及內存、寄存器的復制與讀寫,對著文檔來就好。指令的實現(xiàn)參考
http://nparker.llx.com/a2/opcodes.html
https://wiki.nesdev.com/w/index.php/CPU_unofficial_opcodes
http://www.6502.org/tutorials/6502opcodes.html
PPU
PPU主要用來做圖形渲染。要清楚圖形是怎么渲染的,首先需要了解的是,以前的大頭電視機是怎么工作的。借一張圖看看
圖像其實是從屏幕左上角開始從左到右一個一個像素點進行渲染的,實際過程是電視機背后的電子槍發(fā)射出一個電子,而電視里面都是有一個大線圈,通電后產生了磁場,接著電子經過磁場的偏移打到了屏幕上的熒光材料從而產生了可見圖形。而屏幕顯示的彩色,是由紅、綠、藍RGB三基色進行混合。即三支電子槍發(fā)射出不同的電子,轟擊到屏幕的三色熒光粉上,進行混合后就能產生不同的顏色。
圖像經電子槍的掃描線從左到右渲染,一行完成后又要回到下一行的最左邊,回到左邊的這個時間段沒有像素點被渲染,這個過程稱為H-Blank(Horizontal Blank)。而整個屏幕被渲染完成后,又需要從右下角回到左上角開始繪制下一幀,這個時間段也沒有像素點渲染,稱為V-Blank(Vertical Blank)。FC的圖像是以Tile(像素塊)為基本單位的,一個Tile為8x8的像素塊。
PPU有單獨的一塊顯存VRAM(Video RAM),接下來看看VRAM的內存布局
VRAM內存布局

調色板
即PaletteRAM indexes(0x3F00-0X3F1F)。系統(tǒng)調色板構成了FC能顯示的64種顏色,而分別存儲在VRAM中0x3F00-0x3F0F和0x3F10-0x3F1F(后面的0x3F20-0x3FFF都是鏡像)位置的是16字節(jié)的背景調色板與16字節(jié)的Sprite調色板的索引,通過1字節(jié)來索引到系統(tǒng)調色板。調色板是在系統(tǒng)中寫死的,不同模擬器顏色的差異也就是從這里來的。具體實現(xiàn)時,直接獲取到調色板顏色對應的RGB值,再進行渲染。

圖案表(字體庫)
即PatternTable(0-0x2000),圖案表存儲的是游戲中背景和Sprite需要用到的圖案,分為兩個4kb,由PPU的控制寄存器指定是給背景還是Sprite使用。圖案表以16字節(jié)的方式步進的??纯聪聢D,是冒險島3首頁的圖案表

8x8的像素塊一共64個像素點, 那如何確定像每個素點的顏色呢? 答案就是這16字節(jié), 16字節(jié)分成2個8字節(jié),即兩個64位,從這兩個64位各拿出1位來組成了4位中的低兩位. 這里的4位是干啥用的呢?前面說過0x3F00-0x3F1F的兩個16字節(jié)的調色板索引,用來索引到系統(tǒng)調色板. 所以對于背景與精靈的顏色,就需要用至少4位,(總共2^4=16)才能訪問到這16字節(jié). 整個過程就是
- 用4位確定到調色板索引的地址
- 通過調色板索引的地址讀取到1字節(jié)的調色板索引
- 最后再用該索引找到系統(tǒng)調色板中對應的顏色
名稱表
即NameTable,F(xiàn)C總共有4個名稱表,位于0x2000-0x2FFF,一共4kb,每個名稱表占用1024字節(jié)。前面說過圖像基本單位是8x8的像素塊,F(xiàn)C使用的屏幕分辨率是256x240,剛好可以分成32x30個像素塊,而名稱表每1個字節(jié)存儲的是像素塊在圖案表中的編號,總共需要32x30=960個字節(jié)。同樣看看冒險島3的名稱表

上下個各2個名稱表,問題來了,屏幕像素不是只有256x240,應該只要一個名稱表就夠了吧?這就是FC神奇的地方了,這樣設計的目的是為了方便做屏幕滾動,現(xiàn)在的游戲屏幕滾動一般都是直接對同一塊空間進行操作,也就是整塊圖像緩存空間重新刷新填充。而FC是直接通過修改PPU內部的寄存器在名稱表上面進行偏移來達到滾動的效果,所以整塊空間不需要頻繁改動,后面再詳細說明。最后剩余64個字節(jié)就是給屬性表所使用的。
屬性表
屬性表位于名稱表的最后64字節(jié),分成8x8個字節(jié),前面說過分辨率是256x240, 除以8x8就是32x30,即屬性表每1個字節(jié)分配給1個32x30的像素塊。 而現(xiàn)在前面所說的4位還缺少2位,,這里1字節(jié),分成4個2位, 于是將32x30的像素塊再分成4塊,可以分成4個8x8(實際有一個像素塊不完整)的像素塊,,每個8x8像素塊就再使用圖案表中的低2位+這個作為高2位,去確定到一個調色板索引的地址。到這里就可以發(fā)現(xiàn)了一個問題,,就是沒辦法為每個像素點確定到所有的調色版索引的地址,因為8x8像素塊每個點中的高兩位其實都是一樣的,但前面說過了實際FC也是以8x8像素塊為基本單位,確定了圖案形狀后,每個像素塊中的像素點還能有幾種變化就夠了。另外,這里的屬性表是給背景使用的,而精靈的屬性表存儲在SPR-RAM中。
SPR-RAM
即Sprite-RAM,是PPU給精靈使用的單獨一塊256字節(jié)的空間,每個精靈占用4字節(jié),也就是說屏幕上最多顯示64個精靈。精靈是指的屏幕上面的活動塊,比如游戲的角色或者狀態(tài)欄一直需要變化的部分一般就是使用的多個精靈組合成的??纯瘩R里奧就是由8個8x8的精靈像素塊組成的

再看看4字節(jié)主要儲存了哪些信息
- Byte0存儲的是精靈左上角的y坐標-1。
- Byte1存儲了精靈像素塊在圖案表中的編號。
- Byte2存儲了資源信息
bit0-1存儲的就是確定調色版索引地址的高2位
bit5決定了精靈的顯示對于背景的優(yōu)先級。
bit6表明精靈是否是要水平翻轉
bit7表明精靈是否是要存儲翻轉 - Byte3存儲了精靈左上角的X坐標
PPU渲染
前面介紹了相關的內存布局,現(xiàn)在來看看具體的渲染是怎樣的。屏幕渲染的規(guī)則和采用的制式有關,F(xiàn)C大部分資料都是使用NTSC制式的,所以優(yōu)先選取這個,畢竟對于了解工作原理來說,這都不是重點。渲染過程中,每幀掃描線一個有262條,每秒渲染60幀。
- 0-239這240條是屏幕上可見的掃描線(屏幕是256x240分辨率,高為240),在這個過程中需要進行屏幕像素的渲染。
- 240-260為V-Blank,這個時間段是不可見掃描線,主要用來生成nmi、獲取手柄的狀態(tài)信息、為下一幀的渲染做好準備。
- 第261條是預處理掃描線,在這個掃描線開始時需要結束V-Blank,清除其它的一些狀態(tài)信息。這條掃描線和可見掃描線一樣需要更新相關的寄存取信息,但不做任何像素的渲染。
時鐘周期
每條掃描線一共需要花費341個PPU時鐘周期,而1CPU周期=3PPU周期。這里就需要與CPU周期進行同步了,同步有很多種方式,可以直接渲染完一條掃描線,CPU就走341/3個時鐘周期;或者渲染完一幀,CPU走261*341/3個時鐘周期。采用精度最高的方式就是走1個CPU周期,PPU走3個時鐘周期。每個周期渲染一個像素點,當然按照tile也就是1行8個像素點為單位來渲染比較方便,每隔8個時鐘周期,一次渲染8個像素點。屏幕寬是256個像素點,渲染完背景的一行就需要256個時鐘周期,接下來257-320是HBlank,也可以不進行任何渲染,再往后可以提前渲染下一行的前兩個8像素的塊。完整的渲染過程
- 1.抓取當前渲染到的像素塊的編號(當前屏幕左上角名稱表地址+當前位置在屏幕內像素塊個數(shù)的偏移),用編號去獲取到圖案表中的像素塊(16字節(jié)步進)。
- 2.像素塊是8x8,所以還要獲取到當前掃描線在8x8像素塊里面偏移的行和列是多少。這樣就可以獲取到像素點的低2位。
- 3.抓取當前像素塊所在資源表的地址,獲取到高2位,與前面的低2位一起就能組合出調色板索引的地址,最后根據調色板索引獲取到系統(tǒng)調色板的RGB值。
PPU滾動
上面所述的偏移,其實就是PPU實現(xiàn)屏幕滾動的關鍵,下圖來自NesDev

滾動其實就是修改在名稱表上面的偏移來進行的,具體實現(xiàn)時按理來說可以直接根據PPU寄存器的內存映射來(0x2000-0x2007),但關鍵在于游戲程序不按你想的來,就會出現(xiàn)需要抓取的資源沒有更新??梢钥纯催@篇博客
https://gridbugs.org/zelda-screen-transitions-are-undefined-behaviour/
所以最佳的方式是實現(xiàn)PPU內部的寄存器v、t、x、w,在進行PPU的內存映射地址操作過程中對這幾個寄存器進行維護即可。接下來看看這幾個寄存器
- v: 當前的VRAM地址。
- t: 臨時的VRAM地址,也可以被看作是屏幕窗口左上角的地址。
- x: 精準x滾動(3bit)。
- w: 第一次或第二次寫時觸發(fā)(1bit)。
CPU使用主內存的0x2007讀寫數(shù)據時,PPU使用的就是當前的VRAM地址,它也被用來獲取名稱表的數(shù)據以繪制到屏幕上。在用名稱表的數(shù)據進行渲染時,也會更新當前的VRAM地址,保證獲取的數(shù)據是正確的。v和t寄存器由15位組成
- 0-4bit: 模糊x滾動(名稱表中當前像素塊位置的x軸偏移)。
- 5-9bit: 模糊y滾動(名稱表中當前像素塊位置的y軸偏移)。
- 10-11bit: 名稱表選擇。
- 12-14bit: 精準Y軸滾動。
而x寄存器和v、t寄存器的12-14bit就是當前通過名稱表的像素塊編號找到的8x8像素塊具體的像素點偏移的位置。知道了這幾個,剩下的直接根據wiki來就可以了
http://wiki.nesdev.com/w/index.php/PPU_scrolling
具體渲染的時候將當前屏幕每個像素點的RGB值放到一個緩沖區(qū),一幀填充完后,再交給系統(tǒng)的api進行繪制。至于精靈的渲染,過程也是一樣的,只是精靈的渲染要完全按照文檔來,還是有些繁瑣了,而且文檔有些地方語焉不詳,見過其它幾個模擬器也都是不同的實現(xiàn)。之前的做法是直接在預渲染掃描線填充到緩沖區(qū)一次,大部分游戲都沒有問題,直到忍者神龜2,精靈沒有顯示出來,后面發(fā)現(xiàn)原因是精靈的圖案表在可見掃描線渲染過程中才填充,按理來說一般圖案表在一幀渲染前提前準備好了,但這游戲偏偏不這么干就沒辦法了,解決部分就是在可見掃描線再進行精靈像素的拉取。PPU這一塊主要關鍵點就這些了。
APU
即Audio Processing Unit,音頻處理單元實際是2A03CPU的一部分,不過實現(xiàn)時還是當作單獨的硬件。要實現(xiàn)聲音處理的硬件,還是要先清楚聲音是怎樣產生和傳播的。人耳能聽到聲音是因為物體振動影響到了空氣的波動,進而影響到了耳膜振動,接著耳膜發(fā)出信號傳輸?shù)酱竽X的聽覺神經,這樣人就感知到了聲音。對于計算機,因為只能識別二進制,想要聽到人的聲音,首先是人肺部流出的空氣影響到聲帶的振動,帶動空氣的振動,從而影響到麥克風等設備內部的聲音傳感器內的薄膜振動導致產生了電壓的變化,這樣也就把自然界中的模擬信號轉換成了計算機可以識別的電信號(數(shù)字信號)。可以看到,這個過程中計算機只是感知到了信號的變化,但它根本不知道這是干嘛的。所以還是由人來控制,將計算機收集到的信號保存下來。接著將保存的電信號再傳輸?shù)揭繇懙仍O備。以揚聲器為例,電信號使得線圈通過電流后產生了磁場,而設備一般會攜帶一個固定的磁鐵,兩個磁場互相作用影響了線圈的振動,最后使與線圈連接在一起的鼓膜振動發(fā)出了聲音。然后看看實現(xiàn)APU模塊需要的過程。
- 1.將幾個聲音通道產生的數(shù)字信號轉換為模擬信號->混音器混合模擬信號產生聲音
如果是原來的硬件到這里就完了,但模擬器還需要一個過程,因為不同音頻設備驅動方面都有較大差異,所以也沒辦法簡單的直接使用自己的硬件輸出聲音。 - 2.對混音器輸出的模擬信號進行采集(當然這個模擬信號也是直接根據公式算出的0.0-1.0之間的小數(shù),不算真正意義的模擬信號)。也就是一個完整的【采樣->量化->編碼】,因為模擬信號是在一段連續(xù)的時間里不停的產生,采樣就是在這斷時間內間隔的采集聲音樣本,將時間離散化;接著就是量化,采集到的聲音在相鄰的樣本之間,聲音的幅度還是可以有無數(shù)個,所以需要將幅度也進行離散化,也就是將聲音的幅度變化控制在一個具體有限的范圍內;最后就是編碼,也就是將樣本按特定的規(guī)則組織。這樣采集到的數(shù)據就可以交給本機的硬件去進行播放了。
具體到FC,一共有5個聲音通道,2個方波(Pulse)、1個三角波(Triangle)、1個噪音(Noise)、1個增量調整通道(DMC)。方波和三角波用來控制游戲的背景聲音和主旋律,噪音一般用作打擊音效,DMC可以用來輸出DPCM的聲音樣本,一般用作特殊音效。
時鐘周期
同樣模擬硬件少不了的就是時鐘的控制,APU里面各個組件用的比較多的是Divider,可以把它當作一個定時器,倒計時完成后,會觸發(fā)各個組件內部產生一個時鐘周期的變化。APU內部有一個幀計數(shù)器(Frame Counter),用來控制其它組件的時鐘周期,注意不要和圖形渲染的幀搞混了,兩者沒有關系。一幀為14915/18641(取決于寄存器的控制位是4步還是5步模式)個APU周期,而1APU周期=2CPU周期,所以整個APU還是跟隨著CPU指令的執(zhí)行來進行時鐘周期的控制。至于其它的,文檔基本寫得挺詳細了,就不多說了。
https://wiki.nesdev.com/w/index.php/APU
中斷
前面說過,CPU其實就相當于一個死循環(huán),通電后總是在做【取指令->指令譯碼->執(zhí)行指令】這樣重復的過程。 CPU內部的指令指針寄存器PC保存的就是下一條要執(zhí)行的指令的地址。那問題來了,程序該怎么把起始地址信息告訴CPU呢?答案就是中斷。首先必須明確的,機器再高級始終是機器,只會按固定的規(guī)則辦事。對于6502CPU,通電啟動時會主動觸發(fā)一個RESET中斷,接著CPU會從固定的內存地址來讀取中斷處理程序并把該地址放到PC寄存器,所以只要在這個地方保存程序加載到內存后的起始地址,接下來CPU就會從程序的起始地址開始執(zhí)行。直接按字面意思,中斷就是打斷當前執(zhí)行的指令。
6502中斷一共有4種,RESET、IRQ、BRK、NMI。RESET前面已經說過了。IRQ一般是硬件所產生的,比如APU(音頻處理單元)、Mapper(游戲卡帶上面攜帶的用來作內存映射的額外芯片),可通過設置CPU的標志位來屏蔽;BRK一般是軟件所產生的中斷,對應著6502匯編指令BRK;NMI(No-Maskable Interrupt )不可屏蔽中斷,是在PPU的不可見掃描線期間即V-Blank時產生的,可通過設置PPU的控制寄存器進行屏蔽。
不同的中斷實際是有額外的引腳連接到CPU的,但我們這里是模擬硬件,不用管這些,只實現(xiàn)發(fā)送中斷時要做的事情就可以了。從硬件層面來說,CPU執(zhí)行指令的時候,其它的硬件可以直接通過不同的線路發(fā)送信號給CPU,其它硬件的工作以及產生中斷更像是并行的,用多線程模擬合理一點,但這就增加了編碼的難度。而現(xiàn)在的CPU速度已經比6502高了成百上千倍了,使用單線程模擬完全沒任何問題,只需要每次執(zhí)行指令前檢查一下是否有新的中斷即可。 另外,因為中斷產生時需要打斷當前CPU下一條要執(zhí)行的指令,和函數(shù)調用一樣,所以程序中一般會先保存當前的PC、狀態(tài)寄存器等信息到內存,等中斷程序完成后再回到之前的位置。
Mapper
以前的FC游戲從主內存0x8000-0xFFFF,顯存0-0x1FFF,頂多就只能存儲40kb程序相關的資源了,早期的游戲也確實夠了。但可以了解到的是,后面的一些游戲,無論是聲音,游戲的畫面,游戲豐富的內容,這些只有40kb是遠不夠的。但FC硬件也固定了,所以后面任天堂就提供了游戲卡帶的擴展,稱為Mapper。也就是說游戲卡帶上有額外的一個芯片來進行內存映射,對于CPU和PPU來說,看到的內存空間始終那么大,但Mapper可以進行內存切換,也就是在某個時刻,將原來分配好的內存地址的程序或者圖案表切換成卡帶上面其它的,這樣就解決了40kb的限制。這一做法,使得FC游戲的體驗大大的提升,有些卡帶更是會擴展音源。Mapper大概有兩百多種,不過一些是某個Mapper的變種。FC也滿足二八原則,實現(xiàn)少量的Mapper就可以兼容大部分游戲了,游戲占比比較大的就是Mapper0-4了。
輸入設備
其它組件都已經實現(xiàn)了,輸入設備肯定不能少,不然游戲都玩不起來。輸入設備實際也是經過內存映射IO寄存器到0x4016和0x4017,分別對應玩家1和玩家2。游戲讀取手柄的狀態(tài),是定期地按照手柄順序A、B、選擇、開始、上、下、左、右不斷的獲取8個按鍵按下的狀態(tài)。所以實現(xiàn)普通的手柄控制,只需要用額外的空間存儲8個按鍵的狀態(tài),按下是1,釋放是0,最后將鍵盤上的按鍵映射到手柄的按鍵即可。參考
https://wiki.nesdev.com/w/index.php/Standard_controller
https://wiki.nesdev.com/w/index.php/Controller_reading_code
擴展
調試
寫模擬器畢竟不像普通的程序,調試起來還是沒那么容易的。所以可以實現(xiàn)一個輔助的6502匯編指令調試器進行調試,主要就是將一塊程序內存的機器碼反匯編成6502匯編程序,再實現(xiàn)名稱表、圖案表、SpriteRAM的可視化以及內存的dump。

存檔與讀檔
以前玩真機,畢竟頭疼的是,有些游戲關卡太長或者難度太大,就經常玩不到最后,電源就發(fā)熱嚴重了或者游戲機會偶爾抽風,一斷電啥都沒有了,每次都得重來。所以自己實現(xiàn)模擬器,存檔與讀檔是肯定是必須的。所謂存檔,實際就是把當前的內存保存現(xiàn)場,讀檔就是恢復現(xiàn)場。具體到FC,主要就是把主內存、VRAM與各個硬件的寄存器狀態(tài)、以及繪圖用到的緩沖區(qū)、Mapper都存儲下來。只是直接暴力的存儲占用空間有點大,一個游戲才幾十k,存檔卻多好幾倍了,不過對于現(xiàn)在的機器來說這點空間完全無關緊要。
畫質增強
這也是一個比較令人頭疼的問題,F(xiàn)C使用的不過是256x240分辨率的屏幕,而現(xiàn)在的屏幕基本1、2k分辨率起了,強行地拉伸像素塊邊緣就會有明顯的方塊感,這可不是我們的童年。經過調研,發(fā)現(xiàn)比較可靠實用的就是xBRZ圖像縮放算法,整體還是不錯的。
結尾
對于實現(xiàn)一個模擬器,主要就是對硬件要有足夠的理解,控制好各個組件之間時鐘周期的同步,通過以前的這么一個平臺,也可以一窺現(xiàn)在一些平臺的工作。原理了解了,具體的實現(xiàn)過程中可以有多種不同的方式。對于FC,由于硬件本身的資料不是完全開放的,而且比較有意思的是實現(xiàn)Mapper的時候,幾個不同地方的文檔有不同的實現(xiàn)。不過總有一些游戲你不知道會使用哪些奇葩特性,所以很難有模擬器能完美支持所有的游戲,一些模擬器也不是完全模擬硬件,對特殊的游戲會使用一些trick,不過這些對理解平臺的工作都無關緊要了。更多資料,英文基本就NesDev了,里面有個不算長的NesDoc寫得還可以。
http://nesdev.com/NESDoc.pdf
然后下面是我找得到的有用的中文資料。
http://rexq.me/2020/03/22/%E5%A6%82%E4%BD%95%E7%BC%96%E5%86%99%E4%B8%80%E4%B8%AAFC%E6%A8%A1%E6%8B%9F%E5%99%A8/
https://www.cnblogs.com/chunyueye/p/12261027.html
https://zhuanlan.zhihu.com/p/34144965
https://zhuanlan.zhihu.com/p/43999178
https://blog.chaofan.io/archives/如何制作nes模擬器
http://www.360doc.com/content/18/0116/09/33564766_722316244.shtml






