blog · git · desktop · images · contact & privacy · gopher


Die absoluten Grundlagen eines Terminalemulators

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:

eduterm

eduterm

Wo fängt man an?

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:

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.

Einen Kindprozess mit dem Terminal verbinden

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.

Pseudoterminals erledigen einen Großteil der Arbeit

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.

Okay und was muss der Emulator dann emulieren?

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.

Ausblick

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?