Gli pseudo-terminali

di Alessandro Rubini

In queste pagine si spiega cosa sono gli pseudo-terminali
(pty) e si mostrano le interfaccie di programmazione esportate dal
kernel.


Gli pseudo-terminali, o _pty_ (pseudo-tele-type, in memoria delle
vecchie telescriventi), sono un componente estremamente importante dei
sistemi Unix e compatibili. Lo pseudo-terminale è il meccanismo alla
base di _xterm_, _kterm_ e tutti gli altri emulatori di terminale
(appunto); come pure di _sshd_, _rshd_, _telnetd_ e qualunque altro
sistema di _login_ remoto; vengono anche usati da _emacs_ in _sh-mode_
e in altre situazioni. Il codice presentato è stato provato su
Linux-2.6.5, ma in questo ambito non ci sono cambiamenti rispetto al
2.4.

* Cos'è un terminale

In ambiente Unix un «terminale», o _tty_, è un dispositivo attraverso
cui l'utente interattivo può dialogare con un interprete di comandi o
altri programmi. Il termine viene usato indifferentemente per indicare
il dispositivo fisico composto da tastiera e monitor (si pensi ai
vecchi terminali seriali VT100 o VT320) o per indicare il file
speciale che viene usato dalle applicazioni per comunicare con la
periferica.  Poiché i VT100 sono da tempo estinti i VT320
sono una specie a rischio, oggigiorno quando si parla di «terminale»
ci si riferisce quasi sempre al concetto astratto di terminale
o ad un programma che svolge le stesse funzioni, che è perciò
detto «emulatore di terminale».

Un esempio di terminale è la porta seriale (come _/dev/ttyS0_ o
_/dev/ttyUSB0_), un altro esempio è il "terminale virtuale" della
console di testo (_/dev/tty1_, _/dev/tty2_ eccetera), un altro ancora
è la finestra di _xterm_ o di un programma equivalente (_/dev/ttyp0_
o _/dev/pts/0_).

In tutti questi casi, i file speciali in _/dev_ offrono ai processi
che vi accedono le funzionalità specifiche di un terminale: i
parametri di _termios_ (terminal input/output settings), ovvero tutti
quei noiosi ma importanti particolari relativi alla velocità di
trasmissione, parità, convenzioni sulle andate a capo e quant'altro,
ma anche l'assegnamento di alcune funzionalità speciali ad alcuni
caratteri.  Si può simulare un fine-file tramite _<ctrl-D>_ o
uccidere un processo tramite _<ctrl-C>_ solo perché questi caratteri
raggiungono un file speciale associato ad un terminale. Ogni utente
può scegliere quali siano i caratteri speciali e nessuno di questi
ha un significato speciale al di fuori del contesto di terminale.
<!-- a differenza di quanto accade in un noto sistemi operativo obsoleto -->

Per leggere o modificare la configurazione _termios_ di un terminale,
si possono usare le funzioni di libreria _tcgetattr_ e _tcsetattr_
(terminal control get/set attributes) o il comando _stty_ (set tty),
ricordando che il comando agisce sul suo standard input:

	   burla% stty
	   speed 38400 baud; line = 0; erase = ^H; -brkint -imaxbel
	   burla% stty < /dev/ttyS0
	   speed 9600 baud; line = 0; -brkint -imaxbel
	   burla% stty < /etc/passwd
	   stty: standard input: Inappropriate ioctl for device

In entrambi i casi la configurazione viene letta o scritta dal
kernel tramite la chiamata di sistema _ioctl_, con i comandi
==TCGETA==, ==TCSETA== o altri della stessa famiglia,
la cui implementazione è nel file _drivers/char/tty_io.c_.

* Cos'è una coppia di pseudo terminali

Mentre un terminale come _/dev/ttyS0_ è chiaramente associato ad una
periferica (la porta seriale) cui può essere collegato un terminale
fisico, il dispositivo associato alla finestra di _xterm_ non permette
di controllare alcuna periferica hardware e i dati scambiati tra
l'interprete di comandi e il file speciale in _/dev_ vengono gestiti
da un altro processo sulla stessa macchina, _xterm_ appunto.

In tutte le situazioni in cui occorre eseguire un processo all'interno
dell'astrazione "terminale" senza far uso di una vera
interfaccia hardware, ci si appoggia al meccanismo degli
pseudo-terminali, meccanismo secondo il quale ad ogni _pty_ è
associato un altro file speciale che si comporta come se fosse l'altra
estremità del cavo seriale. I due, insieme, si chiamano "coppia di
pseudo-terminali" o più semplicemente "coppia di terminali", _tty
pair_.

I due componenti della coppia si comportano come una _pipe_
bidirezionale e vengono definiti _master_ e _slave_.  Il comportamento
dei file speciali associati non è però completamente simmetrico come
succede per i descrittori di file associati a _pipe_ e _socket_: il
terminale _slave_ è un vero e proprio terminale, ma può essere aperto
solo dopo il _master_ associato; il terminale _master_ invece può
essere aperto una volta sola e non si comporta esattamente come un
terminale (per esempio, non può essere aperto più di una volta).

* L'interfaccia storica

Storicamente, gli pseudo terminali, master e slave, esistevano nella
directory _/dev_, dove si trovano ancora oggi, almeno in alcune
distribuzioni. Qualora mancassero, si possono creare invocando
il comando "==/dev/MAKEDEV pty==". I terminali slave hanno
major numnber 3 e i loro nomi sono per esempio _/dev/ttyp0_; i terminali
master hanno major number 2 e i loro nomi sono come _/dev/ptyp0_,
dove tutte le ==p== indicano "pseudo".
Il codice per gestire queste periferiche è opzionale nel kernel, e
viene abilitato dalla voce ==CONFIG_LEGACY_PTYS==. 

I nomi dei file speciali associati a ciascuna coppia di terminali
differiscono negli ultimi due caratteri, ciascuno dei quali può
assumere uno di 16 valori, per un totale di 256 coppie. Il semplice
programma legacy.c in questa pagina mostra la classica
procedura di apertura di una coppia di terminali, cercando il primo master
disponibile e poi aprendo lo slave associato. Se un master è già in
uso, _open_ ritorna ==EIO== e il ciclo continua; se il supporto non è
abilitato nel kernel, la prima _open_ ritornerà ==ENODEV==; se non
esistono i file speciali in _/dev_ la prima _open_ ritornerà
==ENOENT== e in entrambi i casi il ciclo termina.
Il comportamento del programma può essere osservato con _strace_.

Un programma che usa i terminali per svolgere qualche compito, come
_xterm_ o _sshd_ dovrà naturalmente fare altre operazioni, come
cambiare il proprietario e i permessi di accesso al file speciale
perché rispecchi l'utente che ne ha preso il controllo e le sue
preferenze (si veda "man mesg", per esempio).

I meccanismo con coppie di file appena descritto ha pero` alcuni
problemi no trascurabili: il processo che apre
una sessione deve essere privilegiato (per cambiare il proprietario
del terminale), l'assegnazione del terminale non è atomica e dà
luogo a corse critiche, la scansione dei dispositivi per trovarne uno
libero puo` introdurre ritardi indesiderati.

Infine, 512 file speciali in _/dev_ sono spesso di impiccio,
e questo è normalmente un problema con le macchine _embedded_.
FIXME: mettere questa roba in un riquadro?
Per esempio, il sistema di sviluppo del processore Etrax viene
distribuito con la directory _/dev_ su un dispositivo in sola lettura,
dove sono state create soltanto tre coppie di terminali, per non
sprecare il limitato spazio a disposizione; di conseguenza il server
telnet non può accettare più di tre utenti contemporaneamente e non
è possibile usare la macchina per fare esercitazione a piu` di
tre studenti per volta, a meno di non riprogrammare la memoria
flash con una versione personalizzata del sistema.

* L'interfaccia odierna

Alcuni problemi relativi ai terminali virtuali sono stati risolti
semplicemente osservando che il master viene aperto una sola volta; è
cioe` possibile implementare un solo file speciale per gestire tutti i
master, facendo si` che il kernel, una volta aperto il file, lo associ
ad uno specifico terminale master; un po' come _/dev/tty_ che ha un
significato diverso in base al contesto in cui viene aperto. Il
processo che ha aperto il terminale master potra` poi chiedere il nome
del terminale slave associato. Non è invece possibile unificare tutti
i terminali slave in un solo file, perché altri processi devono poter
aprire i terminali slave in uso. E` così che funzionano i programmi
della famiglia di _talk_, che ritengo ancora preferibile a IRC in
alcuni contesti, e altri servizi asincroni per l'utente testuale.

Questo approccio è stato ratificato nello standard ``Unix98'', che ha
definito una serie di funzioni per aprire e configurare i terminali.
L'uso di tali funzioni nasconde i dettagli di ogni singola
implementazione, come i nomi dei file speciali da usare o il
meccanismo usato per cambiare il proprietario del terminale slave
(spesso tale meccanismo è un processo _setuid_ apposito).

Nel sistema GNU/Linux è stato implementata questa infrastruttura ma si
è andati oltre: invece di usare file speciali statici in _/dev_ per i
terminali slave, si è creato un filesystem apposito in modo che sia il
kernel stesso a rendere visibili i terminali slave in risposta
all'accesso al terminale master, evitando che l'integratore di sistema
debba scegliere tra occupare prezioso spazio su disco o limitare
arbitrariamente il numero di sessioni. L'implementazione del
filesystem, tra codice e dati, è meno di 10kB e viene abilitata
dall'opzione ==CONFIG_UNIX98_PTY==.

L'apertura e la configurazione di una coppia di terminali secondo lo
standard Unix98 vengono effettuate attraverso le funzioni di libreria
_getpt_, _grantpt_, _unlockpt_, _ptsname_, di cui è interessante
leggere le pagine del manuale.  Il programma open.c,
in questa pagina, mostra invece un'approccio di più basso livello 
che sfrutta direttamente i meccanismi di Linux (il kernel) a discapito della
portabilità.

In questo caso, il terminale master è chiamato _/dev/ptmx_
(pseudo-tty multiplexer) e i terminali slave risiedono nella directory
_/dev/pts_, dove viene montato il filesystem
_devpts_. I due comandi _ioctl_ utilizzati nel programma
di esempio sono usati per chiedere al
sistema il numero dello slave da aprire (==TIOCGPTN==, Terminal IOCtl
Get PTy Number) e per sbloccarlo, autorizzando quindi
l'accesso allo slave (==TIOCSPTLCK==, Terminal IOCtl Slave PTy LoCK).
Il terminale slave scompare automaticamente dal filesystem
quando si termina di usarlo; se eseguite _open_ sotto _strace_ vedrete che
il programma apre un terminale slave che non vedrete piu` nel
filesystem una volta finita l'esecuzione.

Il filesystem _devpts_ era già disponibile in Linux-2.2 ed è
praticamente immutato nella versione 2.6, se non per l'aggiunta degli
attributi estesi, una funzionalità disponibile nei principali
filesystem ma ad di fuori dei nostri interessi in questo numero.
Normalmente il filesystem _devpts_ viene montato durante la
procedura di avvio della distribuzione, anche se non presente
in _/etc/fstab_. È possibile smontare _/dev/pts_ solo dopo
aver chiuso tutti gli pseudo-terminali in uso; il sistema
continuerà a funzionare con il meccanismo precedente (a patto
di avere i file speciali in _/dev_ e il supporto relativo
nel kernel). È sempre possibile rimontare _/dev/pts_,
facendo convivere i due sistemi:

	burla% who
	rubini   ttyp1        Apr  6 09:43 (ostro.i.gnudd.com)
	rubini   ttyp2        Apr  6 09:43 (ostro.i.gnudd.com)
	rubini   pts/16       Apr  6 09:43 (ostro.i.gnudd.com)


* Esecuzione di sh in un nuovo terminale

Il programma openpty.c, in questa pagina, apre una
coppia di terminali ed  esegue un interprete di comandi all'interno dello
slave. Per semplificare e accorciare il codice sono state usate le
funzioni _openpty_ e _login\_tty_. Queste funzioni fanno parte di
_libutil_: non fanno rigorosamente parte di _libc_ ma sono
state rese disponibili per evitare che ogni applicativo debba
reimplementarsele. Il Makefile che ho usato, percio`, e`
composto solo di queste due righe:

	 CFLAGS = -ggdb -Wall
	 LDFLAGS = -lutil

Una volta aperta la coppia di terminali, il programma crea un processo
figlio al quale viene assegnato il nuovo terminale slave come
terminale di controllo, prima di eseguire _sh_. Il processo padre,
invece, si occupa di copiare il suo _stdin_ verso il terminale master
e tutto quello che esce dal terminale master sul suo _stdout_.

La situazione risultante è quella rappresentata in 
figura 1, in cui le frecce entranti e uscenti dai processi rappresentano
i file standard di input e output mentre la riga blu uscente
da _openpty_ rappresenta il file aperto verso il terminale master.
Poiché però standard input e output di _openpty_ staranno
probabilmente girando all'interno di un altro terminale (la console,
_xterm_ o, come nel mio caso, _rshd_ -- a sua volta controllato da
_rsh_ dentro ad un _xterm_), occorre configurare il terminale ospite per
permettere l'uso interattivo (un carattere alla volta) dell'interprete di
comandi invocato nel nuovo terminale slave; a questo fine è stato
usato il comando _stty_ presentato precedentemente.

_openpty_ funziona sia con i terminali _legacy_ sia con _devpts_,
in quanto la funzione di libreria usa il codice mostrato
in legacy.c se fallisce con il metodo standard di Unix98:

	burla% tty
	/dev/ttyp0
	burla% ./openpty
	sh-2.05a$ tty
	/dev/ttyp1
	sh-2.05a$ exit
	exit
	burla% sudo mount -t devpts none /dev/pts
	burla% ./openpty 
	sh-2.05a$ tty
	/dev/pts/7
	sh-2.05a$ exit
	burla% tty
	/dev/ttyp0


* Uso di PPP su uno pseudo-terminale

Gli pseudo-terminali si prestano anche ad usi non convenzionali,
sfruttando la loro completa equivalenza, a livello software, con
una porta seriale, ovvero con un modem.

Il protocollo PPP (ma anche SLIP) è implementato da una
"disciplina di linea", un modulo software che può essere usato su
qualsiasi tipo di terminale. La disciplina di linea serve appunto a
_disciplinare_ il comportamento del sistema in risposta ai dati che
raggiungono il kernel tramite quel terminale, oltre a permettere
l'invio di dati verso il terminale.  Una discussione approfondita
della discplina di linea si può trovare nell'articolo disponibile
in rete come www.linux.it/kerneldocs/serial/ .

Ma se PPP puo` lavorare su qualsiasi terminale, allora e` possibile
fare un collegamento IP punto-punto tra due macchine remote, a patto
di poter creare una coppia di terminali in ognuna di esse.  In 
figura 2 è rappresentato il modo per realizzare tale collegamento 
instradando il protocollo dentro un canale _ssh_, invece che su una porta 
seriale come normalmente si fa con PPP.  Ognuno dei due _pppd_ viene fatto
comunicare con un terminale slave, che per il software e`
indistinguibile da un modem, e i due processi _ssh_, client e server,
si occupano di fare da ponte tra il terminale master e il canale
cifrato su protocollo IP.

Il codice per realizzare tale struttura di processi
è riportato in questa pagina, questa volta scritto in linguaggio
_ettcl_ (una versione di Tcl modificata per poter fare da motore del sistema
embedded EtLinux). Su internet si trova una versione di _pppptunnel_
scritta in Perl ed è immediato riscrivere lo strumento in qualsiasi
linguaggio sia in grado di aprire una coppia di terminali; la scelta
del linguaggio non influsice minimamente sulle prestazioni perché il
programma deve solo collegare i file descriptor e passare il controllo
ai due _pppd_, in un caso tramite _ssh_.

Il compito di _ppptunnel_ è quello di aprire una coppia di terminali
(nel "local tty subsystem" in figura) e chiamare _fork_. Il processo
figlio chiude il terminale master ed esegue _pppd_ sul terminale
slave; il processo padre chiude il terminale slave ed esegue _ssh_,
specificando in linea di comando di eseguire _pppd_ sulla macchina
remota dopo aver aperto un terminale di controllo (cioè un'altra
coppia di pseudo-terminali, specificando ==-t==).

Per l'esecuzione del comando nella forma in cui è riportato conviene
che l'utente locale sia autorizzato dall'host remoto e che _sudo_
possa funzionare senza password su entrambe le macchine, ma e`
possibile digitare le password relative ad _ssh_ e al _sudo_ locale
sul terminale in cui viene invocato _ppptunnel_; e` invece necessario
che il _sudo_ remoto non chieda una password.  A livello kernel,
questo collegamento si appoggia sui normali moduli di PPP:
_ppp\_generic_, _ppp\_async_ e i moduli di compressione.

Listati


<legacy.c>
#include <stdlib.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ioctl.h>
#include <sys/stat.h>

int main()
{
    int i, j;
    char devname[]="/dev/pty--";
    char s1[]="pqrstuvwxyzabcde";
    char s2[]="0123456789abcdef";
    int fds, fdm = -1;

    for (i=0; fdm<0 && i<16; i++) {
        for (j=0; fdm<0 && j<16; j++) {
            devname[8] = s1[i];
            devname[9] = s2[j];
            if ((fdm = open(devname, O_RDWR)) < 0) {
                if (errno == EIO) continue;
                exit(1);
            }
        }
    }
    devname[5]='t'; /* /dev/ttyXY */
    if ((fds = open(devname, O_RDWR)) < 0)
	exit(2);
    exit(0);
}
 

<open.c> 
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/ioctl.h>
#include <sys/stat.h>

int main()
{
    int n;
    int zero=0;
    char name[16];
    int fds, fdm;

    if ((fdm = open("/dev/ptmx", O_RDWR)) < 0)
	exit(1);

    if (ioctl(fdm, TIOCGPTN, &n) < 0)
	exit(2);
    sprintf(name, "/dev/pts/%i", n);
    if (ioctl(fdm, TIOCSPTLCK, &zero) < 0)
	exit(3);

    if ((fds = open(s, O_RDWR)) < 0)
	exit(4);
    exit(0);
}
 

<openpty.c>     
#include <stdlib.h>
#include <unistd.h>
#include <pty.h>
#include <utmp.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <sys/types.h>

int main()
{
    int fdm, fds;
    int pid, i;
    fd_set set;
    char buf[64];
    
    if (openpty(&fdm, &fds, NULL, NULL, NULL))
	exit(1);
    if ((pid = fork()) < 0)
	exit(2);
    if (!pid) {
	/* child */
	close(fdm);
	login_tty(fds);
	execl("/bin/sh", "sh", NULL);
	exit(3);
    }
    /* father: copy stdin/out to/from master */
    close(fds); system("stty raw -echo");
    FD_ZERO(&set);
    while (waitpid(pid, &i, WNOHANG)!=pid) {
	FD_SET(0, &set);
	FD_SET(fdm, &set);
	select(fdm+1, &set, NULL, NULL, NULL);
	if (FD_ISSET(0, &set)) {
	    i = read(0, buf, 64);
	    if (i>0) write(fdm, buf, i);
	}
	if (FD_ISSET(fdm, &set)) {
	    i = read(fdm, buf, 64);
	    if (i>0) write(1, buf, i);
	}
    }
    system("stty sane");
    exit(0);
}


<ppptunnel>
#!/usr/local/bin/ettclsh
# -*-tcl-*-

if [llength $argv]!=3 {
    puts stderr "use: \"$argv0 <remotehost> <local-IP> <remote-IP>\""
    exit 1
}
foreach {host ip1 ip2} $argv {}

sys_ttypair master slave
if ![set pid [sys_fork]] {
    # child
    after 3000
    sys_dup $slave stdin
    sys_dup $slave stdout
    close $slave; close $master
    set ttyname [file readlink /dev/fd/0]
    sys_exec sudo \
	    pppd debug local : nodetach noauth lock $ttyname
    exit
}
# father
sys_dup $master stdin
sys_dup $master stdout
close $master; close $slave
sys_exec ssh -t $host sudo \
	pppd debug local $ip2:$ip1 nodetach noauth lock /dev/tty


Figure


<Figura 1>

<Figura 2>