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>