Hatena::ブログ(Diary)

永遠に未完成 RSSフィード

2010-02-10

cmd.exe のコマンドラインの仕様を解析してみた

cmd.exe の引数の扱いがあまりにもカオスだったのでちょっと頑張って調べてみた。

本来ならここは公式の資料に当たるのが正しいアプローチだと思うけど、どうしても公式の資料が見つからなかったので、色々試して推測してみることに。

断片的な資料は見付けたけど、完全じゃない。一応URL貼っておく。Windows Server 2003 のヘルプだけど、恐らくそんなに変わらないと思う。

コマンド シェルの概要

コマンド リダイレクト演算子を使用する

なので、以下で述べる内容は間違いを含む可能性があります。というか正確さは一切保証されないのであしからず。


検証方法

以下のような引数をただ表示するだけの簡単な C のプログラムを用意した。仮に args.exe とでもしておく。

#include <stdio.h>

int main(int argc, char const* argv[]) {
  int i;
  for (i = 1; i < argc; i++) {
    printf("%s\n", argv[i]);
  }
  return 0;
}

こいつを使って 1 つずつ引数を与えて実行しては結果を見てふむふむした。

結論

途中の紆余曲折も書こうかと思ったけど誰も興味ないと思うのでいきなり結論から。私なりに辿りついた結論なので、本当にこうなっているのかどうかは知らない。

コマンドラインの解釈は 4 つのフェーズに分かれる。

1. 環境変数の展開

まず最初に、環境変数が展開される。

  • 環境変数名を % で囲むと展開される。
    • 例: %PATH%
  • 環境変数が存在しない場合は % の表記がそのまま残る。空文字列になったりはしない。
  • 環境変数名は大文字と小文字は区別しない。
  • 環境変数名はこの段階で判断されるので、最終的に消える文字を中に入れておくことで無理矢理展開を防ぐことができる。
    • 例: %^PATH% (^ は フェーズ 2 で削除される)
2. コマンドラインの解釈

次に、コマンドライン全体の解釈をする。全体はパイプやリダイレクトなどもあるため、単一のコマンドとは限らない。

以下のルールに従って解釈される。

  • ^ は、^ 自身を取り除いて次の文字を通常の文字として扱う。
    • つまりエスケープ文字。& | < > ( ) % " ^ などの特殊文字を通常の文字として扱うようにする。
    • ^ 自身を表すにはエスケープして ^^ とする。
    • 通常の文字に付けた場合は単に ^ が取り除かれる。
  • ダブルクォートで囲まれた部分では特殊文字は無効になる。
    • ダブルクォートは閉じられている必要はない。
    • 複数の組みができることもある。
      • 別の見方をすると、ダブルクォートは特殊文字を解釈するかどうかのON/OFFを切り替えるスイッチと考えることもできる。
    • ダブルクォートの開始の " はその直前ではまだ特殊文字は有効なので ^ でエスケープできるが、終了の " は特殊文字は無効化されているので ^ も無効化されておりエスケープできない。
3. 特殊文字の処理

コマンドライン特殊文字を使うことで複数のコマンドを含めることができる。以下のもので 2. でエスケープされていなかったものは特殊文字として処理される。

&
複数のコマンドを区切る。1つ目のコマンドが実行された後に2つ目のコマンドが実行される。
&&
1つ目のコマンドを実行し、成功した場合(終了コードが 0 の場合)のみ2つ目のコマンドを実行する。
||
1つ目のコマンドを実行し、失敗した場合のみ2つ目のコマンドを実行する。
|
1つ目のコマンドの出力を2つ目のコマンドの入力として実行する。
<
コマンドの入力をファイルから読み取る。
>
コマンドの出力をファイルへ書き出す。
>>
コマンドの出力をファイルへ追記する。
( )
複数のコマンドをまとめる。入出力がまとめてできる。入れ子が可能。
  • ( はコマンドの先頭に当たる部分にある場合のみ解釈される。
    • コマンドの先頭とはつまり、コマンドラインの先頭か、& && | || の直後。
    • それ以外の場所では引数の一部の通常文字として扱われる。
    • 入れ子が可能なので、閉じ括弧 ) は対応するものが探される。もちろんエスケープされた ) は無視する。
    • 閉じ括弧が見付からない場合は続けて入力を促される。
  • 有り得ない場所に特殊文字が来た場合は、「コマンドの構文が誤っています。」や、「{特殊文字} の使い方が誤っています。」({特殊文字}は具体的な特殊文字文字)と言ったエラーになる。
4. 個々のコマンドの解釈

コマンドは以下のルールに従って複数の文字列に分割される。最初の文字列がコマンド、残りが引数になる。

追記:コンパイラによって挙動が微妙に違うということが判明。これはどのレイヤーが処理しているのだろう…。

二重のダブルクォートについて、cl.exe と gcc.exe でコンパイルした場合で扱いが違ったので追記。

  • 基本的にコマンドは連続する空白によって分割される。
  • ダブルクォートで囲まれた部分とその前後は連続した文字列とみなされる。
    • この際、囲っているダブルクォートは削除される。
    • ダブルクォートは閉じられている必要はない。
    • ダブルクォートの前後は、というのは、ダブルクォートによる囲いは任意の位置から開始できることを意味する。
      • 例: abc" de"f → 「abc def」
  • ダブルクォート中で二重のダブルクォート("")が見つかった場合、
    • cl.exe でコンパイルした場合は " の文字になる。
      • 例: "abc"" def → 「abc" def」
      • 例: "print(""hello"")" → 「print("hello")」
    • gcc.exe でコンパイルした場合はクォートを閉じつつ " の文字になる。
      • 例: "abc"" def → 「abc"」「def」
      • 例: "print(""hello"")" → 「print("hello)」 (1番目の "" でクォートが閉じられ、2番目の "" は開いてすぐ閉じるクォートになるため消える)
  • \ は、
    • ダブルクォートの内外を問わず、" の前に \ を前置すると " 自身を表現できる。
    • \\" とすると \" の \ をエスケープしたことになり、 \" になる。この " は特殊文字である。
    • \\\" とすると \\ + \" になり、 \" になる。この " は " 文字自身である。
    • 以下、\ が増える度に上記のようなエスケープを繰り返す。
      • \\\\" → \\" (" は特殊文字)
      • \\\\\" → \\" (" は通常文字)
    • 上記以外の場所にある \、つまり後に " が続かない \ は、いくつ重なっていてもその文字自身になる。
      • 例: \\server\path → \\server\path (そのまま)

注意点

ワイルドカードはない

よく * や ? でパターンにマッチしたファイルを引数に展開することがあるが、cmd.exe にはこういった機能は一切ない。

もし動いているように見えたとしたら、それは各々のコマンドが中で独自に解釈している。

シェルがショボいとその上で動くプログラムが苦労する羽目になる。悲しき世の常。

独自の解釈をするプログラムもある

プログラムの中には実行するコマンドの文字列を読み取って引数を独自に解釈するものもある。

その場合、4. の展開前の文字列プログラムが直接解釈する。よって、4. で述べたルールは一切適用されない。

例えば ruby.exe などがそうである。ruby.exe は引数のクォートにシングルクォートが使える。

>ruby -e 'puts "Hello, world!"'
Hello, world!

逆に cmd.exe 的にはうまくいくものも動かなかったりする。

>args -e puts" "'Hello," "world!'
-e
puts 'Hello, world!'

>ruby -e puts" "'Hello," "world!'
-e:1: syntax error, unexpected tFID, expecting $end

独自の解釈するのもいいけど最低限の互換性は持たせろよ!

引数をエスケープするには

以上の結論から、任意の文字列引数として与えたい場合は以下のようにする。

  1. 特殊文字を全て ^ でエスケープする。
    • & | < > ( ) ^ " %
    • % はエスケープしておけば終わりの % にあたる ^ が環境変数の展開を防いでくれる。
  2. " の前にある連続する \ を同数の \ でエスケープする。
  3. 全ての " を \ でエスケープする。
    • " はすでに ^ でエスケープされているが、これを更にエスケープする。つまり \^" になる。
  4. 全体を ^" 〜 ^" で囲む。
    • 単純に "" で囲むと ^ が無効になってしまう。

例:

以下の文字列を1つの引数として与えたいとすると…

puts "Hello, world"

以下のようになる。

^"puts \^"Hello, world\^"^"

ちなみに独自の解釈をするプログラムは、まともな実装なら動いてくれると思うが解釈のしようなんてどうにでもなるので正直なところ完全な対応は不可能。

例えば cmd.exe に組み込みの echo コマンドはダブルクォートなどを一切解釈せずにそのまま出力する。

所感

自分で言うのもなんだけど、結構いい線行ってると思う。

コマンドプロンプトカオスなのは、別のレイヤーで同じ文字を別の意味で使ってるせいじゃないかと思った。そう、" (ダブルクォート)だ。

フェーズ 2 とフェーズ 4 でエスケープ方法が違う。これが混乱の元ではないかなと。と言うか設計ミスだろこれ。

この辺りを設計した当時の担当者は一体何を考えていたのか。はっきり言って残念すぎる。

shiroshiro 2010/02/11 15:04 昔の記憶なので不正確かもしれませんが、Windows APIのレイヤ(CreateProcess)ではコマンドラインは単一の文字列としてべたで渡しているので、「n番めのコマンドライン引数」という概念がそもそもWindowsのOSレベルには無いのだと思います。Cのargvに渡ってくるのはCの処理系のランタイムが分割したもので、この時に処理系によって特殊文字のクオートの扱いに差が出ます。cl.exeとgccの違いはそのためかと。
cmd.exeはCreateProcess APIの手前での処理になりますが、ここでいくら特殊文字の処理を頑張っても結局OSをコールするときにくっつけざるを得ない (そしてその解釈はアプリケーションに任される) ために、せいぜいできるのはリダイレクトの処理や環境変数絡みだけ、ということなのだと思います (従って、cmd.exeに替わる賢いシェルを書く、ということも原理的に不可能)。
責めるとすればCreateProcess APIなんですが、そういうふうにできているということは、「コマンドライン引数なんてまともに使うな」というWindows設計者のメッセージなのではないかと。

thincathinca 2010/02/11 15:49 コメントありがとうございます。

>「n番めのコマンドライン引数」という概念がそもそもWindowsのOSレベルには無い
なんと。個人的には衝撃的な事実。Unix等とはこうも根本的に違うんですね。
C言語のレベルで引数は複数の文字列だったのでそういうものだと思い込んでました。

しかしそうなるとますます残念ですね、Windows…。今時引数オプションのないコマンドの方が珍しいと言うのに…。

t-tanakat-tanaka 2010/02/11 16:17 コマンドプロンプトで「netstat/n」とか打ってみると,
さらに悩めるとおもいます。がんばってください。

thincathinca 2010/02/11 16:53 なにーっ!起動するプロセスの決定はどこでやってるんでしょうね。
謎は深まるばかり…。

pakopako 2010/02/11 18:51 なかなかおもしろい考察ですね。
きみたちすてき♪

t-tanakat-tanaka 2010/02/11 22:35 あと1文字目の「@」が無視される,とかいう仕様もあります。

thincathinca 2010/02/11 23:16 ラベル :label ってのもありますね。
この記事ではあえて省略しましたが。

ほげほげ 2010/02/12 13:13 7からPowerShell も標準搭載されたし、「Windows」でくくって残念扱いするとちょっとかわいそうかも。コマンドレットは概念的には古典UNIX的のコマンドラインより優れてると思います。対応コマンドが今後どれだけ増えるかは未知数ですが。

thincathinca 2010/02/12 14:04 PowerShellについてはまだ詳しく知らないので今度時間がある時にでも調べてみたいですが、
PowerShell になっても「プログラムは複数の文字列を引数として受け取る」と言う点と「Windows は単一の文字列でしかコマンドを実行できない(プロセスを生成できない)」と言う点では PowerShell でも変わってないですよね?(そんなことないのでしたら、すいません)
だとするとやはり残念だと言わざるを得ないですね。

nn 2010/02/13 01:37 wineのcmd.exeの実装が残念なのも、この複雑さじゃ納得。

ScopsScops 2010/02/18 21:33 ↓を実行してみると分かりますが、必ずしも最初の文字列がコマンドとは限らないようです。
1>output.txt echo test

IKUZOIKUZO 2011/01/26 16:21 私は cmd.exe に「ダブルクォートで囲んだ引数を与える」こと自体を疑問視してます。
\\" の " は引用符にならないですし。ヘルプが嘘です。
コマンドプロンプトから \ が末尾に来るパスワードを設定しようとしておもくそハマりました。
よって私は以下のようにしています。
1. 引数はダブルクォートで囲まない
 2. " と半角空白以外の特殊文字は全てキャレットでエスケープ
3. " は \ でエスケープ
 4. 半角空白列はダブルクォートで囲む
5. バッチファイル中に埋めようと思ったら % は二重化(^%% とする)
シェルにできるだけ頼らず、コマンドに文字列をダイレクトに渡す方向が良いのではないでしょうか。

masamasa 2011/02/02 15:18 Windows2000より古いWindowsにはコマンドライン引数を配列で受け取る方法が本当に存在しなかったので、
・開発言語のランタイムがパースして既定のスタートアップ関数(C言語なら main)を呼ぶ
・引数全体を文字列で返す関数が用意されていて、それを自力でパース(VB6以前はそれしか出来なかった)
のどちらかなんですよね

NN 2013/02/04 03:41 しょぼいけれど、今から見れば驚くほど非力なマシンで動かす為に作られたMS-DOSでは、まあ、こんなもんです。展開された状態で受け渡すより、ワイルドカードを必要とするようなアプリが自分で頑張る方がマシだったと。
メモリが余裕になった後も、GUIでコマンドラインのワイルドカードの必要性は極めて薄いわけで、ABIをややこしくしてまでコマンドラインパーサの種類を増やすメリットは無さそうですし。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証