C と POSIX システムコールでゼロ依存のターミナルエディタを書く

著者が初めてCを書き、Kiloガイドに従ってターミナルエディタを作った:raw modeはtcgetattr/tcsetattr、バイトストリームをキー入力に解析(ANSIエスケープシーケンス含む)、ioctl(TIOCGWINSZ)でウィンドウサイズを取得、write()で行ごとに描画。サードパーティライブラリは一切不使用。

日本語
コピー
编辑器自身运行时的截图:终端窗口里显示一段 C 源码,底部状态栏写着文件名与行号 src/main.c - 1075 of 1412 lines,最下方是搜索提示

C と POSIX システムコールで作る依存ゼロのターミナルエディタ

なぜ作ったのか#

OS に強い興味を持っていた私は、聖杯と呼ばれる OS の教科書、Albert Woodhull と Andrew Tanenbaum の 『Operating Systems: Design and Implementation』 を自然と手に取った。この本は MINIX 3 や UNIX/POSIX 準拠の OS(Linux、macOS、FreeBSD など)、かつての Windows NT が低レベルでどう動いているかを教えてくれる。そこで得た知識を活かしたいと思い、ターミナルツールを作ることにした。「でもテキストエディタを作って OS の何が学べるの?」 意外なことに、学べることは山ほどある。Editor を作るにあたっての目標は、サードパーティライブラリを一切使わず、コアなシステムライブラリとシステムコールだけを使うこと。これにより、ユーザーランドのツールが最も低い層でどう振る舞うかを否応なく見せつけられ、OS の動作における「ブラックボックス」が必然的に剥がされていく。

始める前に

警告

コード例は説明のために意図的に簡略化している。完全なソースはこちら。ブログで全てを語り切ることはできないので、以下では大幅に削り、システムプログラミングとコア機能に関わる部分だけを残す。

ターミナルの設定

ファイルを変更し、書き込めるようになる前に、まず把握すべきことがある。テキストと状態をどうやってターミナルに表示するかだ。Operating Systems の教科書と antirez のガイドによれば、ターミナルはデフォルトで cooked モード、つまり canonical モードにある。これは /bin/bash のようなシェルが、たとえば

~ $ ls /home/chloe/ | grep 'my file'

のようなコマンド全体の入力を許すということだ。

Enter を押して初めて、/bin/bash がそのコマンドを表すキー入力を受け取る。

これでは困る。Editor にはキー入力を一つ一つ見てほしいのであって、キーを叩くたびに Enter を押させるのは論外だ。必要なのは raw モード、つまり non-canonical モードである。このモードなら、キーを押した瞬間に Editor が入力を見られる。

そのためには、OS にターミナルモードを切り替えさせるシステムコールを C でいくつか実装する必要がある。以下のコード片では、tcgetattr() でターミナル設定、つまり属性を取得する。関数にポインタを渡すのは極めて一般的な手法で、これにより関数(tcgetattr() など)が外部のデータ構造に書き込める。

この設定は後で復元できるよう、esettings.orig_termios 構造体に保存する。次にこの設定をコピーし、ビット演算で自分の設定を適用する。時間の都合で簡単に説明すると、termios データ構造が保持する二進整数はそれぞれ次を表す。

  • raw.c_iflag - ターミナル入力設定
  • raw.c_oflag - ターミナル出力フラグ
  • raw.c_cflag - ターミナル制御モード
  • raw.c_lflag - ターミナルローカルモード

ある設定をオフにするには、これらのフラグの否定(~)を取ってビット単位の AND(&=)を行う。たとえば ~(BRKINT) のように。複数のフラグはパイプ記号(’|’)で組み合わせて、まとめて一度にクリアできる。

ここでの例外は raw.c_cflag で、これは設定をオンにして文字長を 8 ビットに指定する。そのためにビット単位の OR(|=)で CS8 ビットを 1 に強制する。

Tip

まだ腑に落ちなければ、antirez が明快に説明しているので見てほしい

タイムアウト設定は raw.c_cc で指定する。この API は明快で、定数で位置を示し、その位置に VTIME と VMIN の具体的な時間設定を指定できる。

変更は tcsetattr() でターミナルにコミットする。

void enableRawMode() {

	// Get current terminal settings

	tcgetattr(STDIN_FILENO, &esettings.orig_termios);

	// Duplicate terminal settings

	struct termios raw = esettings.orig_termios;

	// Modify settings on the duplicate

	raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);

	raw.c_oflag &= ~(OPOST);

	raw.c_cflag |= (CS8);

	raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);

	raw.c_cc[VMIN] = 0; // read() can terminate with a min of 0 bytes recieved

	raw.c_cc[VTIME] = 1; // read() maximum wait time is 100 milliseconds

	// Commit to raw mode by passing the duplicate

	tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);

ユーザーがテキストを編集し終えてエディタを終了したとしよう。問題が起きる。OS はターミナルを raw モードのままにしていて、これは他のプロセス(プログラム)にとって非常にまずい。disableRawMode() で古い設定を復元し、古いターミナル設定 esettings.orig_termios を tcsetattr() に渡す。

こうして Editor のためのターミナル準備は完了した :)

正しいキー入力の扱い方

「キー入力はもう片付いたんじゃなかったの?」:(

分かったのは、Editor がキー入力を即座に受け取れるとしても、それはバイト列だということだ。そのバイト列を整形し解釈するのはこちらの仕事である。

実装はこうだ。エディタが受け取る全ての正当なキー入力を表す enum を定義する。この enum は非 ASCII の特殊文字を表す際に 128 を超えるため、整数オーバーフローを避けるために char ではなく int データ型を使う。

enum editorKey {

	BACKSPACE = 127,

	ARROW_LEFT = 1000,

	ARROW_UP,

	ARROW_DOWN,

	ARROW_RIGHT,

	DEL_KEY,

	PAGE_UP,

	PAGE_DOWN,

	HOME_KEY, 

	END_KEY,

	NO_KEY_PRESS,

次に、キー入力に関する全ロジック——ASCII と特殊文字のストリームを処理しやすい単一の整数にマッピングする——を get_key() にまとめる。raw モードではキー入力は即座に届く。ユーザーが通常の文字(大文字でも小文字でも)を入力すれば、その文字を受け取り、通常の ASCII コードとして返す。一方、矢印キーや Home、Delete といった特殊キーは通常、標準 ASCII では送られず、ANSI エスケープシーケンスとして送られる。

read このシステムコールはファイルからの読み取りに使う。UNIX 系 OS は万物をファイルと見なすので、STDIN_FILENO の内容を読み取れる——これは現在のターミナルセッションに接続された入力バッファを指す標準化されたファイルディスクリプタである。

VT100 エスケープコードはエスケープシーケンスで始まる。\x1b(16進数)または\033(8進数)の後に ASCII 文字列が続き、特殊文字や特殊な端末操作を表す。たとえば右矢印キーは\x1b[C、Delete キーは\x1b3~と表される。

Enter や Backspace のようなキーを enum で扱う必要はない。これらはそれ自体が純粋な ASCII の\rと\bである。

Note

get_key() の目的は、1回のキー入力を1つの整数として表すことにある。こうすればキー入力の処理はずっと楽になる!

int get_key() {

	char c = '\0';

	ssize_t res = read(STDIN_FILENO, &c, 1);

	// If not special character, return ascii value as is

	if (c != '\x1b') return c;

	// Capture special keys, i.e Arrow keys

	char special[3];

	

	// Special characters have trailing characters left to read

	read(STDIN_FILENO, &special[0], 1);

	read(STDIN_FILENO, &special[1], 1);

	if (special[0] == '[') {

		switch (special[1]) {

			case 'A': return ARROW_UP;

			case 'B': return ARROW_DOWN;

			case 'C': return ARROW_RIGHT;

			case 'D': return ARROW_LEFT;

			case 'H': return HOME_KEY;

			case 'F': return END_KEY;

		}

		// Numbers as the 2nd char indicates more special keys

		if (special[1] >= '0' && special[1] <= '9'){

			read(STDIN_FILENO, &special[2], 1);

			if (special[2] != '~') return '\x1b';

			switch (special[1]) {

				case '1': case '7': return HOME_KEY;

				case '4': case '8': return END_KEY;

				case '3': return DEL_KEY;

				case '5': return PAGE_UP;

				case '6': return PAGE_DOWN;

			}

		} 

	}

	return '\x1b'; // We got an escape code but it was incomplete

複雑なキー入力を単一の整数として表せれば、それをprocess_keypress()に渡してエディタに対応する操作を実行させられる。プログラムを責務の明確なモジュールに分割するのは、常に良い習慣だ。

表示ロジック

テキストの編成

テキストを手軽に操作・表示するには、走査しやすい構造に編成する必要がある。そこで Editor は、グローバルなエディタ設定の下にesettingsという1つのメイン struct を使う。

以下に示すとおり、テキストは簡単に走査できるよう編成されている。erowは1行に関するデータを保持するデータ型 struct だ。各行が実際の ASCII 文字、すなわちchar *charsを保持しているのがわかる。char *renderも似ているが、タブを通常の ASCII の\tではなく4つの空白(TAB_STOPプリプロセッサマクロで定義)として表示する点が異なる。というのも、\tはカーソル位置計算システムcxとcyでは1文字と数えられるのに、タブは画面上で1文字分より広い幅を占めるからだ。ある行のindent_levelが0より大きいとき、これがカーソル移動のバグを防ぐ。

すべての行はerow構造体の配列として格納する。

Note

こうすることで、カーソルは行から行へ簡単に移動でき、ファイル内容全体に対して完全な O(1) 操作を行わずとも、ある行の情報を手軽に問い合わせられる。

#define TAB_STOP 4

typedef struct erow { // Single row data

	int index;

	int size;		  // char size

	int indent_level;

	int rsize;		  // render size

	char *chars;	

	char *render;

	...

} erow;

struct editorConfig {

	...

	unsigned int cx, cy; 			// Cursor position in file

	unsigned int rx; 				// Rendered cursor position in rendered file

	unsigned int ws_col, ws_row;	// Screen dimensions

	int numrows;					// Number of rows in file

	int rowoff; 					// Line number displayed as first line on screen 

	int coloff;						// Character of current line displayed as first character on screen

	erow* row;						// File row data

	...

ウィンドウサイズの取得

テキストを端末に表示する前に、端末ウィンドウのサイズを OS に問い合わせる必要がある。名前どおりの関数get_window_size()がこの機能を包んでいる。

端末はテキストベースなので、ウィンドウサイズは文字の行数×文字の列数になる。ここでは極めて重要な POSIX システムコールioctl()を使い、端末ドライバに端末サイズを問い合わせる。

ioctl()は、readやwrite()では扱えない特殊なファイル、通常はドライバを操作するために使う。ioctl()に対して、問い合わせるのは端末の出力ストリーム(STDOUT_FILENO)であり、取得したいのはウィンドウサイズ(TIOCGWINSZ)だと伝える。ioctl()にwinsize構造体を渡すと、そこに端末ウィンドウのサイズが書き込まれる。

これが失敗した場合はwrite()システムコールに切り替え、特殊な VT100 エスケープコードを出力してカーソルをウィンドウの端まで移動し、カーソルの相対位置を取得する。

int get_window_size(unsigned int *rows, unsigned int *cols) {

	struct winsize ws;

	if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) {

		// Driver Query Unsuccessful

		if (write(STDOUT_FILENO, "\x1b[999C\x1b[999B", 12) != 12) return -1;

		return get_cursor_position(rows, cols);

	}

	*cols = ws.ws_col;

	*rows = ws.ws_row;

	return 0;

ステータスバーとプロンプト

土台ができてしまえば、テキストの表示は比較的単純なので、紙幅を節約するためここでは詳しく触れない。各行を走査しながらwrite()システムコールで標準出力ストリーム(STDOUT_FILENO)に書き込み、*esettings.rowでレンダリング後の各文字*esettings.row->renderを端末に出力すればよいだけだ。

Extra

エディタはesettings.rowoffとesettings.coloffで、ウィンドウに最初に現れる行と文字を記録する。この情報があれば、最初に現れる行と文字を変えることでスクロールを実現できる。escroll()がやっているのはまさにこれだ

関数edraw_status()は白背景のステータスバーを描画する。write()でSTDOUT_FILENOに書き込み、エスケープコードでカーソルを目的の位置へ移動し、\x1b[7mで背景色を変えるだけだ。

prompt()はテキストエディタのプロンプトの表示と管理を担う。プロンプトは検索機能や、別名で保存したいファイル名の入力に使う。描画方法はこれまでと同様で、ステータスメッセージを出力したあとカーソルを次の行へ移す。その後は無限ループに入り、ユーザーがテキストを入力するかプロンプトをキャンセルするまで入力をポーリングする。

Extra

prompt()はコールバックを受け取り、エディタの他のモジュールがキー入力のたびに通知を受け取れるようにする。Editor のインクリメンタルサーチはこうして実装されている!

テキスト編集の実装

ファイル操作と POSIX I/O

ファイルを開くこと自体は、POSIX システムコールを使っても簡単だ。ただし文字列の所有権には慎重なメモリ管理が要る!

esettings.filenameに代入する前に、既存のバッファポインタを解放し、渡されたfilenameをstrdup()でディープコピーしなければならない。スタックフレームや関数引数として渡されたファイルパスの寿命は、メインプログラムのループより長く続くとは限らない。だからこそヒープ上にディープコピーを作っておけば、後々のダングリングポインタやセグメンテーションフォールトを避けられる!

fopen() は既存のファイルを開くために使う。読み取り専用("r")で開く。

eopen()はgetline()でファイル内容を1行ずつ読み込む。各行は末尾の改行(\r、\n)を除去したうえで、その行の文字列表現をinsert_row()に渡し、erowデータ構造を構築する。最後にesettings.dirty = 0をリセットし、ファイルが未変更、つまりクリーンな状態であることを示す :)

void eopen(char* filename) {

	free(esettings.filename); // Clear previous file pointer

	esettings.filename = strdup(filename);

	

	eselect_syntax_highlight();

	FILE *fp = fopen(filename, "r");

	char *line = NULL;

	size_t linecap = 0;

	ssize_t len;

	while((len = getline(&line, &linecap, fp)) != -1) {

		// Remove Trailing '\n' and '\r'

		while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r')) len--;

		insert_row(esettings.numrows, line, len);

	}

	free(line);

	fclose(fp);

	esettings.dirty = 0;

ファイルを保存するとき、ユーザーがエディタ起動時に引数で編集対象のファイルを指定していなければ、prompt()でファイル名の入力を促す。open() でそのファイルのファイルディスクリプタを取得する。ビットフラグO_RDWR | O_CREATを指定することで、ファイルは読み書きモードで開かれ、まだ存在しなければ作成される。0644はファイルのパーミッションを8進数で表す。ファイルの所有者、つまり作成したユーザーには読み書きの権限があり、システム上の他の全員には読み取り権限しかない。

エラー処理は goto 文で簡潔に保ち、後始末のコードを繰り返さずに済ませています。おかげで if の入れ子も生まれず、主処理のインデントも浅いままです。コードを書くときは、常に読みやすさを優先しましょう。

ファイルサイズの調整には ftruncate() を使い、ユーザーのテキスト内容がちょうど収まるようにして、余分なストレージを消費しないようにしています。

void esave() {

	esettings.filename = prompt("Save file as:\t %s", NULL);

	int len;

	char *buf = erows_to_string(&len);

	int fd = open(esettings.filename, O_RDWR | O_CREAT, 0644);

	if (fd == -1) {

		goto cleanup_buf;

	}

	if (ftruncate(fd, len) == -1) {

		goto cleanup_fd;

	}

	if (write(fd, buf, len) != len) {

		goto cleanup_fd;

	}

	eset_message("%d bytes written to disk", len);

	close(fd);

	free(buf);

	esettings.dirty = 0;

	return;

	cleanup_fd:

		close(fd);

	cleanup_buf:

		free(buf);

	

	eset_message("Error Saving! I/O error: %s", strerror(errno));

おわりに

以上が Editor の核心部分のすべてです。残念ながら、1000 行を超えるコードベースを 1 本のブログで語り切ることはできません。シンタックスハイライトと検索の詳細はあえて省きました。このプロジェクトに興味があれば、私の GitHub で確認できます。

出典: 111Nation DevBlog← ホームへ戻る