作为后端工程师,我们编撰的代码只能活在浏览器、小程序或则Node进程里,这虽然早已成为了一种常识。但这就是我们的能力边界了吗?本文将带你为一台显存仅32M,帧率仅320x240的掌上游戏机适配后端工具链,见证Web技术栈的全新可能性。
本次我们的目标,是只配备了400Mhz单核CPU和32M显存的国产怀旧掌机Miyoo。它尚且完全未能与现今的iOS和安卓手机相提并论,但却能挺好地在精巧别致的容积下,满足玩小霸王、GBA、街机等精典游戏平台模拟器的需求,价钱也极为低廉。这是它和iPadmini的对比图:
这么,如何才算是为它移植了一套后端技术栈呢?我个人的理解里,这起码包括那么几部份:
下边将逐一介绍为完成这三大部份的移植,我所做的一些技术探求。这主要包括:
Let’srock!
搭建Docker工具链
入门嵌入式开发时我们首先应当做到的linux虚拟串口驱动,就是将源码编译为嵌入式操作系统上的应用。这么Miyoo掌机的操作系统是哪些呢?这儿首先有一段故事。
Miyoo是个国外小公司基于全志F1C500S芯片方案订制的掌机,其默认的操作系统是闭源的MelisOS,在美国以Bittboy和PocketGo的名义销售,小有名气。闭源系统自然不能满足爱好者的需求,为此社区对其进行了逆向工程。来自日本的高手司徒(StewardFu)成功将Linux移植到了这台掌机上,但可惜他已因个人缘由退出了开发。如今这台游戏机的开源系统MiyooCFW基于司徒最早移植的Linux4.14内核,由社区维护。
为此,我们的目标系统既不是iOS也不是安卓,而是原汁原味的Linux!怎样为嵌入式Linux编译应用呢?我们须要一套由编译器、汇编器、链接器等基础工具组成的工具链,以建立出可用的ARM二补码程序。
在各个操作系统上搭建开发环境,常常相当烦琐。现今开源掌机社区中流行的方法是使用VirtualBox等Linux虚拟机。这基本解决了工具链的跨平台问题,但还没有达到现代后端工程的开发便利度。为此我选择首先引入Docker,来实现跨平台开箱即用的开发环境。
我们晓得,Docker容器可以理解为更轻量的虚拟机。我们只要一句dockerrun命令才能运行容器,并为其挂载文件、网络等外部资源。其实,如今我们须要的是一个【能编译出嵌入式Linux应用】的Docker容器,这可以通过制做出一个用于启动容器的基准Docker镜像来实现。Docker镜像很容易跨平台分发,因而只要制做并上传镜像,基础的开发环境就做好了。
这么,这个Docker镜像中应当包含哪些内容呢?其实就是编译嵌入式应用的工具链了。司徒已为社区提供了一套在Debian9上预编译好的工具链包,只须要将其解压到/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
复制代码
这样只要用dockerbuild命令,我们能够用纯净的Debian镜像制做出纯净的嵌入式开发镜像了。这么接出来又该怎么用镜像编译文件呢?假定我们做好了miyoo_sdk镜像,这么只要将本地的文件系统目录,挂载到基于镜像所启动的容器上即可。像这样:
docker run -it --rm -v `pwd`:/root miyoo_sdk
复制代码
简单说来,这条命令的意义是这样的:
为此,我们实际上基于Docker,直接在容器里编译了Mac文件系统上的源码。这既没有副作用,也不须要其他数据传递操作。对于日渐复杂的后端工具链依赖问题,我相信这也是一种解决方案,有机会可以单独撰文阐述。
走通HelloWorld
Docker镜像制做好以后,我们能够用上容器里arm-linux-gcc这样的编译器了。这么该如何编译出一个HelloWorld呢?现今还没到引入JS引擎的时侯,先用C语言写出个简单的事例,验证一切都能正常工作吧。
嵌入式Linux设备常用SDL库来渲染基础的GUI,其最简单的示例如下所示,是不是和后端朋友们熟悉的Canvas有些形似呢:
#include
#include
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的文件复制到MiyooTF卡内的/apps目录里后,再用Miyoo自带的程序安装器打开它,才能见到这样的结果了:
这说明Docker编译工具链早已正常工作了!但这还远远不够,现今的关键问题在于,我们的printf去哪了?
点焊排针与并口登陆
基础的Unix知识告诉我们,进程的输出是默认讲到stdout这个标准输出文件里的。通常来说,这种输出就会写入流式的缓冲区,因而勾画到终端上。并且,嵌入式设备的终端在那里呢?通常来说,这种日志写入的是所谓的SerialConsole并口控制台。而这些控制台的数据,则可以通过特别古老的UART传输器来和PC交互,只须要接上三条电路的连线就行。
为此,我们须要想办法接通Miyoo的UART插口,因而能够在笔记本上登录它的Shell。在这方面,司徒的点焊UART接頭这篇文章是特别好的参考资料。我对其中的一句话印象尤其深刻:
廠商真是貼心,特別把GND、UART1RX、UART1TX(由上而下)拉出來,提供開發者一個友好的開發界面
拆机点焊才会用的东西,在大鳄眼中竟然算是友好的开发界面…好吧,不就是点焊吗?现学就是了。
首先我们把后盖拆开,再把显卡卸出来。这步只须要标准的十字螺栓刀,注意别弄丢小零件就行。完成后像这样:
见到图中显卡右上角的三根针了吗?这就是UART的三个插口了(这时我还没点焊,只是把排针摆起来了而已)。它们自上而下分别是GND、RX和TX,只要为它们点焊好排针,将导线连到UART转USB转换器,能够在Mac上登录它啦。联接次序是这样的:
所以linux就该这么学,我们须要先焊上排针。点焊看上去很折腾,现学上去倒并不难,虽然只要先把烙铁头压在焊点上,之后把焊锡丝放起来就行。像我这样的菜鸟,还可以买一些青菜价的练习板,拿几个晶闸管练练手后再焊真的板子。完成后的疗效如下所示,多了三根黑色排针(焊点在反面,好丑就不放图了):
焊好之后,用万用表即可检测焊点是否接通。还记得中学数学里万用表的黑红基极如何联接吗…反正我早就忘光了,也是现学的。实际测得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并口。因而这个问题对我相当棘手——既可能是硬件问题,也可能是软件问题。但总该是个可以解决的问题吧。
好吧,我竟然一路debug遇到了个数学电路设计的硬件问题。那就接着改Linux内核呗。
订制Linux内核驱动
按照司徒提供的线索,我开始尝试将音频驱动从Miyoo的Linux内核源码中屏蔽掉。我们都晓得Linux是宏内核,大量硬件驱动的源码全都在上面。简单改改驱动,虽然不是件多高大上的事情。
首先,我们起码要能把内核编译下来。注意内核不等于嵌入式Linux的系统。一个完整的嵌入式Linux系统,应当大致包括这几部份:
我们只是想禁用掉音频驱动,因而只须要重新编译出Kernel就行。Kernel会编译成名为zImage的镜像。这个过程的用户体验似乎和编译普通的C项目没有哪些区别,也就是先配好编译参数和环境变量,之后make就行了:
make miyoo_defconfig
make zImage
复制代码
在我MacBookPro的Docker里,大致须要12分钟就能把内核编译下来。这儿贴个图,记念下职业生涯第一次编译出的Linux内核:
编译通过后,我十分开心地直接开始尝试更改内核的驱动(注意我没有真机测试这个第一次编译出的内核,这是伏笔)。经过一番研究,我发觉嵌入式Linux的硬件都是通过一种名叫设备树的DSL代码来描述的,更改这些DSL应当能够使Kernel不支持某种硬件了。于是我找到了Miyoo设备树里的音频部份,将其注释掉,尝试编译出不包括音频的设备树描述文件,把它装起来。
之后机器启动后就死机了。
……
看来设备树的配置不管用,我又想到了直接更改音频驱动的C源码。它就是内核项目的/sound/soc/suniv/miyoo.c,上面的C代码看上去并不难,但我尝试了不下七八种更改手法,就是编译不出一份正常的镜像:有时侯可以解决UART未能登入的问题,有时则不行linux服务器配置与管理,但是死机问题也一直没有解决。为何音频驱动会影响视频输出,这让我非常困惑,甚至一度怀疑起了我的工具链。
最终,我得到了一个令人惊讶的推论:
这份内核代码哪怕完全不改,编译下来都是会死机的。
……
于是,我换了社区版本的内核代码,屏幕顺利照亮,问题解决。
然而,社区版本的内核是鬼佬维护的,她们的用户习惯里,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-的编译配置,就可以用交叉编译器来编译它了。
交叉编译自然也很难一帆风顺。这儿我碰到的编译错误,都来自嵌入式环境下的标准库能力缺位。不过当然也只有这两点:
这点小问题,简单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上做了个验证,编译一次通过,没哪些好说的。疗效像这样:
图中你看见的VSCodeDebugger背后可不是V8,而是正经的QuickJS引擎噢。我也用VSCode调试过Dart和C++的代码,当时我没有想到过这样的一套调试器该怎么由一门第三方语言接入。搜索然后我发觉,谷歌甚至早已为编辑器与任意第三方语言之间设计了一个名为DebugAdapterProtocol的通用调试合同,它很具备启发性。原先我感觉非常高大上的编程语言调试系统,也是能用断点、异常等概念来具象化和结构化,并设计出通用合同的。谷歌在工程设计和文档上的积累真不是盖的,赞一个。
如今,我早已将这个支持VSCode调试的QuickJS版本编译到了Miyoo上,只是还没有做过实际的调试——有了订制内核驱动时不停给自己挖坑的教训,我如今自然不敢立Flag说它能用了(掩面)
到此为止,本次实验所关注的能力都早已得到基本的验证了。相应的Docker镜像我也已发布到GitHub,参见MiyooSDK。也欢迎你们的交流。
杂记
此次写的又是一篇长文,这整套工作远没有文章写出来这么一气呵成,而是断断续续地逐渐完成的。如今我手上的东西,还只是个初步的工程原型,有好多工作还可以继续深入。诸如那些地方:
不过,只要有热情持续深入技术,这么收获一定不会让你沮丧。像你们眼中神秘的Linux内核,当然也是个有规可循的程序。虽然是我这样本职写JavaScript的玩票选手,照样可以拿通用的科学方式论来实验剖析它,而这个过程如同玩密室逃脱或则解谜游戏一样有趣——你晓得问题一定能解决,只要用逻辑推理,找到屋子里隐藏的那种开关就行。
我要非常谢谢司徒,他为开源掌机的发展做出了巨大的贡献。此次最为疑难的硬件电路bug,也是由他提供了关键信息后才最终得以解决的。好多时侯我们缺的不是繁琐繁杂的入门手册linux虚拟串口驱动,而是来自更高段位者,一两句话让你茅塞顿开的点拨。他就是这样一位令人敬爱的技术人。
这儿跑个题,天猫上有不少用司徒系统的名义销售掌机的店面,这种店家虽然早已与他本人完全无关了。其实我一直很推荐你们入手这个只要一百多块钱的Miyoo掌机用于娱乐或技术研究,但我还是有些慨叹。所谓遍身罗绮者,不是养鸭人,尚且这么吧。
从搭建工具链到钎焊电路板,再到订制Linux内核和JS引擎,这种技术本身尚且都有点门槛。但富于乐趣的目标,总能让我们更有动力去克服中途的各类困难。我相信兴趣和热情总是最能剌激求知欲的,而永不知足的求知欲,能够驱动我们不停跨过一个个山丘。虽然乔帮主提过的那句格言是如何说的来着?
StayHungry.StayFoolish.
我主要是个后端开发者。假如你对Web编辑器、WebGL渲染、Hybrid构架设计,或则计算机爱好者的碎碎念感兴趣,欢迎关注我噢:)
原链接:
文章评论