將前端技術棧移植到掌上遊戲機

NO IMAGE

作為前端工程師,我們編寫的代碼只能活在瀏覽器、小程序或者 Node 進程裡,這似乎已經成為了一種常識。但這就是我們的能力邊界了嗎?本文將帶你為一臺內存僅 32M,分辨率僅 320×240 的掌上遊戲機適配前端工具鏈,見證 Web 技術棧的全新可能性。

本次我們的目標,是隻配備了 400Mhz 單核 CPU 和 32M 內存的國產懷舊掌機 Miyoo。它固然完全無法與現在的 iOS 和安卓手機相提並論,但卻能很好地在小巧精緻的體積下,滿足玩小霸王、GBA、街機等經典遊戲平臺模擬器的需求,價格也極為低廉。這是它和 iPad mini 的對比圖:

將前端技術棧移植到掌上遊戲機

那麼,怎樣才算是為它移植了一套前端技術棧呢?我個人的理解裡,這至少包括這麼幾部分:

  • 構建環境 – 應用編譯工具鏈
  • 運行時 – 嵌入式 JS 引擎
  • 調試環境 – IDE 或編輯器支持

下面將逐一介紹為完成這三大部分的移植,我所做的一些技術探索。這主要包括:

  • 搭建 Docker 工具鏈
  • 走通 Hello World
  • 焊接排針與串口登錄
  • 定製 Linux 內核驅動
  • 移植 JS 引擎
  • 支持 VSCode 調試器

Let’s rock!

搭建 Docker 工具鏈

入門嵌入式開發時我們首先應該做到的,就是將源碼編譯為嵌入式操作系統上的應用。那麼 Miyoo 掌機的操作系統是什麼呢?這裡首先有一段故事。

Miyoo 是個國內小公司基於全志 F1C500S 芯片方案定製的掌機,其默認的操作系統是閉源的 Melis OS,在國外以 Bittboy 和 Pocket Go 的名義銷售,小有名氣。閉源系統自然不能滿足愛好者的需求,因此社區對其進行了逆向工程。來自臺灣的前輩司徒 (Steward Fu) 成功將 Linux 移植到了這臺掌機上,但可惜他已因個人原因退出了開發。現在這臺遊戲機的開源系統 MiyooCFW 基於司徒最早移植的 Linux 4.14 內核,由社區維護。

因此,我們的目標系統既不是 iOS 也不是安卓,而是原汁原味的 Linux!如何為嵌入式 Linux 編譯應用呢?我們需要一套由編譯器、彙編器、鏈接器等基礎工具組成的工具鏈,以構建出可用的 ARM 二進制程序。

在各個操作系統上搭建開發環境,往往相當繁瑣。現在開源掌機社區中流行的方式是使用 VirtualBox 等 Linux 虛擬機。這基本解決了工具鏈的跨平臺問題,但還沒有達到現代前端工程的開發便利度。因此我選擇首先引入 Docker,來實現跨平臺開箱即用的開發環境。

我們知道,Docker 容器可以理解為更輕量的虛擬機。我們只要一句 docker run 命令就能運行容器,併為其掛載文件、網絡等外部資源。顯然,現在我們需要的是一個【能編譯出嵌入式 Linux 應用】的 Docker 容器,這可以通過製作出一個用於啟動容器的基準 Docker 鏡像來實現。Docker 鏡像很容易跨平臺分發,因此只要製作並上傳鏡像,基礎的開發環境就做好了。

那麼,這個 Docker 鏡像中應該包含什麼內容呢?顯然就是編譯嵌入式應用的工具鏈了。司徒已為社區提供了一套在 Debian 9 上預編譯好的工具鏈包,只需要將其解壓到 /opt/miyoo 目錄下,再安裝一些常見依賴,就可以完成鏡像的製作了。這一過程可以通過 Dockerfile 文件來自動化,其內容如下所示:

FROM debian:9
ADD toolchain.tar.gz /opt
ENV PATH="${PATH}:/opt/miyoo/bin"
ENV ARCH="arm"
ENV CROSS_COMPILE="arm-miyoo-linux-uclibcgnueabi-"
RUN apt-get update && apt-get install -y \
build-essential \
bc \
libncurses5-dev \
libncursesw5-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /root

這樣只要用 docker build 命令,我們就能用純淨的 Debian 鏡像製作出純淨的嵌入式開發鏡像了。那麼接下來又該如何用鏡像編譯文件呢?假設我們做好了 miyoo_sdk 鏡像,那麼只要將本地的文件系統目錄,掛載到基於鏡像所啟動的容器上即可。像這樣:

docker run -it --rm -v `pwd`:/root miyoo_sdk

簡單說來,這條命令的意義是這樣的:

  • docker run 基於 miyoo_sdk 鏡像啟動一個臨時容器
  • -v 將當前目錄掛載到容器的 /root
  • -it 讓我們用當前終端來登錄操作容器的 Shell
  • --rm 使容器用完即棄,除更改當前目錄外,不留任何痕跡

因此,我們實際上基於 Docker,直接在容器裡編譯了 Mac 文件系統上的源碼。這既沒有副作用,也不需要其他數據傳遞操作。對於日益複雜的前端工具鏈依賴問題,我相信這也是一種解決方案,有機會可以單獨撰文詳述。

走通 Hello World

Docker 鏡像製作好之後,我們就能用上容器裡 arm-linux-gcc 這樣的編譯器了。那麼該怎麼編譯出一個 Hello World 呢?現在還沒到引入 JS 引擎的時候,先用 C 語言寫出個簡單的例子,驗證一切都能正常工作吧。

嵌入式 Linux 設備常用 SDL 庫來渲染基礎的 GUI,其最簡單的示例如下所示,是不是和前端同學們熟悉的 Canvas 有些神似呢:

#include <stdio.h>
#include <SDL.h>
int main(int argc, char* args[])
{
printf("Init!\n");
SDL_Surface* screen;
screen = SDL_SetVideoMode(320, 240, 16, SDL_HWSURFACE | SDL_DOUBLEBUF);
SDL_ShowCursor(0);
// 填充紅色
SDL_FillRect(screen, &screen->clip_rect, SDL_MapRGB(screen->format, 0xff, 0x00, 0x00));
// 交換一次緩衝區
SDL_Flip(screen);
SDL_Delay(10000);
SDL_Quit();
return 0;
}

這份 C 源碼可以通過我們的 Docker 環境編譯出來。但顯然稍有規模的應用都不應該直接敲 gcc 那堆參數來直接構建,通過像這樣的 Makefile 來自動化比較好(注意縮進必須用 tab 哦):

all:
arm-linux-gcc main.c -o demo.out -ggdb -lSDL -I/opt/miyoo/arm-miyoo-linux-uclibcgnueabi/sysroot/usr/include/SDL
clean:
rm -rf demo.out

除了登陸 Docker 容器的 Shell 之外,我們還可以通過 -d 參數輕鬆地創建「無頭」的容器,在後臺幫你編譯。像構建這個 Makefile 所需的 make 命令,就可以在 Mac 終端裡這樣一行搞定:

docker run -d --rm -v `pwd`:/root miyoo_sdk make

這樣就能生成 demo.out 二進制文件啦。將這個僅有 12KB 的文件複製到 Miyoo TF 卡里的 /apps 目錄裡後,再用 Miyoo 自帶的程序安裝器打開它,就能看到這樣的結果了:

將前端技術棧移植到掌上遊戲機

這說明 Docker 編譯工具鏈已經正常工作了!但這還遠遠不夠,現在的關鍵問題在於,我們的 printf 去哪了?

焊接排針與串口登錄

基礎的 Unix 知識告訴我們,進程的輸出是默認寫到 stdout 這個標準輸出文件裡的。一般來說,這些輸出都會寫入流式的緩衝區,進而繪製到終端上。但是,嵌入式設備的終端在哪裡呢?一般來說,這些日誌寫入的是所謂的 Serial Console 串口控制檯。而這種控制檯的數據,則可以通過非常古老的 UART 傳輸器來和 PC 交互,只需要接上三條電路的連線就行。

因此,我們需要想辦法接通 Miyoo 的 UART 接口,從而才能在電腦上登陸它的 Shell。在這方面,司徒的 焊接 UART 接頭 這篇文章是非常好的參考資料。我對其中的一句話印象尤其深刻:

廠商真是貼心,特別把 GND、UART1 RX、UART1 TX(由上而下)拉出來,提供開發者一個友好的開發界面

拆機焊接才能用的東西,在大佬眼裡居然算是友好的開發界面…好吧,不就是焊接嗎?現學就是了。

首先我們把後蓋拆開,再把主板卸下來。這步只需要標準的十字螺絲刀,注意別弄丟小零件就行。完成後像這樣:

將前端技術棧移植到掌上遊戲機

看到圖中主板右上角的三根針了嗎?這就是 UART 的三個接口了(這時我還沒焊接,只是把排針擺上去了而已)。它們自上而下分別是 GND、RX 和 TX,只要為它們焊接好排針,將導線連到 UART 轉 USB 轉換器,就能在 Mac 上登陸它啦。連接順序是這樣的:

  • Miyoo 的 GND 接轉換器的 GND
  • Miyoo 的 RX 接轉換器的 TX
  • Miyoo 的 TX 接轉換器的 RX

所以,我們需要先焊上排針。焊接看起來很折騰,現學起來倒並不難,其實只要先把烙鐵頭壓在焊點上,然後把焊錫絲放上去就行。像我這樣的新手,還可以買一些白菜價的練習板,拿幾個二極管練練手後再焊真的板子。完成後的效果如下所示,多了三根紅色排針(焊點在背面,很醜就不放圖了):

將前端技術棧移植到掌上遊戲機

焊好以後,用萬用表即可測量焊點是否接通。還記得高中物理裡萬用表的紅黑表筆怎麼連接嗎…反正我早就忘光了,也是現學的。實際測得 RX 和 TX 各自到 GND 的電阻值都在 600 歐姆左右,就代表連接暢通了。

加上轉接頭,連好之後的效果是這樣的:

將前端技術棧移植到掌上遊戲機

最後我為了能把機器裝回去,又在後蓋上打了個洞,像這樣:

將前端技術棧移植到掌上遊戲機

做完這個硬件改造之後,該如何實現軟件上的連接呢?這就需要能夠登陸串口的軟件了。Unix 裡一切皆文件,因此我們只要找到 /dev 目錄下的串口文件,然後用串口通信軟件打開這個文件就行啦。screen 是 Mac 內置的命令行會話軟件,但用起來較為麻煩,這裡推薦 Mac 用戶使用更方便的 minicom。連接好之後,能看到形如這樣的登陸日誌輸出:

[    1.000000] devtmpfs: mounted
[    1.010000] Freeing unused kernel memory: 1024K
[    1.130000] EXT4-fs (mmcblk0p2): re-mounted. Opts: data=ordered
[    1.230000] FAT-fs (mmcblk0p4): Volume was not properly unmounted. Some data may be corrupt. Ple.
[    1.250000] Adding 262140k swap on /dev/mmcblk0p3.  Priority:-2 extents:1 across:262140k SS
Starting logging: OK
read-only file system detected...done
Starting system message bus: dbus-daemon[72]: Failed to start message bus: Failed to open socket: Fd
done
Starting network: ip: socket: Function not implemented
ip: socket: Function not implemented
FAIL
Welcome to Miyoo
miyoo login: 

看起來已經接近成功,可以 login 進去看日誌了吧?結果一個 bug 攔住了我:所有按鍵按下去都沒反應,完全登陸不了終端,怎麼辦

我從來沒做過這種層面的硬件改造,也沒用過 UART 串口。因此這個問題對我相當棘手——既可能是硬件問題,也可能是軟件問題。但總該是個可以解決的問題吧。

  • 首先軟件上,我反覆確認了串口通信軟件的配置,並梳理了 Linux 啟動時的相關配置流程,將機器 EXT4 格式的 rootfs 分區掛載到 Mac 上,確認了 /etc/inittab 的配置和它啟動的 /etc/main 腳本都是有效的,排除了設備側的軟件問題。
  • 然後在硬件上,我確認了電路不存在虛焊,並實驗改用樹莓派與 Mac 做串口通信,確認了此時終端可以正常使用,排除了外圍硬件的問題。
  • 最後,我發現與樹莓派通信時,Mac 側按鍵可以讓轉接頭的 RX 和 TX 燈都閃亮。但連接 Miyoo 時,按鍵時只會讓 Mac 側的 TX 發送端閃亮,沒有收到本應經過 RX 返回的信號。因此推測問題在於這個接口的 RX 線路 。我整理了詳盡的現象詢問司徒後,得到的回覆是:UART 與耳機共用,必須重新編譯 Linux 內核才行。

好吧,我居然一路 debug 碰到了個物理電路設計的硬件問題。那就接著改 Linux 內核唄。

定製 Linux 內核驅動

根據司徒提供的線索,我開始嘗試將音頻驅動從 Miyoo 的 Linux 內核源碼中屏蔽掉。我們都知道 Linux 是宏內核,大量硬件驅動的源碼全都在裡面。簡單改改驅動,其實不是件多高大上的事情。

首先,我們至少要能把內核編譯出來。注意內核不等於嵌入式 Linux 的系統。一個完整的嵌入式 Linux 系統,應該大致包括這幾部分:

  • Kernel – 包含操作系統的核心子系統,以及所需的硬件驅動
  • Rootfs – 根文件系統,大致就是根目錄下面放的那堆二進制應用
  • UBoot – 引導加載程序,本身相當於一個非常簡單的操作系統

我們只是想禁用掉音頻驅動,因此只需要重新編譯出 Kernel 就行。Kernel 會編譯成名為 zImage 的鏡像。這個過程的用戶體驗其實和編譯普通的 C 項目沒有什麼區別,也就是先配好編譯參數和環境變量,然後 make 就行了:

make miyoo_defconfig
make zImage

在我 MacBook Pro 的 Docker 裡,大致需要 12 分鐘才能把內核編譯出來。這裡貼個圖,紀念下職業生涯第一次編譯出的 Linux 內核:

將前端技術棧移植到掌上遊戲機

編譯通過後,我非常開心地直接開始嘗試修改內核的驅動(注意我沒有真機測試這個第一次編譯出的內核,這是伏筆)。經過一番研究,我發現嵌入式 Linux 的硬件都是通過一種名叫設備樹的 DSL 代碼來描述的,修改這種 DSL 應該就能使 Kernel 不支持某種硬件了。於是我找到了 Miyoo 設備樹裡的音頻部分,將其註釋掉,嘗試編譯出不包括音頻的設備樹描述文件,把它裝上去。

然後機器啟動後就黑屏了

……

看來設備樹的配置不管用,我又想到了直接修改音頻驅動的 C 源碼。它就是內核項目的 /sound/soc/suniv/miyoo.c,裡面的 C 代碼看起來並不難,但我嘗試了不下七八種修改手法,就是編譯不出一份正常的鏡像:有時候可以解決 UART 無法登陸的問題,有時則不行,並且黑屏問題也始終沒有解決。為什麼音頻驅動會影響視頻輸出,這讓我十分困擾,甚至一度懷疑起了我的工具鏈。

最終,我得到了一個令人震驚的結論:

這份內核代碼哪怕完全不改,編譯出來都是會黑屏的

……

於是,我換了社區版本的內核代碼,屏幕順利點亮,問題解決。

但是,社區版本的內核是老外維護的,他們的用戶習慣裡,A 鍵和 B 鍵的定義是相反的(小時候玩過美版 PSP 的同學應該知道我是什麼意思)。於是我又開始折騰,嘗試如何交換 A 和 B 的位置。

結果,我遇到了一個更加詭異的問題,那就是隻要我在鍵盤驅動裡交換 A 和 B 的值,要麼不生效,要麼就總會有其它的按鍵失靈,不能完全交換成功。

於是,我去仔細研究了按鍵驅動所對應的 Linux 內核 GPIO 部分的文檔,檢查了 init 和 scan 階段下這一驅動的行為,甚至懷疑按鍵的宏定義會影響位運算的結果……結果都沒什麼卵用。但我還是找到了個能顯示按鍵信息的調試用宏,之前一直懶得浪費一次編譯時間去打開它,乾脆把它啟用後再試一下。

結果,我又得到了一個令人震驚的結論:

這份代碼把變量的名字寫錯了。該對換的變量不是 A 和 B,是 A 和 X

……

看來我果然沒有寫 Linux 內核的天賦,還是老老實實回去移植 JS 引擎吧。

移植 JS 引擎

搞定內核層以後,我們就可以輕鬆登錄進 Miyoo 的控制檯了。用戶名是 root,沒有密碼。繞了這麼多彎子,第一次登陸成功的時候還是讓人很激動的。截圖紀念一下:

將前端技術棧移植到掌上遊戲機

接下來應用層的 JS 引擎移植,對我來說就是輕車熟路了。這裡祭出我們的老朋友 QuickJS 引擎,它作為一個超迷你的嵌入式 JS 引擎,甚至已經兼容了不少 ES2020 裡的特性。由於它沒有任何第三方依賴,把它遷移到 Miyoo 上,其實並沒有多難,給 Makefile 加上個 CROSS_PREFIX=arm-miyoo-linux-uclibcgnueabi- 的編譯配置,就可以用交叉編譯器來編譯它了。

交叉編譯自然也很難一帆風順。這裡我遇到的編譯錯誤,都來自嵌入式環境下的標準庫能力缺失。不過其實也只有這兩點:

  • malloc_usable_size 不支持,這會影響內存度量數據的獲取,但 JS 照樣可以跑得很歡。順便一提從源碼來看,這個能力在 WASM 裡也不支持。所以其實已經有人把 QuickJS 編譯成 WASM,玩起 JS in JS 的套娃了。
  • fenv.h 缺失,這應該會影響浮點數的 rounding 方式,但實測對 Math.ceilMath.floor 無影響。先不管了,反正又不是不能用(喂)

這點小問題,簡單 patch 一下相關代碼以後就搞定了。編譯成功後,把它複製到 rootfs 分區的 /usr/bin 目錄下,即可在在 Miyoo 的 Shell 裡用 qjs 命令運行 JS 了。這下終於爽了,看我回到主場,噼裡啪啦寫段 JS 測試一下:

import { setTimeout } from 'os'
const wait = timeout =>
new Promise(resolve => setTimeout(resolve, timeout))
let i = 0
;(async () => {
while (true) {
await wait(2000)
console.log(`Hello World ${i}!`)
i++
}
})()

截圖為證,我真的是在 Miyoo 裡面跑的:

將前端技術棧移植到掌上遊戲機

但是這個 JS 代碼的運行結果又該怎麼輸出到真機上呢?我們知道 Linux 上有默認的 /dev/console 系統控制檯和 /dev/tty1 虛擬終端,因此只要在啟動時的 inittab 裡把 console::respawn:/etc/main 改成 tty1::respawn:/etc/main,就可以輸出到圖形化的虛擬終端了。像這樣:

將前端技術棧移植到掌上遊戲機

支持 VSCode 調試器

JS 都能跑了,日誌都能看了,還要啥自行車呢?當然是支持給它下斷點啊!我本來一直以為斷點調試必須要用 V8 那樣的重型引擎配合 Chrome 才行,結果讓我驚喜的是,社區已經為 QuickJS 實現了一個支持調試器的 fork,這樣只需要 VSCode 作為調試器前端,就能調試 QuickJS 引擎運行時的代碼了。配合 VSCode 的 Remote 功能,這玩意的想象空間實在很大。

這一步的支持是全文中最省事的。因為我只在 Mac 上做了個驗證,編譯一次通過,沒什麼好說的。效果像這樣:

將前端技術棧移植到掌上遊戲機

圖中你看到的 VSCode Debugger 背後可不是 V8,而是正經的 QuickJS 引擎噢。我也用 VSCode 調試過 Dart 和 C++ 的代碼,當時我沒有想到過這樣的一套調試器該如何由一門第三方語言接入。搜索之後我發現,微軟甚至已經為編輯器與任意第三方語言之間設計了一個名為 Debug Adapter Protocol 的通用調試協議,它很具備啟發性。原來我覺得十分高大上的編程語言調試系統,也是能用斷點、異常等概念來抽象化和結構化,並設計出通用協議的。微軟在工程設計和文檔上的積累真不是蓋的,贊一個。

現在,我已經將這個支持 VSCode 調試的 QuickJS 版本編譯到了 Miyoo 上,只是還沒有做過實際的調試——有了定製內核驅動時不停給自己挖坑的教訓,我現在自然不敢立 Flag 說它能用了(捂臉)

到此為止,本次實驗所關注的能力都已經得到基本的驗證了。相應的 Docker 鏡像我也已發佈到 GitHub,參見 MiyooSDK。也歡迎大家的交流。

後記

這次寫的又是一篇長文,這整套工作遠沒有文章寫下來那麼一氣呵成,而是斷斷續續地逐步完成的。現在我手上的東西,還只是個初步的工程原型,有很多工作還可以繼續深入。比如這些地方:

  • 還不支持 USB 通信,不能 SSH 登錄
  • 還沒有為 JS 實現 C 的 GUI 渲染器
  • 還沒有移植 JS 的上層框架

不過,只要有熱情持續深入技術,那麼收穫一定不會讓你失望。像大家眼裡神祕的 Linux 內核,其實也是個有規可循的程序。即便是我這樣本職寫 JavaScript 的玩票選手,照樣可以拿通用的科學方法論來實驗分析它,而這個過程就像玩密室逃脫或者解謎遊戲一樣有趣——你知道問題一定能解決,只要用邏輯推理,找到房間裡隱藏的那個開關就行

我要特別感謝司徒,他為開源掌機的發展作出了巨大的貢獻。這次最為疑難的硬件電路 bug,也是由他提供了關鍵信息後才最終得以解決的。很多時候我們缺的不是繁冗瑣碎的入門指南,而是來自更高段位者,一兩句話讓你茅塞頓開的點撥。他就是這樣一位令人尊敬的技術人。

這裡跑個題,淘寶上有不少用司徒系統的名義銷售掌機的店鋪,這些商家其實已經與他本人完全無關了。雖然我仍然很推薦大家入手這個只要一百多塊錢的 Miyoo 掌機用於娛樂或技術研究,但我還是有些感慨。所謂遍身羅綺者,不是養蠶人,大抵如此吧。

從搭建工具鏈到焊接電路板,再到定製 Linux 內核和 JS 引擎,這些技術本身固然都有點門檻。但富有樂趣的目標,總能讓我們更有動力去克服中途的各種困難。我相信興趣和熱情總是最能刺激求知慾的,而永不知足的求知慾,才能驅動我們不停越過一個個山丘。畢竟喬幫主提過的那句名言是怎麼說的來著?

Stay Hungry. Stay Foolish.

我主要是個前端開發者。如果你對 Web 編輯器、WebGL 渲染、Hybrid 架構設計,或者計算機愛好者的碎碎念感興趣,歡迎關注我噢 🙂

#靈感

相關文章

窺探原理:手寫一個JavaScript打包器

Mybatis源碼系列5二級緩存

如何衡量一個人的JavaScript水平?

《程序人生》2020無畏年少青春,迎風瀟灑前行|年度徵文