いものやま。

雑多な知識の寄せ集め

端末とエスケープシーケンスの話。

昔のブログで書いた記事から。
書いたのは2010年9月26日。


事の起こり

会社でscreenを使い出すようになって、じゃあ家でもscreenを使えるようにしようと思い、ついでにbashのプロンプトも会社で使っているのと同じにしようと思って、会社での設定を自分のPC宛に送っておいた。

で、その設定が

PS1='\[\e]0;\w\a\]\n\[\e[32m\]\u@\h \[\e[33m\]\w\[\e[0m\]\n\$ '

というもの。 (ちなみに、cygwinのデフォルトの設定そのまま)

出力としては、

username@hostname /path/to/working/directory
$

という感じで、username@hostnameは緑、/path/to/working/directoryは黄色で表示される。

\uがusername、\hがhostnameに展開されるということとかは知っていたのだけど、前々から気になりつつも調べていなかったのが、どうやって色を変えているのかということ。
見る限りでは、\[\e[32m\]というのと、\[\e[33m\]というのが怪しそうで、32が緑、33が黄色を表すパラメータになっていそう。
けど、気になるのは、これはbashの仕様なのかということ。(\uなどはbashの仕様)
単に色を変えるのであれば、例えば\c0\c1、・・・とかを\uと同様に作っていそうなもの。

よくよく良く考えてみると、ターミナルで表示される色って、どれも同じ。
lsにしろ、vimによる色付けにしろ、出てくる色は限られてる。
となると、これはbashの仕様なのではなく、表示しているもの――つまり、ターミナルの仕様なんじゃないかな、と。

bashの仕様の調査

とりあえず、bashのmanpageを見たところ、

  • \[
    非表示文字のシーケンスの開始。
    これを使うと、プロンプト中に端末の制御シーケンスを埋め込むことが出来る。
  • \]
    非表示文字のシーケンスを終了する。
  • \e
    ASCII のエスケープ文字 (033)

とのこと。

つまり、\[\e[32m\]というのは、

  1. \[
    非表示文字の始まり。
  2. \e[32m
    <ESC>[32mという文字列。
  3. \]
    非表示文字の終わり。

と分解できることになる。

じゃあ、<ESC>[32mという文字列がどういう意味を持つのか、というのが残る謎。
ここで、先程の「ターミナルの仕様なのでは・・・」という推論のもと、端末について色々と調べてみたら、だいぶ分かってきた。

キーボードで文字を打った時の処理

まず分かったのが、キーボードで文字を打ったときの処理のこと。

キーボードで文字を打つとその文字がそのまま画面に現れて、Enterを押すとコマンドが実行されて結果が表示されることから、自分はこれを、

  1. キーボードで文字を打つ。
  2. 打った文字がそのまま画面に表れる。
  3. Enterを押すとコンピュータにその文字列が送られる。
  4. コンピュータの計算結果が表示される。

と勘違いしていた。
(つまり、キーボードの入力が直接端末に表れていると考えていた)

けど、これは間違い。
正しくは、

  1. キーボードで文字を打つ。
  2. 打った文字がコンピュータに送られる。
  3. コンピュータが、入力された文字をエコーバックして端末に表示する。
  4. 打った文字が端末に表示される。
  5. Enterを押す。
  6. コンピュータが計算を実行し、その結果を端末に表示する。

つまり、常に

キーボードの入力 -> コンピュータ -> 端末の出力

となっていて、

キーボードの入力 -> 端末の出力

とはなっていない。
(考えてみれば当たり前なんだけど)

だから、例えばカーソルキーでカーソルを移動させると、それはキーボードの入力が直接カーソルを移動させているように見えるわけだけど、実際には、キーボードの入力に反応したコンピュータが、カーソルを移動させる入力を端末に行うことで、カーソルを移動させていたということになる。
実際、コンピュータ側が固まってしまうと、いくらカーソルキーを押してもカーソルは移動しない。

このことから分かるのは、つまり「直接文字を表示するのではない命令」というのが存在して、それを使うことで端末をコントロールしているんだということ。
その「直接文字を表示するのではない命令」というのが、制御文字であったり、エスケープシーケンスと呼ばれるものだったりする。

エスケープシーケンス

例えば、C言語の最初の一歩、

printf("Hello, world!\n");

だけど、これは本当は、"Hello, world!\n"という文字列を端末に送るだけの命令。

\nは制御文字で、Unixであれば0x0aという文字コードになるけど、printfが行うのは端末に

0x48(H) 0x65(e) 0x6c(l) 0x6c(l) 0x6f(o) 0x2c(,) 0x20( ) 0x77(w) 0x6f(o) 0x72(r) 0x6c(l) 0x64(d) 0x21(!) 0x0a(\n)

というバイト列を送っているだけ。
端末はこの文字列を受け取り、0x21までは画面に文字を表示し、0x0aでカーソルの位置を次の行の先頭に持っていく。

つまり、0x0aは文字を表示する命令ではなく、カーソルの位置を次の行の先頭に持って行く命令として端末には解釈されることになる。

これを知るまでは、制御文字というのは、改行などを「コンピュータに」教えるための「マーク」で、それを受け取ったコンピュータが出力を調整しているのだと思ってたけど、実際には0x0aを出力しているだけで、コントロールを行っているのは端末の方。

さらに、端末によってはもっとコントロールが効いて、そのために<ESC>(=\x1bエスケープ)で始まる一連のコマンド列を使う。
これを、エスケープシーケンスという。
このエスケープシーケンスについては統一された規格というのはないようなのだけど、vt100のエスケープシーケンスというのがデファクトスタンダードになっているらしい。

<ESC>[32mというエスケープシーケンスの場合、「文字色をこれ以降緑にしろ」というのを端末に指示する。
つまり、bash\[\e[32m\]\u@\hという文字列を、

0x1b(<ESC>) 0x5B([) 0x33(3) 0x32(2) 0x6d(m) 0x75(u) 0x73(s) 0x65(e) 0x72(r) ...(以下略)

というバイト列にし端末に送ることで、username@hostnameを緑色で表示させていることになる。

色を変えるエスケープシーケンス

色を変えるエスケープシーケンスは、以下のようになっている:

<ESC>[bg;st;clm

ただし、bgstclは変数で、以下の通り:

  • bg
    背景色(省略可能)[40-47(黒, 赤, 緑, 黄, 青, マゼンタ, シアン, 白)]
  • st
    スタイル(省略可能)[0(標準), 1(太字), 4(下線), 5(点滅), 7(反転)]
  • cl
    文字色(省略可能)[30-37(背景色同様]

例えば、以下のような指定が可能:

エスケープシーケンス 文字の状態
<ESC>[41;4;30m 文字:黒、背景:赤、下線付き
<ESC>[5;32m 文字:緑、点滅
<ESC>[43;35m 文字:マゼンタ、背景:黄
<ESC>[0m (デフォルトの状態に戻す)

その他のエスケープシーケンス

他にも、以下のようなエスケープシーケンスがある:

エスケープシーケンス 説明
<ESC>[argA arg行カーソルを上へ移動
<ESC>[argB arg行カーソルを下へ移動
<ESC>[argD arg文字カーソルを左へ移動
<ESC>[argC arg文字カーソルを右へ移動
<ESC>[y;xH カーソルをyx列へ
<ESC>[H<ESC>[J 画面をクリア(ホームへ移動->そこから全部削除)
<ESC>[K カーソルから行末まで削除

もちろん、改行(\n)や復帰(\r)のような制御文字を使うことも出来る。

応用

ここまでの知識を使うと、次のようなことが出来ることが分かる。

#include <stdio.h>
#include <unistd.h>

int main(void) {
  printf("\x1b[H\x1b[J"); // 画面をクリア
  printf("Hello, World!\n");
  sleep(1);
  printf("\x1b[1A\x1b[K"); // Hello, World! を削除
  printf("Good Bye!!!\n");
  return 0;
}

このように、エスケープシーケンスをprintfで出力することで、画面の制御が出来る。

あと、echoコマンドも-eオプションをつけることでエスケープシーケンスが使えるようになるっぽいので、shell scriptで

#!/bin/bash
echo -e "\x1b[H\x1b[JHello, World!"
sleep 1
echo -e "\x1b[1A\x1b[KGood Bye!!!"

とやることも可能。

なお、少し触れたとおり、このエスケープシーケンスというのは端末によって異なっているということなので、単純に使うのだと互換性がなくなってくる。
そこで出てくるのがtermcapとterminfo、そしてcurses(ncurses)。
これらを使うことで、端末による差を吸収することが出来る。

なので、実際にはエスケープシーケンスを直接使うのではなく、cursesなどのライブラリを使うことになる。


ちなみに、この知識はニコニコ動画のダウンロードツールを作ったときにちょっと使ってたり。

ダウンロードの進捗具合を表示する部分で、

$stdout.printf("\rDownloading video data: %5s%% (%8s of %s) at %12s ETA %s ",
               percent_str, current_size_str, total_size_str, speed_str, eta_str)
$stdout.flush

としているけど、この行頭にある\rでカーソルを行の先頭に戻すことで、過去の出力を上書きして、表示を更新するようにしている。

今日はここまで!