寻思了很久,我还是决定写下这篇文章,但这篇文章可能有点杂,看的话希望忍一忍。Termux是一个终端模拟器,让安卓有如同linux一般强大,但“安卓天生是linux”,真正的实现或许并没想象中那么复杂。我们先做一些铺垫吧。

软件是什么

在最开始我想说清软件到底是什么?你可能会觉得不就是,windows的exe,安卓的apk,苹果的ipa,linux的(…)…。这样讲好像也没太大问题,exe确实是可执行文件,但apk和ipa可是安装包,大部分认识的exe也是安装包所以把它与apk和ipa归为一类也是可以理解的。软件本质上分为两种原生软件和虚拟机上软件,我们稍微来介绍一下吧。

虚拟机上软件

这种称呼只是作者自己这样叫的,你可别信了。java软件就是这类软件的典型代表,如logisim和java版minecraft。它们的典型特点是我们还必须下载一个运行环境(java的jre),这实际就是一种虚拟机的思想,通过一层java虚拟机达到在不同平台运行同一软件的效果,实际上基本所有语言都是这样运行的,不限于java,python,lua,C#等,除了少数的C/C++,go,rust等,实际上远古的语言都是后者类型,但后来发现java这种虚拟机上的语言有极大的遍历,流行的就都变成了前者类型。我们讲讲python,py和pyc其实本质上没有太大的区别,就和java的java和class,lua的lua和luac一样,没有解释器就不能运行。你可能会说,我也看到它们可以打包成exe和apk,确实但这是有代商榷的,究竟是把虚拟环境和程序一起打包进去(如cocos2dlua),还是真的有静态编译技术就不得而知了。

原生软件

与虚拟机上的软件“一次编译(编写)处处允许”不同,原生软件的特点是“一次编写处处编译”,这是从开发者角度,我们先跳过,如果从用户的角度的话,还真就是各种后缀的不同了,而且不同平台还得下不同版本,还得分32位和64位等等。大部分用户对应用的印象还停留在“下载然后安装”,所以有时jar程序和py程序都得来一个exe才行,如minecraft就需要一个exe的启动器。不过这也没错,这才是原生软件。虚拟机上软件面向虚拟机运行,而原生软件则是面向操作系统运行的,注意不是硬件,后面我们会讲。我们举几个常见的系统说明一下,为什么不说苹果,才不是因为我没有苹果呢?

windows

我们平常下载的exe安装包其实就是一个程序,只不过它将真正的程序打包以后,再加了一些用于配置的代码,如配置安装路径,修改注册表,创建快捷方式等,最重要的还是解压数据到指定路径。当然作者其实并不喜欢这种程序,而喜欢portable版,俗称绿色版或免安装版,至少这样可以对软件污染的范围有所控制,像有些软件拿管理员身份安装,谁知道它干了什么。一个标准的windows原生程序包括可执行文件,动态库和资源文件,其实基本所有软件都是这样的模式,目前看来是不可取代的。资源文件是一种统称,包括配置文件,媒体资源和软件自带的一些格式资源,因为它们多而杂,而且不是我们需要关注的,所以统称为一类就行了。在windows上,动态库是dll,可执行文件是exe,exe和dll才是我们的程序,exe是程序的入口。

linux

在linux上,依据不同的发行版本安装包的种类确实还挺多的,如ubuntu的deb(本质来自于debian发行版),不过作为linux爱好者,更喜欢apt install *来安装软件,软件之所以有一个安装过程最大原因还是省去了用户繁琐配置的过程。linux程序只有一种,有可执行权限和elf文件头的文件,这是可执行文件,动态库则是so为后缀,放在“/usr/lib”之类路径下,当然还能通过配置来修改增加动态库路径就是了。在ubuntu下有一个应用列表,它实际就是一个可被桌面识别的配置文件,指向安装过的某个可执行文件,它路径配置比较自由。

安卓

最后再说一说安卓吧,安卓的可执行文件是安装包(本质zip)里面的dex,这是一种类似jar的文件,但它运行在google自己研究的dalvik虚拟机上,可以兼容大部分javaAPI,对于引用的库,除了androidframework,都会被打包进dex,而桌面则是通过软件的配置来构造Intent(用于androidActivity跳转的类),来执行相应的程序。安卓天生是linux,所以安卓提供native技术(实际上与java的jni无异),可以在dex里调用动态库so的函数。所以对于安卓,软件=dex(可执行文件)+so(动态库)。对于linux,软件=有可执行权限的文件+so。对于windows,软件=exe+dll。有关动态库,还得提一嘴的是,它并非必需是在程序的,此时软件使用的是系统的动态库。为什么要说原生软件是面向系统的,就是因为它们离不开系统动态库。

跨平台的难处

对于虚拟机上软件,只要官方虚拟机哪里有,哪里就能运行,并没有考虑跨平台的必要,这是提供方需考虑的,当然不排除第三方移植就是了。而对于原生软件,难点主要有俩,一是系统SDK(开发工具包),二是硬件差异。

系统SDK

原生软件是面向系统的,而非面向硬件,如果和我说汇编,我笑而不语。特别是对于闭源系统,我们离不开官方提供的SDK,就算开源也没谁会闲着开发再开发一套工具,嗯,话说GUN套件到底算什么。对于Windows,我们需要WindowsSDK,不过它基本是直接和Visual系列绑定的就是了,windows官方提供了不少编程语言如Basic,C/C++,C#,F#等,不过用得多的都是C系列就是了,谁让基本好多系统都支持C系列语言,至于为什么,我也不知道。这里还需要指出C#编译出的软件本质也属于虚拟机上程序,它依赖于.net运行时环境,只不过在windows里正好有,就好像原生环境一样。对于Android,我们需要AndroidSDK,早期没有AndroidStudio的时候,靠的是有扩展能力的编辑器(如eclipse)或者直接使用SDK中的命令程序,反正挺麻烦的,所以集成开发环境(IDE)则么也比命令行舒服太多了。对于苹果系列(包括mac和iphone)使用的是Xcode,因为没用过,所有不知道,直接跳过。最惨的或许是我们的linux了,没有IDE,只有GUN套件的gcc和g++,不过也正应如此我们才能更加深入的理解操作系统原理。接下来,我们的平台都是安卓和linux了,可别在意哦。

硬件差异

对于SDK差异,克服其实并不是很困难,如linux,window和mac有着不同的窗口创建API,但可以通过glfw框架简单的统一起来,它们主要表现在媒体方面,而基础操作的话,即标准C/C++库,则都是一样的,注意只是提供的API相同,编译后的结果是不同的。所以我们可以发现跨平台的C库基本都是媒体库如Qt,SDL,GTK等,我们需要注意跨平台C库和C库的区别,后者基于标准C/C++库实现,基本无平台差异性。这部分用于区分不同的桌面平台,而真正的难题在于硬件的差异,而其中问题最大的是显示,对于声音键盘等都比较同质化,而CPU的话虽然架构有所差异,但还是离不开冯诺依曼结构,显示才是最大的问题,这也是平台端与手机端较难对接的原因。你觉得只是屏幕变小那么简单?桌面端有窗口系统得益于巨大的屏幕,所以比例的问题并不是很大,但手机因为屏幕小,只能全屏,不同手机分辨率不同,或许这个开源安卓太多发行版有关吧,显示问题就出来了,如果是等比放缩的话其实观感影响不大,但拉伸还是看得挺难受的,所以以前安卓开发准备drawable是时候有好多分辨率,属实难受,不过现在大多基于框架(如游戏引擎),提供分辨率适配功能。但难以跨平台终究还是两方面原因,一是确实硬件不行,二是懒和利益问题。对于前者主要表现在手机与桌面端的高端游戏上(有光追,体积雾等需要高端显卡的游戏),低端游戏移植还是挺多的,这就涉及第二方面中的懒了,如懒得在安卓再设计一套UI,如果是逻辑控制,数据处理等方面可以靠C系语言的强大跨平台编译性,而UI才是最麻烦的东西,涉及流程复杂,像没有键盘的安卓还需要虚拟键盘,还有很强的平台相关性,这也是为什么MVC设计如此重要,如果单纯看绘图的话,勉强还有opengl,但原始积累的控件才是很麻烦的事。为什么大部分商业软件并没在linux上发行,这就与利益问题相关了,比如linux的开源协议,所以我们使用的linux软件基本都是开源免费的。

一个软件的运行过程

在linux下可以通过strace *来查看软件的运行过程

这里的每一行都是一个系统调用(如execve,mmap)等,括号内是参数,等号后是执行状态,0表示正常,不过实际上这是有问题的,mkdir本身是系统调用,是在linux内核里面的,安卓确实有,但Termux只是个模拟软件没有权限调用,实际只能模拟一个类似的实现

这里的time也是一个系统调用,strace无法跟踪,所以我们还是举一个简单的helloworld比较合适

输出太长了,关键的其实就只要下面的这一句write(1, "Hello world!", 12Hello world!)作者虽然学过操作系统原理,但真的实践的话还是差距太大了。我们看到execve感觉这样程序应该已经执行完了才对,但linux的程序实际都要依赖于我们当前的shell来运行,我们的a.out虽说是程序,但对于CPU而言是有杂质的,其次它并不在内存里(通俗讲是运行内存),而在外存里CPU不能直接运行。mmap是内存映射,主要将代码拷贝到内存里,这就可以理解execve实际是用来解析可执行文件的,mprotect则是给程序分配内存用的防止内存溢出,prctl进程操作等等,我们直接说结论吧,其实只有纯正的命令行,它是显卡的一个运行状态(linux下Ctrl+Alt+F1切换)(除非你知道在做什么否则别这样),我们才能看到一个程序真正的运行过程,我们实际使用的shell都是桌面进程的子进程,内部有许多封装和状态的模拟,早就离教材里的情况有十万八千里了,C程序的运行我之前在lua分析里讲过,进程实际是就是一段内存,有四个部分堆内存,代码段,栈内存,全局变量。而这些东西的建立与运行过程都是通过系统调用来实现的,所以我说过编程都是面向系统的。这时你或许要和我理论汇编了,确实我得讲一下这个东西了,C程序的编译阶段就包括汇编的过程,比如我们Helloworld的汇编代码如下

一句代码比较显眼bl printf,也就是这里并没有完全实现输出,还是得靠其它函数实现,我们看看它的源码吧(来自glibc)…额,太复杂了,不过总之就是它依赖于系统调用实现的[说实话,有点破防了,以前学了操作系统原理,就想着这自己写一个操作系统,也曾跟着教程一步步来,但到后面我都不知道这样做的意义在哪,花费如此多的时间,完成度还不如很久以前的dos系统,有些时候要考虑很多东西,而这些东西都是历史积累的结果,短时间内想要把这些全部理解实在太困难了,而且跟着做完以后,你会发现它仍然无法适应如今的情况,也就是它真的只能用来学习,除非能摆脱如今的计算机架构有所创新,否则再写一个与如今系统差不多有又许多问题的系统,根本没有意义,就是兼容C也毫无意义,那时我才明白基于linux内核并非什么丢脸的事,以前我还十分看不起那些基于linux的手机系统呢,唉,过去的事,提提就过去吧]。这时或许你会说,我用汇编实现helloworld的代码应该是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
data SEGMENT
msg DB 'Hello, world.$'
data ENDS
code SEGMENT
ASSUME CS:code,DS:data
start:MOV AX,data
MOV DS,AX
lea dx,msg
mov ah,9h
int 21h
MOV AX,4C00h
INT 21h
code ENDS
END start

其中重要的部分是int 21h,有些人也喜欢用int 0x80,后者是纯粹的系统调用的写法,编译器会”理解性”的帮你解释,而前者则是真正的中断服务,中断是CPU与其它设备交流的核心指令,int 21h是dos提供的字符中断例程,与之类似的还有int 10h是dos提供的图形中断例程,有没有发现即使汇编你还是离不开系统,如果你让它在你的系统上编译肯定是这样的,dosbox综究还只是个模拟器。不过int指令确实是CPU与其它硬件交流的核心,好了直接跳到重点吧,CPU的大多指令都是逻辑控制与计算和对内存的控制,这与C的基本语句都是类似的,而复杂的与硬件交流的指令如int,in,out等,我们则会通过封装在系统调用里的库来实现的,操作系统真正重要的其实就是这一部分,而封装的过程则是驱动的编写,linux内核就是宏内核,它把许多与硬件交流的部分全封装起来,如进程管理(本质就是内存管理),并将系统调用外露出来,当然还要封装一层C/C++接口来方便编程就是了,最后套个shell就可以形成一个操作系统了。操作系统重要的作用就是保护硬件,所以特别关注int指令还是可以理解的。最后我们应该可以理解一个软件真正的运行过程了,看看下面这个图吧。

演不下去了,这还是最简单的shell,实际的系统更加复杂,不过比我们需要的超出太多了,所以不讲了,笔者在底层虽说有一定理解,但还是离实际差太远了,希望谅解作者的水平。

重识C语言

或许你会疑惑一个问题,操作系统是用C写的,但C又是在操作系统上编译的,好像是个死循环,实际上C语言的GUN套件包括编译器也是C写的。实际上这都是历史积累的结果,这个从python就很好理解,python的原始解释器是CPython,但后来又有一个用python写的解释器PyPy,与python不同C是可以编译为二进制文件的,它的运行依赖于CPU而不是解释器,所以一旦有了一个原始的程序,比如通过烧录,它有编译C的功能,这样在通过C写一个新的编译器,编译为CPU上程序即可,这样以后都用这个就行了,不过这不是重点。我们都知道C语言的编译分四部:预处理,编译,汇编和链接。
其中的枢纽是函数,函数始终贯穿四个步骤,我们并没有提C++并非说它没有讨论的必要,只是C++终究只是为了引入一些现代思想(面向对象,内存保护等)而形成的C的超集,理解底层还是得靠C。在预处理中,主要有两大作用,一是选择需要编译的代码(主要用于跨平台编译),二是声明需要的函数和结构体,编译则是将C语言转化为适应于相应系统的汇编代码,汇编将汇编代码转为二进制文件,链接则是将申明的函数连接到相应的实现位置。这些都挺好理解,但为什么说枢纽是函数,在我们开发过程中,很容易发现对于逻辑控制和数据处理这类CPU擅长的事,不会用到任何函数,它们是可以直接汇编为机器指令。而函数封装的则是一些本身非CPU的直接性指令,如输出语句,这时就需要引入外部的库。在linux里,无特别配置的话,头文件的默认路径是/usr/include/usr/local/include,里面除了C标准库,我们发现还有一些比较注目的如linux和sys,如果安装过桌面系统的就还有gl、x11之类的图形框架。前面这两个一般的开发者(如我)基本估计都没有用过,实际上系统调用就申明在linux/syscalls.h里面,只是一些名称对应数字的宏,实际上许多驱动的相关函数也在这个目录里面,这类东西其实与我差距还是太远了。我们再来认识一下C语言吧,C其实只是汇编语言上的一层不太复杂的抽象,汇编嘛,只不过是机器指令的映射罢了,对于无危险性的行为直接编译即可,而较危险(主要是与硬件的交互)则主要封装在系统里,然后通过向外提供的接口来调用。至此其实对所谓的操作系统应该有大概的理解了,那就是硬件管理,通俗讲就是驱动程序。不过嘛,除了咱们的linux,准确来说是内核吧,上层还是封装了许多东西才有我们的操作系统,想想我们曾学过的操作系统原理,不知为何总是对不上windows之类的系统。说太多了,其实我觉得C之所以一直占据底层,估计是很多人懒得写编译器罢了,要么就是原始的软件生态太难超过了,比如go之类的语言,编译结果一样是可以依赖CPU运行的。其实用过GUN套件编译C的人大多对这样应该都是很了解的了,要不就当我水点字数吧。

从安卓开发看安卓

开发者在一部安卓手机上有多大的控制权,实际的意思是软件的控制权有多大,不过我们得考虑一种叫root的东西,默认没有就行了。这要从安卓的框架说起,从下到上分为为4层,linux内核层,系统运行层,应用框架层,应用层。一般的用户只是使用软件,停留在应用层,而安卓的开发者可在应用框架层使用java,在系统运行层使用C/C++再通过回调返回结果到应用层。这些其实都不重要,重要的是它是linux内核的,重要的是我们可以通过NDK使用内核外露的C库,没法控制的大头在于权限管理。在linux里面一个程序(如ls)要运行必需要有可执行权限,但是安卓应用并没有权限管理的功能,准确来说是linux层的那种权限管理,不过这是针对一般应用,如果是系统应用或root应用就别考虑了,这除了找漏洞还真没啥办法,已经超出了我的能力范围。回到正题,其实对于一个应用它能完全控制的路径如下(不过依据不同的发行系统可能稍有偏差)

dex+lib属于程序的核心部分,然后还有apk的备份和缓存区,外部的嘛没什么好说的,重点来了,那个文件夹就是6.71GB的那个,不过这里应该是存在软链接的,比如在Termux下的路径是这样的

我猜/data/data/data/user/0应该是链接,没root我也不能肯定啊。这时我们还有一个值得注意的是u0_a166这个用户和用户组,这个实际就是系统分配给软件的,看来终究还是得适应linux内核。这里面的文件,shared_prefs是安卓在java层有一个SharedPreferences存储的位置,databases虽然这里没有但它也是java层提供的sqlite存储的位置,cache缓存对于这种东西我都是保持沉默的,files是软件随便存储的私人空间通过“openFile*”即可获得相应的流,而这也就是我们实现一个类linux的核心据点了。有人觉得外存不行吗?确实不行,你看

除了root用户,我们没有任何权限,你可能会说不对啊,只要在Manifest.xml里申明permission的话可以写入文件啊。其实我们可能需要稍微讲讲权限管理定位本质是什么,安卓层的咱不讲,我们讲linux层,你有没有发现关于权限的操作往往于硬件相关,这意味着我们需要通过函数调用来实现相关操作,最简单的实现权限管理的方法就是这这个函数里加上权限的判断,实际上我们可能还得讲进程管理的问题,实际上在linux里一个用户对应一个进程或者说一个shell,我们通过shell实现相应操作这个shell实际已经通过登陆的用户拥有着权限标识,但是要行使权限还是得靠系统调用,系统自然就可以根据你的权限标识来判断操作是否继续执行,简单的权限管理就可以实现了。这也解释了,我们之前为什么要这么关心文件和权限的问题,因为linux系统权限管理的核心就在这里,这也确立了files文件的重要性。对于更加细致的就不讲了吧,本来就有很多相关作品,再讲一遍就太浪费时间了。文件为何如此重要,这来源于liunx万物皆文件的思想,比如/dev,/tmp,/run等文件,linux实际上还可以通过修改/proc下的文件来对进程中的变量操作,不过都是root才有的权限就是了。为什么会认为Termux是伪终端,因为Termux只实现了/home和/usr目录,而内核实际上还是依赖安卓自身的内核,同时useradd之类的用户管理命令Termux也是做不到的,真的学linux的话,Termux并不是一个好的工具,但它对开发者而言却是一个手机利器(比如我)。

Termux实现的可能性

讲了这么就底层与安卓的东西,终于可以来讲Termux该怎么实现了,首先我们要明确到底实现哪些功能,比如用户管理显然不行,超应用范围的文件操作也是不行的,其实有些是没必要的,在files文件下Termux自己其实就像一个root用户可以任意修改读写权限,虽然范围有所限制,但子树还是能和原来的树有相似功能的,我们要完成的事情实际只要一个,在files里执行C编译后的程序,实际得使用NDK来编译,但我们可以使用Termux的GCC来近似达到目的,一旦说明了上述功能的可能性,实际上就可以让许多C程序在Termux上运行了,只要利用安卓的界面,图形界面其实也是可以实现的,比如国改的UTermux或Aidlearning,它们的实现都是类似的,不过我们先从基础的开始。光说不练假本事,接下来我们写一个小项目,执行flies下的HelloWorld并把结果输出到界面上。先随便写一个界面

然后将我们的可执行文件拷贝到软件目录下(这里是外置内存卡,别忘了加权限)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void onClick1(View view) {
String fromFile = "a.out";
String toFile = "a.out";
String fromFullPath = Environment.getExternalStorageDirectory().getAbsolutePath()
+ "/" + fromFile;
String toFullPath = getFilesDir()
+ "/" + toFile;
try {
//输入流
FileInputStream fis = new FileInputStream(fromFullPath);
//输出流
FileOutputStream fos = openFileOutput(toFile, MODE_WORLD_WRITEABLE);

byte data[] = new byte[1024];
int length;
while((length = fis.read(data))!=-1) {
fos.write(data, 0, length);
}

fis.close();
fos.close();

log.append("成功将"
+ fromFullPath
+ "拷贝至"
+ toFullPath
+ "\n");
return;
} catch (FileNotFoundException e) {
log.append("未找到文件!\n");
log.append(e.toString());
} catch(IOException e) {
log.append("IO异常!\n");
log.append(e.toString());
}
}

然后是执行的核心代码(其实挺简单的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void onClick2(View view) {
String toFile = "a.out";
String toFullPath = getFilesDir()
+ "/" + toFile;

File file = new File(toFullPath);
boolean success = file.setExecutable(true);
if(!success)
log.append("Permission Denied!\n");

Runtime runtime = Runtime.getRuntime();
try {
//保存输出
InputStream is;

Process proc = runtime.exec(file.getAbsolutePath());
if(proc.waitFor() != 0) {
log.append("运行错误 exit code="+proc.exitValue()+"\n");
is = proc.getErrorStream();
} else {
is = proc.getInputStream();
}
byte data[] = new byte[1024];
int length;
while((length = is.read(data))!=-1) {
log.append(new String(data, 0, length));
}
log.append("\n");
} catch (IOException e) {
log.append("IO异常!\n");
log.append(e.toString());
} catch(InterruptedException e) {
log.append("执行中断!\n");
log.append(e.toString());
}
}

最后编译测试一下(a.out已经用Termux编译后提前准备好了)

嗯,完美执行了……感觉有点不够,真的有这么简单嘛?是的确实就是这么简单,安卓实际已经封装了不少方法,只不过这种API一般用得比较少,开发者就没怎么注意过。实际上,runtime除了exec执行命令外还有许多方法,如loadLibrary可以载入动态库,traceInstructions进行跟踪等。实际上,内核这东西安卓已经有了,我们做的不过是一层封装罢了。你问我,我前面讲这么多的意义在哪?当然是更好的理解API啦,有时API用起来难,实际难在不理解背后的原理。

看看Termux源码

最后我们稍微速览一下Termux的源码吧,这里是地址。嗯,才4M左右的内存,不对啊记得明明安装包都有80M左右,赶快拆包看看。

好家伙,bootstrap不是个Web框架吗?开玩笑啦,其实这个只是Termux运行基本库,主要是用来兼容安卓下没有的linux的一些库,还有一些基本命令。Termux编译了四个不同的架构,加起来正好80M左右,好了回到正题

我们看到源码分三部分来写,emulator应该是核心部分,view应该是界面,app的话应该是用来整合成一个应用的。在app的Manifest里面我们看到了三个Activity,至于还有一个别名Activity就别管了,TermuxActivity即是主界面,(注意View是界面不是Activity,两者有一定区别,所以Activity在app里而不在view里)。另外两个TermuxHelpActivity和TermuxFileReceiverActivity,这是啥,算了没见过不懂,还是先看看源码吧。嗯嗯,在TermuxActivity里可以看到这个

1
2
3
4
5
6
7
8
9
10
11
@Override
public void onCreate(Bundle bundle) {
***
mTerminalView = findViewById(R.id.terminal_view);
mTerminalView.setOnKeyListener(new TermuxViewClient(this));

mTerminalView.setTextSize(mSettings.getFontSize());
mTerminalView.setKeepScreenOn(mSettings.isScreenAlwaysOn());
mTerminalView.requestFocus();
***
}

mTermuxView是TermuxView的实例对象,它定义在view里面,是Termux的主要界面shell部分的内容。我们继续看,在TermuxView里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public final class TerminalView extends View {
***
@Override
public boolean onCheckIsTextEditor() {
return true;
}
***
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
***

return new BaseInputConnection(this, true) {
@Override
public boolean finishComposingText() {
***
sendTextToTerminal(getEditable());
getEditable().clear();
return true;
}

@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
***
Editable content = getEditable();
sendTextToTerminal(content);
content.clear();
return true;
}
***
void sendTextToTerminal(CharSequence text) {
stopTextSelectionMode();
final int textLengthInChars = text.length();
for (int i = 0; i < textLengthInChars; i++) {
char firstChar = text.charAt(i);
int codePoint;
if (Character.isHighSurrogate(firstChar)) {
if (++i < textLengthInChars) {
codePoint = Character.toCodePoint(firstChar, text.charAt(i));
} else {
// At end of string, with no low surrogate following the high:
codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR;
}
} else {
codePoint = firstChar;
}

boolean ctrlHeld = false;
if (codePoint <= 31 && codePoint != 27) {
if (codePoint == '\n') {
// The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed,
// instead of a key event like most other keyboard apps. A terminal expects \r for the enter
// key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to
// check the behaviour).
codePoint = '\r';
}

// E.g. penti keyboard for ctrl input.
ctrlHeld = true;
switch (codePoint) {
case 31:
codePoint = '_';
break;
case 30:
codePoint = '^';
break;
case 29:
codePoint = ']';
break;
case 28:
codePoint = '\\';
break;
default:
codePoint += 96;
break;
}
}

inputCodePoint(codePoint, ctrlHeld, false);
}
}
};
}
}

这部分是用来监听输入法输入的代码,void sendTextToTerminal(CharSequence text) {里面的text即是我们一次输入的内容,对于有些字符稍作处理后,再把任务交给inputCodePoint(codePoint, ctrlHeld, false);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public void inputCodePoint(int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) {
if (LOG_KEY_EVENTS) {
Log.i(EmulatorDebug.LOG_TAG, "inputCodePoint(codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent="
+ leftAltDownFromEvent + ")");
}

if (mTermSession == null) return;

final boolean controlDown = controlDownFromEvent || mClient.readControlKey();
final boolean altDown = leftAltDownFromEvent || mClient.readAltKey();

if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return;

if (controlDown) {
if (codePoint >= 'a' && codePoint <= 'z') {
codePoint = codePoint - 'a' + 1;
} else if (codePoint >= 'A' && codePoint <= 'Z') {
codePoint = codePoint - 'A' + 1;
} else if (codePoint == ' ' || codePoint == '2') {
codePoint = 0;
} else if (codePoint == '[' || codePoint == '3') {
codePoint = 27; // ^[ (Esc)
} else if (codePoint == '\\' || codePoint == '4') {
codePoint = 28;
} else if (codePoint == ']' || codePoint == '5') {
codePoint = 29;
} else if (codePoint == '^' || codePoint == '6') {
codePoint = 30; // control-^
} else if (codePoint == '_' || codePoint == '7' || codePoint == '/') {
// "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102"
// - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal
codePoint = 31;
} else if (codePoint == '8') {
codePoint = 127; // DEL
}
}

if (codePoint > -1) {
// Work around bluetooth keyboards sending funny unicode characters instead
// of the more normal ones from ASCII that terminal programs expect - the
// desire to input the original characters should be low.
switch (codePoint) {
case 0x02DC: // SMALL TILDE.
codePoint = 0x007E; // TILDE (~).
break;
case 0x02CB: // MODIFIER LETTER GRAVE ACCENT.
codePoint = 0x0060; // GRAVE ACCENT (`).
break;
case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT.
codePoint = 0x005E; // CIRCUMFLEX ACCENT (^).
break;
}

// If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline:
mTermSession.writeCodePoint(altDown, codePoint);
}
}

显然这个方法处理完ctrl就交给mTermSession来处理alt了,它是TerminalSession的实例对象,但它是在TermuxActivity里面实例化的,并通过view的attackSession来绑定,总之知道它是处理层的东西就行了,我们去看看吧,它在emulator模块里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public final class TerminalSession extends TerminalOutput {
@Override
public void write(byte[] data, int offset, int count) {
if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count);
}

/** Write the Unicode code point to the terminal encoded in UTF-8. */
public void writeCodePoint(boolean prependEscape, int codePoint) {
if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
// 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range.
throw new IllegalArgumentException("Invalid code point: " + codePoint);
}

int bufferPosition = 0;
if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27;

if (codePoint <= /* 7 bits */0b1111111) {
mUtf8InputBuffer[bufferPosition++] = (byte) codePoint;
} else if (codePoint <= /* 11 bits */0b11111111111) {
/* 110xxxxx leading byte with leading 5 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
} else if (codePoint <= /* 16 bits */0b1111111111111111) {
/* 1110xxxx leading byte with leading 4 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
} else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */
/* 11110xxx leading byte with leading 3 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
/* 10xxxxxx continuation byte with following 6 bits */
mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
}
write(mUtf8InputBuffer, 0, bufferPosition);
}
}

最终会传入mTerminalToProcessIOQueue来处理相关事务,它是一个byte队列,用来将terminal数据传入我们进程的数据结构,然后我们的读取线程在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void initializeEmulator(int columns, int rows) {
mEmulator = new TerminalEmulator(this, columns, rows, /* transcript= */2000);

int[] processId = new int[1];
mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns);
mShellPid = processId[0];

final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor);
***
new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") {
@Override
public void run() {
final byte[] buffer = new byte[4096];
try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) {
while (true) {
int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true);
if (bytesToWrite == -1) return;
termOut.write(buffer, 0, bytesToWrite);
}
} catch (IOException e) {
// Ignore.
}
}
}.start();
***
}

JNI是native方法的封装类,对应我们之前看到的libtermux.so,看样子它最终是用C来实现的,不过对于安卓java与C没太大差别。我们换到jni,看看termux.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
static int create_subprocess(JNIEnv* env,
char const* cmd,
char const* cwd,
char* const argv[],
char** envp,
int* pProcessId,
jint rows,
jint columns)
{
int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);
if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx");

#ifdef LACKS_PTSNAME_R
char* devname;
#else
char devname[64];
#endif
if (grantpt(ptm) || unlockpt(ptm) ||
#ifdef LACKS_PTSNAME_R
(devname = ptsname(ptm)) == NULL
#else
ptsname_r(ptm, devname, sizeof(devname))
#endif
) {
return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx");
}

// Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display.
struct termios tios;
tcgetattr(ptm, &tios);
tios.c_iflag |= IUTF8;
tios.c_iflag &= ~(IXON | IXOFF);
tcsetattr(ptm, TCSANOW, &tios);

/** Set initial winsize. */
struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns };
ioctl(ptm, TIOCSWINSZ, &sz);

pid_t pid = fork();
if (pid < 0) {
return throw_runtime_exception(env, "Fork failed");
} else if (pid > 0) {
*pProcessId = (int) pid;
return ptm;
} else {
***
}
}
JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
JNIEnv* env,
jclass TERMUX_UNUSED(clazz),
jstring cmd,
jstring cwd,
jobjectArray args,
jobjectArray envVars,
jintArray processIdArray,
jint rows,
jint columns)
{
***
int procId = 0;
char const* cmd_cwd = (*env)->GetStringUTFChars(env, cwd, NULL);
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
***
return ptm;
}

当我看到int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);的时候,算了,我们实现的方式看来存在根本上的区别,我就说为什么每次运行Termux的时候通知栏有一个消不掉的东西(手机截不到屏),这原来是系统的东西,我还以为是程序的东西。欸欸,有没有觉得很惊讶,是我孤陋寡闻了?嘿嘿,看我为什么做了这么多铺垫,意义不就来了嘛!至于“/dev/ptmx”嘛,它是linux内核自带的伪终端设备,看来android果然天生就是linux,好了,我们说过就是速览一下,看来也该结束了,你问我怎么实现的好像没说?我只能说补一补linux的知识吧。