はじめに
みなさん、シェルについてご存じでしょうか?bashやzshなどエンジニアであれば誰もが触ったことのあるであろうターミナルから使用することのできるあれです。もちろん知ってるよという人もいると思いますが、意外とその仕組みまで知った上で使用している人は少ないのではないでしょうか?(自分もそのうちの一人です、、、)
そこで!この記事では、シェルの全体像と仕組みについて解説してみたいと思います。また、最後に仕組みをなぞった上で簡単なシェルの実装を行ってみたので、この機会に改めてエンジニアの身近にあるシェルについて理解を一緒に深めていきましょう。
弊社Nucoでは、他にも様々なお役立ち記事を公開しています。よかったら、Organizationのページも覗いてみてください。
また、Nucoでは一緒に働く仲間も募集しています!興味をお持ちいただける方は、こちらまで。
シェルとは?
シェルとはオペレーティングシステムの制御を行うためのプログラムです。シェルではオペレーティングシステムの中核をなすカーネルとユーザーの間のインタラクションを担当します。インタラクションを担当するために、シェルにはコマンドラインインターフェース(CLI)を持つことも特徴の一つです。
ユーザーはコマンドと呼ばれる命令文を扱うことでPCの操作を行うことができます。このコマンドの表示や結果の出力を行ってくれるのがCLIの役割となっています。
ユーザーはキーボードにより入力を行いカーネルがCLIに入力した内容を表示させます。ユーザーがCLIからコマンドの実行を行うとシェルはそのコマンドの解釈を行い、コマンドが外部コマンド(後述)だった場合にはカーネルに実行依頼を行い、カーネルが処理を行います。カーネルは処理を実行し、その結果をユーザーに返します。これらを表すと次のような図になります。
注意したいのが、他のサイトではシェルがユーザーの入力を直接受け取っていたり、カーネルによる実行結果をシェルが解釈を行ってユーザーに表示しているような説明を行っている場合ありますが、実際にはカーネルがキーボードからの入力を受け取って、カーネルが文字列を解釈するようにシェルに渡しています。また出力についても特段、シェルが解釈を行うような機能はなく、カーネルから直接出力されます。
基本的に私たちはシェルが提供するCLIでのみ入力や出力を確認することができないので、すべての処理においてシェルが関わっていると勘違いしてしまったのがそのような説明の原因だと考えられます。
一般的なシェルには、shやbashなどがあります。Macの場合、このようにターミナルから使用することができます。
シェルの大枠を説明したところで次にシェルに関連した用語などを紹介していきます。
オペレーティングシステムとカーネル
オペレーティングシステムとはコンピュータ全体を制御し、管理するためのソフトウェアの集合体です。そのためオペレーティングシステムの中にカーネルやシェルなどが含まれています。一方でカーネルはオペレーティングシステムの中核であり、ハードウェアとソフトウェアの間のインターフェースを提供します。カーネルは、プロセス管理、メモリ管理、ファイルシステム管理、デバイス管理など、システムの基本的な機能を提供します。
コマンド
コマンドとはユーザーが実行してほしい内容を表した命令となっています。シェルは受け取ったコマンドをカーネルが処理できるように解釈を行います。コマンドには内部コマンドと外部コマンドの2種類があります。
内部コマンド
内部コマンドはシェル自身に組み込まれているコマンドのことを指します。内部に組み込まれているためシェルから直接コマンドを実行することができます。cd(ディレクトリ変更)やexit(シェルの終了)、echo(文字列の出力)などのようなシェルの制御や操作などの基本的な機能を提供します。
外部コマンド
外部コマンドは、内部コマンドと異なりシェルの外部に存在するプログラムを指します(多くのコマンドは外部コマンド)。シェルは外部コマンドを呼び出すことによりコマンドを実行します。より正確に言うと、システム上の任意の場所にインストールされている(通常はPATH環境変数)ディレクトリのリストを探索して該当するコマンドが実行できる実行ファイルのパスをカーネルに渡す役割をシェルは行います。
そのため、内部コマンドでは直接シェルが実行できるのに対して、外部コマンドの場合はカーネルに実行ファイルのパスを渡して実行してもらうという手続きが必要となります。なので最初に掲載した全体像の図は主に外部コマンドを想定して示した図となっています。
コマンド例(bash)
コマンドは様々ありますが、bashにおけるコマンド例をいくつか紹介します。
・内部コマンド
#ディレクトリの移動
cd
#現在の作業ディレクトリのパスを表示
pwd
#直近のコマンドの履歴を表示
history
#テキストを標準出力に出力
echo
#シェルの終了
exit
・外部コマンド
#ディレクトリの内容を表示
la
#テキストファイル内のパターンに一致する行を検索
grep
#ファイルやディレクトリを削除
rm
#ディレクトリを作成
mkdir
#ファイルの内容を標準入出力に出力
cat
簡単にですがコマンドを紹介してみました。まだまだコマンドは数多くの種類があります。こちらの記事にて数多く紹介されていますので参考にしてみてください。
シェルスクリプト
シェルスクリプトとは、コマンドを集めたテキストファイルとなっています。実行したいコマンドをシェルスクリプトにまとめておくことで作業の効率化や、可読性、メンテナンス性の向上は図ることができます。
シェルスクリプトの実際の中身はこのようになっています。
#!/bin/bash
echo 'Hello world!' #Hello Worldと表示
pwd #カレントディレクトリの表示
cd Qiita #Qiitaディレクトリに移動
このスクリプトを実行するとこのように表示されます。
シェルスクリプトの作り方
簡単にシェルスクリプトの作成方法と実行方法について説明します(Macbook)。
まず、シェルスクリプトをterminalから作成します。
$touch qiita_shell.sh
作成が完了したら、お好みのエディタでshファイルを以下のように編集してみましょう。
#!/bin/bash
echo 'Hello World!'
編集が完了したらターミナル上でこのように実行権限を付与しましょう。権限を与えない場合permission errorが発生すると思います。権限を与えたらファイルを実行させます。
$chmod +x qiita_shell.sh
$./qiita_shell.sh
実行が完了すると、このようにqiita_shell.shに記載したHello World!が出力されます。
このように簡単にシェルスクリプトは作成することができます。より詳細にシェルスクリプトについて学びたい場合はこちらの記事を参考にしてみてください。シェルスクリプトの扱いが網羅的に解説されています。
シェルの種類
シェルと一言で表してもその種類は様々です。この章ではシェルの種類とそれぞれの違いについて説明したいと思います。
sh
shの正式名称はBourne ShellでUnix系のオペレーティングシステムで最初に使用されたシェルの一つです。shはStephen Bourneによって開発され、1977年にリリースされました。shはシェルスクリプト言語の基礎となっており、そのあとに登場した様々なシェル(bash、kshなど)の基盤となっています。
shは軽量でリソース効率が高く基本的なシェルタスクを実行するのに適していますがより高度な機能や拡張性が必要な場合などはより高機能なbashなど他のシェルを使用する必要があります。
bash
bashは、Bourne Again Shellの略称で、その名の通りにshの拡張版として開発されました。bashはshの互換性を保ちつつ、多くの新機能や拡張機能を追加しました。具体的には柔軟なコマンドライン編集やヒストリ機能などがあげられます。シェルスクリプト言語としての機能も拡張され、shよりも複雑なスクリプトを作成することができます。
bashは多くのLinuxディストリビューションやUnix系のオペレーティングシステムでデフォルトシェルとして採用されています。
zsh
zshはZ shellの略称でsh、bashに代わるシェルとして開発されました。1990年に初版がリリースされました。zshは、shやbashの機能を拡張して、より洗練されたコマンドラインインターフェースを提供します。
具体的な機能としては、強力なコマンド補完機能であったり、ユーザーによる新しい機能をシェルに取り入れられるなど柔軟な拡張性がzshには備わっています。
csh
cshはC shellの略称で、Unixシェルの1つです。これまで紹介してきたシェルはsh(Bourne Shell)から派生したシェルでしたがcshはshの派生ではなく独自開発されたシェルとなっています。特徴的なのはC言語風にシェルスクリプトを記述できる点にあります。cshはCプログラマにとってより直観的で使いやすいシェルを目標していました。そのため、cshはshと異なる構文や機能を有するシェルとなっています。
ksh
kshはKornShellの略称でUnixシェルの1つです。kshはshと高い互換性を持っており、shの機能を拡張しています。また、先ほど紹介したcshとshのいくつかの機能を組み合わせて開発が行われており、両方の利点を持つシェルとして開発が行われました。
Command Prompt
Command Prompt(コマンドプロンプト)はWindows OSに搭載されているシェルです。コマンドプロンプトは長らく、Windowsの標準的なシェルとして広く普及していましたが、最近ではPowerShellと呼ばれる互換性のあるより高機能なシェルが標準で提供されるようになりPowerShellがコマンドプロンプトの代替として使用されるようになってきました。
PowerShell
PowerShellはコマンドプロンプトにて、簡単に触れたように、コマンドプロンプトに対して互換性のあるようり高機能なシェルとしてWindowsにおいて提供されるようになったシェルです。PowerShellではコマンドプロンプトで実行できる内容に加えて、1000以上のコマンド、関数の実行並びに、ユーザー定義関数の作成・実行などコマンドプロンプトよりも高機能かつ拡張性の高いシェルとしてリリースされています。
シェルの仕組み
コマンドが入力されてから実行されるまでに何が行われているのか確認してみましょう。
まず、入力されてから実行まで大きく分けて以下のようなステップがあります。
1. 入力の読み取り
2. 解析
3. 関数の呼び出し
1つ1つ確認してみます。
入力の読み取り
シェルはカーネルから入力を受け取ると、処理を行うため文字列をメモリに保存します。入力されたテキストの末尾にはヌル文字(\0)が含まれ、文字列の終わりを表します。
解析
受け取った入力は、トークン化プロセスを通じてスペースを区切り文字として使用して文字列に分割されます。これにより入力されたコマンドや引数が個別のトークンに分割され、シェルによって理解されやすくなります。
関数の検索
解析後、シェルは先頭にある単語(ls)が内部コマンドの場合、コマンドをそのまま直接実行します。外部コマンドの場合、先頭にある単語と一致する関数があるかどうかシェルは検索を行います。
シェルが実行する関数のファイルを発見すると、その実行ファイルのパスをメモリに保存されます。このパスと引数をexecvと呼ばれるシステムコールに引数として与えてカーネルに渡してカーネルが実行処理を行います。
実際にlsは通常bin/に配置されています。なのでシェルはこのようにメモリの上書きを行います。
より詳細な仕組み
さらに、コマンドが実行されるときの内部的な仕組みをみていきましょう。
コマンドlsを実行すると、システムはlsというプログラムの実行を開始して、それに対応するプロセスを生成します。プロセスは一意の識別IDであるプロセスID(PID)を持ち、PIDを使用してプロセスの識別、操作を行います。
そのため、シェルはプロセスの作成、終了、状態の監視などプロセスの操作を行うためにいくつかの関数やシステムコールを使用します。
システムコール(syscall)とはユーザーがカーネルにサービスを要求するためのインターフェースでプロセスの管理やファイルアクセスなど様々な操作を行うために使用されます。
また、シェルが実行されるとシェルそのものプロセスとして稼働しています。そのためシェルのプロセスを親プロセスとなり、実行するコマンドのプロセスは子プロセスとなり親と子のプロセスの関係が発生します。どういうことか図を用いて説明します。
まずシェルを実行すると、シェルが閉じられるまでアクティブとなる(親)プロセスが作成されます。
コマンド(ls -a)が入力されて、シェルがカーネルに実行ファイルのパスを渡して実行する準備が整うとsyscall fork()を利用して新しいプロセスを作成します。このプロセスが子プロセスとなります。
子プロセスが作成されると、子プロセスにおいてコマンドが実行できるようになります。また、実行にはsyscall execve()が使用されます。execve()ではコマンド/bin/lsとその引数-aを受け取ります。これを行うとコマンドが子プロセスにおいて実行さます。またsyscall exit()を利用して実行の成功を確認します。
execve()の実行が完了すると、syscall wait()により子プロセスが終了します。
これら一連の流れを経ることで、実際にコマンドが入力されてから実行が完了します。
シェルの実装
それではこれらの仕組みを踏まえた上で簡単なシェルの実装を行ってみましょう。
まずは、コマンドを読み取る関数を用意します。このコードでは、mallocを用いて、MAX_COMMAND_LENGTH分だけのメモリを用意します。その後fgetsを利用してコマンドの入力を受け取ります。
char* read_command() {
char* command = malloc(MAX_COMMAND_LENGTH);
if (command == NULL) {
fprintf(stderr, "メモリの確保に失敗しました\n");
exit(EXIT_FAILURE);
}
if (fgets(command, MAX_COMMAND_LENGTH, stdin) == NULL) {
if (feof(stdin)) {
fprintf(stderr, "入力の終了 (EOF) が検出されました。\n");
exit(EXIT_SUCCESS);
} else {
perror("入力エラー");
exit(EXIT_FAILURE);
}
}
command[strcspn(command, "\n")] = '\0';
return command;
}
次にコマンドを解析する関数を用意します。この関数ではstrok_rを使用してコマンドのトークン化を行います。DELIM(スペース、タブなど)によって定義されたものを区切り文字としてトークン化を行い配列argsに格納します。
char** parse_command(char* command) {
int bufferSize = MAX_ARGS;
char** args = malloc(bufferSize * sizeof(char*));
if (args == NULL) {
fprintf(stderr, "メモリ割り当てに失敗しました。\n");
exit(EXIT_FAILURE);
}
char* token;
char* saveptr;
int arg_count = 0;
token = strtok_r(command, DELIM, &saveptr);
while (token != NULL) {
args[arg_count++] = token;
if (arg_count >= bufferSize) {
bufferSize += MAX_ARGS;
args = realloc(args, bufferSize * sizeof(char*));
if (args == NULL) {
fprintf(stderr, "メモリ再割り当てに失敗しました。\n");
exit(EXIT_FAILURE);
}
}
token = strtok_r(NULL, DELIM, &saveptr);
}
args[arg_count] = NULL;
return args;
}
次にコマンドを実行する処理を実装します。ここでは仕組みで説明した通りにforkを利用して子プロセスの作成を行います。また、実行にはexecvpを用いました。これはPATH環境変数の検索も行ってくれる便利なものです。実行ファイル名を渡すだけで実行することが可能です(execvの場合は実行ファイルまでの完全パスを指定する必要があります)。また、waitpidを使用して、子プロセスが終了あるいは停止するまで親プロセスの実行をブロックします。
int execute_command(char **args) {
pid_t pid, wpid;
int status;
pid = fork();
if (pid == 0) {
if (execvp(args[0], args) == -1) {
perror("コマンドの実行に失敗");
}
exit(EXIT_FAILURE);
} else if (pid < 0) {
perror("プロセスの作成に失敗");
} else {
do {
wpid = waitpid(pid, &status, WUNTRACED);
} while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}
これらの関数をまとめるとこのようになります。
プログラム全体
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_COMMAND_LENGTH 100 // コマンドの最大長
#define MAX_ARGS 10 // 最大引数の数
#define DELIM " \t\r\n\a" // 区切り文字にタブや改行も含める
// コマンドを読み取る関数
char* read_command() {
char* command = malloc(MAX_COMMAND_LENGTH);
if (command == NULL) {
fprintf(stderr, "メモリの確保に失敗しました\n");
exit(EXIT_FAILURE);
}
if (fgets(command, MAX_COMMAND_LENGTH, stdin) == NULL) {
if (feof(stdin)) {
fprintf(stderr, "入力の終了 (EOF) が検出されました。\n");
exit(EXIT_SUCCESS);
} else {
perror("入力エラー");
exit(EXIT_FAILURE);
}
}
command[strcspn(command, "\n")] = '\0';
return command;
}
// コマンドを解析する関数
char** parse_command(char* command) {
int bufferSize = MAX_ARGS;
char** args = malloc(bufferSize * sizeof(char*));
if (args == NULL) {
fprintf(stderr, "メモリ割り当てに失敗しました。\n");
exit(EXIT_FAILURE);
}
char* token;
char* saveptr;
int arg_count = 0;
token = strtok_r(command, DELIM, &saveptr);
while (token != NULL) {
args[arg_count++] = token;
if (arg_count >= bufferSize) {
bufferSize += MAX_ARGS;
args = realloc(args, bufferSize * sizeof(char*));
if (args == NULL) {
fprintf(stderr, "メモリ再割り当てに失敗しました。\n");
exit(EXIT_FAILURE);
}
}
token = strtok_r(NULL, DELIM, &saveptr);
}
args[arg_count] = NULL;
return args;
}
// コマンドを実行する関数
int execute_command(char **args) {
pid_t pid, wpid;
int status;
pid = fork();
if (pid == 0) {
if (execvp(args[0], args) == -1) {
perror("コマンドの実行に失敗");
}
exit(EXIT_FAILURE);
} else if (pid < 0) {
perror("プロセスの作成に失敗");
} else {
do {
wpid = waitpid(pid, &status, WUNTRACED);
} while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}
int main() {
char *command;
char **args;
int status;
do {
printf("> "); // プロンプトの表示
command = read_command(); // コマンドの読み取り
args = parse_command(command); // コマンドの解析
status = execute_command(args); // コマンドの実行
free(command);
free(args);
} while (status);
return EXIT_SUCCESS;
}
こちらのプログラムをコンパイルして実行するとこのようになります。コマンドを実行すると実際に動作していることを確認できます。
まとめ
いかがでしたでしょうか
今回は身近にあるシェルについて解説してみました。シェルは古くからある基本的な仕組みですが、その内容は複雑だったりします。もし、今回の説明で間違いや誤認しているであろう部分があればコメント欄においてご指摘ください。
弊社Nucoでは、他にも様々なお役立ち記事を公開しています。よかったら、Organizationのページも覗いてみてください。
また、Nucoでは一緒に働く仲間も募集しています!興味をお持ちいただける方は、こちらまで。