📝

Linuxのプロセスのコマンドライン引数についていろいろ

2022/10/08に公開約4,600字2件のコメント

2022/10/16 以下ご指摘をもとに内容を修正および追記
https://zenn.dev/link/comments/463223a4de9ec2

はじめに

Linux上でコマンドを実行したときのコマンドライン引数についてつらつら書きたくなったので書きます。

プロセスのコマンドライン引数とは、たとえばfoo bar bazというコマンドを実行したら、通常はコマンドライン引数はfoobar、およびbazになります。直観的には引数は”bar”と"baz"だけのようにおもえるかもしれませんが、とにかくこういう定義です。

コマンドライン引数はプログラムの中からはCやC++ではmain関数のargv配列引数から参照できます。上述の例であればargv[0]には実行ファイル名が入ります。それ以降の"bar"はargv[1]に、"baz"はargv[2]に入っています。argvに相当する変数はシェルスクリプトだと$0,$1,$2...、Pythonだとsys.argv、Goだとos.Argsなどになります。ただしシェルスクリプトやPythonなどのスクリプトはC言語などのようにコマンドライン引数をそのまま見せているのではなく、少し加工したものを見せています。これについては後述します。

コマンドライン引数の一つ目の要素について

コマンドライン引数の一つ目の要素(以後argv[0]と書く)には慣習的には実行ファイルの名前が入ります。プログラム実行時にはプログラムがどんな言語で書かれていようと最終的には以下に示すexecve()というシステムコールを呼び出すのですが、pathname引数にはプログラムの実行ファイル名、argv引数にはコマンドライン引数を指定します。

int execve(const char *pathname, char *const argv[], char *const envp[]);

このとき、慣習に従うとpathnameargv[0]に同じ値を指定する、というわけです。

どういうときにargv[0]を実行ファイルの名前以外にするかというと、たとえばbashはログインシェルの場合はargv[0]bashではなく先頭に"-"を付けて、"-bash"とします。これによってbashは実行開始時に自分自身がログインシェルかどうかを知って、処理を分岐させます(たとえば読み込む設定ファイルを変更するなど)。bashのargv[0]の値については、次の節で実際に確かめます。

実行ファイルを実行する際に、実行ファイルがbashスクリプトのようにインタプリタ経由で実行される場合は、argv[0]にはスクリプト名ではなく、インタプリタの実行ファイル名が入ります。たとえば"test.sh"というbashスクリプトがあるとすると"./test.sh"を実行した際のargv[0]はbashの実行ファイル名であり、"./test.sh"はargv[1]に入ります。しかしこれだとプログラマから見ると扱いづらいので、bashからは$0argv[0]に、$1argv[1]にアクセスできます。これも後の節で実際に確かめます。

procfsを使ってプロセスのコマンドライン引数の値を確かめる

各プロセスのコマンドライン引数は/proc/<pid>/cmdlineから参照できます。たとえば筆者が現在sshでログインしているLinuxマシンでは、システムのログを収集するrsyslogdプロセスのコマンドライン引数は以下のような値になっていました。

sat@tea:~$ pgrep rsyslogd
568
sat@tea:~$ cat /proc/568/cmdline
/usr/sbin/rsyslogd-n-iNONEsat@tea:~

なにやら妙なことになりました。実行ファイル名っぽい文字列/usr/sbin/rsyslogdの後にコマンドオプションっぽい文字列が繋がっています。しかも次のプロンプトの前に改行がありません。これは/proc/<pid>/cmdlineの仕様上、全引数をスペースなどの区切りなしで出力している…というわけではなく、実は、各引数は以下のようにヌル文字(値が0であるバイト。C言語的には"\0")で区切られているものの、bashはヌル文字を画面上に表示しない(できない)、という理由によります。

$ hexdump -c /proc/568/cmdline
0000000   /   u   s   r   /   s   b   i   n   /   r   s   y   s   l   o
0000010   g   d  \0   -   n  \0   -   i   N   O   N   E  \0           
000001d

なお末尾の改行が無い理由はわかりません。

2022/10/16 追記

上記改行が無い理由についてコメントをいただいたので、ここにそのまま抜粋します。

これは(引数に改行文字が入る可能性があるため)末尾改行ではなく末尾 \0 に単純に文字を変えているだけだと思います
。「\0 区切り」ではなく「末尾 \0」です。厳密には GNU ですが xargs -0 など Linux にとって末尾 \0 は広く使われてお
り一般的なデータ形式だと考えられます。

もし末尾に余計な改行がある場合、xargs は以下のようにデータを解釈してしまいます。

$ printf 'foo\0bar\0\n'  | xargs -0 printf '[%s]\n'
[foo]
[bar]
[
]

ここから行の定義にこだわらずに単純に改行文字を \0 に置き換えるだけのほうが単純で一貫性があり合理的だと考えられ
ます。

余談ですが、bash も末尾 \0 のデータを読み取ることが出来ます。この時、末尾に \0 が無いと最終行は読み取れません>。例えば以下の baz は読み取れません(厳密にはループを抜けた後の REPLY 変数に入っています)。デフォルトの末尾改行
の場合と末尾の文字が異なるだけで同じ動きであり一貫性があります。

$ printf 'foo\0bar\0baz' | { while read -d ''; do printf '%s\n' "$REPLY"; done; }
foo
bar

システム上に存在するbashのargv[0]について見てみましょう。ps axの実行結果の最後のフィールドはコマンドライン引数をスペースで区切った値になっているので、これを利用してシステムに存在するbashをリストします。

sat@tea:~$ ps ax | grep bash
   5239 pts/3    Ss+    0:00 /usr/bin/bash --init-file /home/sat/.vscode-server/bin/74b1f979648cc44d385a2286793c226e611f59e7/out/vs/workbench/contrib/terminal/browser/media/shellIntegration-bash.sh
   8725 pts/4    Ss     0:00 -bash
   8907 pts/4    S      0:00 /bin/bash ./test.sh
   8909 pts/4    S      0:00 /bin/bash ./test.sh
   8929 pts/4    S+     0:00 grep --color=auto bash
for p ax 

pidが5239, 8725, 8907, 8909のプロセスがbashだとわかりました。このうちargv[0]の一文字目が"-"になっているpid=8725のプロセスが、筆者が上記コマンドを叩いているログインシェルです。

したがって実際はargv[0]が"/usr/sbin/rsyslogd"、argv[1]が"-n"、argv[2]が"-iNONE"となります。

bashスクリプトの例も見てみましょう。ここで使うスクリプトは$0を出力した直後に無限にsleepするというものです。

sat@tea:~$ cat test.sh
#!/bin/bash

echo $0

sleep infinity
sat@tea:~$ ./test.sh
fg
./test.sh # `$0`を出力
^Z
[2]+  Stopped                 ./test.sh
sat@tea:~$ bg
[2]+ ./test.sh &
sat@tea:~$ hexdump -c /proc/8909/cmdline
0000000   /   b   i   n   /   b   a   s   h  \0   .   /   t   e   s   t
0000010   .   s   h  \0                                               
0000014

$0の値はスクリプト名である./test.shである一方で、argv[0]の値は/bin/bashで、スクリプト名はargv[1]に入っていることがわかりました。つまりユーザから見ると実行ファイル./test.shをそのまま実行しているように見えるものの、実際に動いているプログラムはbashで、bashがスクリプトを解釈しながら実行して、argv[1]をプログラムからは$0以降にマップして見せているというわけです。

カーネルが保持するコマンド名とコマンドライン引数の違い

本記事で述べてきたargv[0]は「だいたいの場合は実行ファイル名が入っている」のですが、psコマンドなどによって表示されるカーネルから見たコマンド名とはまた別なことに注意が必要です。カーネルから見たコマンド名については以下記事をごらんください。

https://zenn.dev/satoru_takeuchi/articles/2d44cb4358a6f1

カーネルから見たコマンド名は"実行ファイル名のbasenameの先頭15バイト"であってargv[0]とは異なるのです。

おわりに

いろいろ書きましたが、本記事によってコマンド名や実行ファイル名、コマンドライン引数、およびプログラムのソースコードの中から参照できるコマンドライン引数(少し加工していることもある)についての混乱が減らせたら嬉しいです。

Discussion

bashからは$0でargv[1]に、$1でargv[1]にアクセスできます。

ここは「$0argv[0]」ではないでしょうか?

なおbash -c

文章が途切れているようです。

なお末尾の改行が無い理由はわかりません。

これは(引数に改行文字が入る可能性があるため)末尾改行ではなく末尾 \0 に単純に文字を変えているだけだと思います。「\0 区切り」ではなく「末尾 \0」です。厳密には GNU ですが xargs -0 など Linux にとって末尾 \0 は広く使われており一般的なデータ形式だと考えられます。

もし末尾に余計な改行がある場合、xargs は以下のようにデータを解釈してしまいます。

$ printf 'foo\0bar\0\n'  | xargs -0 printf '[%s]\n'
[foo]
[bar]
[
]

ここから行の定義にこだわらずに単純に改行文字を \0 に置き換えるだけのほうが単純で一貫性があり合理的だと考えられます。

余談ですが、bash も末尾 \0 のデータを読み取ることが出来ます。この時、末尾に \0 が無いと最終行は読み取れません。例えば以下の baz は読み取れません(厳密にはループを抜けた後の REPLY 変数に入っています)。デフォルトの末尾改行の場合と末尾の文字が異なるだけで同じ動きであり一貫性があります。

$ printf 'foo\0bar\0baz' | { while read -d ''; do printf '%s\n' "$REPLY"; done; }
foo
bar

@ko1nksm 遅くなりましたが、いただいたコメントを本文に反映しました。まことにありがとうございました。

ログインするとコメントできます