文章目录
  1. 1. 搭建 Docker 工具链
  2. 2. 走通 Hello World
  3. 3. 焊接排针与串口登录
  4. 4. 定制 Linux 内核驱动
  5. 5. 移植 JS 引擎
  6. 6. 支持 VSCode 调试器
  7. 7. 后记

作为前端工程师,我们编写的代码只能活在浏览器、小程序或者 Node 进程里,这似乎已经成为了一种常识。但这就是我们的能力边界了吗?本文将带你为一台内存仅 32M,分辨率仅 320x240 的掌上游戏机适配前端工具链,见证 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 架构设计,或者计算机爱好者的碎碎念感兴趣,欢迎关注我噢 :)

文章目录
  1. 1. 搭建 Docker 工具链
  2. 2. 走通 Hello World
  3. 3. 焊接排针与串口登录
  4. 4. 定制 Linux 内核驱动
  5. 5. 移植 JS 引擎
  6. 6. 支持 VSCode 调试器
  7. 7. 后记