Windowsでの端末入出力
序
前回の記事でロケールは分かったと思うので、今回は端末についていくつか事例を説明します。
以下を読むにあたり、先にAIさんに以下のプロンプトを投げて概要をつかんでください。
WindowsでのCプログラミングにおける端末入出力の説明を、C標準関数とWin32APIでそれぞれ説明してください。その際ロケール設定やコードページがどう影響するかも説明に入れてください。また、LinuxやMacで開発してる人にWindowsとの違いが判るように説明してください。
今回は端末とは言ってもクライアント側の話で、キャラクタデバイス的な話はありません(エンコーディング視点の話からの派生なので)。
1. Win32APIを使った端末入出力
前回はCを使って入出力してましたが、Cの標準関数を使用していました。今回はWindowsのWin32APIを直に使います。Windowsに付属している動的ライブラリを直に呼び出すということです。
1-1. エコープログラム(標準入出力-1バイト単位)
入力したものをそのまま出力するやつです。
WindowsでのEOF入力はCtrl+Zです。このプログラムは端末だと行単位入力なのでEnterも続けて押してください。1バイトしかバッファを持っていませんが、動かすとちゃんと日本語の入出力も出来るのが分かります。
$ ./build/echo.exe
日本語
日本語
^Z
$
※VSでビルドした場合は./build/Release/echo.exeとかにあります
使用しているAPIは以下です。EOFの判断方法などはReadFileの同期側の説明を読んでください。
GetStdHandle, ReadFile, WriteFile
ただし、この例のようにAPIを頻繁に呼び出すと、どうしても遅くなります。
以下は実際の時間計測結果です。
$ pacman -S mingw-w64-ucrt-x86_64-hyperfine # 簡易ベンチマーク用プログラムのインストール
...
$ dd if=/dev/zero bs=4096 count=2560 >zero10mb.dat # 0埋め10MBファイルの作成
...
$ hyperfine -n echo 'build\echo.exe <zero10mb.dat >nul'
Benchmark 1: echo
Time (mean ± σ): 11.108 s ± 0.107 s [User: 0.463 s, System: 10.600 s]
Range (min … max): 10.949 s … 11.249 s 10 runs
※pacman,ddはMSYS2環境に付属しているコマンドです
※hyperfineは簡易ベンチマークコマンドです。標準のtimeやbash組み込みのtimeでも計測できますが、hyperfineは複数回測定して平均/最大/最小/誤差などを出してくれます。
※ddとecho.exeは直にパイプで繋ぐ方が一般的ですが、MSYS2環境のbashが作るパイプはとても遅いので、ファイルをリダイレクトしています
たった10MBの読み込みで平均11秒もかかっています。
1-2. エコープログラム(標準入出力-行単位)
次は読み込みを行単位で行い、日本語のエンコーディングを調べるために16進ダンプする機能を入れてみます。行の長さには実質制限がないので、(特にCだと)実装にはいろいろな工夫が必要になります。
一般的な言語やライブラリだと無制限に長い文字列を取得する形になってしまったりすることもありますが、通常それは良くない実装です。今回は都合によりバイト単位ですが、4096バイトのバッファを用意し、そこに一旦読み込んでから行単位の読み込みをしていきます。端末からの入力であれば通常自動的に行単位になりますが、リダイレクトやパイプの読み込みはそうならず、複数行がいっぺんに来たり、行の途中までしか読み込まれなかったりするようになり、実装の複雑度が上がります。
そうした複雑な読み込みに対応するため、よくあるのは何層かのパイプライン方式の読み込みですが、実際のファイル読み込み処理が奥まった場所に行って見通しが悪いので、今回はmainから直に諸々の処理を呼び出す形に実装してみました。バッファから行を読み取り、使い切ったら中身をシフト(MoveMemory)して続けて読み込む処理がmainからある程度見て取れるはずです。リングバッファだと繋ぎ目が行内に入ったときの処理が面倒ですが、シフトすればそこは解決するので。これにより、バッファを直に参照した文字列が使えるようになり、文字列生成時のコピー回数を減らすことが出来ます。
まずは日本語のエンコーディングを調べます。
$ ./build/echo_line.exe
日本語
日本語
93 FA 96 7B 8C EA 0D 0A
^Z
$ echo "日本語" | iconv -t cp932 | hexdump -C
00000000 93 fa 96 7b 8c ea 0a |...{...|
00000007
標準入力から読み取れているコードはCP932=シフトJISのようです。そして標準出力に出したコードも変換してないのでシフトJISになります。これが文字化けしていないことから、標準入力も標準出力もシフトJISであることが分かりました。
標準入力も標準出力もシフトJISなのは、端末のコードページが932であることに起因しています。端末のコードページを確認するコマンドである、chcp.comを使うと…
$ chcp.com
現在のコード ページ: 932
実際に932であることが分かります。さらにchcp.comを使って65001(UTF-8)にしてエンコーディングを同様に調べてみます。
$ ./build/echo_line.exe
日本語
日本語
E6 97 A5 E6 9C AC E8 AA 9E 0D 0A
^Z
$ echo "日本語" | hexdump -C
00000000 e6 97 a5 e6 9c ac e8 aa 9e 0a |..........|
0000000a
MSYS2のbashはLANG=ja_JP.UTF-8だとUTF-8でecho出力するので、今回は標準入出力がUTF-8になっていることが分かります。つまりWin32APIでは端末のコードページに連動して、標準入出力のエンコーディングが変わるということです。これがWindows本来の挙動で、freadなどC標準関数がlocale設定に従っていたのは、あくまでMSが用意したC標準ランタイムライブラリのおかげだということが分かります。Linuxなど*nixライクな環境だとAPI(システムコール)とfreadなどC標準関数でエンコーディングが変わったりしないので、汎用なコードを書く場合は設計上注意が必要です。
MSYS2上のアプリだと、MinGW系のアプリはMS製のCランタイムライブラリを使用するので、エンコーディングが変わります。ここでは見ていませんが、Cygwin系のアプリ(MSYS2の標準アプリ)だとCランタイムライブラリが違うので、原則*nix系と同じ動作になり、エンコーディングが変わりません。
| 環境 | Cランタイムライブラリ | エンコーディング |
|---|---|---|
| cmd/powershell | ucrtbase.dll/msvcrt.dll | 変換される |
| MSYS2(UCRT64) | ucrtbase.dll | 変換される |
| MSYS2(MINGW64) | msvcrt.dll | 変換される |
| MSYS2(CLANG64) | ucrtbase.dll | 変換される |
| MSYS2(MSYS) | msys-2.0.dll | 変換されない |
※msvcrtは古いのでUNICODE処理があまり良くない
※ucrtbase.dllとmsvcrt.dll両方に依存してるアプリなどもありますが、.exeがucrtbase.dllで書かれてて、依存dllがmsvcrt.dllで書かれてるなどのケースです
次に速度の確認をします。
$ hyperfine -n echo_line 'build\echo_line.exe <zero10mb.dat >nul'
Benchmark 1: echo_line
Time (mean ± σ): 101.0 ms ± 2.0 ms [User: 32.5 ms, System: 62.1 ms]
Range (min … max): 98.4 ms … 107.8 ms 26 runs
16進ダンプ処理が加わっているにも関わらず、11秒が0.1秒まで減り、100倍以上の速度効果が出ています。スループットは入力側が10MB / 0.101 = 99MB/s、出力側はダンプにより4倍にはなっているので、4 x 10MB / 0.101 = 396MB/s程度の処理能力だということになります。出力側は破棄しているのでボトルネックにはならないとすると、入力側の性能で99MB/sということになってしまいますが、ddだと
$ hyperfine -n dd 'dd if=zero10mb.dat count=2560 bs=4096 >nul'
Benchmark 1: dd
Time (mean ± σ): 17.9 ms ± 0.9 ms [User: 9.6 ms, System: 8.6 ms]
Range (min … max): 16.7 ms … 23.7 ms 102 runs
10MB / 0.0179 = 558MB/sは出ているので、入出力がボトルネックではなくこの環境でのプログラムの処理性能自体が396MB/s程度しかないのかもしれません。まあSystem負荷も何故かそれなりにあるので、Windowsの同期I/O自体が遅いだけという可能性もおおいにありますが…。一応SSD(キャッシュされてると思うけど)でIntel Core U5 225でDDR5なので1GB/s未満というのは結果として遅すぎる気はします。この辺はそれぞれの性能を細かく追って特性をつかんでからでないと何とも言えず、今回のはただの雑感です。
その次は、端末とリダイレクト/パイプの読まれ方の違いを見てみます。ビルドの際に#define VERBOSEを付けてみてください。コードに直接貼ってもいいし、ビルドオプションで指定できる人はそうしてもらっても構いません(make/gccを使うcmakeだと-DCMAKE_C_FLAGS="-DVERBOSE"など)。これを付けてビルドすると、ReadFile()で読み込んだバイト数が出るようになります。
以下で端末実行してみます。
$ ./build/echo_line.exe
日本魚
「日本語」と入力中誤って「日本魚」と打ち込んだとします。その際、BackSpaceキーで一文字消して改めて「語」を入力してEnterすると…
$ ./build/echo_line.exe
日本語
dwLength: 8
日本語
93 FA 96 7B 8C EA 0D 0A
こうなります。行単位の入力になっているため、編集中の操作は標準入力から読まれず、"日本語\r\n"が直に読み込まれ、(シフトJISで)8バイト入力されたことが分かります。続けて、「こんにちは[Enter][Ctrl+Z][Enter]」と入力すると、プログラムが終了します。
$ ./build/echo_line.exe
日本語
dwLength: 8
日本語
93 FA 96 7B 8C EA 0D 0A
こんにちは
dwLength: 12
こんにちは
82 B1 82 F1 82 C9 82 BF 82 CD 0D 0A
^Z
$
これが端末からの行単位入力です。次に、これをパイプから入力する形にしてみます。bashの力を借りて…
$ (echo '日本語';echo 'こんにちは') | awk '{print}' ORS="\r\n" | iconv -t cp932 | ./build/echo_line
dwLength: 20
日本語
93 FA 96 7B 8C EA 0D 0A
こんにちは
82 B1 82 F1 82 C9 82 BF 82 CD 0D 0A
$
サブシェルからechoを2回実施し、awkで改行コードを変換し、iconvでUTF-8の文字列をシフトJISに変換して、echo_lineにパイプで渡しています。注目すべきは、ReadFile()が一度しか呼ばれておらず(EOF時は除く)、一気に20バイト読まれたということです。
試しにもっと大きなサイズを読み込ませると…
$ dd if=/dev/zero bs=512 count=16 | ./build/echo_line.exe
16+0 レコード入力
16+0 レコード出力
8192 bytes (8.2 kB, 8.0 KiB) copied, 0.0005232 s, 15.7 MB/s
dwLength: 4096
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
dwLength: 4096
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
$
こうなります。ddコマンドでは512バイトずつ16回に分けて出力していますが、パイプを経由することで、echo_line側のバッファ最大である4096バイトまで読み込まれていることが分かります。
標準入出力だけでも、端末の場合とリダイレクト/パイプの場合で、読まれ方や読まれるデータが結構違うのが分かったと思います。端末だと行単位の入力をベースにしており、パイプだとブロックデータがゴリっとやってきます。この挙動はWindowsでも*nixライクな環境でもほぼ同じです。
1-3. 端末のエンコーディング調査
これは私の知る限りWindowsのAPIにしかない機能です。端末の入出力のコードページ(chcpで取得/設定するやつ)を取得できます。
GetConsoleCP, GetConsoleOutputCP, SetConsoleCP, SetConsoleOutputCP
この機能のおかげでWindowsのCランタイム(ucrtbase.dll/msvcrt.dll)は端末入出力時にエンコーディングを変換出来るのです。
実行すると…
$ ./build/terminal_codepage.exe
GetConsoleCP(): 932
GetConsoleOutputCP(): 932
こんな感じになります。chcpとかすると…
$ chcp.com 65001
Active code page: 65001
$ ./build/terminal_codepage.exe
GetConsoleCP(): 65001
GetConsoleOutputCP(): 65001
$
連動して変わるのが分かります。ではパイプやリダイレクトにするとどうなるでしょう?
$ chcp.com 932
現在のコード ページ: 932
$ ./build/terminal_codepage.exe </dev/null >terminal_codepage.log
$ cat terminal_codepage.log
GetConsoleCP(): 932
GetConsoleOutputCP(): 932
$
プロセスに紐づいているコンソールに問い合わせるため、取得できるようです。つまり、対象は標準入出力/エラー出力とは無関係なデバイスとしてのコンソールになります。
逆に設定もすることが出来ます。今回のプログラムでは、コマンド引数に入出力のコードページを設定できます。
$ ./build/terminal_codepage.exe 65001 65001
GetConsoleCP(): 65001
GetConsoleOutputCP(): 65001
$ chcp.com
Active code page: 65001
$ ./build/terminal_codepage.exe 932 932
GetConsoleCP(): 932
GetConsoleOutputCP(): 932
$ chcp.com
現在のコード ページ: 932
$
今度はchcpが連動して変わっているのが分かります。プロセス終了時でもコンソールは残っているので、設定も残ります。chcpなどではコードページ切り替え時に色々な設定をリセットしているように見えますが、今回のプログラムでは丁寧な変更をしておらず、イレギュラーな変更をそのまま反映させているので、そのまま端末を使用する場合はご注意ください。
また入力と出力を個別に設定できるので、異なるコードページを設定するとecho.exeやecho_line.exeなどが文字化けするのが分かります。
$ ./build/terminal_codepage.exe 65001 932
GetConsoleCP(): 65001
GetConsoleOutputCP(): 932
$ ./build/echo_line.exe
日本語
dwLength: 11
譌・譛ャ隱・
E6 97 A5 E6 9C AC E8 AA 9E 0D 0A
^Z
$ ./build/terminal_codepage.exe 932 932
GetConsoleCP(): 932
GetConsoleOutputCP(): 932
$ ./build/echo_line.exe
日本語
dwLength: 8
日本語
93 FA 96 7B 8C EA 0D 0A
^Z
$
1-4. 端末の直接入出力
今回は標準入出力/エラー出力すら使用しない端末(コンソール)の入出力をAPIで行ってみます。
- コンソールもデフォルトで行単位の入力モード
-
ReadFile()などとは異なり、ワイド文字バージョンのAPIがある - デフォルトでEOFを自動制御できない
以下はコードです。
実行すると…
$ ./build/direct_console.exe
日本語
日本語
$
こんな感じです。EOF判定がないので、Ctrl-Cで終了させてます。
ワイド文字なので端末のエンコーディングを気にする必要がなく、入出力バラバラのエンコーディングでも文字化けせずにエコーできます。
$ ./build/terminal_codepage.exe 65001 932
GetConsoleCP(): 65001
GetConsoleOutputCP(): 932
$ ./build/direct_console.exe
日本語
日本語
$ ./build/terminal_codepage.exe 932 932
GetConsoleCP(): 932
GetConsoleOutputCP(): 932
$
標準入出力が端末でない場合、失敗します。
$ cmd //c '.\build\direct_console <nul'
$ echo $?
1
$ cmd //c '.\build\direct_console >nul'
123
$ echo $?
1
cmdはコマンドプロンプトで使用されるshellです(実際にはMSYS2からだと少しラッパーが噛みます)。引数の//cはcygwin派生であるMSYS2のbashによるコマンド引数上のパス書き換え対策で、実際には/cと等価です。つまりコマンドプロンプト上でdirect_consoleを実行してすぐ戻る、という指示です。その際、リダイレクトを入れています。Windowsのnulは通常のファイル名ではなく、*nix系の/dev/null相当のパスを意味します。つまり出来るだけWindowsネイティブな方法でリダイレクトした、というだけです。
最初のdirect_console実行では標準入力がリダイレクトされているので、ReadConsole()が失敗して入力する間もなく終了し、終了コードで1が返っています。2回目のdirect_console実行では標準出力がリダイレクトされているので、"123"を入力した後WriteConsole()が失敗して終了し、終了コードで1が返っています。
当たり前ですが、コンソールAPIは対象が端末(コンソール)でないと失敗するということです。
1-5. 標準入出力が端末かどうかの判定
POSIXではisatty()、Windows Cライブラリでは_isatty()がありますが、Win32API直だとザックリ以下のような判定になります。
※Windowsだとコンソールアプリかどうかの判定は別
Windows APIのGetFileType()を呼んでるだけです。
実行すると…
$ ./build/isatty.exe >isatty.log
$ cat isatty.log
stdin: console(or character device)
stdout: other
$ ./build/isatty.exe <isatty.log
stdin: other
stdout: console(or character device)
$
こんな感じになります。console判定を少しボカした表現にしてるのは/dev/nullみたいな特殊デバイスもコレになるからです。
$ ./build/isatty.exe </dev/null
stdin: console(or character device)
stdout: console(or character device)
$ cmd //c '.\build\isatty <nul'
stdin: console(or character device)
stdout: console(or character device)
$
この判定(のようなもの(実際には結構いろんな端末がある))を利用することで、端末とリダイレクト/パイプを分けて処理できるようになります。よく見る端末のときだけ色を付けたりページャーを挟んだり、パスワード入力を端末からしかできなくしたり、などですね。
2. まとめ
- 端末かどうかはプログラム的に判定できる
- Windowsでは端末専用の入出力APIがあり、そこではワイド文字が使用できる
- Windowsでは端末の入出力にコードページ設定がある
- Windowsの汎用ファイル入出力APIではファイル/パイプ/端末を区別することなくアクセスできる(ただしワイド文字不可)
- WindowsCランタイムは端末の入出力コードページからロケール設定に従い自動変換される
Discussion