【深圳 IO 攻略】第 16 關(guān):幽靈娃娃

本文首發(fā)于 B 站《深圳 IO》文集(https://www.bilibili.com/read/readlist/rl569860)。原創(chuàng)不易,轉(zhuǎn)載請注明出處。
關(guān)卡展示

這一關(guān)提供了一個沾滿灰塵的隨機數(shù)發(fā)生器。我們要做的是:當(dāng)隨機數(shù)發(fā)生器生成?1 時,令揚聲器播放【邪惡地笑】音效;而當(dāng)隨機數(shù)發(fā)生器生成 2 時,令揚聲器播放【令人毛骨悚然的尖叫】音效。
這兩種音效的波形我們可以在數(shù)據(jù)手冊中找到:

這些音效的波形數(shù)據(jù)由大量的數(shù)字組成,僅靠 MC 系列芯片里的 acc 和 dat 寄存器已經(jīng)無法存儲。這里我們必須要使用到元件面板里的擴展存儲元件。
擴展存儲元件有兩種:一種是 100P-14,黃底,有 14 格額外的存儲空間,數(shù)據(jù)可讀可寫,但初值只能是全 0,任何寫操作都必須在運行時完成,不能在運行前寫好初始數(shù)據(jù)。相當(dāng)于現(xiàn)實世界里的隨機存儲器(RAM)。
另一種是 200P-14,黑底,同樣有 14 格額外的存儲空間。但是里面的數(shù)據(jù)必須在設(shè)計電路板時就確定好,運行時只能讀,不能寫。相當(dāng)于現(xiàn)實世界里的只讀存儲器(ROM)。
這兩個擴展存儲元件有以下共同點:
1. 都帶有兩個指針。讀取 a0/a1 口可以獲得當(dāng)前的左/右指針的位置,范圍 0~13;而向 a0/a1 口寫數(shù)據(jù)則可以更改左/右指針的位置。當(dāng)寫入的地址值在 0~13 范圍之外時,會自動把地址值設(shè)置為傳入的數(shù)取除以 14 的余數(shù)。例如,向 a0 口傳 15?時,會將左指針置為 1;向 a1?口傳 -1 時,會將右指針置為 13。


2. 讀 d0 口會讀取到左指針?biāo)赶虻臄?shù)字,同時左指針自動向后移動一格。讀 d1 口會讀取到右指針?biāo)赶虻臄?shù)字,同時右指針自動向后移動一格。指針自增的這個特性非常優(yōu)秀,可以讓我們在讀連續(xù)數(shù)據(jù)時不需要反復(fù)操作地址口,只要連續(xù)讀數(shù)據(jù)口就完事了。下面舉一個連續(xù)從 ROM 中讀取三個數(shù),并將 acc 的百位、十位、個位依次置為這三個數(shù)的例子:




3. 對于 RAM 而言,d0/d1 口不僅可讀,而且可寫。向 RAM 的 d0/d1 口寫數(shù)據(jù)時,左/右指針指向的空間里的內(nèi)容會被覆蓋為新內(nèi)容,同時左/右指針自動向后移動一格(指針自增這一點無論讀寫都一樣)。下面給出一個將 ROM 中的內(nèi)容復(fù)制到 RAM 中的示例程序:

首先我們從 ROM 的數(shù)據(jù)口(x0)讀入數(shù)據(jù),并將讀入的數(shù)據(jù)寫入 RAM 的數(shù)據(jù)口(x2)(mov x0 x2)。執(zhí)行完本次操作后,ROM 的右指針以及 RAM 的左指針都會自增 1。此時我們判斷任意一個地址值,如果指向了 0(teq x1 0,或 teq x3 0),說明前一次讀/寫操作是在 13 地址處進(jìn)行的,那么就說明所有的數(shù)據(jù)都復(fù)制完畢了,直接結(jié)束程序(+ slp 999)。如果自增后的地址值沒有指向 0,說明前一次讀寫并不是在 13 地址處進(jìn)行的,那么就要跳轉(zhuǎn)回去循環(huán)執(zhí)行復(fù)制操作(- jmp 1),直到將 13 地址處的數(shù)據(jù)復(fù)制完成為止。
運行程序,我們發(fā)現(xiàn) RAM?最終會被寫入和 ROM 一模一樣的數(shù)據(jù):

前面我們花了很大的篇幅介紹 ROM 和 RAM 這兩個擴展存儲元件,現(xiàn)在我們回到題目。這道題因為要在特定的觸發(fā)條件下播放兩種可能的聲音波形,且這兩種波形數(shù)據(jù)的長度均為 13。所以我們肯定是需要兩個 ROM 來存儲兩種聲音的波形數(shù)據(jù)的。
這道題我們發(fā)現(xiàn)可以將一塊 MC6000 物盡其用,4 個 x 口,正好接在兩個 ROM 的地址口和數(shù)據(jù)口上;剩下兩個 p 口,一個接隨機數(shù)生成器的輸入信號,一個接揚聲器的輸出信號。一點不浪費!我們先二話不說,搭出如下的電路圖:

左邊的 ROM 記錄的是《邪惡地笑》的波形數(shù)據(jù),而右邊的 ROM 記錄的是《令人毛骨悚然的尖叫》的波形數(shù)據(jù)。這兩個 ROM 都以 50 結(jié)尾,是因為播放完對應(yīng)音效后,需要將揚聲器的波形值重設(shè)為 50,而不能一直停留在音效的最終波形值上。
我們現(xiàn)在的思路就是:當(dāng)隨機數(shù)發(fā)生器生成 1 時,我們在接下來的 14 個時鐘周期里不斷讀左邊 ROM 的數(shù)據(jù)口,并將相應(yīng)的波形發(fā)送給揚聲器;同理,當(dāng)隨機數(shù)發(fā)生器生成 2 時,我們在接下來的 14 個時鐘周期里不斷讀右邊 ROM 的數(shù)據(jù)口,并將相應(yīng)的波形發(fā)送給揚聲器。
根據(jù)一開始舉的例子,我們已經(jīng)知道了讀完整個 ROM 的條件是:讀取數(shù)據(jù)后的地址值為 0。因此我們寫出如下的具有循環(huán)結(jié)構(gòu)的代碼:

首先,我們要給揚聲器賦上 50 的初始聲波(@ mov 50 p1)。接下來,我們判斷當(dāng)前時鐘周期里的隨機數(shù)是否為 1(teq p0 1)。如果是 1,我們不斷從左邊的 ROM 中讀取數(shù)據(jù)發(fā)給揚聲器(+ mov x0 p1, + slp 1),并判斷讀取后的地址值是否大于 0(+ tcp x1 0)。如果地址值大于 0,說明當(dāng)前聲波還沒輸出完畢,需要跳到第 3 行繼續(xù)發(fā)送(+ jmp 3),直到地址值為 0 為止,關(guān)閉所有的 + - 號指令,跳到最后一行,休眠一秒,等待下一次信號(slp 1)。如果一開始的隨機數(shù)不是 1,而是 2(- teq p0 2),則改為從右邊的 ROM 循環(huán)讀?。? mov x2 p1, + slp 1, + tcp x3 0, + jmp 8)。等到讀取完畢,右邊的 ROM 的地址值為 0?時,跳到最后一行,休眠一秒,等待下一次信號(slp 1)。
點擊左下角的【模擬】,稍等片刻,便會彈出結(jié)算界面:

優(yōu)化電量
播放特定音效時,我們需要讀取連續(xù)的 14 個波形數(shù)據(jù)。14 除自己外的因數(shù)有 1、2、7,也就是說,我們在循環(huán)的過程中,每次循環(huán)讀取 1/2/7 個波形數(shù)據(jù)時,都能保證循環(huán)正常結(jié)束。為什么每次讀取的數(shù)據(jù)長度必須要是總長度的因數(shù)呢?假如一次讀取 3 個波形數(shù)據(jù),那么在第 5 次循環(huán)時,就會錯誤地觸發(fā)“讀取第 15 個波形數(shù)據(jù)”這樣的事件。
現(xiàn)在我們的 MC6000 里還有兩行代碼空間。我們可以將其中一個音效的循環(huán)改為“每次讀取兩個波形數(shù)據(jù)”,從而減少判斷循環(huán)是否結(jié)束的次數(shù)。如下圖所示,播放音效 2 的循環(huán)改成了“每次讀取兩個波形數(shù)據(jù)”。

優(yōu)化代碼行數(shù)
這個沾滿灰塵的隨機數(shù)發(fā)生器不會生成值為 0 的隨機數(shù),因此我們可以將讀到的隨機數(shù)分成三類:小于 2(即 1),等于 2,大于 2。沒錯,一次 tcp 三態(tài)判定就可以一勞永逸。

第一行將揚聲器初始化為 50,所有設(shè)計方案都是一樣的(@ mov 50 p1)。然后我們將 p0 的值存入 acc,并對 acc 做三態(tài)判定(mov p0 acc, tcp acc 2)。這時候可能有讀者會問了:p 口數(shù)據(jù)又不像 x 口那樣只能讀一次,明明可以反復(fù)讀,為啥還得存到 acc 里再做三態(tài)判定呢?因為:①p 口數(shù)據(jù)只能在當(dāng)前時鐘里反復(fù)讀,不代表在后續(xù)的時鐘周期里還能反復(fù)讀當(dāng)前這一秒的數(shù)字,“一旦錯過就不再”;②播放音效時,循環(huán)里的跳轉(zhuǎn)指令會破壞 + - 號狀態(tài),每次循環(huán)回來都要重新判定。重新判定的時候,我們不能以實時的隨機數(shù)作為依據(jù),只能以進(jìn)入音效播放流程前的隨機數(shù)作為依據(jù)。所以我們在進(jìn)入音效播放流程前,必須要將隨機數(shù)存入 acc,以方便后續(xù)的判斷。
那為什么前一個方案就不需要將 p0 的值存入 acc 呢?因為前一個方案里,兩個音效的播放流程是各自獨立,不共享代碼的。在各自的流程里,循環(huán)部分全部是用 + 號串聯(lián)起來的,只要跳轉(zhuǎn)指令也使用 + 號,就可以保證不破壞 + - 號狀態(tài)。只有當(dāng)循環(huán)的過程中使用了 + - 號分離了代碼塊時,循環(huán)最后的跳轉(zhuǎn)指令才會起到破壞 + - 號狀態(tài)的副作用。只有這種時候,才需要在循環(huán)的每個周期里都重新執(zhí)行測試指令恢復(fù) + - 號狀態(tài)。
回到程序。如果 acc 的值大于 2,那么直接跳到第 7 行休眠一秒(+ jmp 7)。進(jìn)入下一個時鐘周期后,因為兩個 ROM 的數(shù)據(jù)指針都沒有動過,所以后續(xù)的 tcp x3 0 判斷一定是不成立的,會直接跳回第 2 行,重新對新的隨機數(shù)做三態(tài)判定。
如果 acc 的值小于等于 2,那么我們需要分情況討論是 1 還是 2。我們先假設(shè) acc 是 2,從右邊的 ROM 中讀一格波形數(shù)據(jù)發(fā)給揚聲器(mov x2 p1)。如果 acc 的值是 1,那么再撤銷剛才發(fā)的波形數(shù)據(jù),改為從左邊的 ROM 中讀取(- mov x0 p1)。讀取完畢后,休眠 1 秒,讓波形數(shù)據(jù)作用到揚聲器上(slp 1)。這就相當(dāng)于:當(dāng) acc 是 2 時,只讀取右邊 ROM 的波形;而當(dāng) acc 是 1 時,左右兩邊的 ROM 一起讀取,但最終生效的是左邊 ROM 的波形。因此,只要進(jìn)入了音效播放流程,右邊的 ROM 指針是一定會動的。此時,我們可以統(tǒng)一以右側(cè) ROM 的地址值是否為 0,作為判定循環(huán)結(jié)束的依據(jù)(tcp x3 0)。如果該地址值大于 0,說明當(dāng)前聲波還沒輸出完畢,需要跳到第 3 行,將 + - 號狀態(tài)還原,并繼續(xù)發(fā)送波形數(shù)據(jù)(+ jmp 3)。等到讀取完畢,右邊的 ROM 的地址值為 0?時,再跳回到開頭,重新對新的隨機數(shù)做三態(tài)判定。
點擊左下角的【模擬】,稍等片刻,便會彈出結(jié)算界面:

我們通過三態(tài)判定,及巧用 + - 號的方式,合并了初始案例中的相似代碼,省去了一行休眠、一行地址判定、一行跳轉(zhuǎn)的代碼,用電量消耗驟然增加的代價,把總代碼行數(shù)減少到了 9 行。當(dāng)不同條件下需要執(zhí)行的邏輯相似時,我們可以【將相似的邏輯合并】,以一定程度的電量為代價,換取【相似邏輯間共享代碼,節(jié)約代碼行數(shù),甚至節(jié)約成本】的成果。【邏輯合并】是優(yōu)化代碼行數(shù)及優(yōu)化成本時的常用套路,后續(xù)關(guān)卡中仍然會再度用到。
碎碎念
代碼壓縮到了 9 行?沒用到 dat 寄存器?對不起,因為你的 6 個口都接上導(dǎo)線了,想降成本換成 MC4000,沒門。即使左邊 ROM 的地址口沒用上,那也是接了 5 個口。連接的口數(shù)減少不到 4 個,就別想換成 4000。