前言
前段時間無意中瀏覽到了描述FC(Family Computer)游戲的一些工作原理的博客,瞬間勾起了兒時對小霸王游戲機(jī)如癡如醉的過往坠狡,看到網(wǎng)上從以前游戲卡帶中導(dǎo)出來的游戲:超級瑪麗、魂斗羅等才幾十k大小菩收,大的也不過幾百k抓艳,極少數(shù)超過1M的,而這點(diǎn)空間現(xiàn)在的一張普通質(zhì)量的圖片可能都存不下跪者。所以想到了不如自己實(shí)現(xiàn)一個FC模擬器,一探它背后神秘的魔法熄求。即是重新追憶一下那些逝去的時光渣玲,又是對計算基礎(chǔ)知識很好的一個實(shí)踐。然現(xiàn)實(shí)很骨感弟晚,網(wǎng)上能找得到的相關(guān)硬件資料太少忘衍,資料比較全面的就是NesDev,但是全英文卿城,而且很多東西過于詳細(xì)枚钓,花了很長時間讀可能還找不到重點(diǎn)在哪里,除非做過類似的東西否則很難就此上手瑟押。所以寫篇博客記錄一下整個實(shí)現(xiàn)的流程搀捷。
總覽
所謂模擬器,實(shí)際就是我們在軟件層面來模擬硬件的工作勉耀,也就是說實(shí)現(xiàn)一個FC程序的執(zhí)行環(huán)境。要實(shí)現(xiàn)這么一個模擬器蹋偏,首先就是要清楚FC主要由哪些硬件構(gòu)成以及各個硬件是如何協(xié)作的便斥,反正現(xiàn)代的計算機(jī)基本都是基于馮洛伊曼體系啦。與我們一般的電腦一樣威始,主要就是CPU枢纠、內(nèi)存、顯卡黎棠、輸入設(shè)備晋渺、輸出設(shè)備幾部分镰绎。具體到FC,以前游戲機(jī)都是插卡的木西,所以卡帶也要算一部分畴栖。接下來描述一下大致的過程,主機(jī)通電后
- 卡帶的加載
游戲的數(shù)據(jù)主要包括圖像和程序八千,所以第一步首先就是根據(jù)固定的頭部解析nes文件(卡帶硬件導(dǎo)出的)吗讶,拿到相關(guān)的數(shù)據(jù)后,就可以開始將程序和圖像分別裝載到主內(nèi)存和顯存恋捆,而CPU主要就是和主內(nèi)存打交道照皆,包括其它的硬件也都是通過CPU內(nèi)部的IO寄存器映射到主內(nèi)存,IO寄存器可以當(dāng)作是CPU與外部設(shè)備通過總線連接的端點(diǎn)沸停。當(dāng)然具體實(shí)現(xiàn)時膜毁,直接通過操作具體的某個內(nèi)存地址來實(shí)現(xiàn)與其它硬件的通信就可以了。
- 卡帶的加載
- CPU運(yùn)行程序代碼
CPU(Center Processing Unit)對計算機(jī)而言愤钾,始終是最核心的硬件瘟滨,其它組件的運(yùn)行都是通過它來帶動的。對于FC绰垂,通電重置后會觸發(fā)一個RESET中斷室奏,也就是會將CPU的指令指針寄存器PC(Program Counter)跳轉(zhuǎn)到RESET中斷存儲的地址,因?yàn)镻C總是存儲的程序下一條要執(zhí)行指令的內(nèi)存地址嘛劲装,所以程序也就從這里開始執(zhí)行了胧沫。執(zhí)行的過程也就是[取指令]->[指令譯碼]->[取操作數(shù)]->[計算]。
- CPU運(yùn)行程序代碼
- PPU開始讀取圖像數(shù)據(jù)并進(jìn)渲染
圖像處理單元PPU(Picture Processing Unit)也就是我們常說的顯卡占业,主要就是用來處理圖形的渲染绒怨、窗口的顯示,一般顯卡會有自己單獨(dú)的一塊內(nèi)存谦疾,主要用來存儲圖像以及相關(guān)的信息南蹂。對于FC,PPU會定期從顯存中抓取游戲背景念恍、精靈的數(shù)據(jù)六剥,并渲染到窗口上。這里說的定期峰伙,實(shí)際上就是需要與CPU的時鐘周期進(jìn)行同步啦疗疟,這樣才能保證獲取到的數(shù)據(jù)是正確的。一般來說瞳氓,PPU需要有比CPU更快的執(zhí)行速度策彤,CPU除了執(zhí)行指令,還有對各個硬件進(jìn)行協(xié)調(diào)的功能,對于PPU而言店诗,也是通過幾個IO寄存器來進(jìn)行的裹刮,一般CPU執(zhí)行一次,屏幕至少要抓取幾個像素點(diǎn)庞瘸。
- PPU開始讀取圖像數(shù)據(jù)并進(jìn)渲染
-
輸入與輸出
輸入設(shè)備主要包括手柄捧弃、光槍之類的,和現(xiàn)在的計算機(jī)不太一樣恕洲,我們用鍵盤輸入時一般都是通過中斷來通知CPU塔橡,鍵盤的中斷處理程序接著再根據(jù)輸入的掃描碼翻譯成鍵盤上對應(yīng)按鍵值。對于FC而言霜第,是程序通過代碼定期(一般是在一幀繪制完成后)按手柄按鍵順序從內(nèi)存映射的IO寄存器中讀取輸入的值葛家。輸出主要就是包括屏幕的像素點(diǎn)以及聲音。
各個組件的協(xié)作見上圖泌类,接下來就依次介紹一下各個部以及一些額外的擴(kuò)展癞谒。
-
游戲卡帶
這部分需要讀取nes文件并按部就班的解析和存儲相關(guān)的信息,nes文件實(shí)際就是FC平臺的可執(zhí)行文件刃榨,與Windows上的PE(常見的.exe)弹砚、Linux上面的ELF可執(zhí)行文件一樣,都不是純二進(jìn)制程序枢希,額外包含了一些固定的頭部信息桌吃。這是平臺所規(guī)定的,需要從中解析出實(shí)際的程序才能放到CPU上面執(zhí)行“危現(xiàn)在需要關(guān)注的信息
- Mapper Id
FC卡帶上自帶的額外擴(kuò)展的芯片Mapper的id茅诱,后面再詳細(xì)介紹。
- Mapper Id
- CHR-ROM/VROM
即Charater-ROM, 這部分就是存儲的游戲中所需要用到的圖像信息搬卒,或者換個說法瑟俭,也就是常說的字體庫。程序包那么小的體積契邀,存圖片肯定是不現(xiàn)實(shí)的摆寄,它是存儲游戲中背景和精靈需要引用的圖案的點(diǎn)陣,需要裝載到顯存坯门。
- CHR-ROM/VROM
- PRG-ROM
即Program-ROM微饥,這部分存儲的就是游戲程序編譯后的二進(jìn)制代碼,需要裝載到主內(nèi)存古戴。
- PRG-ROM
- Mirroring-Type
鏡像類型欠橘,主要是決定了程序運(yùn)行過程顯存中存儲的背景渲染信息的那部分內(nèi)存是如何規(guī)劃,在PPU部分再詳細(xì)解釋允瞧。
- Mirroring-Type
知道大概需要哪些東西了简软,就可以先定義一個獲取該文件信息相關(guān)的接口了。
public interface INesLoader {
// 獲取16KB PRG(程序)數(shù)據(jù)的頁數(shù)
int getPRGPageCount();
// 獲取8KB CHR(圖像)數(shù)據(jù)的頁數(shù)
int getCHRPageCount();
// 通過索引獲取對應(yīng)的PRG數(shù)據(jù)塊
byte[] getPRGPageByIndex(int index);
// 通過索引獲取對應(yīng)的CHR數(shù)據(jù)塊
byte[] getCHRPageByIndex(int index);
// 獲取屏幕的鏡像類型
int getMirroringType();
int getMapperId();
String getFileMD5();
}
具體文件格式可以看看這篇博客
https://zhuanlan.zhihu.com/p/44035613
CPU
FC使用的是2A03 CPU述暂,主要在6502 CPU的基礎(chǔ)上擴(kuò)展了對音頻處理(pAPU)的支持痹升,所以CPU使用的其實(shí)仍是6502的匯編指令集。前面說過畦韭,模擬器主要模擬的是硬件疼蛾,是FC程序的執(zhí)行環(huán)境。FC程序也是直接使用的6502匯編進(jìn)行的編程艺配,不過它本身用什么也不重要察郁,關(guān)鍵點(diǎn)在于編譯后的程序最后是在什么硬件上運(yùn)行的,因?yàn)榫幾g后的都是二進(jìn)制转唉,我們需要解決的是把這些二進(jìn)制機(jī)器碼對應(yīng)到CPU所支持的指令集皮钠,這樣程序才能正常在該CPU上運(yùn)行。(這里需要與Win上面的PE赠法、Linux的ELF可執(zhí)行文件區(qū)別開的是麦轰,Win和Linux上面編譯后的程序雖然都是CPU可識別的機(jī)器碼,但它們畢竟是運(yùn)行在操作系統(tǒng)上砖织,程序運(yùn)行需要的資源的分配與管理款侵、相應(yīng)的系統(tǒng)調(diào)用都依賴該操作系統(tǒng)的內(nèi)核,所以即使最終都是同一套機(jī)器碼侧纯、在同樣的硬件上面運(yùn)行新锈,也很難做到跨平臺,重點(diǎn)在于這些程序需要另一套程序(內(nèi)核)來進(jìn)行管理)眶熬,而FC的程序不需要額外的管家妹笆,直接在硬件上面裸奔,所以直接將程序裝載到主內(nèi)存就可以跑了聋涨。因?yàn)橐MCPU和內(nèi)存晾浴,所以基本的思路就是對二進(jìn)制的FC程序進(jìn)行解釋執(zhí)行就可以了。接著先看看CPU直接訪問的主內(nèi)存各部分是怎么劃分的
主內(nèi)存布局
RAM
實(shí)際就是程序運(yùn)行期間可以完全供自己操作的內(nèi)存牍白,不過前面1kb(0-0x200)也是有固定用途的脊凰,ZeroPage指內(nèi)存的第一頁,臨時存放一些數(shù)據(jù)茂腥,CPU可以用來快速尋址和執(zhí)行狸涌;棧就是用來存放計算時需要臨時保存的一些值,或者子程序(函數(shù))調(diào)用和觸發(fā)中斷時需要將PC的下一條要執(zhí)行指令的地址最岗、狀態(tài)寄存器等信息保存在棧中帕胆,等待執(zhí)行完后再恢復(fù)現(xiàn)場; RAM(0x0200-0x0800)就是沒有固定用途可任意操作的了。而0x0800-0x2000內(nèi)存地址實(shí)際都是前面0-0x800的鏡像般渡,也就是說訪問地址0x800實(shí)際是訪問到了地址0懒豹,以此類推芙盘。所以可以看到,供程序自由發(fā)揮的也就2KB(0-0x0800)脸秽。
I/O Regesters
0x2000-0x4020主要包含了PPU奔浅、APU(Audio Processing Unit)匹颤、手柄等輸入設(shè)備的IO寄存器的內(nèi)存映射,直接對映射的內(nèi)存地址進(jìn)行讀寫就可實(shí)現(xiàn)對這些設(shè)備的控制以及狀態(tài)信息的獲取〈圉可以看到涡驮,前0x4020個字節(jié)對所有程序的內(nèi)存都是這樣規(guī)劃的固耘。
Expansion ROM 與 SRAM
Expansion ROM留作卡帶程序的擴(kuò)展空間;SRAM(Save RAM)主要用來給某些存在存檔的游戲預(yù)留的空間眷射,這兩部分暫時都不用管。
PRG-ROM
游戲卡帶那部分提過雕沿,0x8000-0xFFFF這32KB空間用來存儲游戲程序代碼练湿。
關(guān)于CPU,還有幾點(diǎn)需要了解的审轮。
- 之前圖已經(jīng)給出了鞠鲜,2A03 CPU擁有16位的地址總線 ,可尋址的范圍是2字節(jié)断国,即0x0000-0xFFFF贤姆,主內(nèi)存總共空間大小為64KB,默認(rèn)字節(jié)序采用小端序稳衬;而數(shù)據(jù)和控制總線都是8位的霞捡,所以具體操作內(nèi)存的時候?qū)嶋H都是以字節(jié)為單位進(jìn)行的。
- 實(shí)現(xiàn)CPU首先需要實(shí)現(xiàn)CPU的寄存器薄疚,寄存器主要包括PC(Program Counter)寄存器碧信、SP(Stack Poninter)寄存器、A(Accumulator)累加器街夭、X和Y索引寄存器以及處理器狀態(tài)寄存器砰碴。棧指針寄存器就是始終指向當(dāng)前棧頂?shù)奈恢谩PU指令實(shí)現(xiàn)的過程中也需要對狀態(tài)寄存器的標(biāo)志位進(jìn)行對應(yīng)的改變板丽。
- 尋址模式 呈枉,也就是說看匯編指令(機(jī)器碼)使用什么樣的方式尋址,一共尋址模式包含10來種埃碱,也都是和機(jī)器碼一起已經(jīng)定義好的猖辫,至于程序使用哪種方式尋址程序開發(fā)人員自己發(fā)揮。所以具體實(shí)現(xiàn)時可以先完成各個尋址模式砚殿,然后再看各個指令的機(jī)器碼分別對應(yīng)哪種就行了啃憎。不管哪種尋址模式,最終目的都是拿到內(nèi)存地址最終的數(shù)值似炎,進(jìn)行運(yùn)算辛萍。這部分也純粹是一個體力活悯姊,前面說過具體操作都以字節(jié)為單位,指令的機(jī)器碼也是1字節(jié)贩毕,所以最多也只能有256個指令挠轴。實(shí)際6502CPU的指令只有幾十個,剩下的要么是組合了不同的尋址模式(同一指令耳幢,尋址模式不同對應(yīng)的機(jī)器碼也不同),要么是留作擴(kuò)展欧啤。另外睛藻,指令包括官方指令和非官方指令,暫時實(shí)現(xiàn)官方記載的指令就可以支持大部分游戲了邢隧。根據(jù)文檔店印,指令的排列還是有一定規(guī)律的,所以最好參照已有的模擬器代碼來倒慧。測試可以寫單元測試按摘,可以用專門的測試Rom,倒比較好看執(zhí)行結(jié)果纫谅。
- 時鐘周期炫贤,既然模擬CPU,必須得控制好它的時鐘周期付秕。2A03CPU的主頻才1.78MHz兰珍,要知道現(xiàn)在的CPU基本都是GHz起步了,這差了成百上千倍了询吴,要是不加以控制掠河,那么游戲里面指令執(zhí)行的速度就會快到飛起,那樣根本沒法玩猛计。所以運(yùn)行過程中需要計算一下主頻唠摹,首頁要清楚的是主頻(也稱CPS, Cycle Per Second)實(shí)際是指CPU每秒度過的時鐘周期數(shù),一般一條指令需要1-幾個時鐘周期不等奉瘤,看看以下的公式
平均每個時鐘周期花費(fèi)的時間 = 1 / 每秒度過的時鐘周期數(shù)
程序運(yùn)行時間 = CPU指令總的時鐘周期數(shù) * 每個周期花費(fèi)的時間
所以要算出當(dāng)前的主頻
主頻 = CPU指令總的時鐘周期數(shù) / 程序運(yùn)行時間
而CPU指令的周期數(shù)和程序運(yùn)行的時間都是運(yùn)行過程中需要進(jìn)行統(tǒng)計的勾拉, 算出當(dāng)前的主頻后,直接一個While循環(huán)盗温,當(dāng)前的大于目標(biāo)主頻就直接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);
}
}
看到這里肌访,應(yīng)該也有了想法找默,一般的模擬器都有幾倍加速的,其實(shí)加大下目標(biāo)主頻就行了吼驶,反正怎么調(diào)還是比現(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;
}
}
就是從內(nèi)存讀取操作碼,然后對應(yīng)到指令风钻,如果指令還需要操作數(shù)顷蟀,就繼續(xù)在PC指向的地址取值,并移動PC指針到下一個地址骡技。至于內(nèi)存鸣个,因?yàn)槎鄠€地方要用到,所以也可以抽象出一個接口來布朦。
public interface IMemory {
// 從地址address讀取1字節(jié)數(shù)據(jù)
int readByte(int address);
// 寫1字節(jié)數(shù)據(jù)到地址address
void writeByte(int address, int value);
int getSize();
}
總的來說囤萤,CPU沒有那么多彎彎繞繞,具體的指令無非就是一些基本的運(yùn)算以及內(nèi)存是趴、寄存器的復(fù)制與讀寫涛舍,對著文檔來就好。指令的實(shí)現(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主要用來做圖形渲染唆途。要清楚圖形是怎么渲染的富雅,首先需要了解的是,以前的大頭電視機(jī)是怎么工作的肛搬。借一張圖看看
圖像其實(shí)是從屏幕左上角開始從左到右一個一個像素點(diǎn)進(jìn)行渲染的没佑,實(shí)際過程是電視機(jī)背后的電子槍發(fā)射出一個電子,而電視里面都是有一個大線圈温赔,通電后產(chǎn)生了磁場图筹,接著電子經(jīng)過磁場的偏移打到了屏幕上的熒光材料從而產(chǎn)生了可見圖形。而屏幕顯示的彩色让腹,是由紅远剩、綠、藍(lán)RGB三基色進(jìn)行混合骇窍。即三支電子槍發(fā)射出不同的電子瓜晤,轟擊到屏幕的三色熒光粉上,進(jìn)行混合后就能產(chǎn)生不同的顏色腹纳。
圖像經(jīng)電子槍的掃描線從左到右渲染痢掠,一行完成后又要回到下一行的最左邊,回到左邊的這個時間段沒有像素點(diǎn)被渲染嘲恍,這個過程稱為H-Blank(Horizontal Blank)足画。而整個屏幕被渲染完成后,又需要從右下角回到左上角開始繪制下一幀佃牛,這個時間段也沒有像素點(diǎn)渲染淹辞,稱為V-Blank(Vertical Blank)。FC的圖像是以Tile(像素塊)為基本單位的俘侠,一個Tile為8x8的像素塊象缀。
PPU有單獨(dú)的一塊顯存VRAM(Video RAM)蔬将,接下來看看VRAM的內(nèi)存布局
VRAM內(nèi)存布局
調(diào)色板
即PaletteRAM indexes(0x3F00-0X3F1F)。系統(tǒng)調(diào)色板構(gòu)成了FC能顯示的64種顏色央星,而分別存儲在VRAM中0x3F00-0x3F0F和0x3F10-0x3F1F(后面的0x3F20-0x3FFF都是鏡像)位置的是16字節(jié)的背景調(diào)色板與16字節(jié)的Sprite調(diào)色板的索引霞怀,通過1字節(jié)來索引到系統(tǒng)調(diào)色板。調(diào)色板是在系統(tǒng)中寫死的莉给,不同模擬器顏色的差異也就是從這里來的毙石。具體實(shí)現(xiàn)時,直接獲取到調(diào)色板顏色對應(yīng)的RGB值颓遏,再進(jìn)行渲染徐矩。
圖案表(字體庫)
即PatternTable(0-0x2000),圖案表存儲的是游戲中背景和Sprite需要用到的圖案州泊,分為兩個4kb,由PPU的控制寄存器指定是給背景還是Sprite使用漂洋。圖案表以16字節(jié)的方式步進(jìn)的遥皂。看看下圖刽漂,是冒險島3首頁的圖案表
8x8的像素塊一共64個像素點(diǎn), 那如何確定像每個素點(diǎn)的顏色呢? 答案就是這16字節(jié), 16字節(jié)分成2個8字節(jié),即兩個64位,從這兩個64位各拿出1位來組成了4位中的低兩位. 這里的4位是干啥用的呢?前面說過0x3F00-0x3F1F的兩個16字節(jié)的調(diào)色板索引,用來索引到系統(tǒng)調(diào)色板. 所以對于背景與精靈的顏色,就需要用至少4位,(總共2^4=16)才能訪問到這16字節(jié). 整個過程就是
- 用4位確定到調(diào)色板索引的地址
- 通過調(diào)色板索引的地址讀取到1字節(jié)的調(diào)色板索引
- 最后再用該索引找到系統(tǒng)調(diào)色板中對應(yī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趴腋,應(yīng)該只要一個名稱表就夠了吧吊说?這就是FC神奇的地方了,這樣設(shè)計的目的是為了方便做屏幕滾動优炬,現(xiàn)在的游戲屏幕滾動一般都是直接對同一塊空間進(jìn)行操作颁井,也就是整塊圖像緩存空間重新刷新填充。而FC是直接通過修改PPU內(nèi)部的寄存器在名稱表上面進(jìn)行偏移來達(dá)到滾動的效果蠢护,所以整塊空間不需要頻繁改動雅宾,后面再詳細(xì)說明。最后剩余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(實(shí)際有一個像素塊不完整)的像素塊,昏苏,每個8x8像素塊就再使用圖案表中的低2位+這個作為高2位尊沸,去確定到一個調(diào)色板索引的地址。到這里就可以發(fā)現(xiàn)了一個問題,贤惯,就是沒辦法為每個像素點(diǎn)確定到所有的調(diào)色版索引的地址洼专,因?yàn)?x8像素塊每個點(diǎn)中的高兩位其實(shí)都是一樣的,但前面說過了實(shí)際FC也是以8x8像素塊為基本單位孵构,確定了圖案形狀后屁商,每個像素塊中的像素點(diǎn)還能有幾種變化就夠了。另外颈墅,這里的屬性表是給背景使用的蜡镶,而精靈的屬性表存儲在SPR-RAM中。
SPR-RAM
即Sprite-RAM恤筛,是PPU給精靈使用的單獨(dú)一塊256字節(jié)的空間官还,每個精靈占用4字節(jié),也就是說屏幕上最多顯示64個精靈毒坛。精靈是指的屏幕上面的活動塊望伦,比如游戲的角色或者狀態(tài)欄一直需要變化的部分一般就是使用的多個精靈組合成的〖逡螅看看馬里奧就是由8個8x8的精靈像素塊組成的
再看看4字節(jié)主要儲存了哪些信息
- Byte0存儲的是精靈左上角的y坐標(biāo)-1屯伞。
- Byte1存儲了精靈像素塊在圖案表中的編號。
- Byte2存儲了資源信息
bit0-1存儲的就是確定調(diào)色版索引地址的高2位
bit5決定了精靈的顯示對于背景的優(yōu)先級豪直。
bit6表明精靈是否是要水平翻轉(zhuǎn)
bit7表明精靈是否是要存儲翻轉(zhuǎn) - Byte3存儲了精靈左上角的X坐標(biāo)
PPU渲染
前面介紹了相關(guān)的內(nèi)存布局愕掏,現(xiàn)在來看看具體的渲染是怎樣的。屏幕渲染的規(guī)則和采用的制式有關(guān)顶伞,F(xiàn)C大部分資料都是使用NTSC制式的饵撑,所以優(yōu)先選取這個,畢竟對于了解工作原理來說唆貌,這都不是重點(diǎn)滑潘。渲染過程中,每幀掃描線一個有262條锨咙,每秒渲染60幀语卤。
- 0-239這240條是屏幕上可見的掃描線(屏幕是256x240分辨率,高為240),在這個過程中需要進(jìn)行屏幕像素的渲染粹舵。
- 240-260為V-Blank钮孵,這個時間段是不可見掃描線,主要用來生成nmi眼滤、獲取手柄的狀態(tài)信息巴席、為下一幀的渲染做好準(zhǔn)備。
- 第261條是預(yù)處理掃描線诅需,在這個掃描線開始時需要結(jié)束V-Blank漾唉,清除其它的一些狀態(tài)信息。這條掃描線和可見掃描線一樣需要更新相關(guān)的寄存取信息堰塌,但不做任何像素的渲染赵刑。
時鐘周期
每條掃描線一共需要花費(fèi)341個PPU時鐘周期,而1CPU周期=3PPU周期场刑。這里就需要與CPU周期進(jìn)行同步了般此,同步有很多種方式,可以直接渲染完一條掃描線牵现,CPU就走341/3個時鐘周期铐懊;或者渲染完一幀,CPU走261*341/3個時鐘周期施籍。采用精度最高的方式就是走1個CPU周期居扒,PPU走3個時鐘周期概漱。每個周期渲染一個像素點(diǎn)丑慎,當(dāng)然按照tile也就是1行8個像素點(diǎn)為單位來渲染比較方便,每隔8個時鐘周期瓤摧,一次渲染8個像素點(diǎn)竿裂。屏幕寬是256個像素點(diǎn),渲染完背景的一行就需要256個時鐘周期照弥,接下來257-320是HBlank腻异,也可以不進(jìn)行任何渲染,再往后可以提前渲染下一行的前兩個8像素的塊这揣。完整的渲染過程
- 1.抓取當(dāng)前渲染到的像素塊的編號(當(dāng)前屏幕左上角名稱表地址+當(dāng)前位置在屏幕內(nèi)像素塊個數(shù)的偏移)悔常,用編號去獲取到圖案表中的像素塊(16字節(jié)步進(jìn))。
- 2.像素塊是8x8给赞,所以還要獲取到當(dāng)前掃描線在8x8像素塊里面偏移的行和列是多少机打。這樣就可以獲取到像素點(diǎn)的低2位。
- 3.抓取當(dāng)前像素塊所在資源表的地址片迅,獲取到高2位残邀,與前面的低2位一起就能組合出調(diào)色板索引的地址,最后根據(jù)調(diào)色板索引獲取到系統(tǒng)調(diào)色板的RGB值。
PPU滾動
上面所述的偏移芥挣,其實(shí)就是PPU實(shí)現(xiàn)屏幕滾動的關(guān)鍵驱闷,下圖來自NesDev
滾動其實(shí)就是修改在名稱表上面的偏移來進(jìn)行的,具體實(shí)現(xiàn)時按理來說可以直接根據(jù)PPU寄存器的內(nèi)存映射來(0x2000-0x2007)空免,但關(guān)鍵在于游戲程序不按你想的來空另,就會出現(xiàn)需要抓取的資源沒有更新」难眩可以看看這篇博客
https://gridbugs.org/zelda-screen-transitions-are-undefined-behaviour/
所以最佳的方式是實(shí)現(xiàn)PPU內(nèi)部的寄存器v痹换、t、x都弹、w娇豫,在進(jìn)行PPU的內(nèi)存映射地址操作過程中對這幾個寄存器進(jìn)行維護(hù)即可。接下來看看這幾個寄存器
- v: 當(dāng)前的VRAM地址畅厢。
- t: 臨時的VRAM地址冯痢,也可以被看作是屏幕窗口左上角的地址。
- x: 精準(zhǔn)x滾動(3bit)框杜。
- w: 第一次或第二次寫時觸發(fā)(1bit)浦楣。
CPU使用主內(nèi)存的0x2007讀寫數(shù)據(jù)時,PPU使用的就是當(dāng)前的VRAM地址咪辱,它也被用來獲取名稱表的數(shù)據(jù)以繪制到屏幕上振劳。在用名稱表的數(shù)據(jù)進(jìn)行渲染時,也會更新當(dāng)前的VRAM地址油狂,保證獲取的數(shù)據(jù)是正確的历恐。v和t寄存器由15位組成
- 0-4bit: 模糊x滾動(名稱表中當(dāng)前像素塊位置的x軸偏移)。
- 5-9bit: 模糊y滾動(名稱表中當(dāng)前像素塊位置的y軸偏移)专筷。
- 10-11bit: 名稱表選擇弱贼。
- 12-14bit: 精準(zhǔn)Y軸滾動。
而x寄存器和v磷蛹、t寄存器的12-14bit就是當(dāng)前通過名稱表的像素塊編號找到的8x8像素塊具體的像素點(diǎn)偏移的位置吮旅。知道了這幾個,剩下的直接根據(jù)wiki來就可以了
http://wiki.nesdev.com/w/index.php/PPU_scrolling
具體渲染的時候?qū)?dāng)前屏幕每個像素點(diǎn)的RGB值放到一個緩沖區(qū)味咳,一幀填充完后庇勃,再交給系統(tǒng)的api進(jìn)行繪制。至于精靈的渲染槽驶,過程也是一樣的责嚷,只是精靈的渲染要完全按照文檔來,還是有些繁瑣了捺檬,而且文檔有些地方語焉不詳再层,見過其它幾個模擬器也都是不同的實(shí)現(xiàn)贸铜。之前的做法是直接在預(yù)渲染掃描線填充到緩沖區(qū)一次,大部分游戲都沒有問題聂受,直到忍者神龜2蒿秦,精靈沒有顯示出來,后面發(fā)現(xiàn)原因是精靈的圖案表在可見掃描線渲染過程中才填充蛋济,按理來說一般圖案表在一幀渲染前提前準(zhǔn)備好了棍鳖,但這游戲偏偏不這么干就沒辦法了,解決部分就是在可見掃描線再進(jìn)行精靈像素的拉取碗旅。PPU這一塊主要關(guān)鍵點(diǎn)就這些了渡处。
APU
即Audio Processing Unit,音頻處理單元實(shí)際是2A03CPU的一部分祟辟,不過實(shí)現(xiàn)時還是當(dāng)作單獨(dú)的硬件医瘫。要實(shí)現(xiàn)聲音處理的硬件,還是要先清楚聲音是怎樣產(chǎn)生和傳播的旧困。人耳能聽到聲音是因?yàn)槲矬w振動影響到了空氣的波動醇份,進(jìn)而影響到了耳膜振動,接著耳膜發(fā)出信號傳輸?shù)酱竽X的聽覺神經(jīng)吼具,這樣人就感知到了聲音僚纷。對于計算機(jī),因?yàn)橹荒茏R別二進(jìn)制拗盒,想要聽到人的聲音怖竭,首先是人肺部流出的空氣影響到聲帶的振動,帶動空氣的振動陡蝇,從而影響到麥克風(fēng)等設(shè)備內(nèi)部的聲音傳感器內(nèi)的薄膜振動導(dǎo)致產(chǎn)生了電壓的變化痊臭,這樣也就把自然界中的模擬信號轉(zhuǎn)換成了計算機(jī)可以識別的電信號(數(shù)字信號)∫阏可以看到趣兄,這個過程中計算機(jī)只是感知到了信號的變化绽左,但它根本不知道這是干嘛的悼嫉。所以還是由人來控制,將計算機(jī)收集到的信號保存下來拼窥。接著將保存的電信號再傳輸?shù)揭繇懙仍O(shè)備戏蔑。以揚(yáng)聲器為例,電信號使得線圈通過電流后產(chǎn)生了磁場鲁纠,而設(shè)備一般會攜帶一個固定的磁鐵总棵,兩個磁場互相作用影響了線圈的振動,最后使與線圈連接在一起的鼓膜振動發(fā)出了聲音改含。然后看看實(shí)現(xiàn)APU模塊需要的過程情龄。
- 1.將幾個聲音通道產(chǎn)生的數(shù)字信號轉(zhuǎn)換為模擬信號->混音器混合模擬信號產(chǎn)生聲音
如果是原來的硬件到這里就完了,但模擬器還需要一個過程,因?yàn)椴煌纛l設(shè)備驅(qū)動方面都有較大差異骤视,所以也沒辦法簡單的直接使用自己的硬件輸出聲音鞍爱。 - 2.對混音器輸出的模擬信號進(jìn)行采集(當(dāng)然這個模擬信號也是直接根據(jù)公式算出的0.0-1.0之間的小數(shù),不算真正意義的模擬信號)专酗。也就是一個完整的【采樣->量化->編碼】睹逃,因?yàn)槟M信號是在一段連續(xù)的時間里不停的產(chǎn)生,采樣就是在這斷時間內(nèi)間隔的采集聲音樣本祷肯,將時間離散化沉填;接著就是量化,采集到的聲音在相鄰的樣本之間佑笋,聲音的幅度還是可以有無數(shù)個翼闹,所以需要將幅度也進(jìn)行離散化,也就是將聲音的幅度變化控制在一個具體有限的范圍內(nèi)蒋纬;最后就是編碼橄碾,也就是將樣本按特定的規(guī)則組織。這樣采集到的數(shù)據(jù)就可以交給本機(jī)的硬件去進(jìn)行播放了颠锉。
具體到FC法牲,一共有5個聲音通道,2個方波(Pulse)琼掠、1個三角波(Triangle)拒垃、1個噪音(Noise)、1個增量調(diào)整通道(DMC)瓷蛙。方波和三角波用來控制游戲的背景聲音和主旋律悼瓮,噪音一般用作打擊音效,DMC可以用來輸出DPCM的聲音樣本艰猬,一般用作特殊音效横堡。
時鐘周期
同樣模擬硬件少不了的就是時鐘的控制,APU里面各個組件用的比較多的是Divider冠桃,可以把它當(dāng)作一個定時器命贴,倒計時完成后,會觸發(fā)各個組件內(nèi)部產(chǎn)生一個時鐘周期的變化食听。APU內(nèi)部有一個幀計數(shù)器(Frame Counter)胸蛛,用來控制其它組件的時鐘周期,注意不要和圖形渲染的幀搞混了樱报,兩者沒有關(guān)系葬项。一幀為14915/18641(取決于寄存器的控制位是4步還是5步模式)個APU周期,而1APU周期=2CPU周期迹蛤,所以整個APU還是跟隨著CPU指令的執(zhí)行來進(jìn)行時鐘周期的控制民珍。至于其它的襟士,文檔基本寫得挺詳細(xì)了,就不多說了嚷量。
https://wiki.nesdev.com/w/index.php/APU
中斷
前面說過敌蜂,CPU其實(shí)就相當(dāng)于一個死循環(huán),通電后總是在做【取指令->指令譯碼->執(zhí)行指令】這樣重復(fù)的過程津肛。 CPU內(nèi)部的指令指針寄存器PC保存的就是下一條要執(zhí)行的指令的地址章喉。那問題來了,程序該怎么把起始地址信息告訴CPU呢身坐?答案就是中斷秸脱。首先必須明確的,機(jī)器再高級始終是機(jī)器部蛇,只會按固定的規(guī)則辦事摊唇。對于6502CPU,通電啟動時會主動觸發(fā)一個RESET中斷涯鲁,接著CPU會從固定的內(nèi)存地址來讀取中斷處理程序并把該地址放到PC寄存器巷查,所以只要在這個地方保存程序加載到內(nèi)存后的起始地址,接下來CPU就會從程序的起始地址開始執(zhí)行抹腿。直接按字面意思岛请,中斷就是打斷當(dāng)前執(zhí)行的指令。
6502中斷一共有4種警绩,RESET崇败、IRQ、BRK肩祥、NMI后室。RESET前面已經(jīng)說過了。IRQ一般是硬件所產(chǎn)生的混狠,比如APU(音頻處理單元)岸霹、Mapper(游戲卡帶上面攜帶的用來作內(nèi)存映射的額外芯片),可通過設(shè)置CPU的標(biāo)志位來屏蔽将饺;BRK一般是軟件所產(chǎn)生的中斷贡避,對應(yīng)著6502匯編指令BRK;NMI(No-Maskable Interrupt )不可屏蔽中斷俯逾,是在PPU的不可見掃描線期間即V-Blank時產(chǎn)生的贸桶,可通過設(shè)置PPU的控制寄存器進(jìn)行屏蔽舅逸。
不同的中斷實(shí)際是有額外的引腳連接到CPU的桌肴,但我們這里是模擬硬件,不用管這些琉历,只實(shí)現(xiàn)發(fā)送中斷時要做的事情就可以了坠七。從硬件層面來說水醋,CPU執(zhí)行指令的時候,其它的硬件可以直接通過不同的線路發(fā)送信號給CPU彪置,其它硬件的工作以及產(chǎn)生中斷更像是并行的拄踪,用多線程模擬合理一點(diǎn),但這就增加了編碼的難度拳魁。而現(xiàn)在的CPU速度已經(jīng)比6502高了成百上千倍了惶桐,使用單線程模擬完全沒任何問題,只需要每次執(zhí)行指令前檢查一下是否有新的中斷即可潘懊。 另外姚糊,因?yàn)橹袛喈a(chǎn)生時需要打斷當(dāng)前CPU下一條要執(zhí)行的指令,和函數(shù)調(diào)用一樣授舟,所以程序中一般會先保存當(dāng)前的PC救恨、狀態(tài)寄存器等信息到內(nèi)存,等中斷程序完成后再回到之前的位置释树。
Mapper
以前的FC游戲從主內(nèi)存0x8000-0xFFFF肠槽,顯存0-0x1FFF,頂多就只能存儲40kb程序相關(guān)的資源了奢啥,早期的游戲也確實(shí)夠了秸仙。但可以了解到的是,后面的一些游戲桩盲,無論是聲音筋栋,游戲的畫面,游戲豐富的內(nèi)容正驻,這些只有40kb是遠(yuǎn)不夠的弊攘。但FC硬件也固定了,所以后面任天堂就提供了游戲卡帶的擴(kuò)展姑曙,稱為Mapper襟交。也就是說游戲卡帶上有額外的一個芯片來進(jìn)行內(nèi)存映射,對于CPU和PPU來說伤靠,看到的內(nèi)存空間始終那么大捣域,但Mapper可以進(jìn)行內(nèi)存切換,也就是在某個時刻宴合,將原來分配好的內(nèi)存地址的程序或者圖案表切換成卡帶上面其它的焕梅,這樣就解決了40kb的限制。這一做法卦洽,使得FC游戲的體驗(yàn)大大的提升贞言,有些卡帶更是會擴(kuò)展音源。Mapper大概有兩百多種阀蒂,不過一些是某個Mapper的變種该窗。FC也滿足二八原則弟蚀,實(shí)現(xiàn)少量的Mapper就可以兼容大部分游戲了,游戲占比比較大的就是Mapper0-4了酗失。
輸入設(shè)備
其它組件都已經(jīng)實(shí)現(xiàn)了义钉,輸入設(shè)備肯定不能少,不然游戲都玩不起來规肴。輸入設(shè)備實(shí)際也是經(jīng)過內(nèi)存映射IO寄存器到0x4016和0x4017捶闸,分別對應(yīng)玩家1和玩家2。游戲讀取手柄的狀態(tài)拖刃,是定期地按照手柄順序A鉴嗤、B、選擇序调、開始醉锅、上、下发绢、左硬耍、右不斷的獲取8個按鍵按下的狀態(tài)。所以實(shí)現(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
擴(kuò)展
調(diào)試
寫模擬器畢竟不像普通的程序,調(diào)試起來還是沒那么容易的氓涣。所以可以實(shí)現(xiàn)一個輔助的6502匯編指令調(diào)試器進(jìn)行調(diào)試牛哺,主要就是將一塊程序內(nèi)存的機(jī)器碼反匯編成6502匯編程序,再實(shí)現(xiàn)名稱表劳吠、圖案表引润、SpriteRAM的可視化以及內(nèi)存的dump。
存檔與讀檔
以前玩真機(jī)痒玩,畢竟頭疼的是淳附,有些游戲關(guān)卡太長或者難度太大,就經(jīng)常玩不到最后蠢古,電源就發(fā)熱嚴(yán)重了或者游戲機(jī)會偶爾抽風(fēng)奴曙,一斷電啥都沒有了,每次都得重來草讶。所以自己實(shí)現(xiàn)模擬器洽糟,存檔與讀檔是肯定是必須的。所謂存檔,實(shí)際就是把當(dāng)前的內(nèi)存保存現(xiàn)場脊框,讀檔就是恢復(fù)現(xiàn)場颁督。具體到FC践啄,主要就是把主內(nèi)存浇雹、VRAM與各個硬件的寄存器狀態(tài)、以及繪圖用到的緩沖區(qū)屿讽、Mapper都存儲下來昭灵。只是直接暴力的存儲占用空間有點(diǎn)大,一個游戲才幾十k伐谈,存檔卻多好幾倍了烂完,不過對于現(xiàn)在的機(jī)器來說這點(diǎn)空間完全無關(guān)緊要。
畫質(zhì)增強(qiáng)
這也是一個比較令人頭疼的問題诵棵,F(xiàn)C使用的不過是256x240分辨率的屏幕抠蚣,而現(xiàn)在的屏幕基本1、2k分辨率起了履澳,強(qiáng)行地拉伸像素塊邊緣就會有明顯的方塊感嘶窄,這可不是我們的童年。經(jīng)過調(diào)研距贷,發(fā)現(xiàn)比較可靠實(shí)用的就是xBRZ圖像縮放算法柄冲,整體還是不錯的。
結(jié)尾
對于實(shí)現(xiàn)一個模擬器忠蝗,主要就是對硬件要有足夠的理解现横,控制好各個組件之間時鐘周期的同步,通過以前的這么一個平臺阁最,也可以一窺現(xiàn)在一些平臺的工作戒祠。原理了解了,具體的實(shí)現(xiàn)過程中可以有多種不同的方式速种。對于FC得哆,由于硬件本身的資料不是完全開放的,而且比較有意思的是實(shí)現(xiàn)Mapper的時候哟旗,幾個不同地方的文檔有不同的實(shí)現(xiàn)贩据。不過總有一些游戲你不知道會使用哪些奇葩特性,所以很難有模擬器能完美支持所有的游戲闸餐,一些模擬器也不是完全模擬硬件饱亮,對特殊的游戲會使用一些trick,不過這些對理解平臺的工作都無關(guān)緊要了舍沙。更多資料近上,英文基本就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