今天要來聊聊我那個 flash ROM 開發板的軟體… 老實說,它現在處於一種很玄的狀態。完成了,但又沒完全完成。有點像薛丁格的程式碼吧,哈。
簡單回顧一下,這個板子的作用,就是讓你把寫好的二進位碼燒錄到一顆 flash 晶片裡。然後你就可以把這顆晶片,插在你自製的微電腦上,取代傳統的 ROM 或 EEPROM。算是我為了方便開發跟測試軟體做的一個小工具。
不過呢,要把程式從電腦送到板子上,再寫進 flash 晶片,其實需要兩套軟體互相配合:一套是跑在開發板上微控制器(MCU)裡的韌體(Firmware),另一套則是在我電腦上跑的「終端機」程式,用來控制整個流程。
這兩套軟體… 嗯,都存在了。功能也都有,可以把 ROM 映像檔從電腦上傳到板子的 RAM,然後再從 RAM 寫入到 flash。但… 我踩了幾個 bug。你會很驚訝嗎?我是不怎麼意外啦,自己搞的 project 嘛,沒 bug 才奇怪。
所以,這篇文章寫下的此刻,軟體大概就是這個狀態。我還在斷斷續續地修修改改,最新的版本我都會丟到 GitHub 上,有興趣可以自己去翻。這不是什麼驚天動地的作品,所以我們不用一行一行去看程式碼,我覺得更有趣的是整個設計的思路跟… 踩坑的過程。😂
板子上的韌體在幹嘛?
開發板的核心是一顆 ATmega328PB 晶片。這顆晶片,就是早期 Arduino Uno 上面那顆的心臟啦,在台灣要買到它超容易的,不管去光華商場晃一圈,或是上網找那些電子材料行,都很方便。韌體大部分時間其實很閒,就是在主迴圈裡一直等,看序列埠(Serial Port)有沒有從電腦傳來什麼指令。
它主要聽得懂四個指令:
| 指令 | 白話文解釋 |
|---|---|
DNLD |
Download 的縮寫。收到這個指令,MCU 就會開始接收從電腦傳來的一堆二進位資料,然後把它們塞進板子上的 SRAM 裡。就是個搬運工。 |
FLSH |
Flash 的縮寫。這指令下去,MCU 就會把剛剛存在 SRAM 裡的資料,複製到那顆 flash 晶片裡面。這是真正的「燒錄」。 |
READ |
從 flash 晶片裡讀取資料。電腦會告訴它從哪個地址開始讀,然後 MCU 就把讀到的資料傳回電腦,讓我們檢查一下有沒有燒錯。 |
SRAM |
跟 READ 很像,但這個是從 SRAM 讀資料。主要是用來確認第一步「上傳」有沒有成功,資料有沒有乖乖待在 RAM 裡面。 |
很多操作其實都是圍繞著幾個核心的函式在跑。比如說下面這個,它負責把一個 16-bit 的地址和 8-bit 的資料,放到對應的匯流排上。
void flashWrite(uint16_t address, uint8_t value) {
addrport.setWord(address);
dataport.setByte(value);
setPin(&PORTC, FL_WE, LOW); // 鎖存地址
_delay_us(FLASH_BYTE_DELAY); // 暫停一下下
setPin(&PORTC, FL_WE, HIGH);
}
細節不用太在意,那個 `addrport` 和 `dataport` 是我自己寫的類別,用來控制幾顆 I/O 擴充晶片。重點是那個流程:設定好地址、設定好資料,然後把 flash 晶片的 `/WE` (Write Enable) 這個腳位拉到低電位,等一個非常非常短的時間,再拉回高電位。一個位元組就這樣寫進去了。
所有寫入相關的動作,都靠這個基本功。像下面這個「寫入一個位元組」的函式,就是一個例子:
void flashByteWrite(uint16_t address, uint8_t value) {
setPin(&PORTC, FL_CE, LOW); // 致能 flash 晶片
flashWrite(0x5555, 0xAA);
flashWrite(0x2AAA, 0x55);
flashWrite(0x5555, 0xA0);
flashWrite(address, value);
setPin(&PORTC, FL_CE, HIGH); // 禁用 flash 晶片
}
你看,它就是連續呼叫了好幾次 `flashWrite`。這串神秘的 `0x5555`, `0xAA` 之類的東西,其實是一個「指令序列」。你在跟 flash 晶片說:「欸,注意囉,我接下來要給你一個地址跟一筆資料,你幫我把它寫進去。」這個算是跟晶片溝通的標準流程,每一家廠牌的 datasheet 都會寫。
電腦這邊的 Python 程式
在我那台跑著 MX Linux 的老 MacBook Air 上,跑的是一個 Python 寫的程式。它本質上就是個客製化的序列埠終端機。我自己是覺得,這種純文字的介面有種復古的美感,所以還用了 Curses 這個函式庫來做。
電腦和開發板之間是用一條 USB-to-serial (FTDI) 的線連著,只需要三條線:`TX`、`RX`、`GND`。在我電腦上,這個裝置的路徑是 `/dev/ttyUSB0`,所以我就很懶直接寫死在程式碼裡了。我知道這很不好… 未來版本應該會改成可以用命令列參數指定啦。
傳輸速率也是,我直接設 9600 baud。這通常是我做任何序列埠通訊專案的安全起手式。雖然我試過一次想拉到 19200,但結果不太妙,還需要再研究一下問題出在哪。
程式是選單式的,很老派吧:
- [F]ile selection: 選你要燒錄的 ROM 映像檔。檔案要放在程式目錄下的一個 `files/` 資料夾裡。
- [U]pload data: 把檔案裡的二進位資料,透過序列埠傳到開發板的 SRAM 裡。傳完後,板子會回傳前 16 個位元組,程式會跟原始檔案比對一下,算是一個很陽春的錯誤檢查。
- [W]rite data to Flash: 告訴板子,可以把 SRAM 裡的資料正式燒進 flash 晶片了。
- [R]ead Flash memory: 讀取 flash 的內容,檢查燒錄結果。
- [S]how RAM: 讀取 SRAM 的內容,檢查上傳步驟有沒有問題。
那個差了一千倍的 Bug…
說了這麼多,所以問題到底在哪?嗯… 主要是一些邊界檢查跟輸入清理的問題。比如說你讀取記憶體時,輸入一個根本不存在的地址,程式可能就崩潰了。總之,就是還很粗糙。
但我遇到一個 bug 真的很有趣。我發現資料上傳到 SRAM 都沒問題,但要寫入 flash 的時候,每個 sector (磁區) 的前三個 byte,永遠都會變成 `0xFF`。
這就很可疑了。`0xFF` 正是晶片在執行「磁區抹除」後,每個 byte 會有的預設值。我的直覺是… 難道我寫入資料的動作太快,晶片根本還沒完成抹除?
我抹除磁區的程式碼大概長這樣:
void sectorErase(uint16_t startAddress) {
setPin(&PORTC, FL_CE, LOW); // 致能 flash 晶片
flashWrite(0x5555, 0xAA);
// ... 一連串的指令序列 ...
flashWrite(startAddress, 0x30);
_delay_us(FLASH_SECTOR_ERASE_DELAY); // 等它一下
setPin(&PORTC, FL_CE, HIGH); // 禁用 flash 晶片
}
關鍵就在那個 `_delay_us`。我看 datasheet 上寫說,下完抹除指令後,你要給晶片一點時間反應。所以我把 `FLASH_SECTOR_ERASE_DELAY` 設成了建議值,25。
我想說,會不會是需要再多一點點時間?所以我把延遲從 25 一路改成 30、50、100、甚至到 255… 結果完全沒用。後來我火大了,直接加了一個一秒鐘的延遲。`_delay_ms(1000)`。
欸,居然成功了!
但這顯然不對啊,燒個晶片哪有要等一秒的。所以我只好摸摸鼻子,回去再仔細看一次那份由 Microchip 提供的官方 ATmega328PB datasheet。然後我發現了… 幹嘛,真的是… 它的建議值是 25ms (毫秒),不是 25µs (微秒)。
我用的函式是 `_delay_us()`,單位是微秒。我直接把 `_delay_us` 換成 `_delay_ms`… 一切就正常了。就這樣,差了一千倍。這個故事告訴我們,datasheet 要讀兩遍。不,最好是三遍,還要連單位都看清楚。
所以,這東西算是完成了嗎?
嗯… 概念驗證(Proof of Concept)算是成功了啦。基本的骨架都在,而且那個要命的 bug 也解掉了。但說真的,這程式碼離「產品級」還差得遠。如果有人手賤輸入一些奇怪的東西,它八成會當掉。我自己用是還好,反正當了重開就好,但如果要給別人用,那肯定不行。
身為一個業餘愛好者,有時候你得知道在哪裡停下來。你可以花無數時間去處理那些邊邊角角的例外狀況,但… 很快就會有下一個閃亮亮的新專案來吸引你的注意力了,對吧?我的原則是,把軟體改善到一個「堪用」的程度,然後就差不多可以往下個目標前進了。
說到這個,這也讓我開始想… (這念頭通常很危險)
我當初做這個專案,是為了能方便地把 ROM 映像檔燒錄到我的 Zolatron 自製電腦上。但,現在這個「PC -> 開發板 -> Flash 晶片」的流程,真的是最好的方法嗎?有沒有可能… 更直接一點?
這又是另一個故事了,也許我們下次可以來聊聊現在這個做法有什麼問題,以及… 一個可能更酷的替代方案。
對了,你有沒有曾經為了一個 bug 卡關好幾個小時,結果發現只是 datasheet 上的一個單位看錯,或是某個變數打錯字的經驗?在下面留言分享一下你遇過最讓人想撞牆的 bug 吧!
