Linux 的偽終端的基本原理 及其在遠程登錄(SSH,telnet等)中的應用


本文介紹了linux中偽終端的創建,介紹了終端的回顯、行緩存、控制字符等特性,並在此基礎上解釋和模擬了telnet、SSH開啟遠程會話的過程。

一、輕量級遠程登錄

之前制作的一塊嵌入式板子,安裝了嵌入式linux操作系統,可以通過串口(Console)登錄。為了方便使用,需要尋找通過網線遠程登錄的方法。最初的想法是SSH,不過板子的ROM太小,存不了體積龐大龐大的OpenSSH套裝。后來換用了telnet,直接拿busybox的telnetd做服務器,效果很好。

后來有一天,發現了Linux中有一個直接建立TCP連接的工具:nc 。在服務端使用nc -l 端口號 來進行監聽,在客戶端使用nc IP地址 端口號來建立連接。建立連接后,nc會把從stdin讀入的字節流發送給另一方,把接收到的字節流寫入stdout中。配合方便的管道操作,不正可以將shell的輸入/輸出傳送到遠端機器上嗎?於是在Ubuntu中實驗操作如下(之后發現這種操作叫做“反彈shell”):

打開一個終端A,輸入命令

mkfifo /tmp/p  # 創建臨時管道
sh -i </tmp/p |& nc -l 2333 >/tmp/p

該命令將bash的標准輸入輸出與nc的標准輸出輸入連接起來,並由nc將其與socket連接起來。同時,nc監聽2333端口(如果使用小於1024的端口,需要root權限),等待遠程連接。現在打開另一個終端B,准備連接:

nc localhost 2333

這時,在終端B中出現了sh的提示符。輸入一般的shell命令后可以執行並得到結果。看來linux自帶的工具已經靈活、強大到足夠搭建一個小型的遠程登錄系統。這個過程可以使用下面的圖來描述:

通過tty命令,我們看到,此時的shell並沒有一個tty終端。確實,它的標准輸入輸出都是管道。這會帶來一個問題,需要操縱tty的一些命令,比如vi、less、sudo等都無法正常使用(可以動手試試效果怎么樣)。更為要命的是,在終端B中按下Ctrl+C這樣的控制鍵,內核把結束信號發送給了客戶端nc,而不是遠程的程序!

Ctrl+C直接殺死nc,結束了會話。對比telnet,我們的登錄系統還缺少什么東西。這就是偽終端(pseudoterminal)。

二、了解偽終端

1. 終端和它的作用

終端(terminal)這個詞擁有很多含義,這里盡量將其分開說明。

歷史上,終端(有時被成為tty,tele typewriter)是用戶訪問計算機主機的硬件設備,可以理解為一個顯示器和一個鍵盤的組合。

  • 現代Linux里面比較接近此概念的是(一系列)虛擬控制台(virtual console)。在Ubuntu等發行版本中按下Ctrl+Alt+F1(或F2, F3, ...)即可切換到相應控制台下。/dev/tty1等文件是這些硬件在linux下的設備文件。程序通過這些文件的讀寫實現對控制台的讀寫,通過ioctl實現對硬件參數的設置。

終端還可以指代設備文件,實現軟件接口。比如常見的/dev/tty1文件,還有/dev/pts目錄下的所有文件。

  • 對終端設備文件進行讀寫,能夠從鍵盤讀取輸入,從顯示器進行輸出,實現交互式的輸入輸出
  • linux中的每個進程有一個“控制終端(control terminal)”的屬性(取值為設備文件),用於實現作業控制。在終端上輸入Ctrl+C、Ctrl+Z,則以該終端為控制終端的前台進程組會收到終止、暫停的信號。
  • 對終端設備進行ioctl操作,可以實現終端相關的硬件參數設置。login、sudo的不顯示密碼,都離不開對終端設備的操作。

終端還可以指代“終端模擬器”。終端模擬器是應用程序,用於模擬一個終端。它一般是GUI程序,帶有窗口。從窗口輸入的字符作為模擬鍵盤的輸入,在窗口上打印的字符作為模擬顯示器的輸出。終端模擬器還需要創建模擬的終端設備(如/dev/pts/1),用於當做命令行進程(CLI進程)的輸入輸出、控制終端。當鍵盤鍵入一個字符,它要讓CLI進程從終端設備中讀到這個字符,當CLI進程寫入終端設備時,終端模擬器要讀到並顯示出來。

終端模擬器的這個需求,恰恰和telnet這種遠程登錄服務器的需求相似。telnet服務器也要創建模擬的終端設備,用於當做命令行進程(CLI進程)的輸入輸出、控制終端。當從網絡收到一個字符,它要讓CLI進程從終端設備中讀到這個字符,當CLI進程寫入終端設備時,telnet要把輸出發送到網絡。

這種共同的需求在linux中有一個統一實現——偽終端(pseudoterminal)。沒錯,上面的/dev/pts/文件夾里的以數字命名的文件就是偽終端的設備文件。

2. 偽終端的介紹

通過man pts可以查閱linux對偽終端的介紹。偽終端是偽終端master和偽終端slave(終端設備文件)這一對字符設備。/dev/ptmx是用於創建一對master、slave的文件。當一個進程打開它時,獲得了一個master的文件描述符(file descriptor),同時在/dev/pts下創建了一個slave設備文件。

master端是更接近用戶顯示器、鍵盤的一端,slave端是在虛擬終端上運行的CLI(Command Line Interface,命令行接口)程序。Linux的偽終端驅動程序,會把“master端(如鍵盤)寫入的數據”轉發給slave端供程序輸入,把“程序寫入slave端的數據”轉發給master端供(顯示器驅動等)讀取。

我們打開的“終端”桌面程序,其實是一種終端模擬器。當終端模擬器運行時,它通過/dev/ptmx打開master端,創建了一個偽終端對,並讓shell運行在slave端。當用戶在終端模擬器中按下鍵盤按鍵時,它產生字節流並寫入master中,shell便可從slave中讀取輸入;shell和它的子程序,將輸出內容寫入slave中,由終端模擬器負責將字符打印到窗口中。

(終端模擬器的顯示原理就不在這里展開了,這里認為鍵盤按鍵形成一列字節流、向顯示器輸出字節流后便打印到屏幕上)

linux中為什么要提出偽終端這個概念呢?shell等命令行程序不可以直接從顯示器和鍵盤讀取數據嗎?為了同屏運行多個終端模擬器、並實現遠程登錄,還真不能讓bash直接跨過偽終端這一層。在操作系統的一大思想——虛擬化的指導下,為多個終端模擬器、遠程用戶分配多個虛擬的終端是有必要的。上圖中的shell使用的slave端就是一個虛擬化的終端。master端是模擬用戶一端的交互。之所以稱為虛擬化的終端,它除了轉發數據流外,還要有點終端的樣子。

3. 作為終端的偽終端

最為一個虛擬的終端,每一個偽終端里面封裝了一個終端驅動,讓它能做到這些事情:

  1. 為程序提供一些輸入輸出模式的幫助,比如輸入密碼時隱藏字符
  2. 為用戶提供對進程的控制,比如按下Ctrl+C結束前台進程

對,這些就是轉發數據之外的控制。

終端的屬性:回顯控制和行控制

當用戶按下一個按鍵時,字符會出現在屏幕上。這可不是CLI進程寫回來的。不信的話可以在終端里運行cat,隨便輸入些什么按回車。第二行是cat返回來的,第一行正是終端的特性。

終端驅動里存儲了一個狀態——回顯控制:是否將寫入master的字符再次送回master的讀端(顯示器)。默認情況下這個是啟用的。在命令行里可以使用stty來更改終端的狀態。比如在終端中運行

stty -echo

則會關掉當前終端的回顯。這時按下按鍵,已經沒有字符顯示出來了。輸入ls等命令,能夠看到shell正常接收到我們的命令(此時回車並沒有顯示出來)。這時cat后,盲打一些文字,按下回車后看到只有一條文字了。

除了用戶通過命令行方式,CLI的程序還能通過系統調用來設置終端的回顯,比如loginsudo等程序就是通過暫時關閉回顯來隱藏密碼的。具體方式是在slave的文件描述符上調用ioctl函數(參考man tty_ioctl),不過推薦使用更友好的tcsetattr函數。詳細設置可查閱man tcsetattr

另外,終端驅動還提供有行緩沖功能。還是以cat為例:當我們輸入文字,在鍵入回車之前,cat並不能讀取到我們輸入的字符。這里的cat的行為可以理解為逐字符讀寫:

while(read(0, &c, 1) > 0) //read from stdin, while not EOF
    write(1, &c, 1);  //write to stdout

是誰阻止cat及時讀入字符了呢?其實是終端驅動。它默認開啟了一個行緩沖區,這樣等程序要調用read系統調用時,先讓程序阻塞着(blocked),等用戶輸入一整行后,才解除阻塞。我們可以使用下列命令將行緩存大小設置為1:

stty min 1 -icanon

這時,運行cat,嘗試輸入文字。每輸入一個字符,能夠立即返回一個字符。(把min改為time,還能設置輸入字符最長被阻塞1秒)

這些終端的狀態屬性信息還有很多,比如設置終端的寬度、高度等。具體可以參考man stty

特殊控制字符

特殊控制字符,是指Ctrl和其他鍵的組合。如Ctrl+C、Ctrl+Z等等。用戶按下這些按鍵,終端模擬器(鍵盤)會在master端寫入一個字節。規則是:Ctrl+字母得到的字節是(大寫)字母的ascii碼減去0x40。比如Ctrl+C是0x03,Ctrl+Z是0x1A。參見下表:

驅動收到這些特殊字符,並不會像收到正常字節那樣處理。在echo的時候,它返回兩個可見字符。比如鍵入Ctrl+C(0x03),就會回顯^和C(0x5E 0x03)兩個字符。更重要的是,驅動將會攔截某些控制字符,他們不會被轉發給slave端,而是觸發作業控制(job control)的規則:向前台進程組發送SIGINT信號。

要想繞過這一機制,我們可以使用stty的一些設置。下面的命令能夠同時關閉控制字符的特殊語義、設置行緩沖大小為1:

stty raw

然后,運行cat命令,我們鍵入的所有字符,包括控制字符Ctrl+C(0x03),都會成功傳遞給cat,並且被原樣返回。(可以試試上下左右、回車鍵的效果)

三、實驗:利用偽終端實現遠程登錄

理解偽終端的基本原理后,我們就可以嘗試解釋telnet和SSH等遠程登錄的原理了。每次用戶通過客戶端連接服務端的時候,服務端創建一個偽終端master、slave字符設備對,在slave端運行login程序,將master端的輸入輸出通過網絡傳送至客戶端。至於客戶端,則將從網絡收到的信息直接關聯到鍵盤/顯示器上。我們將這個過程描述為下圖:

說了這么多,其實這個結構相比本文第一張圖而言,只多了一個偽終端。下面具體描述各部分的實現細節。

服務端②:創建偽終端,並將master重定向至nc

按照man pts中的介紹,要創建master、slave對,只需要用open系統調用打開/dev/ptmx文件,即可得到master的文件描述符。同時,在/dev/pts中已經創建了一個設備文件,表示slave端。但是,為了能讓其他進程(login,shell)打開slave端,需要按照手冊介紹來調用兩個函數:

Before opening the pseudoterminal slave, you must pass the master's file descriptor to grantpt(3) and unlockpt(3).

具體信息可以查閱man 3 grantpt,man 3 unlockpt文檔。

我們可以直接關閉(man 2 close)終端創建進程的0和1號文件描述符,把master端的文件描述符拷貝(man 2 dup)到0和1號,然后把當前進程刷成ncman 3 exec)。這雖然是比較優雅的做法,但比較復雜。而且當沒有進程打開slave的時候,nc從master處讀不到數據(read返回0),會認為是EOF而結束連接。所以這里用一個笨辦法:將所有從master讀到的數據通過管道送給nc,將所有從nc得到的數據寫入master。我們需要兩個線程完成這件事。

此小節代碼總結如下:

//ptmxtest.c

//先是一些頭文件和函數聲明
#include<stdio.h>
#define _XOPEN_SOURCE
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/ioctl.h>

/* Chown the slave to the calling user.  */
extern int grantpt (int __fd) __THROW;

/* Release an internal lock so the slave can be opened.
   Call after grantpt().  */
extern int unlockpt (int __fd) __THROW;

/* Return the pathname of the pseudo terminal slave associated with
   the master FD is open on, or NULL on errors.
   The returned storage is good until the next call to this function.  */
extern char *ptsname (int __fd) __THROW __wur;

char buf[1]={'\0'};  //創建緩沖區,這里只需要大小為1字節
int main()
{
    //創建master、slave對並解鎖slave字符設備文件
	int mfd = open("/dev/ptmx", O_RDWR);
	grantpt(mfd);
	unlockpt(mfd);
    //查詢並在控制台打印slave文件位置
	fprintf(stderr,"%s\n",ptsname(mfd));

	int pid=fork();//分為兩個進程
	if(pid)//父進程從master讀字節,並寫入標准輸出中
	{
		while(1)
		{
			if(read(mfd,buf,1)>0)
				write(1,buf,1);
			else
				sleep(1);
		}
	}
	else//子進程從標准輸入讀字節,並寫入master中
	{
		while(1)
		{
			if(read(0,buf,1)>0)
				write(mfd,buf,1);
			else
				sleep(1);
		}
	}

	return 0;
}

將文件保存后,打開一個終端(稱為終端A),運行下列命令,在命令行中建立此程序與nc的通道:

gcc -o ptmxtest ptmxtest.c
mkfifo /tmp/p
nc -l 2333 </tmp/p | ./ptmxtest >/tmp/p

至此,圖中的②構建完畢,已經有一個nc在監聽2333端口,它的輸入輸出通過管道送到ptmxtest程序中,ptmxtest又將這些信息搬運給master端。

在我的Ubuntu中運行命令后顯示,創建的slave設備文件是/dev/pts/20。

服務端①:將login程序與終端關聯起來

在圖中①處的地方,需要將login與偽終端的輸入輸出關聯起來。這一點通過輸入輸出重定向即可完成。不過,想要實現Ctrl+C等作業控制,還需要更多的設置。這涉及到一些Linux的進程管理的知識(感興趣的可以去搜索“進程、進程組、會話、控制終端”等關鍵字)。

一個進程與終端的聯系,不僅取決於它的輸入輸出,還有它的控制終端(Controlling terminal,可通過tty命令查詢,通過/dev/tty打開)。簡單地說,進程控制終端是誰,誰才能向進程發送控制信號。這里要將login的控制終端設為偽終端,具體說是slave設備文件才行。

設置控制終端需要使用終端設備的ioctl來實現。查看man tty_ioctl,可以找到相關信息:

Controlling terminal

TIOCSCTTY int arg
Make the given terminal the controlling terminal of the calling process. The calling process must be a session leader and not have a controlling terminal already. For this case, arg should be specified as zero.

...

TIOCNOTTY void
If the given terminal was the controlling terminal of the calling process, give up this controlling terminal. ...

比較重要的信息是,我們可以指定TIOCSCTTY參數來設置控制終端,但它要求調用者是沒有控制終端的會話組長(Session leader)。所以要先指定TIOCNOTTY參數來放棄當前控制終端,並用setsid函數(man 2 setsid)創建新的會話並設置自己為組長。

我們將login包裝一層,完成上面的操作,得到新的程序mylogin:

//mylogin.c

#include<stdio.h>
#define _XOPEN_SOURCE
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<termios.h>
#include<sys/ioctl.h>

int main(int argc, char *argv[])
{
	int old=open("/dev/tty",O_RDWR);  //打開當前控制終端
	ioctl(old, TIOCNOTTY);  //放棄當前控制終端
  
    //根據"man 2 setsid"的說明,調用setsid的進程不能是進程組組長(從bash中運行的命令是組長),故fork出一個子進程,讓組長結束,子進程脫離進程組成為新的會話組長
	int pid=fork();
	if(pid==0){
		setsid();  //子進程成為會話組長
		perror("setsid");  //顯示setsid是否成功
		ioctl(0, TIOCSCTTY, 0);  //這時可以設置新的控制終端了,設置控制終端為stdin
		execv("/bin/login", argv);  //把當前進程刷成login
	}
	return 0;
}

保存文件后,打開一個終端(稱為終端B),編譯運行:

gcc -o mylogin mylogin.c
#假設這里的slave設備是/dev/pts/20
#因為login要讀取密碼文件,需要用root權限執行
sudo ./mylogin </dev/pts/20 >/dev/pts/20 2>&1

該命令將實驗圖中①處的slave設備,重定向至mylogin的stdin、stdout和stderr。在程序執行時,會將控制終端設置為偽終端,然后執行login。至此,服務端全部建立完畢。

客戶端:連接遠程機器,配置本地終端

客戶端處於實驗圖的③處。打開新的終端(終端C),這里簡單地使用nc連接遠程socket,並且nc的輸入輸出重定向至鍵盤、顯示器即可。但是要注意,nc是運行在終端C上的,而終端C的默認屬性會攔截字符Ctrl+C、使用行緩沖區域。這樣nc的輸入輸出其實並不直接是鍵盤、顯示器。為此,我們先設置終端C的屬性,再運行nc:

stty raw -echo
nc localhost 2333  #該行沒有回顯,要摸黑輸入

然后,在終端C中出現了我們打印的setsid的信息,和login的提示符。在終端C中,使用鍵盤可以正常登錄,得到shell的提示符。使用tty命令能夠看到當前shell使用的控制終端是/dev/pts/20,也就是我們創建的偽終端。輸入w命令可以看到系統中登錄的用戶和登錄終端。

至此為止,我們實現了類似telnet的遠程登錄。

結語

linux中終端驅動本身有回顯、行緩存、作業控制等豐富的屬性,在此基礎上實現的偽終端在終端模擬器、遠程登錄等場合下能夠得到多種應用。

在實驗過程中也牽扯到進程控制、輸入輸出重定向、網絡通信這么多的知識,更體現出linux的復雜精致的結構。我感覺,linux 就像一個包羅萬象、又自成體統的小宇宙,它采用獨特的虛擬化技術,靈活的模塊化和重用機制,虛擬出各種設備,實現了驅動程序的隨意拼插。在這里,所有模塊都得到了充分的利用,並能夠像變形金剛那樣對各類需求提出面面俱到的解決方案。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM