ぶていのログでぶログ

思い出したが吉日

Rustで始める自作シェル その1

この記事はRust Advent Calender 2021の8日目の記事です。 空白だったので遡って穴埋めしています。


このブログでも何度か話題にしているが、私はreddish-shellという自作シェルをOSSで開発している。 コマンドを実行するだけなら簡単ではあるものの、日常で使える程度にしっかりしたシェルを作ろうとしたら様々なノウハウが必要になる。 そう言ったノウハウはシステムプログラミングとして本やネット上に存在するが、シェルを作ることを目的として紹介されていないので体系的に学びづらいと感じる。というか大変だった…。 私の持つ知識をダンプし、シェルの作り方としてまとめることで、今後シェルを作ろうと思う人の役に立てればと思う*1。 なお、私はシェル作成について独学であるため、間違った内容も書いているかもしれない。 その時は優しくTwitterやコメントで教えていただけると助かります。

なぜRustか

私がRustでシェルを作っているから…という身も蓋もない理由。 ただ、Rustでシェルを書く前はRuby(mruby+C)で実装していたのでRubyでも紹介出来なくはない。 しかし、より低レベルでシステムコールを操作した方が理解が深まったり、他言語で実装する場合も応用が効くと考え、今回の記事ではRustを使うことにした。

方針

シェルと言ってもかなりの機能を有していたり、さまざまな環境で動くことを想定している。 それら全てを網羅しようとすると、このブログでは収まらないし私もそこまでの知識はないので、今回作成するシェルでは制限をいれることにする。

  • 環境: Linuxのみ
  • 機能:
    • 単純なコマンドの実行
    • リダイレクト
    • パイプライン

環境はLinuxのみに絞る。 Macユーザーでも扱えるようにしたいところではあるが、LinuxとMacでは同じシステムコールでも挙動が違ったりするのでそれらを吸収する必要がある。 しかし実装が複雑になってしまうのと、そもそも私がMacを持っていない(!)のでLinuxのみとする。 Windows…ナンデスカソレハ 実装する機能についても上で書いた3つに絞る。 これ以上機能を増やすと記事が書き終わらないので…! 3つではあるが最低限必要な機能は実装できるとは思う。

Rustについて

この記事ではRustについて細かい説明は省いている。 そのためRustの知識が必要になるが、Rustがわからなくてもなるべく理解できるように補足を書いている。 わからないところがあればコメントかTwitterなりで聞いてもらえればと思う。

また、使用するRustだがMinimum Supported Rust Version (MSRV)以上であれば問題なく動くと思う。 しかしながら、後述するサンプルコードについては edition = "2021"を指定している関係*2でRust 1.56.0以降が必要になる。

利用するライブラリについて

Rustにはクレート(crate)という機能があり、crates.ioに登録されているライブラリを手軽に利用することができる。 そこにはシェル作成に便利なライブラリも登録されているが、今回の目的にある「低レベルでシステムコールを操作する」が達成できなくなってしまうため極力利用しないことにする。

ただし、nix crateのみは利用する。 これはlibc crateというlibcのFFIバインディングライブラリのラッパーになっている。 具体的には、libc crateで提供される関数群はどれもC言語での定義をそのまま持ってきているために、Rustでは扱いづらい。 そのため、Rustで使いやすくするためにResult列挙型で返却するようになっていたり、一部を除いてunsafeを外して利用できるようになっている。

開発環境

LinuxとRustが動く環境であればなんでも良いと思う。 MacユーザーであればDocker、WindowsならWSLでも問題なく開発できると思う。 以下に私が動作確認を行っている環境を記載する。

  • OS: Ubuntu21.10
  • Rust: 1.57.0

サンプルコードについて

各章ごとにサンプルコードを用意していて、以下のリポジトリにある。

github.com

stepXディレクトリ配下に移動し、cargo run すると動作検証することができる。

1. 単純なコマンドを実行する

この章のサンプルコードはこちら

前置きが長くなったが、早速実装していく。 まずは、とてもシンプルに1つのコマンドを受け取り、それを実行するというところまで実装する。 例えば dateecho hello world といった感じの入力を想定している。 このように1つのコマンドを単純に実行するという流れを、bashでは単純なコマンド(Simple Command)と呼んでいるのでここではそれに従う。

コマンドを実行するためにはEXEC(3)を利用する。 これは、引数で指定されたコマンドを実行し今のプロセスに置換する。 bashでいうところのexecコマンドである(そのまんま)。 execで始まる関数はいくつかあるが、今回使用するのは execvp である。 この関数のC言語での定義は以下の通りである。

int execvp(const char *file, char *const argv[]);

execvpは第1引数に指定されたファイルを実行する。 このとき、/ が含まれていない場合はPATH環境変数を参照し実行するファイルを検索する。 ファイルが存在していればそれを実行し、存在しなければ-1を返してENOENTをerrorをセットする。 第2引数には実行するコマンドに渡す引数を指定する。 nixではnix::unistd::execvpとなっており定義は以下の通り。

pub fn execvp<S: AsRef<CStr>>(filename: &CStr, args: &[S]) -> Result<Infallible>

第1引数にはCStr型のポインタを指定し、第2引数にCStr型へのAsRefトレイトを持った型のスライスのポインタを指定する。 戻り値の型のInfallibleは到達しないことを示す型になっている。 なので、この関数呼び出しはErrorのみ返すようになっている。

CStrについて説明する。 通常Rustで文字列を扱う場合はstr型もしくはString型を利用する。 しかしこれらの型はnullを含めることができないためC言語のchar*に直接渡すことができない。 それを解決するのがこのCStr/CString型である。 詳しい使い方はCString in std::ffi - Rustを参照のこと。 なお、CString::newするときにNulErrorが返ってこないかチェックすべきなのだが、今回はそこの処理を省いているため仮にNulErrorになった場合panicしてプログラムが終了する。

それでは実装していく。 まずは適当なディレクトリで cargo new する。 今回作成するシェルは toysh (toy + shell)という名前にしておく。

❯ cargo new toysh
     Created binary (application) `toysh` package

cargo new するとtoyshディレクトリができているので移動する。 その中にCargo.tomlがあるので編集する。 [dependencies] の下に nix = "0.23.1"*3 を追記しnix crateを使えるようにする。

--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,3 +6,4 @@ edition = "2021"
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

 [dependencies]
+nix = "0.23.1"

次にsrc/main.rsにプログラムを書いていく。 まず利用する関数などをuseしていく。

use nix::unistd::execvp;
use std::{
    ffi::CString,
    io::{stdin, stdout, Write},
};

次にActionという列挙型を定義する。 いま時点ではコマンドの実行機能しかないので少々大げさではあるが、今後の拡張性を考えて先に定義しておく。 定義するバリアントは Vec<String> を持つSimpleCommandとしておく。 Vecはサイズの変更が可能な配列である。

#[derive(Debug)]
enum Action {
    SimpleCommand(Vec<String>),
}

mainではこの後定義するshell_loopを呼び出している。 本来のシェルであれば最後に実行したコマンドのexit statusをみて、自身のexit codeを設定すべきなのだが、今回はそこまで実装しない。 読者への宿題ということで…。

fn main() {
    shell_loop()
}

shell_loopでは、shell_read_lineがNoneを返すまでループを回している。 lineには標準入力から読み込んだ文字列1行分が格納されている。

読み込んだlineをshell_parse_lineに渡してパースする。 もしパース結果がNoneであればループの先頭に戻り、そうでなければ後続の処理を行う。

match文を使いaction変数の内容によって処理を分けている。 Action列挙型のSimpleCommandの場合、要素をcommandに格納し、そのcommandをshell_exec_simple_commandに渡している。 if let文でも同じことができるが今後を見据えてmatch文にしている。 なお、unimplemented!の行がコメントアウトされているが、clippy*4でこの行は不要と警告される(clippy最高!)ためそうなっている*5

fn shell_loop() {
    while let Some(line) = shell_read_line() {
        let action = match shell_parse_line(&line) {
            None => continue,
            Some(action) => action,
        };

        match action {
            Action::SimpleCommand(command) => shell_exec_simple_command(command),
            // _ => unimplemented![],
        }
    }
}

shell_read_lineでは、標準入力から1行読み込みそれを返している。 stdin().read_lineは改行を含めてresultに格納するので、改行を削除してから返却している*6

fn shell_read_line() -> Option<String> {
    print!("> ");
    stdout().flush().unwrap(); // バッファリング対策

    let mut result = String::new();
    match stdin().read_line(&mut result) {
        Ok(size) => {
            if size == 0 {
                None
            } else {
                // 改行を削除
                let result = result.trim_end();
                Some(result.to_string())
            }
        }
        Err(e) => {
            eprintln!("{}", e);
            None
        }
    }
}

shell_parse_lineでは入力が空ならNoneを返し、そうでない場合空白で分割する。 通常のシェルであればダブルクォートやシングルクォートで括ることで空白を含めることが可能であるが、処理が複雑になるので今回はそこまで考慮しない。

fn shell_parse_line(line: &str) -> Option<Action> {
    match line.is_empty() {
        true => None,
        false => {
            // lineを空白で分割
            let commands = line.split(' ').map(|s| s.to_string()).collect::<Vec<_>>();
            Some(Action::SimpleCommand(commands))
        }
    }
}

最後にshell_exec_simple_command。 まず入力された Vec<String>Vec<CString> に変換している。 前述したとおりexecvpはENOENTなどのエラーを返す可能性があるが、今回は簡単のために考慮しない(エラーになったらpanicする)。

fn shell_exec_simple_command(command: Vec<String>) {
    let args = command
        .into_iter()
        // Nullは含まれていないと信じてunwrapする
        .map(|c| CString::new(c).unwrap())
        .collect::<Vec<_>>();

    execvp(&args[0], &args).unwrap();
}

ここまで書いたら cargo run するとビルドが実行されtoyshが実行されるはず。

❯ cargo run
   Compiling toysh v0.1.0 (/home/ykky/src/github.com/buty4649/self-made-shell-sample-code/step1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33s
     Running `target/debug/toysh`
> echo OK
OK

コマンドを実行するとtoyshが終了してしまう。 これではシェルとして機能していないので次章で解決する。

2. forkしてシェルが終了しないようにする

この章のサンプルコードはこちら

前章ではコマンドの実行まで作れたが、コマンドが終了するとtoyshも終了してしまうという問題があった。 これを解決するためにfork(2)を利用する。 forkは*nix系OSの特徴的なシステムコールで、呼び出し元のプロセスを複製し子プロセスとして生成する(呼び出し元のプロセスは親プロセスと呼ばれる)。 子プロセスに複製される対象はメモリ、ファイルディスクリプタ、シグナルの処理方法などである*7

forkは親プロセスなら子プロセスのPIDを返し、子プロセスなら0を返し、そしてエラーならば-1を返す。 そのため戻り値を見て自分が親プロセスなのか子プロセスなのか、もしくはエラーなのかを判定し処理を行う。 C言語で書くと以下のようになる。

pid_t pid = fork();

if(pid > 0) {
  // 親プロセスの処理
} else if(pid == 0) {
  // 子プロセスの処理
} else {
 // エラー処理
}

Rustにおいてはnix::unistd::forkを利用する。 これはfork(2)を単純にラップしたものではなく、Rustで扱いやすいようにForkResult列挙型を返すようになっている。 親プロセスの場合はParentバリアントが、子プロセスの場合はChildバリアントが返ってくるのでmatch文で処理しやすくなっている。 具体的には以下のようなコードになる。

use nix::unistd::{fork, ForkResult};

// nix::unistd::forkはunsafe関数になっている
match unsafe{ fork() } {
  Ok(ForkResult::Parent { child, ...}) => ..., // 親プロセスの処理
  Ok(ForkResult::Child) => ..., // 子プロセスの処理
  Err(e) => ..., // エラー処理
}

forkをしたら子プロセスではexec関数を呼び出しコマンドを実行する。 親プロセスではwaitpid(2) / nix::sys::wait::waitpidを利用し子プロセスの終了を待つ*8。 なお、子プロセスが終了した際に親プロセスでwaitpidを呼び出していない場合*9、子プロセスの消滅処理が行われずメモリが解放されないままになる。 このような状態になったプロセスをゾンビプロセスと呼ぶ。

では、forkとwaitpidをtoyshに組み込む。 まずはforkとwaitpidをuseする。

use nix::{
    sys::wait::waitpid,
    unistd::{execvp, fork, ForkResult},
};

そして、shell_exec_simple_commandを以下のように変更する。 主な変更点はforkを利用し、親プロセスではwaipidで子プロセスを待ち、子プロセスではコマンドをexecvpするようにしている。

fn shell_exec_simple_command(command: Vec<String>) {
    match unsafe { fork() } {
        Ok(ForkResult::Parent { child, .. }) => {
            waitpid(child, None).unwrap();
        }
        Ok(ForkResult::Child) => {
            let args = command
                .into_iter()
                // Nullは含まれていないと信じてunwrapする
                .map(|c| CString::new(c).unwrap())
                .collect::<Vec<_>>();

            execvp(&args[0], &args).unwrap();
        }
        Err(e) => {
            eprintln!("fork error: {}", e);
        }
    }
}

変更後cargo runすると、何度もコマンドを実行してtoyshが終了しなくなっていると思う。 toyshを終了する場合は、Ctrl-DもしくはCtrl-Cを押す。

❯ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/toysh`
> echo hello
hello
> echo world
world
> (Ctrl-Dを押すと終了する)

これで単純なコマンドを実行できるようになった。 しかし、Ctrl-Cを押すと実行中のコマンドとともにtoyshが終了してしまう。 次章ではこの問題を解決する。

3. フォアグラウンドプロセスを適切に設定する

この章のサンプルコードはこちら

Ctrl-Cを押すと実行中のコマンドとともにtoyshが終了してしまうのはなぜだろうか? 答えは、toyshと実行したコマンドが同じ プロセスグループ に所属していてかつ フォアグラウンドプロセス になっているために、端末*10からSIGINT シグナル が送られているからである。 それぞれの用語について解説する。

プロセスグループ

プロセスグループは、1つ以上のプロセスをグルーピングして管理することができる機能である。 例えば、プロセスグループに所属しているプロセスに対して一括してシグナルを送信するといったことが使い方ができる。 また、シェルにおいてはパイプライン処理などのジョブを管理する際に利用する。 プロセスグループIDと同じPIDを持つプロセスは、プロセスグループリーダーと呼ばれる。 プロセスグループの変更はsetpgid(2)を利用する。 確認はgetpgid(2)を使うか、ps -o pgid を実行する。 以下はpsコマンドの実行例である。

❯ ps -o pid,pgid,comm -ef
    PID    PGID COMMAND
 405741  405741 fish
 730145  730145  \_ toysh
 730293  730145      \_ sleep

fishとtoysh,sleepコマンドが別のプロセスグループ(PGIDカラム)になっていることがわかる。

フォアグラウンドプロセスとバックグラウンドプロセス

端末を利用しているプロセスをフォアグラウンドプロセスと呼び、そうでないプロセスをバックグラウンドプロセスと呼ぶ。 バックグラウンドプロセスが端末に対して読み込みを行うと、端末はプロセスに対してSIGTTINシグナルを送る。 同様に端末に対して書き込みを行う*11とSIGTTOUシグナルを送る。 SIGTTIN/SIGTTOUシグナルを受信したプロセスはデフォルトでは停止状態になる。

フォアグラウンドプロセスの設定はtcsetpgrp(3)を使う。 バックグラウンドプロセスからtcsetpgrp(3)を呼び出すと、呼び出したプロセスに対してSIGTTOUシグナルが送られる。 シェルにおいては、コマンド実行前後にtcsetpgrp(3)を呼び出す必要がある。 コマンド実行後の呼び出し時には、シェルはバックグラウンドプロセスになっているためSIGTTOUシグナルが通知される。 そうするとシェルが停止してしまうのでこれを回避するために、シグナルをブロックする必要がある。

シグナルとシグナルハンドラ

シグナルとはプロセスやプロセスグループにイベントを通知するための仕組みである。 例えば、Ctrl-Cを押すとフォアグラウンドプロセスに対してSIGINT(SIGnal INTerrupt)が通知される。 プロセスが各シグナルを受け取った際のデフォルトの動作についてはsignal(7)を参照のこと。

このシグナルを受け取った際の動作はsigaction(2) / nix::sys::signal::sigacionを使うことで変更できる。 SigAction構造体のhandlerメンバーに変更後の動作を設定し引数に渡す。 設定できる内容は、デフォルトの動作を利用するSigHandler::SigDfl*12、シグナルを無視するSigHandler::SigIgn、そして独自に動作を定義するSigHandler::HandlerまたはSigHandler::SigActionのいづれかを指定する。 このシグナルごとに特定の動作を行う処理はシグナルハンドラと呼ばれる。

子プロセスとの同期を取る

用語の説明ではないがこれから実装する際に必要になるのでここで説明する。 プロセスグループの設定やフォアグラウンドプロセスの設定は親プロセスで行う*13。 しかしながら、これらの設定を行う前に子プロセス側でexec関数を呼ばれると困ってしまう。 そこで、どうにかして子プロセスと同期を取り、各設定の完了後に正しくexec関数を呼ぶようにしなければならない。

いくつか方法は考えられるが、ここではbash*14でも使われているpipe(2)を利用した同期方法を採用する。 原理や具体的な実装方法について説明する。 pipe(2)で生成されるパイプはプロセス間通信で利用できる単方向のデータチャンネルになっている。 パイプはforkされた際に子プロセスにコピーされるので、コピーされたパイプのファイルディスクリプタを経由して親プロセスと子プロセス間で通信できる。 子プロセスでは、読み込み用のパイプを使いread(2)しておく。 親プロセスで各設定を行い、設定が完了したら書き込み用のパイプをclose(2)する。 そうすると子プロセスで実行されているread(2)は復帰する*15ので、後続の処理を行う。 具体的なコードにすると以下のようになる。

use nix::unistd::{close,fork,ForkResult,pipe,read};

// forkすると子プロセスにもコピーされる
let (pipe_read, pipe_write) = pipe().unwrap();

match unsafe { fork() } {
  Ok(ForkResult::Parent { child, .. }) => {
    // 親プロセスで行う処理を実行

    // パイプを閉じて子プロセスの処理を走らせる
    close(pipe_read).unwrap();
    close(pipe_write).unwrap();

        :
  },
  Ok(ForkResult::Child) => {
    // 利用しないパイプは閉じておく
    close(pipe_write).unwrap();

     // 親プロセスの処理が終わるまで待機する
    loop {
        let mut buf = [0];
        match read(pipe_read, &mut buf) {
            // シグナルによる割り込みを無視
            Err(e) if e == Errno::EINTR => (),
            _ => break,
        }
    }
    close(pipe_read).unwrap();
  },
}

実装する

各用語がわかったので実装していく。 まずは利用する構造体や関数をuseする。

use nix::{
    errno::Errno,
    sys::{
        signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal},
        wait::waitpid,
    },
    unistd::{close, execvp, fork, getpgrp, pipe, read, setpgid, tcsetpgrp, ForkResult},
};

次に端末から送られてくるシグナル群(SIGTSTP*16、SIGTTIN、SIGTTOU)を無視に設定する関数と、デフォルト動作に戻す関数を定義する。 端末から送られてくるシグナルはシェルでは無視したほうが都合がよいし、forkするとシグナルの無視設定はコピーされるのでコマンドを実行する前にこれらの無視設定を戻した方がよい。

// 無視設定にする関数
fn ignore_tty_signals() {
    let sa = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty());
    unsafe {
        sigaction(Signal::SIGTSTP, &sa).unwrap();
        sigaction(Signal::SIGTTIN, &sa).unwrap();
        sigaction(Signal::SIGTTOU, &sa).unwrap();
    }
}

// デフォルト設定に戻す関数
fn restore_tty_signals() {
    let sa = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
    unsafe {
        sigaction(Signal::SIGTSTP, &sa).unwrap();
        sigaction(Signal::SIGTTIN, &sa).unwrap();
        sigaction(Signal::SIGTTOU, &sa).unwrap();
    }
}

shell_loopではループに入る前に上記で設定したignore_tty_signalsを呼び出す。

fn shell_loop() {
    ignore_tty_signals();

    :

最後にshell_exec_simple_commandを変更する。

fn shell_exec_simple_command(command: Vec<String>) {
    let (pipe_read, pipe_write) = pipe().unwrap();

    match unsafe { fork() } {
        Ok(ForkResult::Parent { child, .. }) => {
            // 子プロセスをプロセスグループリーダーにする
            setpgid(child, child).unwrap();

            // 子プロセスのプロセスグループをフォアグラウンドプロセスに設定する
            tcsetpgrp(0, child).unwrap();

            // 子プロセスとの同期を終了する
            close(pipe_read).unwrap();
            close(pipe_write).unwrap();

            waitpid(child, None).ok();

            // 自分のプロセスグループをフォアグラウンドプロセスに戻す
            tcsetpgrp(0, getpgrp()).unwrap();
        }
        Ok(ForkResult::Child) => {
            // シグナルアクションは親プロセスから継承されるためデフォルトに戻す
            restore_tty_signals();

            // 不要なパイプは閉じておく
            close(pipe_write).unwrap();

            // 親プロセスの処理が終わるまで待機する
            loop {
                let mut buf = [0];
                match read(pipe_read, &mut buf) {
                    // シグナルによる割り込みを無視
                    Err(e) if e == Errno::EINTR => (),
                    _ => break,
                }
            }
            close(pipe_read).unwrap();

            let args = command
                .into_iter()
                // Nullは含まれていないと信じてunwrapする
                .map(|c| CString::new(c).unwrap())
                .collect::<Vec<_>>();

            execvp(&args[0], &args).unwrap();
        }
        Err(e) => {
            eprintln!("fork error: {}", e);
        }
    }
}

この変更を適用するとコマンド実行中にCtrl-Cを押してもtoyshは終了しなくなる。

❯ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/toysh`
> sleep 10
^C> echo ok
ok

一旦休憩

ここまでで単純なコマンド(SimpleCommand)の実行ができるようになった。 シェルとしてみれば単純な機能ではありまだまだ機能不足ではあるが、単純なコマンドが実行できるようになるだけでもテンションが上がってよい。

toyshの機能としてリダイレクトとパイプラインの実装を行いたかったが、長くなり疲れてしまったのでこの記事は一旦ここで終わりにする。 近いうちに続きの記事を書く予定。

*1:っとかっこいいこと書いているけど、社内で自作シェルについて食いつきがよかったり、最近ではシェルを作れないとLinuxが使えると認められなかったりするので、ブログ書いたら承認欲求が満たせそうな気がしたのであった

*2:2021である必然性はないのだが最新が最高の精神でこうなっている

*3:記事時点の最新バージョン

*4:静的解析ツール https://github.com/rust-lang/rust-clippy/

*5:が、そもそも書いておく必要はなかったかもなぁ

*6:シェルの構文においては、改行も区切り文字として認識するのでここで削除するのは良くないかも。複数行対応が必要になったときに考える

*7:メモリに関してはコピーオンライト(COW)となるため、厳密にはすべてのメモリがコピーされるわけではなく親プロセスと共有される。子プロセスでメモリ内容の更新があると初めて親プロセスのメモリ空間からコピーを行い内容を変更する

*8:シグナルを受けて停止や終了したりする可能性もあるが今回はそこまで考慮しない

*9:正確にはSIGCHLDシグナルを親プロセスが処理していない場合に発生する。なおsigaction(2)を使用しSA_NOCLDWAITフラグを指定しているとゾンビプロセスにはならない。

*10:ttyやpty、pts

*11:単純な出力だけではSTGTTOUが送られないようだ。端末の設定により挙動が変わるとのこと。詳しくは https://qiita.com/tajima_taso/items/c5553762af5e1a599fed

*12:シグナルハンドラを元に戻すときに利用したり、SaFlagsのみ変更したい場合に利用する

*13:子プロセスで行ってもいいがこれらの処理を親プロセスにまとめたほうが処理がすっきりすると思う

*14:http://git.savannah.gnu.org/cgit/bash.git/tree/jobs.c#n5107

*15:EBADFエラーが返る

*16:Ctrl-Zしたときに送られる