开源一个Flutter编写的完整终端模拟器
上次开源了一个简易的终端模拟器,我也知道并不是标准的,但自己也一直在用,然后就发现了一些棘手的问题,就又跑去研究了一些完整终端的源码,termux,Android Terminal,最后成功的将他们的原理在 Flutter 实现
其实这个源也可能会是你学习使用 dart:ffi 的一个例子,其中用到的 char **,也就是二级指针的传递在也很少能在官方的 example 中也很难找到直接的例子,也是我处理这种类型遇见的比较麻烦的坑,主要就是没有案例。我将 termux 的 C 语言部分完全重构以供 Flutter 使用,由于 UI 框架使用的 Flutter 经过测试可以在 Macos 上跑起来!!!
Process 类的 stdout 是哪里来的?
自己在使用中遇见了这个棘手的问题,还是由于经验不够,还去知乎上提了我遇见的问题,
知乎传送
经过与同学的探讨后(死皮赖脸问人家),可以知道 Process 中的 stdout 是来自于 pipe(管道),也可以看到 stdout 也有 pipe 这个方法,而管道是存在缓冲的,举个 🌰
使用
1 | cp -rv sourceDir targetDir |
命令,由于开启了-v 参数,所以在标准终端中,cp 命令会一行一行打印出正在复制的文件,而当用 dart 的 Process 去执行这样的操作,你在对 stdout 的监听中并不会收到一次一行的回调,而是一次一堆的回调,那就是由于管道是存在缓冲机制的,达到缓冲上限后才能拿到一次,或者程序结束后,缓冲区未满也能拿到。
我们再切换到标准终端模拟器
1 | cp -rv sourceDir targetDir | xargs echo |
我们在终端中也使用管道,通过 xargs 将其打印出来,这个时候会发现,打印的东西跟次数,跟 dart 中 stdout 的回调是一样的,不止 dart,包括 java 中 runtime 拿到的输入流,也无法拿到无缓冲的输出.
终端与管道的缓冲差别
终端也具有缓冲,终端为行缓冲,管道为全缓冲,行缓冲中,遇见换行符\n 即可向终端中输出一次,或者主动在 C 语言中调用 fflush()方法,会将已经在缓冲区的内容输出一次,如果没有以上两个条件,就只能等到缓冲区满 1024 个字节,才能输出一次
标准终端又是怎么做到拿到行缓冲的输出的?
我能想到的最快的方法就是去看一些标准终端的开源库,现在比较优秀有 termux,跟 Android Terminal,termux 可以说是目前安卓上最强大的终端了,有大量的可扩展资源,我就直接 clone 下来,从 manifest 中找到主类,从 Activity 中 oncreate 中一点一点看,还是花了挺多时间,毕竟 termux 还是比较大型的储存库,也有注释,但始终找不到关键的地方,能够在 Flutter 实现的地方,最后定位到了 UI 中获取输入,包括将输出同步到屏幕,这一系列都指向了 JNI,也就是一个 java 到 c/c++的一个通道,我也是从这才开始知道项目中的那个 C 语言是什么时候用的了。
标准终端实现原理
这种终端称伪终端(pty)
必须先看一波来自互联网的科普
伪终端(pseudo terminal,有时也被称为 pty)是指伪终端 master 和伪终端 slave 这一对字符设备。其中的 slave 对应 /dev/pts/ 目录下的一个文件,而 master 则在内存中标识为一个文件描述符(fd)。伪终端由终端模拟器提供,终端模拟器是一个运行在用户态的应用程序。
Master 端是更接近用户显示器、键盘的一端,slave 端是在虚拟终端上运行的 CLI(Command Line Interface,命令行接口)程序。Linux 的伪终端驱动程序,会把 master 端(如键盘)写入的数据转发给 slave 端供程序输入,把程序写入 slave 端的数据转发给 master 端供(显示器驱动等)读取。请参考下面的示意图(此图来自互联网):
我们打开的终端桌面程序,比如 GNOME Terminal,其实是一种终端模拟软件。当终端模拟软件运行时,它通过打开 /dev/ptmx 文件创建了一个伪终端的 master 和 slave 对,并让 shell 运行在 slave 端。当用户在终端模拟软件中按下键盘按键时,它产生字节流并写入 master 中,shell 进程便可从 slave 中读取输入;shell 和它的子程序,将输出内容写入 slave 中,由终端模拟软件负责将字符打印到窗口中。
文本描述符又是啥!?
来自百度:
Linux 中一切皆文件,比如 C++ 源文件、视频文件、Shell 脚本、可执行文件等,就连键盘、显示器、鼠标等硬件设备也都是文件。
一个 Linux 进程可以打开成百上千个文件,为了表示和区分已经打开的文件,Linux 会给每个文件分配一个编号(一个 ID),这个编号就是一个整数,被称为文件描述符(File Descriptor)。
以下操作仅在 Unix 系统上
大致知道这个文本描述符就是一个 int 值,通过这个值就能进行读写,C 语言中 write(fd, str, length),就能直接写入文本描述符,java 中也有一个 FileDescriptor 类,用来读写文本描述符,Dart 没有,不过可以解决。
简述一下终端原理,在 C 语言中调用 open(“/dev/ptmx”)会得到一个文本描述符,然后同时会在/dev/pts/下获得一个文件的产生,文件名是 0,1,2,3,系统会依次往上给你分配。
/dev/ptmx 是一个字符设备文件,当进程打开 /dev/ptmx 文件时,进程会同时获得一个指向 pseudoterminal master(ptm)的文件描述符和一个在 /dev/pts 目录中创建的 pseudoterminal slave(pts) 设备。通过打开 /dev/ptmx 文件获得的每个文件描述符都是一个独立的 ptm,它有自己关联的 pts
直接看我更改后的实现
1 | int get_ptm_int( |
这个函数主要就用来得到 ptm 的文本描述符,中间还有一些对终端,由于时间缘故,我暂时注释了对 java 的回调报错,之后用对 dart 的回调代替。拿到这个 ptm 描述符后,我们就可以对这个 ptm 描述符读写,往里面写的内容都能再读出来,感觉有点对此一举?并不是,任何的二进制程序往里面进行写操作,而你的终端 UI,只需要一直读就可以了,看一下 termux 在 java 部分的实现
1 | new Thread("TermSessionInputReader[pid=" + mShellPid + "]") { |
两个死循环,一个负责读 ptm,将读出的内容同步到 UI
而另一个负责将输入队列的类容写进 ptm
在看 termux 中比较关键的一个函数(经过我更改后的)
1 | void create_subprocess(char *env, |
实际上我为了配合 Dart 的部分,将 termux 原有的 create_subprocess 拆分成了两块,具体逻辑并未做修改,增加了中文注释,留意其中调用了一次 fork(),这个函数调用后,就会再分叉一个进程,之后的代码都会被执行两次,函数中通过 pid 的值来判断父进程与子进程分别应该干啥,pid 大于 0 即为父进程,可以看到父进程更改了 pProcessId 这个指针指向的值,子进程去执行了调用函数时的命令,包括设置当前环境,执行参数等,通过 ptsname_r 函数拿到了 ptm 对应的 pts,然后通过 dup2 函数将改程序的 0,1,2 复制到了 pts(/dev/pts/*),也就是 stdin,stdout,stderr,最后调用 exec,所以此时 exec 调用的二进制的输出全会写进 pts,而写进 pts 就能从 ptm 出来,也就实现了伪终端
Dart 不能读写文本描述符怎么办?
通过 dart:ff 对接,C 语言可以读就不存在
1 | void write_to_fd(int fd, char *str) |
Flutter 的部分实现也比较复杂,因为要重写一套完整的终端序列不是简单的事,termux 作为安卓原生项目,有大量的社区资源跟第三方开发者的支持,现在才已经比较完善,关于 Dart 调用 ffi 也可以参考我之前的帖子
效果!!!
Python 的使用:
光标移动:
ls 等命令颜色的输出:
开源地址
flutter_terminal
目前这个新的终端模拟器已经完全的引进了自己的项目,作者的维护能力非常有限,更新速度也比较慢,如果对这个项目有兴趣有问题都可以在下面留言,感谢各位前辈!!!
参考帖子
开源一个Flutter编写的完整终端模拟器
http://blog.nightmare.press/2020/03/05/开源一个Flutter编写的完整终端模拟器/