上次開源了一個簡易的終端模擬器,我也知道並非標準的,但本身也一直在用,而後就發現了一些棘手的問題,就又跑去研究了一些完整終端的源碼,termux,Android Terminal,最後成功的將他們的原理在Flutter實現html
其實這個源也可能會是你學習使用dart:ffi的一個例子,其中用到的char **,也就是二級指針的傳遞在也不多能在官方的example中也很難找到直接的例子,也是我處理這種類型碰見的比較麻煩的坑,主要就是沒有案例。我將termux的C語言部分徹底重構以供Flutter使用,因爲UI框架使用的Flutter通過測試能夠在Macos上跑起來!!!java
本身在使用中碰見了這個棘手的問題,仍是因爲經驗不夠,還去知乎上提了我碰見的問題, 知乎傳送 通過與同窗的探討後(死皮賴臉問人家),能夠知道Process中的stdout是來自於pipe(管道),也能夠看到stdout也有pipe這個方法,而管道是存在緩衝的,舉個🌰ios
使用c++
cp -rv sourceDir targetDir
複製代碼
命令,因爲開啓了-v參數,因此在標準終端中,cp命令會一行一行打印出正在複製的文件,而當用dart的Process去執行這樣的操做,你在對stdout的監聽中並不會收到一次一行的回調,而是一次一堆的回調,那就是因爲管道是存在緩衝機制的,達到緩衝上限後才能拿到一次,或者程序結束後,緩衝區未滿也能拿到。 咱們再切換到標準終端模擬器git
cp -rv sourceDir targetDir | xargs echo
複製代碼
咱們在終端中也使用管道,經過xargs將其打印出來,這個時候會發現,打印的東西跟次數,跟dart中stdout的回調是同樣的,不止dart,包括java中runtime拿到的輸入流,也沒法拿到無緩衝的輸出.github
終端也具備緩衝,終端爲行緩衝,管道爲全緩衝,行緩衝中,碰見換行符\n便可向終端中輸出一次,或者主動在C語言中調用fflush()方法,會將已經在緩衝區的內容輸出一次,若是沒有以上兩個條件,就只能等到緩衝區滿1024個字節,才能輸出一次shell
我能想到的最快的方法就是去看一些標準終端的開源庫,如今比較優秀有termux,跟Android Terminal,termux能夠說是目前安卓上最強大的終端了,有大量的可擴展資源,我就直接clone下來,從manifest中找到主類,從Activity中oncreate中一點一點看,仍是花了挺多時間,畢竟termux仍是比較大型的儲存庫,也有註釋,但始終找不到關鍵的地方,可以在Flutter實現的地方,最後定位到了UI中獲取輸入,包括將輸出同步到屏幕,這一系列都指向了JNI,也就是一個java到c/c++的一個通道,我也是從這纔開始知道項目中的那個C語言是何時用的了。api
這種終端稱僞終端(pty)app
必須先看一波來自互聯網的科普框架
僞終端(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 直接看我更改後的實現
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: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的使用:
光標移動:ls等命令顏色的輸出:
目前這個新的終端模擬器已經徹底的引進了本身的項目,做者的維護能力很是有限,更新速度也比較慢,若是對這個項目有興趣有問題均可以在下面留言,感謝各位前輩!!!