1 はじめに
前の記事で、sudo rm -rf /*した後に選択ソートを実装して、ユーザーが入力した数値列をソートしてファイルに書き出すということをやりました。
psコマンドが使えなかったこともあり、プロセス関連のことはほとんど触れられませんでした。というわけで今回は消え去ってしまったpsコマンドをbash組み込みコマンドのみから実装したいと思います。ちゃんと今回も最初からsudo rm -rf /*します。

基本的に出てくるコマンドや構文は紹介をしますが、関数や変数の定義、配列、if文、for文など前回の記事で紹介したものは詳しく扱いません。前回の記事を参考にしてください。それではいきましょう。
・参考文献
オライリー・ジャパン 入門bash
・実行環境
Raspberry Pi 4 Model B+
Raspbian Buster Lite 2020-02-13-raspbian-buster-lite.img
2 下準備
sudo rm -rf /*により消えてしまったコマンドはたくさんありますが、lsとcatはよく使うので再定義しておきます。また補完も変になっているので直しておきます。
2.1 lsコマンド
echo *をlsのエイリアスとする(つまりalias ls="echo *"とする)のでも良いですが、せっかくなのでディレクトリは青色、それ以外は白色で表示するlsコマンドを作ります。
そのためにはtestコマンド([コマンドでも同じ)を使います。このコマンドではファイル属性演算子を用いて条件判定を行います。例えば、-aというファイル属性演算子は「そのファイルが存在するかどうか」を表します。具体例を見てみましょう。

/において、procディレクトリは存在するので終了ステータスとして0を返しますが、binディレクトリはもう存在しないので1を返しています。
これを使ってlsコマンドを作ります。-dはディレクトリが存在するかどうかを表すファイル属性演算子です。
function ls 
{ 
for i in *
do
  if [ -d $i ]
  then 
    echo -ne "\e[34m$i\e[m\t" # ディレクトリは青色
  else
    echo -ne "$i\t" # それ以外は白色
  fi
done
echo # 改行のためのecho
}
echoで色をつけるのにこちらの記事を参考にしました。また、ファイル名の間にはtab文字(\t)を入れています。このlsを適当なディレクトリで実際に使ってみましょう。

普通のlsと違って改行が少し変ですが、今回はよしとしましょう。ターミナルの表示列数を表すシェル変数COLUMNSを使って場合分けすればもう少しきれいに出力できると思います。
2.2 catコマンド
これにはwhile文を使います。構文は以下です。
while < 条件式 >
do
  < 一連の文 >
done
< 条件式 >にはif文と同じように、コマンドの終了ステータスを用います。例えば以下です。
a=0
while [ $a -ne 3 ]
do
  echo $a
  let a++
done
-neは等しくない(not equal)を意味します。シェル変数aが3以外のときは[ $a -ne 3 ]は終了ステータス0を返すのでループし、aが3になると1を返すのでループから抜けます。組み込みコマンドletは算術演算子を評価して、その結果を変数に代入します。上の例ではaをインクリメントするだけです。
実際にやってみると以下のようになります。

aが3になったときに、ちゃんとループから抜けてコマンドが終了していることがわかります。
それではwhile文を使ってcatコマンドを実装します。簡単のため、引数を1つ取ってその中身を表示する機能のみを実装します。
function cat 
{
while read val
do
  echo $val
done
} < $1
readコマンドは、シェル変数に値を代入するコマンドです。今回の例ではシェル変数valにファイルの中身を1行ずつ代入して、echoで表示します。$1で指定したファイルの中身が空になった時、readコマンドは終了ステータスとして1を返すので、ちゃんとループから抜けることができます。
2.3 補完について
また、sudo rm -rf /*するとtabでうまく補完できなくなることがあります。

↓ tab入力

見たことないエラーが出ています。catコマンドにおいてtabの補完がどうなっているかは、組み込みコマンドcompleteを使用することで見れます。

これはcatコマンドの補完が_longoptという関数で決められているという意味です。この関数の詳細はdeclare -f _longoptで見れますが、ここでは深入りしないでおいておきます。要はこの関数がsudo rm -rf /*した影響でうまく動かなくなっているということです。
今回はシンプルに補完をファイル名で行うようにしましょう。具体的には普通のファイル名から補完する-fオプションを使って以下のように書きます。
complete -f cat
前の記事でも述べたとおり、ファイル名補完はESC+/でもできるのですが、これでtabでもできるようになりました。ついでにcdコマンドもtabが使えるようにしておきましょう。-dオプションなら補完はディレクトリ名から行われます。
complete -d cd
3 psコマンドの作成
それでは実際にpsコマンドを作っていきます。まずはプロセスの情報を含む/procディレクトリの説明から入ります。
3.1 /procディレクトリ
Linuxにおいては、プロセスの情報を含む/procディレクトリというものが存在します。これは通常のディレクトリとは異なる擬似的なディレクトリで、sudo rm -rf /*しても消えません。
実際に見てみると、こうなります。

上の方の数字のみからなるディレクトリには、そのプロセスID(PID)に対応したプロセスの情報が入っています。
ちなみにこの/procディレクトリはLinuxでは存在しますが、純粋なUNIX(例えばBSD系のFreeBSDやMac OS、System V系のSolarisなど)では存在しない、もしくは存在しても中身が異なることがあるため注意です。
それではこのログインシェル(つまりbash)のプロセスIDのディレクトリを見てみましょう。現在のシェルのプロセスIDはecho $$で調べることができます。

この中でstatというファイルがあり、これにそのプロセスに関する情報が記述されています。

最初から見ていきましょう。
- 1つ目はプロセスIDで、値は768です。
- 2つ目がカッコで括られた実行形式のファイル名で、(bash)です。
- 3つ目はプロセスの状態でRです。Rは実行中(Running)を表しています。
全部は紹介しきれないので、詳細はman procで見てください。
これらの値を取得して、画面に表示するのがpsコマンドとなります。ただpsコマンドのデフォルトでは表示するプロセスが少なすぎるので、aオプションをつけたps aコマンド、つまり端末を持つ全てのプロセスを表示するpsコマンドを作りたいと思います。
まずはこのstatファイルを引数にとり、シェル変数に代入するread_stat関数を作ります。
function read_stat
{
read -a values
pid=${values[0]}
comm=${values[1]}
state=${values[2]}
tty_nr=${values[6]}
time=$(( ${values[13]} / 100 ))
} < $1
まずはreadコマンドの-aオプションを使って、valuesという配列にstatの各値を入れていきます。シェル変数の解説は以下です。
- 
pidプロセスID
- 
comm実行形式のファイル名
- 
stateプロセスの状態
- 
tty_nrプロセスの接続している端末名
- 
time実行時間(単位は秒)
実際に実行してみます。

ちゃんと値が入っているのが確認できます。
これで基本的な情報は表示できるのですが、端末名はうまく表示できません。それはtty_nrに入っている数値(上の例では34816)をtty1やpts/0のような文字列に変換しないといけないためです。ここでデバイスファイルについて少し説明しましょう。
3.2 デバイスファイル
UNIX系のOSでは、HDDやUSBメモリ、端末といったハードウェアもファイルとして扱うことができます。このようなファイルをデバイスファイルと言います。出力を捨てるための/dev/nullやランダムな文字列を生成する/dev/randomもデバイスファイルで、皆さんも使ったことがあるかもしれません。デバイスファイルにはブロック型と文字型の2種類があります。前者はディスク装置を操作するためのファイルで、後者はそれ以外を扱うためのファイルです。
デバイスファイルはメジャー番号とマイナー番号を使って管理されています。実際のLinuxのドキュメントで確認してみましょう。
 
例えば「ブロック型デバイスファイルのメジャー番号1」は「RAMディスク」に割り当てられています。マイナー番号ではRAMディスクの何番目かを表します。
psコマンドで表示される端末は基本的にメジャー番号4のTTYデバイスか、メジャー番号136のUnix98 PTY スレーブです。TTYデバイスはラズパイに直接繋がっている画面、Unix98 PTY スレーブは擬似端末(pseudo terminal)の一種で、要はSSHで接続した時の画面です。
ではここで、tty_nrに戻りましょう。この数値列の意味はman procに書いてあるので、引用してみます。
(7) tty_nr %d
The controlling terminal of the process. (The minor device number is contained in
the combination of bits 31 to 20 and 7 to 0; the major device number is in bits 15 to 8.)
tty_nrの数値列の31から20ビットと7から0ビットの部分がマイナー番号、15ビットから8ビットの部分がメジャー番号であるということです。
これらを使って、シェル変数ttyに端末名を表示する関数を作成します。
function get_tty
{
major_num=$((tty_nr>>8))
minor_num=$((tty_nr&255))
if [ $major_num -eq 4 ]
then
  tty=tty${minor_num}
elif [ $major_num -eq 136 ]
then
  tty=pts/${minor_num}
else
  tty=???
fi
}
メジャー番号は8ビット左シフト(tty_nr>>8)を使うことで取り出し、マイナー番号は2進数で11111111である255との論理積(tty_nr&255)から取り出しています。本当は31から20ビットも使わないと正確ではないですが、今回はこれでも問題ないのでこの形にしています。
メジャー番号が4なら普通の端末(tty1とか)、136なら擬似端末(pts/0とか)、それ以外は不明(???)として、それにあったものをシェル変数ttyに代入しています。
実際に使ってみましょう。

この実験はmacからSSHで接続して行っているので、ちゃんと擬似端末が表示されました。
3.3 psコマンドの作成
ここまでに作ったread_stat関数とget_tty関数を用いて、psコマンドを作ります。
function ps { 
echo -e "PID\tTTY\tSTATE\tTIME\tCMD"
for stat in /proc/[1-9]*/stat # 数字で始まりstatがあるディレクトリを考える
do 
  read_stat $stat
  if [ $tty_nr -ne 0 ] # 端末を持つプロセスのみ表示
  then
    get_tty # ttyを取得
    echo -e "${pid}\t${tty}\t${state}\t${time}\t${comm:1:-1}" # commの値の()は外す
  fi
done
}
ここでシェル変数commは文字列がかっこ付きなので、それを取り外して出力(${comm:1:-1})しています。
なお、古いbashでは-1が使えないこともあるので、その場合は同じ意味である${comm:1:${#comm}-1}を使うと良いと思います。
実際に使ってみましょう。

ちゃんと出力されました。今回はラズパイに直接つなげているモニターでもbashが起動しており、そのPIDが614ということもわかりました。
終わりに
bashの組み込みコマンドだけで、意外となんでもできるってことが実感できたと思います。時間があったら、次はプロセスの管理(ジョブの概念やkillコマンド、シグナルなど)の話もやりたいと思います。