4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

シェルスクリプトでバイナリファイルをランダムアクセスする方法

Last updated at Posted at 2023-08-20

はじめに

この記事ではシェルスクリプトでバイナリデータを扱う方法とランダムアクセスする方法についてまとめました。シェルスクリプトでバイナリデータを扱うことはあまりないかもしれませんが、まとまった情報があるとそういう事をしたい人にとって役に立つでしょう。使用するシェルは POSIX シェル、使用するコマンドは OS に標準インストールされている POSIX コマンドのみを使用し、POSIX で標準化されている範囲だけで実現しています。

ちなみにシェルスクリプトでバイナリデータを扱えるからと言って、シェルスクリプトでバイナリデータが扱いやすいということにはなりません。64ビット符号なし整数で扱うにはどうするか、計算を高速に行うにはどうするかなど、さまざまな問題はシェルや awk を単に使うだけではも解決できません。頑張ったところでネイティブの速度には 100 倍以上のレベルで負けます。一般的な話としてはシェルスクリプト(と POSIX コマンド)だけを使ってバイナリデータを処理するのは避けましょう。頑張った所で OS 標準コマンドだけで動く程度の意味しかありません。

つまりこの記事はどうにかしてシェルスクリプトでバイナリデータを扱おうという試行錯誤とノウハウと罠が書かれているということです。どうしてもシェルスクリプトでやりたいという理由がなければ不要なものです。なお、この記事は(それなりに実用的な速度で動く)シェルスクリプト製の Base64 エンコーダーとデコーダーを開発したときの関連記事です。元記事はこちらへどうぞ。

前提知識

シェルスクリプトで、しかも POSIX で標準化されているコマンドの範囲に限定すると、バイナリを扱うためのコマンドは限られています。これは特に NULL 文字(\0)を扱えるコマンドが少ないからです。そこでテキストの数字に変換してから処理し、テキストの数字からバイナリに戻すという処理を行わなければなりません。この変換及び数字の処理が遅いのでシェルスクリプトでバイナリデータを扱うのは適していません。パフォーマンスが重要な場合は POSIX コマンド以外か他の言語を使用するようにしてください。

バイナリを扱うために使用するコマンド

本記事では以下の一部のバイナリデータを扱うことができるコマンドを使います。

  • バイナリデータの読み込み
    • cat ・・・ バイナリデータの結合や出力を行う
    • dd ・・・ バイナリを全てまたは部分的に読み込んでバイナリで出力する
    • od ・・・ バイナリを全てまたは部分的に読み込んでテキストの数字で出力する
  • バイナリデータの書き込み
    • printf ・・・ エスケープシーケンスからバイナリで出力する

バイナリデータの処理自体にはシェルや awk や任意のコマンドを使うことができます。それは od コマンドによってテキストの数字に置き換えられているからです。

参考 バイナリを扱えるコマンド・扱えないコマンド

zsh や GNU awk など一部のシェルやコマンドは NULL 文字を直接扱う(変数に代入する)ことができます。しかしその他のシェルや awk では扱えません。そのためこの記事ではシェルや awk は NULL 文字を扱えないものとして考えています。

多くのコマンドが NULL 文字を扱えない理由は、NULL 文字は C 言語において文字の終了を意味するものであるため、文字列の途中に NULL 文字が入ることを特別に考慮しなければいけないからです。文字列を扱っているコマンドのように見えて内部的には C 言語の文字列として扱うことができないわけです。バイナリはテキストではないものとして一般的に認識されており、入力をテキストとして扱うという発想で設計している場合はバイナリを扱えません。また ASCII 文字は本来 7 ビット文字であるため、8 ビット目を考慮していない場合もバイナリを扱えませんが、最近は 8 ビット文字を扱えない実装は少なくなっています。

参考までに POSIX コマンドの中でバイナリデータを扱えるコマンドと扱えないコマンドを書いておきます。厳密に検証を行ったわけではないので注意してください。間違いがあるかもしれません。ちなみにロケールは LC_ALL=C で検証しています。その他のロケールでは文字として不正なバイト列の並び(例えば UTF-8 ロケールにおいて UTF-8 文字として不正な文字)の場合にエラーになる場合があります。

  • バイナリデータを扱えるコマンド
    • dd, od ・・・ バイナリを扱えるのは明らか
    • cat ・・・ 本来はファイルを結合するコマンド(テキスト出力コマンドではない)
    • wc ・・・ 改行の数を数えているだけなのでおそらく問題ないはず
    • sort ・・・ NULL 文字は最初に来るように思える
    • tr ・・・ ただし Solaris の System V 版(非POSIX)では NULL 文字が抜け落ちる
    • tail ・・・ 扱えない環境は見つかっていないが文字列出力なので微妙
    • grep ・・・ おそらく扱えるが一部の実装では非標準の -a オプションが必要
  • バイナリデータを扱えないコマンド
    • sh ・・・ zsh を除いて扱えない(変数への代入などができない)
    • awk ・・・ GNU awk 以外は扱えない(変数への代入などができない)
    • head ・・・ Solaris 11 で NULL 文字で切れる
    • cut, sed, xargs ・・・ Solaris 11 で NULL 文字が抜け落ちる
  • バイナリデータを出力するコマンド(入力なし、引数では使えない)
    • printf ・・・ エスケープシーケンスからバイナリを出力できる
    • echo ・・・ 同上、ただしエスケープシーケンスの扱いは移植性がない

1. バイナリファイルの読み書き

まずはバイナリデータ(バイナリファイル)を数値に変換することなくバイナリのまま読み書きする方法です。バイナリデータの処理が不要な場合や、バイナリファイルの特定の位置からの読み込みや、特定の位置への書き込みを行うことができます。

cat コマンド - バイナリファイルの結合

cat コマンドはファイルの中身を画面に出力するコマンドではなく、正しくはファイルを結合するコマンドです。元々ファイルを結合するためのものなので扱えるデータはテキストに限りません。

cat コマンドは(バイナリ処理の途中などで)小さく分割したファイルを結合するときに使用します。説明は不要かもしれませんが、次のような感じで使います。そもそもやれることが少ないので使い方に悩むことはないでしょう。

$ cat file1.bin file2.bin > file3.bin

dd コマンド - バイナリファイルの部分的な読み書き

dd コマンドはさまざまな機能を持ったコマンドですが、バイナリデータの扱いに関して言えば、ファイルの部分的な読み込みや位置を指定しての読み書きができるコマンドです。シェルスクリプトや awk で処理し、printf コマンドで変換したバイナリデータを、特定のファイルの末尾以外の場所に書き込むということもできます。バイナリファイルの修正に使うことができますが、実際の所あまり必要とはならないでしょう。

余談ですが dd コマンドのオプションは歴史的事情から他の一般的なコマンドとかけ離れたものになっています。入力ファイルは if オプション、出力ファイルは of オプションで指定しますが、未指定の場合標準入力と標準出力が使われます。また dd コマンドは標準で以下のステータスを出力します。これを抑制するには status オプションを使用すればよいのですが、これは POSIX で標準化されておらず移植性がありません。移植性がある方法でステータス出力のみを消す方法は「ddコマンドのステータス出力を消す方法」で解説しています。

$ dd if=file.bin
abcdefghijklmnopqrstuvwxyz
0+1 レコード入力
0+1 レコード出力
27 bytes copied, 0.0035562 s, 7.6 kB/s

$ dd if=file.bin status=none # POSIX 標準ではない
abcdefghijklmnopqrstuvwxyz

$ dd if=file.bin 2>/dev/null # エラーも含めてすべて表示されない
abcdefghijklmnopqrstuvwxyz

dd コマンドの使い方の例をいくつか紹介します。dd コマンドは指定したサイズのブロック単位で処理するということを念頭に置いてください。ブロックサイズを 1 バイトにすれば柔軟なサイズで読み込むことができますがパフォーマンスは落ちるでしょう。なおブロックサイズの指定は bs で入出力の両方を、ibs で入力のみ、obs で出力のみを指定することができます。デフォルトは 512 バイトです。

# ファイルをすべて読み込んで出力する(cat 相当)
$ dd if=file.bin 2>/dev/null
abcdefghijklmnopqrstuvwxyz

# ファイルの 5 バイト目から全てを読み込む
#(ブロックサイズを5バイトにし、1ブロックスキップする)
$ dd if=file.bin ibs=5 skip=1 2>/dev/null
fghijklmnopqrstuvwxyz

# ファイルの 5 バイト目から 10 バイト読み込む
#(ブロックサイズを5バイトにし、1ブロックスキップして、2ブロック読み込む)
$ dd if=file.bin ibs=5 skip=1 count=2 2>/dev/null
fghijklmno

# ファイルの 3 バイト目から 7 バイト読み込む
#(ブロックサイズを1バイトにし、3ブロックスキップして、7ブロック読み込む)
$ dd if=file.bin ibs=1 skip=3 count=7 2>/dev/null
defghij

出力ファイルへの出力を最初からではなく特定の位置からにすることができます。これには seek オプションを使います。これは既存のファイルを(途中から)書き換えたり、ファイルを切り詰めたりするのに使うことができます。

$ cat file.bin
abcdefghijklmnopqrstuvwxyz

$ cat out.bin
ABCDEFGHIJKLMNOPQRSTUVWXYZ

# 入力ファイルの3〜5バイト目を出力ファイルの10バイト目以降に出力する
$ dd if=file.bin of=out.bin bs=1 skip=3 count=5 seek=10 2>/dev/null
ABCDEFGHIJdefgh

conv=notrunc オプションを利用すると元ファイルの内容はそのままに、ファイルの途中を書き換えることができます。

$ cat file.bin
abcdefghijklmnopqrstuvwxyz

# ファイルの10バイト目以降に出力する
$ printf '123' | dd of=file1.txt bs=10 seek=1 conv=notrunc

$ cat file.bin
abcdefghi123nopqrstuvwxyz

2. バイナリデータの読み込み

通常バイナリデータは読み込んで処理する必要があります。シェルや awk は バイナリ(特に \0)を扱うことができないため処理を行うには数値に置き換えなければなりません。そのためには od コマンドを使うことになります。

odコマンド - バイナリデータから数値への変換

od コマンドは入力をバイナリで出力するコマンドです。名前から 8 進数(octal)で出力するコマンドのように思えますが、実際には 8 進数、10 進数、16 進数で出力することができます。基数は -t オプションで指定します。

$ echo abcde | od -to1 # 8 進数で出力
0000000 141 142 143 144 145 012
0000006

$ echo abcde | od -td1 # 10 進数で出力
0000000   97   98   99  100  101   10
0000006

$ echo abcde | od -tx1 # 16進数で出力
0000000 61 62 63 64 65 0a
0000006

エンディアンに注意

-tx1 の 1 は何バイト毎に解釈するかを意味しています。例えば 2 バイト単位で解釈する場合には次のようになります。数字の順番が入れ替わっているように見えるのは、リトルエンディアン の CPU だからです。od コマンドの出力は CPU のバイトオーダーによって異なるので注意してください。GNU 版にはエンディアンを指定する拡張機能がありますが POSIX では標準化されていません。

$ echo abcde | od -tx2
0000000 6261 6463 0a65
0000006

$ echo abcde | od -tx2 --endian=big # ビッグエンディアンで出力(非POSIX)
0000000 6162 6364 650a
0000006

1 バイト単位以外で出力する場合にはエンディアンの違いに注意する必要があります。現在のコンピュータの多くはリトルエンディアンで出力は逆順に並ぶため、これを数値として扱う場合には変換を行わなければならなくなりシェルスクリプトでは少々扱いづらいでしょう。結果的に 1 バイト単位で扱うことが多くなると思います。

-An-v の指定

od コマンドは元々人が読むために作られているため、左側のアドレスがあったり同じ値が連続する場合に * に置き換えて出力するという機能があります。これを抑制するには -An(アドレスの非表示)と -v (「*」の置き換えを抑制)を指定する必要があります。-v は同じ値が連続していない場合には機能せず見逃しがちなので注意してください。

$ printf '%01000d' | od -tx1 # 0 を 1000 個出力
0000000 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30
*           ← 同じ値を連続して出力すると読みづらいので「*」に置き換えている
0001740 30 30 30 30 30 30 30 30
0001750

$ echo abc | od -tx1 -An -v # アドレスの非表示と「*」の置き換えを抑制
 61 62 63 0a

特定の位置からや指定サイズの読み込み

場合によってはファイルの途中から読み込んだり、指定したサイズだけ読み込みたい場合もあるでしょう。その場合は -j (スキップ)と -N (サイズ指定)オプションを使うと実現できます。

$ echo abcdefg | od -j2 -N3 -tx1 -An -v
 63 64 65

tr、paste、fold などを使った整形

od コマンドの出力は(アドレス出力を抑制したときに)次のようなものになりますが、環境によって出力は微妙に異なります。

$ echo abc | od -tx1 -An -v # Ubuntu
 61 62 63 0a

$ echo abc | od -tx1 -An -v # macOS
           61  62  63  0a
              ↑ スペース2つ

$ echo abc | od -tx1 -An -v # Solaris
         61 62 63 0a
  ↑タブ

これらはいずれの形式もシェルまたは awk で同じように読み込むことができる形式なので整形する必要は必ずしもありません。しかしながら一行 16 個単位での処理固定で柔軟性がありません。シェルやコマンドの多くは一行単位で読み込むので、一行の個数は一度に処理する個数、つまりチャンクサイズとして機能します。場合によってはその他の個数で読み込みたい場合もあるでしょう。整形したり一行の個数を変更する方法をいくつか例を紹介します。

# 空白一つ区切りに統一
$ od -tx1 -An -v file.txt | tr -ds '\t' ' '
 61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70
 71 72 73 74 75 76 77 78 79 7a 0a

# 区切りなし
$ od -tx1 -An -v file.txt | tr -d '\t '
6162636465666768696a6b6c6d6e6f70
7172737475767778797a0a

# 全てを一行に(区切りなし・末尾改行なし)
$ od -tx1 -An -v file.txt | tr -d '\t\n '
6162636465666768696a6b6c6d6e6f707172737475767778797a0a

# 全てを一行に(区切りなし・末尾改行あり)
$ od -tx1 -An -v file.txt | tr -d '\t ' | paste -sd '\0' -
6162636465666768696a6b6c6d6e6f707172737475767778797a0a

# 一行 20 個単位(区切りなし・末尾改行あり)
$ od -tx1 -An -v file.txt | tr -d '\t\n ' | paste -sd '\0' - | fold -b -w20
6162636465666768696a
6b6c6d6e6f7071727374
75767778797a0a

# 一行 20 個単位(空白区切り・末尾改行あり)
$ od -tx1 -An -v file.txt | tr -ds '\t' ' ' | paste -sd '\0' - | fold -b -w30
 61 62 63 64 65 66 67 68 69 6a
 6b 6c 6d 6e 6f 70 71 72 73 74
 75 76 77 78 79 7a 0a

# 一行 20 個単位(空白区切り・末尾改行なし)
$ od -tx1 -An -v file.txt | tr -ds '\t\n' ' ' | fold -b -w30
 61 62 63 64 65 66 67 68 69 6a
 6b 6c 6d 6e 6f 70 71 72 73 74
 75 76 77 78 79 7a 0a

末尾の改行はあったほうがテキスト形式としてより正確ですが、無くても扱う方法はあるので必須ではありません。末尾の改行を省略すると paste コマンドが不要になり、tr コマンドに統一できるので少し効率が良くなります。また最後に echo を追加するだけで末尾改行を補うことができるので paste を使った書き方はおそらく不要でしょう。このあたりの整形処理は一般的な文字列処理なので他の方法もあると思います。

3. バイナリデータの処理

バイナリデータの読み込んだ後はシェルまたは awk で処理することになります。読み込む形式は大きく2つあり、一つはスペースで区切られた数値の並び、もう一つは区切りなしの数値の並びです。一般的には区切ありの方が扱いやすいです。区切りがない場合は一つの値は固定サイズにしなければならないので 8 進数か 16 進数を使うことになるでしょう。

シェルでの読み込み

シェルでの読み込みは read コマンドを利用します。まずは区切りありの場合です。

echo abc | od -tx1 -An -v | {
  # 全データ読み込み前に処理を行う場合はここに書く

  while IFS= read -r line; do
    set -- $line # $1, $2, $3, ... に1バイトずつセットされる
  done

  # 全データ読み込み後に処理を行う場合はここに書く
}

次に区切りなしの場合です。区切りがないので読み込んだあとの処理が面倒です。

echo abc | od -tx1 -An -v | tr -d '\t ' | {
  # 全データ読み込み前に処理を行う場合はここに書く

  while IFS= read -r line; do
     echo "${line%"${line#??}"}" # 前から 1 バイト(2文字)を出力する
     line=${line#??} # 前から 1 バイト(2文字)を削る
  done

  # 全データ読み込み後に処理を行う場合はここに書く
}

区切りがない場合は文字数から自力で区切らないのいけないのですが、POSIX で標準化された範囲のシェルには「n文字目からm文字を取り出す」ことが簡単にはできません。そのためこのようなコードが必要になります。od コマンドの出力の一行の文字列が長い場合はパフォーマンスが悪くなるので注意が必要です。

シェルに文字列として読み込んだ後はおそらく数値に変換する必要があるでしょう。そのためには算術式展開を利用します。以下に例を示します。

echo abc | od -tx1 -An -v | {
  while IFS= read -r line; do
    set -- $line
    for i in "$@"; do
      echo "0x$i = $((0x$i))"
    done
  done
}

# 補足 この例では頭に 0x をつけて 16 進数として解釈している。頭に 0 をつけて 
# 8 進数として解釈する場合、シェルによっては(POSIX モードにしない限り)
# 10 進数として解釈される。zsh では emulate -R sh、mksh では set -o posix で
# POSIX モードにするか頭に 0 ではなく 8# をつける必要がある。

上記のコードの出力は次のようになります。

0x61 = 97
0x62 = 98
0x63 = 99
0x0a = 10

数値になったので、あとは自由にバイナリ処理を行うことができます。シェルはビット演算を行うこともできるので、あとの処理はそう悩むことはないと思います。

awkでの読み込み

awk は標準でスペース区切りのデータを扱うので、区切りありの出力をそのまま読み込むことができます。

echo abc | od -tx1 -An -v | {
  awk '{ for (i = 1; i <= NF; i++) print $i }'
}

出力は次のようになります。

61
62
63
0a

また、文字列の位置を指定して取り出せるので区切りなしの出力でも簡単に読み取れます。

echo abc | od -tx1 -An -v | tr -d '\t ' | {
  awk '
    {
      len = length($0)
      for (i = 1; i <= len; i+=2) {
        print substr($0, i, 2)
      }
    }
  '
}

ただし一つ問題があり、awk は POSIX で標準化された範囲では 16 進数を簡単に扱うことができません。またビット演算を行うこともできません。 そのためそのような処理が必要な場合には、自分で関数を作る必要があります。先程の例では 16 進数で出力しましたが、awk で処理する場合は 10 進数で区切りありの方が簡単だ思います。16 進数から 10 進数などに変換する場合、計算でもできないことはありませんが、意外と時間がかかるので連想配列を使う方をおすすめします。次の例は連想配列を使い 16 進数から 10 進数に変換する例です。

echo abc | od -tx1 -An -v | tr -d '\t ' | {
  awk '
    BEGIN {
      for (i = 0; i < 256; i++) h2d[sprintf("%0x", i)] = i
    }
    {
      len = length($0)
      for (i = 1; i <= len; i+=2) {
        print h2d[substr($0, i, 2)]
      }
    }
  '
}

4. バイナリデータの書き込み

シェルや awk で数値として読み込み、さまざまな計算を行います。そして最終的にバイナリデータとして出力することになるでしょう。そのためには printf コマンドを使います。awk の printf ステートメントではないので注意してください。

printfの%bはNULL文字を出力できない

まず最初に枝切りとして使えないものの例を紹介します。printf コマンドでバイナリを出力するには以下の 2 つの方法があります。数値は 8 進数表記です。

$ printf '\0\1\2' | od -tx1
0000000 00 01 02
0000003

$ printf '%b' '\0' '\01' '\02' | od -tx1 # %b は頭に 0 が必要
0000000 00 01 02
0000003

先程の例は Ubuntu で実行した場合の例ですが、FreeBSD で実行するとこのように NULL 文字は消えてしまいます。

$ printf '%b' '\0' '\1' '\2' | od -tx1
0000000    01  02
0000002

FreeBSD の printf コマンドの BUGS には次のように %b\0 を文字列の終りを示す文字として扱われると書かれています。一応バグとして扱われているようですが、バグであることに気づいおり、2000 年の FreeBSD 3.5 の時からこの記述はあるようで、互換性を維持するためにこの仕様を変えることはないのでしょう。

BUGS
     The escape	sequence \000 is the string terminator.	 When present in the
     argument for the b	format,	the argument will be truncated at the \000
     character.

ということで、移植性を考える場合 %b で NULL 文字を出力することはできないということです。printf '\0\1\2 のように一つの引数にまとめれば良いだけだと思うかもしれませんが、これはこれで罠があります。その話は後述します。

printf出力のために8進数に変換する

printf コマンドでバイナリを出力するには(数値計算を行っているときの 10 進数から)8 進数に変換する必要があります。10 進数や 16 進数ではだめです。

$ printf '\141\142\143' # 「\」+ 8 進数 の形にする必要がある
abc

$ printf '\x61\x62\x63' # bash、ksh、mksh、zsh で実行した場合
abc

$ printf '\x61\x62\x63' # dash、yash で実行した場合
\x61\x62\x63

シェルで 8 進数で出力する方法として次のようなものを見かけるかもしれませんが、コマンド置換($(...))はサブシェルが生成されるため遅いという注意点があります。

for i in 97 98 99 100 101 102 103; do
  printf "\\$(printf '%03o' "$i")"
  # printf '%03o' "$i" で 141 142 143 ・・・ のように一つづつ
  # 変換した後、printf "\\141" のようにしてバイナリで出力
  # 一つづつ変換してるので、その数だけサブシェルが生成され遅い
done

この問題は次のようにすることで改善できます。

# 一回のサブシェル生成で処理可能
printf "$(printf '\\%03o' 97 98 99 100 101 102 103)"

# 別解 サブシェルの生成は二回だがより柔軟にコードが書ける
# サブシェルはパイプの前後で最大二回生成される
# (シェルによってはパイプラインの最後はサブシェルにはならない)
{
  echo "[" # 前に文字を出力したり
  for i in 97 98 99 100 101 102 103; do
    printf '\\%03o\n' "$i"
  done
  printf '%s\n' "]\\012" # 後ろで文字を出力したり
} | {
  while IFS= read -r line; do
    printf "$line"
  done
}

また 10 進数から 8 進数への変換関数を使う方法もあります。これはサブシェルを使用しないので(データの個数が十分少なければ)速いです。

# 0 ~ 255 の値のみを受け付ける
dec2oct() {
  eval "$1=$(((($2 % 256) / 64) % 8))$((($2 / 8) % 8))$(($2 % 8))"
}

dec2oct ret 83
echo "$ret" # => 123

8進数表記は必ず3桁で書く

printf コマンドで 8 進数表記を書くときに数字の部分は必ず3桁である必要があります。もし後に別の数字が来ると意図しない解釈が行われてしまうからです。

printf '\1a'  # "\1" + "a" と解釈される ・・・ (1)
printf '\10'  # "\10" と解釈されてしまう
printf '\0010'  # (1) と同じように "\001" + "0" と解釈される

POSIX の 規定では、数値の部分は 1~3 桁の数値ということになっています。

"\ddd", where ddd is a one, two, or three-digit octal number,

実はこの解釈は古いシェルでは異なっており、bash 2 系(2002 年)や zsh 4.2系(2004 年)では頭 0 を含めた最大 4 桁を 8 進数と解釈していました。現在のシェルでは 3 桁の 8 進数にしておけば問題ありません

余談ですが(今回は関係ない)%b の場合は仕様が異なり、頭0 に続いて 0~3 桁(合計で1~4桁)なので注意が必要です。

"\0ddd", where ddd is a zero, one, two, or three-digit octal number

printfの特殊文字\と%はエスケープする

\% そのものを出力するには同じ文字を繰り返して書く必要があります。

$ printf '\\'
\

$ printf '%%'
%

mkshではprintfではなくprintを使う

mksh では printf コマンドはシェルビルトインコマンドではありません。外部コマンドは遅いので避ける必要があります。代わりに print コマンドを使用します。print コマンドは printf と仕様が異なりますが、今回必要とする 8 進数をバイナリに出力するためだけなら同じように使えます。

シェルが mksh かどうかは KSH_VERSION 変数を参照するのが一番簡単でしょう。KSH_VERSION 変数は mksh だけではなく ksh でも設定されていますが、print コマンドは mksh の祖先でもある ksh で実装されたものなので、ksh でも利用可能です。次のような感じで使い分けることができます。

if [ ${KSH_VERSION+x} ]; then
  print -n "..."
else
  printf -- "..."
fi

awkのprintfはNULL文字を出力できない

ここまではシェルで処理したデータをシェルでバイナリ出力する話でしたが、awk で処理する場合には awk の printf ステートメントを使って直接バイナリ(NULL 文字)を出力することはできないので注意する必要があります。ただし gawk だけは例外で NULL 文字を扱うことができます。

# mawk は Debian / Ubuntu 系の標準の awk
$ mawk 'BEGIN { printf "\000" }' | od -tx1
0000000

# macOS の標準の awk は nawk(one true awk)
$ nawk 'BEGIN { printf "\000" }' | od -tx1
0000000
# 注意 Debian / Ubuntu 系では nawk は origial-awk という名前で提供されている
# ただし nawk というコマンドは original-awk のエイリアスにはなっていないので
# 直接 original-awk を使用すること

# gawk は NULL 文字を出力できる
$ gawk 'BEGIN { printf "\000" }' | od -tx1
0000000 00
0000001

そのため awk の system 関数を使って、外部コマンドの printf コマンドを呼び出すか、8 進数表記を含む文字列を出力し、printf コマンドに渡す必要があります。

$ awk 'BEGIN { v="\123"; system("printf "v) }' | od -to1
0000000 123
0000001

# 参考 この書き方で 8 進数表記で出力できる
$ awk 'BEGIN { printf "\\123" }'
\\123

$ printf "$(awk 'BEGIN { printf "\\123" }')" | od -to1
0000000 123
0000001

$ awk 'BEGIN { printf "\\123\n" }' | {
  while IFS= read -r line; do
    printf "$line"
  done
} | od -to1
0000000 123
0000001

# xargs でバックスラッシュが解釈されるため二重にエスケープが必要
# この書き方はデータが大きいときに問題がある 5. 参照
$ awk 'BEGIN { printf "\\\\123" }' | xargs -n 1 printf | od -to1
0000000 123
0000001

5. バイナリデータの出力速度の改善

小さいデータであれば、シェルでバイナリ処理を行う方が便利だと思いますが、大きなデータを処理する場合は awk の速度の速さのほうが有利でしょう。この間 Base64 を扱う方法をシェルスクリプトで実装しましたが、そのような場合です。

基本的な考え方はバイナリデータを読み取って awk に渡し、awk で処理して出力するのですが、データが大きいとパフォーマンスが問題になってきます。その解決方法も一筋なわけでは行きません。

外部コマンドのprintfは呼び出しが遅い

シェルスクリプトのパフォーマンスを考える時、外部コマンドの呼び出しは遅いということに注意する必要があります。外部コマンドの呼び出し回数が多いと数百倍以上のレベルで遅くなります。したがってどれだけ外部コマンドの呼び出し回数を減らすかがパフォーマンス向上の鍵になります。

xargsとARG_MAXの制限

少し話を戻して printf コマンドの %b ですが、もし FreeBSD でNULL 文字を扱うことができたなら、xargs を使って次のようにしてパフォーマンス向上を図ることができます。しかし %b は NULL 文字を出力を使えないのでこの方法は使えません。printf コマンドは引数を一つにして呼び出す方法しか使えないわけです。

$ awk 'BEGIN { for (i=0; i < 100; i++) print "\\\\123" }' | xargs printf '%b'
SSSSS...  # \123 は S

# 上記は以下のような呼び出しが行われる
printf '%b' '\123' '\123' '\123' '\123' '\123' ...

少し補足になりますが、この方法でパフォーマンスが向上する理由は xargs は複数の引数をまとめて(printf)コマンドを呼び出すからです。ただし xargs は引数をすべてまとめるわけではありません。最大いくつまで引数をまとめるかは(オプションと)ARG_MAX のサイズに依存します。ARG_MAX は最大どれだけのサイズの引数を渡すことができるかという OS の制限値で OS 毎に異なります。

ARG_MAX の値は getconf ARG_MAX で取得できますが、ARG_MAX のサイズだけ引数を渡せるわけではありません。大雑把に計算式を書くと次のようになります。

(環境変数のサイズ + 引数を含めたコマンドラインのサイズ) < ARG_MAX

つまり環境変数のサイズが大きければ、渡せる引数の最大数も減るということです。この話はここ で詳しく検証しています。

# 一万個の引数を渡せる
$ /usr/bin/printf "%b" {1..100000} > /dev/null

# 環境変数が大きければ、渡せる引数の数も減る
$ export A=$(printf '%01000000d')
$ /usr/bin/printf "%b" {1..100000} > /dev/null
/usr/bin/printf: 引数リストが長すぎます

また引数一つに対してどれだけのバイト数を消費するかなどの細かい規定は POSIX にはありません。結局のところ ARG_MAX を用いて最大どれだけの引数を渡せるかを計算するのは困難 ということになります。sysconf には次のように書かれています。

ARG_MAX を使うのは難しい、なぜなら、 exec(3) の引き数領域 (argument space) のうち
どれくらいが ユーザーの環境変数によって使われるかは分からないからである。

Linuxの一つの引数サイズの制限

どちらにしろ printf '%b' は使えないため、printf の呼び出しは一つの引数にまとめる必要があります。しかし Linux には ARG_MAX とは別に一つの引数サイズにも制限があります。私の環境の Linux では ARG_MAX の値は 2097152 です。およそ 2 MB の引数を渡せるように思えるかもしれませんが、実際には Linux では一つの引数は 128 KB までしか渡せません

$ getconf ARG_MAX # 最大 2 MB の引数を渡せる?
2097152

$ /usr/bin/printf "$(printf '%0131072d' 0)" > /dev/null # 128 KB が限界
-bash: /usr/bin/printf: Argument list too long

このような制限は OS や設定によって異なり、移植性がある制限の計算方法はありません。総括すると ARG_MAX を使って最大どれだけサイズを渡せるかを計算するのはまず無理だということです。そもそも getconf コマンドは POSIX コマンドですが環境によってはインストールされていないこともあるのであまり依存はしたくありません。

xargs+shを使った制限の完全な解決方法

さてここまでの話から以下の結論が導き出されます。

  • printf コマンドは一つの引数を使ってバイナリを出力しなければならない
  • printf コマンドの一つの引数にはサイズ制限がある

パフォーマンスの向上には、外部コマンドの printf コマンドの呼び出し回数を減らすことが重要ですが、上記の2つの制限が壁になってきます。

さてここまで話を濁してきましたが、引数の制限があるのは外部コマンドだけです。シェルビルトインコマンドには制限はありません。外部コマンドの printf コマンドを使う場合には制限がありますが、シェルビルトインの printf コマンドであれば制限はないのです。そこで私が思いついた回避策が xargs とシェルの printf を使った回避策です。つまり以下のような方法を使うということです。

xargs sh -c 'IFS=; printf -- "$*"' prog
# prog は $0 に相当するダミーのプログラム名

xargs コマンドによって使用可能な最大個数の引数が sh コマンドに渡されます。そしてシェル(sh コマンド)は、それらを区切り文字なし(IFS=)で結合し、制限のないシェルビルトインコマンドの printf で出力するという方法です(mksh では printf が外部コマンドなので print を使う必要があります)。

この方法を使うことで、外部コマンド(この例では sh コマンド)の呼び出し回数を減らしながら、Linux の一つの引数のサイズ制限の回避をシンプルに両立させることができます。

さいごに

ということで、シェルスクリプトでバイナリを扱える方法でした。

正直って本当に面倒です。C 言語で書いたほうが思ったように書けます。コードもシンプルになりパフォーマンスも高くなるでしょう。

まあそれでもシェルスクリプトでバイナリは扱えるということです。この記事の方法を応用して OS に標準でインストールされている POSIX コマンドだけを使ったシェルスクリプト版の Base64コマンドを実装しました(sh-base64)。全部で100行もありませんし、シェル関数として作っているので「あなたのシェルスクリプト」に含めて配布することが容易で、シェルスクリプト一つをコピーするだけで、その他に何もインストールせずにどの環境でも動くようにすることができます。

同じようなことをしたい人思っている人のお役に立てれば幸いです。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?