端末アプリはGUI?CUI? 擬似端末を使って仕組みを体感する

この記事は、ChatGPTの助けを借りながら作った記事です。個人のメモレベルです。

端末アプリってCUI? GUI?

端末アプリはCUIですか?

ユーザー体験としてはCUI(文字ベースの操作)です。

実際に、端末アプリを使うとき私たちはマウスではなくキーボードでコマンドを打ち込みますし、画面にもテキストしか表示されません。シェルもCLI(コマンドライン・インターフェース)です。

つまり、「中で動いている世界」は完全にCUIです。

でもGUIじゃないの?

「アプリとしての実体」はGUIアプリです。

  • GNOME Terminal や xterm は、GTK や Qt などのGUIライブラリを使って描画されています。
  • ウィンドウ表示、フォント・配色の設定、マウス操作、コピー&ペーストなどが可能。
  • そもそもX Window SystemやWaylandの上で動作しています。

つまり、CUIの操作感をGUIの枠の中で再現しているアプリケーションなんですね。

Linux の端末と擬似端末

擬似端末(PTY)ってなに?

端末アプリの裏側で登場するキーワードが「擬似端末(pseudo terminal, PTY)」です。これがあるから、端末アプリはCUIを再現できています。

擬似端末 → カーネル内でソフトウェア的に作られる、仮想的な端末デバイスペア

端末アプリはこのPTYのマスタ側を操作し、シェルはスレーブ側に接続されます。つまり、

  • ユーザーがキーボードで入力 → PTYマスタ → PTYスレーブ → シェルに届く
  • シェルの出力 → PTYスレーブ → PTYマスタ → 端末アプリが画面に描画

このようにして、まるで本物の端末があるかのような文字入出力を再現しています。これが「端末エミュレータ」と呼ばれる理由です。

他にもある「端末」の種類

Linuxには、「端末」と呼ばれる仕組みが複数あります。それぞれの特徴を知っておくと、端末アプリの正体も理解しやすくなります。

● 仮想端末(Virtual Terminal / VT)
  • 物理的な画面とキーボードを使って文字だけで操作する、OSレベルで提供されるCUI環境
  • /dev/tty1/dev/tty6 など
  • Ctrl + Alt + F1F6 で切り替え可能
  • GUIが立ち上がる前のログイン画面などで使われる
● 擬似端末(Pseudo Terminal / PTY)
  • ソフトウェア的に作られる端末
  • /dev/pts/0 などの形で存在
  • 端末アプリ(gnome-terminal, xtermなど)、SSH、tmux、scriptコマンドなどが使う
  • マスタとスレーブのペアで動作し、シェルとの通信を中継する
● 物理端末(Serial Terminal)
  • 昔ながらのRS-232シリアル接続されたハードウェア端末
  • /dev/ttyS0 などとして扱われる
  • サーバー管理や組み込みLinuxで今も現役のことも

実践! C言語で端末アプリ的なものを作ってみる

端末アプリがどうやって擬似端末を使ってシェルを起動しているのか、C言語でデモコードを見てみましょう。

◆ 完成イメージ
  • 起動すると、bash シェルが起動
  • ユーザーは文字を入力でき、それが bash に送られる
  • bash の出力が画面に表示される
  • 実質的に「ミニ端末アプリ」として使える
◆ ソースコード:対話型端末アプリ
 1#define _XOPEN_SOURCE 600  // forkpty() を使うために必要なマクロ定義
 2
 3#include <unistd.h>
 4#include <stdio.h>
 5#include <stdlib.h>
 6#include <sys/select.h>
 7#include <sys/wait.h>
 8#include <string.h>
 9#include <errno.h>
10
11// forkpty(), openpty() などのためのヘッダー
12// OSごとに擬似端末関連のヘッダーを切り替え
13#ifdef __linux__
14    #include <pty.h>     // Linux の場合
15#elif defined(__APPLE__)
16    #include <util.h>    // macOS の場合(BSD系)
17#else
18    #error "Unsupported platform"
19#endif
20
21int main() {
22    int master_fd;   // 擬似端末のマスターファイルディスクリプタ
23    pid_t pid;       // fork の戻り値(子プロセスID)
24    fd_set fds;      // select() 用のファイルディスクリプタ集合
25    char buf[256];   // 入出力バッファ
26    ssize_t n;       // read/write のバイト数
27
28    // forkpty() = fork + openpty + login_tty をまとめた便利関数
29    // マスタPTYが master_fd に設定される
30    // 子プロセスでは標準入出力がスレーブPTYに接続されている
31    pid = forkpty(&master_fd, NULL, NULL, NULL);
32    if (pid == -1) {
33        perror("forkpty");
34        exit(1);
35    }
36
37    if (pid == 0) {
38        // ===== 子プロセス(bashなどシェルを実行する)=====
39        // スレーブ側の擬似端末がこのプロセスの標準入出力として接続されている
40        execlp("bash", "bash", NULL);  // bash を起動(失敗時のみ次の行が実行される)
41        perror("execlp");
42        exit(1);
43    } else {
44        // ===== 親プロセス(端末エミュレータ側)=====
45
46        // 無限ループで双方向通信
47        while (1) {
48            FD_ZERO(&fds);                     // セットを初期化
49            FD_SET(STDIN_FILENO, &fds);        // 標準入力(キーボード)を監視対象に
50            FD_SET(master_fd, &fds);           // 擬似端末のマスター出力も監視
51
52            // select() でどちらかの入力が来るのを待つ
53            int maxfd = (STDIN_FILENO > master_fd ? STDIN_FILENO : master_fd) + 1;
54            if (select(maxfd, &fds, NULL, NULL, NULL) == -1) {
55                perror("select");
56                break;
57            }
58
59            // 標準入力(キーボード)からデータが来た場合
60            if (FD_ISSET(STDIN_FILENO, &fds)) {
61                n = read(STDIN_FILENO, buf, sizeof(buf));
62                if (n <= 0) break;  // EOF またはエラーなら終了
63                write(master_fd, buf, n);  // 擬似端末マスターに書き込み(シェルに届く)
64            }
65
66            // 擬似端末(bash からの出力)が来た場合
67            if (FD_ISSET(master_fd, &fds)) {
68                n = read(master_fd, buf, sizeof(buf));
69                if (n <= 0) break;  // bash が終了した場合など
70                write(STDOUT_FILENO, buf, n);  // 画面(stdout)に出力
71            }
72        }
73
74        // 子プロセスの終了を待つ(bash の終了を回収)
75        wait(NULL);
76    }
77
78    return 0;
79}
🧠 解説
  • forkpty()fork()openpty()login_tty() をまとめてやってくれる便利関数
    ※ forkpty() については、この記事の一番下の「おまけ2」で改めて説明します。
  • 親プロセスは マスタPTY を使ってシェルと通信
  • 子プロセスは スレーブPTY に標準入出力を接続して bash を起動
  • select() を使うことで、標準入力とPTYの両方の読み取りを同時に扱えるようになります。
  • stdin からの入力は擬似端末に書き込み、シェルが受け取る
  • シェルの出力はそのまま stdout に流し、画面に表示

これにより、bash を対話的に操作できるようになります。

◆ 実行例
1$ ./interactive_terminal
2bash: no job control in this shell
3user@hostname:~$ echo hello
4hello
5user@hostname:~$ pwd
6/home/user
7user@hostname:~$ exit
8exit
9$

echopwd のようなコマンドが bash に送られ、実行結果が表示されているのが確認できます。 まさに「最小限の端末アプリ」と言ってよいレベルです。

このように、C言語と擬似端末の基本的なAPI(forkpty, select, read, write)だけで、実用的な「端末アプリのコア部分」を自作することができます。

「黒い画面の裏側」を自分で実装することで、OSや端末の理解がグッと深まるはずです。 現代の GUI 端末アプリも、実はこれとほぼ同じ構造の上に成り立っています。

※ ただしここで説明している端末アプリは、 CUI としての動作にとどまります。画面表示も入力もすべて「文字ベース」で、 GUI ではありません。 GUI にするには GUI ライブラリなどを使って画面を作り込む必要があります。

まとめ:端末アプリは「CUIっぽいGUIアプリ」

端末アプリは、GUIアプリケーションである。しかし、その中で提供しているのはCUIの体験である。

擬似端末という仕組みを通じて、端末アプリは「CUIらしさ」をGUIの世界で再現しています。 見た目はCUI、中身はGUI、心はシェル ── そんなハイブリッドな存在です。

観点仮想端末 (/dev/tty1)端末アプリ(gnome-terminalなど)擬似端末(/dev/pts/N)
表示文字のみ(CUI)文字のみ(CUI)なし(内部的な通信チャネル)
実体OSが提供する仮想画面GUIアプリデバイスファイルのペア
動作環境GUI不要GUI必要(X/Wayland)GUI/非GUIどちらでも可
マウス操作不可可能(ただし操作対象はCUI)不可(プログラム間通信)

おまけ

おまけ1:どの端末を使っているか確認する方法

1$ tty
2/dev/pts/1

このように表示されれば、あなたが今使っているのは擬似端末 /dev/pts/1、つまり端末アプリ上のシェルです。

一方、/dev/tty1 のように表示されれば、GUIを使っておらず、仮想端末(純粋なCUI)でログインしているということです。

おまけ2:forkpty()openpty() , login_tty()

forkpty() とは?

● 主な役割

これは「端末を作る」「プロセスを分ける」「子にスレーブ端末をくっつける」という、端末アプリやターミナルエミュレータを書くときの最短ルートとも言える便利関数です。

以下の 3つの処理 を一括で実行します:

  1. 擬似端末(pty)を作成する
    openpty() を呼び出して、マスターFDとスレーブFDを作成する
    /dev/ptmx/dev/pts/N のペア(例)

  2. 子プロセスを作成する
    fork() 相当の処理を行い、プロセスを分岐する

  3. 子プロセスでスレーブ端末を標準入出力として設定
    子プロセス内で login_tty(slave_fd) を呼び出す
    ・標準入力・出力・エラー出力をスレーブFDに接続する
    ・制御端末の設定も行う(setsid()TIOCSCTTY 相当)

● 定義(シグネチャ)

1pid_t forkpty(int *amaster,
2              char *name,
3              const struct termios *termp,
4              const struct winsize *winp);

引数の意味

引数説明
amasterint*出力用:マスター側のファイルディスクリプタが格納されるポインタ
namechar*オプション:スレーブ側のデバイス名(/dev/pts/3 など)を格納するバッファへのポインタ(不要なら NULL
termpconst struct termios*オプション:スレーブ端末の初期設定を指定(NULL 可)
winpconst struct winsize*オプション:端末ウィンドウサイズ(行・列など)の設定(NULL 可)

戻り値

pid_t 型で、fork() と同じく以下の値を返します:

  • 0:子プロセス内からの返り
  • >0:親プロセス内からの返り(返値は子の PID)
  • <0:エラー時(擬似端末の作成や fork に失敗)

使用には定義マクロが必要

一部の環境では、以下のように定義マクロを指定する必要があります:

1#define _XOPEN_SOURCE 600
2#include <pty.h>

これは forkpty() を有効にするために必要な POSIX 拡張機能を有効化するためです。

openpty() とは?

● 主な役割

擬似端末(pseudo terminal, pty)の「マスター」と「スレーブ」のペアを作成する。

● 定義(シグネチャ)

1int openpty(int *amaster, int *aslave, char *name,
2            const struct termios *termp,
3            const struct winsize *winp);

引数の意味

引数名内容
amaster出力:マスター側のFD(親プロセスが使う)
aslave出力:スレーブ側のFD(子プロセスが使う)
name出力:スレーブ側のデバイスファイル名(/dev/pts/N など)
termp入力:termios 構造体へのポインタ(端末属性の初期設定)
winp入力:winsize 構造体へのポインタ(端末サイズ設定)

具体的には何をしているのか?

openpty() は、以下のような処理をまとめて自動で行ってくれます。

  1. 擬似端末のマスター側ファイルディスクリプタ(ptmx)を開く
1int master_fd = open("/dev/ptmx", O_RDWR);  // 端末マスター
  1. スレーブ側の端末ノード名を取得して、スレーブ側も開く
1grantpt(master_fd);      // 権限の付与
2unlockpt(master_fd);     // スレーブ側を使用可能に
3char *slave_name = ptsname(master_fd);  // スレーブ側のデバイス名(例: /dev/pts/2)
4int slave_fd = open(slave_name, O_RDWR);
  1. 必要であれば、端末設定(termios)やウィンドウサイズ(winsize)を適用
1if (termp) tcsetattr(slave_fd, TCSANOW, termp);
2if (winp)  ioctl(slave_fd, TIOCSWINSZ, winp);

これらの作業を全部自前でやると面倒なので、openpty() が一括でやってくれます。

login_tty() とは?

擬似端末の slave を、子プロセスの「標準入出力+制御端末」に設定する。 これによって、シェルや対話型アプリケーションが「本物の端末に接続されているかのように」動作できるようになります。

● 主な役割

次の3つをまとめて行うことです。

  1. 標準入力(stdin)を fd に置き換え
  2. 標準出力(stdout)を fd に置き換え
  3. 標準エラー出力(stderr)を fd に置き換え

👉 つまり、指定された端末 fd を プロセスの「標準入出力三つすべて」に割り当てる。

さらに、

  1. その端末を「制御端末(controlling terminal)」として設定する → setsid() などで新しいセッションリーダーになったプロセスに制御端末を持たせる。

● 定義(シグネチャ)

1int login_tty(int fd);

なぜ必要?

シェルや対話型プログラム(bash, vim, top など)は、端末に接続された標準入出力を前提として動作します。 単に擬似端末を作るだけでは不十分で、擬似端末とシェルや対話型プログラムを接続する必要があります。 それを login_tty() が行ってくれます。

  • 子プロセスが fork() されただけでは、標準入出力は親と同じまま。
  • forkpty() を使うことで、子プロセス側に擬似端末のスレーブ側を標準入出力として接続する必要がある。
  • login_tty() がこの「接続」を自動でやってくれる。
forkpty()login_tty() の関係
1forkpty(&master_fd, NULL, NULL, NULL);
  • 内部的には以下とほぼ同等の処理を行っています:
 1openpty(&master, &slave, ...);  // 擬似端末ペアを作る
 2pid = fork();
 3if (pid == 0) {
 4    // 子プロセス側
 5    close(master);
 6    setsid();               // 新しいセッションを開始
 7    ioctl(slave, TIOCSCTTY); // スレーブを制御端末にする
 8    dup2(slave, 0);         // 標準入力
 9    dup2(slave, 1);         // 標準出力
10    dup2(slave, 2);         // 標準エラー出力
11    close(slave);
12    ...
13} else {
14    // 親プロセス側
15    close(slave);
16    ...
17}

この一連の処理を、login_tty() が一括して行ってくれるのです。

forkpty() との関係まとめ

forkpty() は、以下を全部まとめて実行してくれる関数です:

手順関数説明
1openpty()擬似端末のマスター・スレーブを開く
2fork()プロセスを分岐
3login_tty(slave_fd)子プロセス側でスレーブを標準入出力に設定し、制御端末にする

関連ページ