美文网首页Flutter圈子Flutter
开源一个Flutter编写的完整终端模拟器

开源一个Flutter编写的完整终端模拟器

作者: Nightmare_梦魇兽 | 来源:发表于2020-03-05 15:53 被阅读0次

    上次开源了一个简易的终端模拟器,我也知道并不是标准的,但自己也一直在用,然后就发现了一些棘手的问题,就又跑去研究了一些完整终端的源码,termux,Android Terminal,最后成功的将他们的原理在Flutter实现

    其实这个源也可能会是你学习使用dart:ffi的一个例子,其中用到的char **,也就是二级指针的传递在也很少能在官方的example中也很难找到直接的例子,也是我处理这种类型遇见的比较麻烦的坑,主要就是没有案例。我将termux的C语言部分完全重构以供Flutter使用,由于UI框架使用的Flutter经过测试可以在Macos上跑起来!!!

    Process类的stdout是哪里来的?

    自己在使用中遇见了这个棘手的问题,还是由于经验不够,还去知乎上提了我遇见的问题,
    知乎传送
    经过与同学的探讨后(死皮赖脸问人家),可以知道Process中的stdout是来自于pipe(管道),也可以看到stdout也有pipe这个方法,而管道是存在缓冲的,举个🌰

    使用

    cp -rv sourceDir targetDir
    

    命令,由于开启了-v参数,所以在标准终端中,cp命令会一行一行打印出正在复制的文件,而当用dart的Process去执行这样的操作,你在对stdout的监听中并不会收到一次一行的回调,而是一次一堆的回调,那就是由于管道是存在缓冲机制的,达到缓冲上限后才能拿到一次,或者程序结束后,缓冲区未满也能拿到。
    我们再切换到标准终端模拟器

    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 端供(显示器驱动等)读取。请参考下面的示意图(此图来自互联网):

    image

    我们打开的终端桌面程序,比如 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
    直接看我更改后的实现

    int get_ptm_int(
        int rows,
        int columns)
    {
        //调用open这个路径会随机获得一个大于0的整形值
        int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);
        //这个值会从0依次上增
        // 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);
        return ptm;
    }
    

    这个函数主要就用来得到ptm的文本描述符,中间还有一些对终端,由于时间缘故,我暂时注释了对java的回调报错,之后用对dart的回调代替。拿到这个ptm描述符后,我们就可以对这个ptm描述符读写,往里面写的内容都能再读出来,感觉有点对此一举?并不是,任何的二进制程序往里面进行写操作,而你的终端UI,只需要一直读就可以了,看一下termux在java部分的实现

            new Thread("TermSessionInputReader[pid=" + mShellPid + "]") {
                @Override
                public void run() {
                    try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) {
                        final byte[] buffer = new byte[4096];
                        while (true) {
                            int read = termIn.read(buffer);
                            if (read == -1) return;
                            if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return;
                            mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT);
                        }
                    } catch (Exception e) {
                        // Ignore, just shutting down.
                    }
                }
            }.start();
    
            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();
    

    两个死循环,一个负责读ptm,将读出的内容同步到UI
    而另一个负责将输入队列的类容写进ptm

    在看termux中比较关键的一个函数(经过我更改后的)

    void create_subprocess(char *env,
                           char const *cmd,
                           char const *cwd,
                           char *const argv[],
                           char **envp,
                           int *pProcessId,
                           int ptmfd)
    {
    #ifdef LACKS_PTSNAME_R
        char *devname;
    #else
        char devname[64];
    #endif
    
    #ifdef LACKS_PTSNAME_R
        devname = ptsname(ptmfd);
    #else
        ptsname_r(ptmfd, devname, sizeof(devname));
    #endif
        //创建一个进程,返回是它的pid
        pid_t pid = fork();
        if (pid < 0)
        {
            // return throw_runtime_exception(env, "Fork failed");
        }
        else if (pid > 0)
        {
            *pProcessId = (int)pid;
        }
        else
        {
            // Clear signals which the Android java process may have blocked:
            sigset_t signals_to_unblock;
            sigfillset(&signals_to_unblock);
            sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0);
    
            close(ptmfd);
            setsid();
            //O_RDWR读写,devname为/dev/pts/0,1,2,3...
            int pts = open(devname, O_RDWR);
            if (pts < 0)
                exit(-1);
            //下面三个大概将stdin,stdout,stderr复制到了这个pts里面
            //ptmx,pts pseudo terminal master and slave
            dup2(pts, 0);
            dup2(pts, 1);
            dup2(pts, 2);
            //Linux的api,打开一个文件夹
            DIR *self_dir = opendir("/proc/self/fd");
            if (self_dir != NULL)
            {
                //dirfd没查到,好像把文件夹转换为文件描述符
                int self_dir_fd = dirfd(self_dir);
                struct dirent *entry;
                while ((entry = readdir(self_dir)) != NULL)
                {
                    int fd = atoi(entry->d_name);
                    if (fd > 2 && fd != self_dir_fd)
                        close(fd);
                }
                closedir(self_dir);
            } //清除环境变量
            // clearenv();
    
            if (envp)
                for (; *envp; ++envp)
                    putenv(*envp);
    
            if (chdir(cwd) != 0)
            {
                char *error_message;
                // No need to free asprintf()-allocated memory since doing execvp() or exit() below.
                if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1)
                    error_message = "chdir()";
                perror(error_message);
                fflush(stderr);
            }
            //执行程序
            execvp(cmd, argv);
    
            // Show terminal output about failing exec() call:
            char *error_message;
            if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1)
                error_message = "exec()";
            perror(error_message);
            _exit(1);
        }
    }
    

    实际上我为了配合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语言可以读就不存在

    void write_to_fd(int fd, char *str)
    {
        write(fd, str, strlen(str));
    }
    char *get_output_from_fd(int fd)
    {
        int flag = -1;
        flag = fcntl(fd, F_GETFL); //获取当前flag
        flag |= O_NONBLOCK;        //设置新falg
        fcntl(fd, F_SETFL, flag);  //更新flag
        //动态申请空间
        char *str = (char *)malloc((4097) * sizeof(char));
        //read函数返回从fd中读取到字符的长度
        //读取的内容存进str,4096表示此次读取4096个字节,如果只读到10个则length为10
        int length = read(fd, str, 4096);
        if (length == -1)
        {
            free(str);
            return NULL;
        }
        else
        {
            str[length] = '\0';
            return str;
        }
    }
    

    Flutter的部分实现也比较复杂,因为要重写一套完整的终端序列不是简单的事,termux作为安卓原生项目,有大量的社区资源跟第三方开发者的支持,现在才已经比较完善,关于Dart调用ffi也可以参考我之前的帖子

    效果!!!

    Python的使用:


    Python

    光标移动:


    在这里插入图片描述

    ls等命令颜色的输出:


    在这里插入图片描述

    开源地址

    flutter_terminal

    目前这个新的终端模拟器已经完全的引进了自己的项目,作者的维护能力非常有限,更新速度也比较慢,如果对这个项目有兴趣有问题都可以在下面留言,感谢各位前辈!!!

    参考帖子

    Linux 伪终端(pty)

    关于Linux的缓冲机制

    Linux下的consolen(控制台)和terminal(终端)

    ptmx/pts

    相关文章

      网友评论

        本文标题:开源一个Flutter编写的完整终端模拟器

        本文链接:https://www.haomeiwen.com/subject/whufrhtx.html