自作シェルでヒストリを扱えるようにする

2019-07-24

学習用OSのXV6には簡易的なシェルが付属しているが、ヒストリ機能を扱えるようにしたという話。

シェル上でカーソルキーの入力を受け取るには

XV6のシェルはユーザ空間で走るプログラムshとして用意されている。 コマンドラインからの入力を1行ごとに受け取って、指定されたコマンドに従ってプログラムを起動する、ということを繰り返している。

入力された行の読み込みはgetcmd関数内のgets呼び出しで行っている。 getsではシステムコールreadを呼び出して1文字ずつ読み出して、改行が入力されるまでをバッファに格納している。 これを入力途中にカーソルキーの上下で過去に入力した行を指定、編集できるようにしたい。

readで読み込んだキャラクタがカーソル上下だったら…とすればいいかと思いきや、そう単純にはいかない。 readへの1バイト要求に対して、なにかキーが入力された瞬間に処理が返されるわけではなくて、カーネル内で改行が入力されるまでバッファリングされるようになっている。

バックスペースによる行編集はカーネル内で行われて、最終的にENTERで確定した行がユーザ側では得られるのでその限りでは便利なんだけど、カーソル上下が押された場合に対応できない。 カーネルにヒストリ機能を持たせてしまうのは、ヒストリ機能の変更にカーネルの修正が必要になってしまうのでよくない。

そこでどうするか。 ENTERの入力までバッファリングされる機能の有効/無効を制御できるようにして、ユーザプログラム側で1文字ずつの入力ごとに処理できるようにする。

termios互換を用意する

Linuxにはioctlというデバイスを制御するシステムコールがあって、これを呼び出せば標準入力のバッファリングの設定も制御できる。 これを模したシステムコールをXV6にも用意してやって、互換性をもたせられるようにする。

ioctl を直接使ってもいいんだけど、それをラップする?ライブラリでtermiosというものがあるのでそのライブラリも合わせて用意してやる。 実装tcsetattrと、設定を復帰させるためのtcgetattrを簡易的に用意した。 ioctl経由で受け取ったカーネル側でバッファリングやエコーバックの有無を切り替えるようにする。

キーボード入力でカーソルキーを扱う

XV6をqemuでのエミュレーション(や実機)で動かす場合はI/Oから受け取ったキーコードが内部で変換され、カーソルキーは0xe20xe5に割り当てられてコンソールで扱われている。 それに対してqemuの-nographicオプションで動かす場合はターミナル経由になるので、エスケープシーケンス(カーソル上:\x1b, [, A、カーソル下:…B)で渡される。 シェル上では統一して扱えるほうが都合がいいので、キーボードからの入力もコンソールに渡す際にエスケープシーケンスに変換してやる。

ヒストリ機能を実装する

以上で1文字入力ごとにユーザプログラム側で受け取れるようになったので、後は好きなようにできる。

バッファリングやエコーバックの切り替えは、kiloのソースを参考に、

#include "termios.h"

bool setRawMode(bool enable, int fd) {
static struct termios orig_termios;
static bool termios_saved;
static bool rawmode;

if (enable) {
struct termios raw;

if (!isatty(fd))
return false;
if (!termios_saved) {
if (tcgetattr(fd, &orig_termios) == -1)
return false;
termios_saved = true;
}

raw = orig_termios; /* modify the original mode */
/* input modes: no break, no CR to NL, no parity check, no strip char,
* no start/stop output control. */
raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
/* output modes - disable post processing */
raw.c_oflag &= ~OPOST;
/* control modes - set 8 bit chars */
raw.c_cflag |= CS8;
/* local modes - choing off, canonical off, no extended functions,
* no signal chars (^Z,^C) */
raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
/* control chars - set return condition: min number of bytes and timer. */
//raw.c_cc[VMIN] = 0; /* Return each byte, or zero for timeout. */
//raw.c_cc[VTIME] = 1; /* 100 ms timeout (unit is tens of second). */
raw.c_cc[VMIN] = 1; /* Return each byte, or zero for timeout. */
raw.c_cc[VTIME] = 0; /* 100 ms timeout (unit is tens of second). */

/* put terminal in raw mode after flushing */
if (tcsetattr(fd,TCSAFLUSH,&raw) < 0)
return false;
rawmode = true;
return true;
} else {
if (rawmode && termios_saved) {
tcsetattr(fd, TCSAFLUSH, &orig_termios);
rawmode = false;
}
return true;
}
}

などという関数を用意してやって、 シェルで行入力を行う際には setRawMode(true) で1文字ごとに受け取れるようにして、カーソル上下キーが入力されたら編集中の入力を消去して過去に入力された行を復帰させてやればヒストリ機能が実装できる。 行が入力されてコマンドを実行する際には setRawMode(false) で通常のバッファリングに戻してやる。 またプログラム終了時に必ず戻すようにするために atexit() でも設定しておく。

行入力を扱う関数のインタフェースは別に任意で構わないが、既存のReadlineに似せて作るといいかもしれない (できてないけど…)。

そんなこんなでXV6にPOSIX的な開発環境を少し整えたので、改造したsh.cはXV6で動くし、Linuxでもコンパイルして動かせる。 機能を実装するのはLinux上で行えて便利。