開源一個Flutter編寫的完整終端模擬器

Nightmare_夢魘獸發表於2020-03-05

上次開源了一個簡易的終端模擬器,我也知道並不是標準的,但自己也一直在用,然後就發現了一些棘手的問題,就又跑去研究了一些完整終端的原始碼,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 端供(顯示器驅動等)讀取。請參考下面的示意圖(此圖來自網際網路):

開源一個Flutter編寫的完整終端模擬器
我們開啟的終端桌面程式,比如 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

相關文章