blog · git · desktop · images · contact
2018-02-24
Terminals. XTerm, st, libvte und so weiter. Benutzt so ziemlich jeder und viele Leute auch den ganzen Tag lang. Die große Frage im Hinterkopf ist, was die Dinger eigentlich machen und wie sie funktionieren.
Zeit, einen sehr einfachen Terminalemulator zu schreiben. Das war schon lange auf meiner TODO-Liste. Mein Beispielprogramm ist hier:
Nehmen wir an, dass man unter Linux unterwegs ist. Da hat man bestimmt
schonmal „w
“ eingegeben. Die Ausgabe sieht etwa so aus:
$ w
16:43:05 up 8:24, 2 users, load average: 0.58, 1.06, 1.26
USER TTY LOGIN@ IDLE JCPU PCPU WHAT
void tty1 09:31 7:11m 14:12 0.00s xinit /home/...
void pts/10 16:42 0.00s 0.02s 0.00s w
$
„tty1
“ ist die Linux-Textkonsole, an der ich mich eingeloggt habe. Die
zweite Zeile zeigt „pts/10
“ an und das ist das XTerm, in dem ich „w
“
aufgerufen habe. (All die anderen offenen Terminals sieht man hier
nicht, weil VTE seit einiger Zeit utmp nicht mehr aktualisiert.)
Der Begriff „pts“ ist der Wegweiser in die richtige Richtung. Dazu gibt es sogar eine Manpage:
$ man pts
Dort steht dann auch schon fast alles, was man wissen muss. Im Wesentlichen:
Wie kommt man an ein solches Paar? Ist ein bisschen haarig. Die BSDs
haben dafür „openpty()
“, das die ganze Arbeit erledigt. suckless st
benutzt das. In der Manpage dazu steht aber, dass die Funktion „nicht
von POSIX standardisiert ist“, also benutzt man sie besser nicht? Oder
doch? Spielt es eine Rolle? Wer weiß. Letztendlich hat sogar musl libc
diese Funktion drin (mit nettem Kommentar zur Überlegenheit).
Bei meinem kleinen Beispielprogramm habe ich jetzt mal
„posix_openpt()
“ benutzt. Das hat ein etwas anderes Interface und man
muss leider mehr Arbeit selbst erledigen. Läuft etwa so ab:
posix_openpt()
liefert einen Deskriptor zu einem Master-Device.grantpt(master)
passt die Dateisystemberechtigungen an, damit man
das dazugehörige Slave-Device öffnen kann.unlockpt(master)
unlockt das Slave-Device, das zum jeweiligen
Master gehört. Im Wesentlichen heißt das, dass man das Device jetzt
tatsächlich öffnen darf.Warum genau man diesen kleinen Tanz tanzen muss, ist eine offene Frage.
Das Interface von „openpty()
“ fühlt sich da besser an.
Nun gut. Man führe diese drei Calls aus und dann hat man ein passendes
Deskriptor-Pärchen. In meinem Programm wird das in „pt_pair()
“
erledigt.
Auch das ist nicht so schwer:
p = fork();
if (p == 0)
{
close(pty->master);
setsid();
if (ioctl(pty->slave, TIOCSCTTY, NULL) == -1)
{
perror("ioctl(TIOCSCTTY)");
return false;
}
dup2(pty->slave, 0);
dup2(pty->slave, 1);
dup2(pty->slave, 2);
close(pty->slave);
execle("/bin/sh", "/bin/sh", NULL, env);
}
Man forkt, wobei das Kind quasi alle Eigenschaften des Elternteils erbt. Im Kind schließen wir dann den Master, weil wir hier nur mit dem Slave arbeiten werden. Diesen Slave-Deskriptor „kopieren“ wir dann auch nach stdin, stdout und stderr, was es der Shell letztendlich erlaubt, mit unserem Terminal zu reden.
Wir erstellen auch eine neue Session mittels setsid()
, was implizit
auch eine neue Prozessgruppe erstellt. Diese beiden Konzepte sind
heutzutage ein bisschen in Vergessenheit geraten. Hier gibt es eine
Erklärung:
https://www.win.tue.nl/~aeb/linux/lk/lk-10.html
Als Skizze sieht es ungefähr so aus (hoffentlich hilft das beim Verständnis):
sh
+-------------------------------------------+
| |
| +---------------+ +-------+ +--------+ |
| |+----+ +------+| |+-----+| |+------+| |
| || ls | | grep || || vim || || find || |
| |+----+ +------+| |+-----+| |+------+| |
| +---------------+ +-------+ +--------+ |
| |
+-------------------------------------------+
ls
, grep
, vim
und find
sind jeweils eigene Prozesse. ls
und
grep
sind zusammen in einer Prozessgruppe, denn – so nehmen wir mal an
– der User hat „ls | grep foo
“ ausgeführt. vim
und find
haben
jeweils eine eigene Prozessgruppe. Alle zusammen, inklusive sh
, sind
in einer Session und sh
ist der Leader dieser Session.
Nehmen wir mal an, dass „ls | grep foo
“ die Prozessgruppe im
Vordergrund ist. Das heißt, dass man vim
und find
davor gestartet
und dann beide mit ^Z in den Hintergrund befördert hat. Drückt man jetzt
^C, so wird ein SIGINT an die Prozessgruppe im Vordergrund geschickt,
also zu unserer Pipe.
Betrachten kann man all das mittels ps
(was aber standardmäßig keine
Session-IDs anzeigt):
$ ps -o pid,pgid,sess,comm
PID PGID SESS COMMAND
3301 3301 3301 bash
22474 22474 3301 vim
22547 22547 3301 find
23059 23059 3301 bash
23060 23059 3301 cat
23126 23059 3301 sleep
23291 23291 3301 ps
Manche dieser Prozesse haben eine gemeinsame Prozessgruppen-ID, alle
teilen sich aber auf jeden Fall die Session-ID. PID und PGID sind für
die meisten identisch, was bedeutet, dass diese Prozesse jeweils auch
Leader dieser Prozessgruppen sind – vim
, find
und ps
also. Auf der
anderen Seite teilen sich cat
und sleep
die PGID: Ich habe „while
sleep 1; do date; done | cat
“ ausgeführt und dann ^Z gedrückt. Dafür
musste die Shell also erstmal forken (die Bash mit der PID 23059 wird
neuer Prozessgruppen-Leader) und dann noch ein paar mal zusätzlich
forken, um ein paar neue Prozesse zu erzeugen.
Okay, genug des Exkurses. Zurück zum Code.
Oben wird zusätzlich auch das „Controlling Terminal“ für die ganze
Session festgelegt. Das passiert mit ioctl(pty->slave, TIOCSCTTY,
NULL)
und das ist vermutlich der interessanteste Teil dieses Abschnitts
und die Überleitung zum nächsten. Hiermit wird nämlich der Kernel
befähigt, ein SIGINT an die Prozessgruppe im Vordergrund zu senden, wenn
man ^C im Terminalemulator drückt.
Okay, wir haben jetzt einen Kindprozess und der ist mit unserem Slave verbunden. Schreibt man jetzt in den Master, so wird das als Eingabe beim Slave ankommen.
Das bedeutet, dass die andere Hälfte des Terminalemulators ein normaler
X11-Client sein kann. Alles, was man tun muss, ist, X11-Key-Events in
„Bytes“ zu übersetzen und ins Master-Device zu schreiben. So ein Event
liefert einem ein recht ausführliches struct mit Felder für den Keycode
und Modifier wie „Shift“. Man muss also herausfinden, um welches
ASCII-Zeichen es dabei geht. Zum Glück reicht für mein sehr einfaches
Beispielprogramm die Funktion XLookupString()
schon aus.
Und natürlich muss man auch die Gegenrichtung verarbeiten, also aus dem Master-Device lesen und diese Zeichen in einem X11-Fenster darstellen.
+------------+ +================+ +----------------------+
| X11 client | <--> | pseudoterminal | <--> | terminal application |
+------------+ +================+ +----------------------+
Der „X11-Client“ ist der Terminalemulator. Es muss kein X11-Client sein, aber darauf kommt’s jetzt nicht an.
Das „Pseudoterminal“-Dingsi ist nicht Teil des eigenen Codes. Das ist ein Treiber im Kernel und er erledigt einen erstaunlich großen Teil der Arbeit.
Man muss sich zum Beispiel nicht mit Signalen herumschlagen. Man schreibt tatsächlich ein „^C“ (also ein Byte mit dem Wert 0x03) in den Master-Deskriptor und das war’s. Das Pseudoterminal kümmert sich dann darum, herauszufinden, welche Prozessgruppe gerade im Vordergrund ist, und ist dann auch für die Signalerzeugung zuständig.
Man muss sich auch nicht um „Terminalmodes“ kümmern. Schonmal „read
-s
“ in einem Shellskript benutzt? Man probiere:
read -sp 'Type something and hit Enter: '
echo "You said: '$REPLY'"
Gibt man an diesem Prompt etwas ein, so sieht man es nicht. Die Shell
(oder jedes andere Programm) kann das erreichen, indem sie ein passendes
ioctl(stdin, TCSETSW, ...)
ausführt, um „dem Terminal“ zu sagen, dass
der Echo-Mode bitte abgeschaltet werden soll. Stellt sich aber raus,
dass auch hierfür der Pseudoterminal-Treiber zuständig ist und nicht der
tatsächliche Emulator.
Im Endeffekt ist ein Terminalemulator wie XTerm also nichts anderes als ein Konverter von X11-Events zu Bytes.
Ist natürlich gelogen.
Es gibt ein paar Sachen, die man nicht selbst implementieren muss. Auf der anderen Seite schert sich der Pseudoterminal-Treiber aber überhaupt nicht um irgendwelche Escape-Sequenzen – und hier geht der Spaß dann los.
printf '\033[1mThis is bold text.\n'
Führt man das in meinem Beispielprogramm aus, sieht man keinen fetten
Text. Man sieht auch keine Farben. Man wird auch keine curses-Programme
ausführen können. Nicht einmal vi
funktioniert (ed
tut aber seinen
Dienst, also sollte niemand ein ernstliches Problem haben :-)).
All diese Escape-Sequenzen muss man interpretieren. Ist man dabei kompatibel mit einem VT100? Versteht man die „nicht standardisierten“ Escape-Sequenzen eines XTerm, die die Nutzung von 256 Farben ermöglichen? Unterstützt man einen sekundären Puffer oder besondere „line drawing characters“?
Sobald der User „Pfeiltaste hoch“ drückt, welche Bytes schreibt man dann
auf sein Master-Device? ^C war einfach, weil das schon seit Urzeiten auf
0x03 gemappt ist. Pfeiltasten haben kein solch einfaches Mapping.
Schonmal ein verirrtes ^[[A
im Terminal gesehen? Das könnte eine
Escape-Sequenz gewesen sein, die der Terminalemulator erzeugt und auf
sein Master-Device geschrieben hat, um der Terminalanwendung besagtes
„Pfeiltaste hoch“ zu signalisieren. Die Anwendung hat dabei auch viel
Spaß, denn sie muss ebenfalls viele dieser Sequenzen erzeugen und
interpretieren können.
Oh und wo wir schon bei „Sequenzen“ sind: UTF-8 ist ein Multibyte-Encoding. :-) Mein einfaches Beispielprogramm ignoriert das alles und erwartet nur ASCII.
Auch reines ASCII kann aber schon herausfordernd sein (oder „lästig“). Es gibt das Newline-Zeichen. Wenn man das liest, sollte man den Cursor um eine Zeile nach unten schieben. Was macht man nun, wenn das Terminal 80 Zeichen breit ist und man soeben 80 „a“ gelesen hat, die von einem Newline abgeschlossen werden? Das 80. „a“ hat den Cursor in die 81. Spalte geschoben, welche aber nicht existiert, also hat man vermutlich die Zeile „gewrapped“ und ist in der nächsten Zeile gelandet. Ignoriert man dann das besagte Newline? Oder verarbeitet man es und erzeugt damit zwei Zeilenvorschübe?
Solche einfache Probleme können schon Kopfweh verursachen. Von unsäglichen Schandtaten wie dem folgenden ganz zu schweigen:
printf 'ä\033[120b'
Das gibt ein „ä“ aus (UTF-8!) und dann kommt eine Escape-Sequenz, die das Terminal anweist, das vorherige Zeichen 120 mal zu wiederholen.
Wie man sieht, sind die Grundlagen der Terminalemulation nicht so schwer zu verstehen. Das sieht man auch an der kurzen Git-Historie meines Beispielprogramms. Trotzdem habe ich auch hierbei wieder viel gelernt.
An dem Punkt höre ich aber auf. Vielleicht baue ich noch ein, zwei interessante Sachen ein, ich werde aber ganz sicher nicht damit anfangen, einen komplett neuen Terminalemulator zu schreiben. Es gibt schon einen Grund, warum das „curses“ heißt. Diesmal ist es auch nicht sinnvoll, das Rad neu zu erfinden – wenn man an einem Terminal mithacken will, dann wäre st der richtige Kandidat.
Es könnte aber interessant sein, sich den Code der Pseudoterminals anzuschauen. Oder nochmal einen Blick auf die BSD-Details zu werfen.
Auch frage ich mich, wie es damals mit echter Terminal-Hardware war. Ich gehe davon aus, dass es damals sowas wie „Pseudoterminals“ noch nicht gab. Das heißt dann, dass ein VT100 sowas wie „cooked mode“ oder „echo mode“ selbst implementieren musste. Oder?