4
2

More than 3 years have passed since last update.

POSIX準拠のシェルスクリプトでバイナリデータを扱う

Last updated at Posted at 2020-04-05

はじめに

シェルスクリプトでバイナリデータを扱う方法は以下のようにすでにいくつか記事があります。

本当は別の記事を書いてバイナリデータを扱う方法については省略しようと思っていたのですが、改めてやってみると色々と問題が出てきて話が長くなりそうなので別でまとめることにしました。

基本方針

POSIX 準拠の機能だけで実装する。外部コマンドにはなるべく頼らず速度よりも可搬性を重視、存在する全ての POSIX 準拠シェルで実際に動作するようにする。Bourne Shell は POSIX 準拠ではなく古くて殆ど使われてないと思うため考慮しない。

シェルスクリプトでバイナリを扱う方法

まずPOSIX 準拠の範囲においてシェルスクリプト単体ではバイナリデータを扱えません。問題なのは NULL 文字 (\0) で zsh では変数に NULL 文字を入れられますが、その他のシェルでは入れることが出来ません。そこで POSIX で標準化されている od コマンドを使用してシェルスクリプトと相性がいい 8 進数に変換します。本当は od コマンドも使いたくなかったのですがこれだけはどうしようもないようです。

$ cat data.txt
Hello World

$ od -t o1 -v data.txt
0000000 110 145 154 154 157 040 127 157 162 154 144 012
0000014

od コマンドの互換性問題

さて私は POSIX 準拠にこだわっていますが、なにも縛りプレイをしたいのではなく「実際に存在する多くの環境で動くようにする」ために POSIX 準拠にしています。しかしながら世の中には POSIX 準拠のコマンドだけを使用していても対応できない場合もあります。(そういった事があるため、外部コマンドも使用したくないのです。)BusyBox ベースの OS がその代表例でインストールするコマンド(アプレット)を選択できるため OpenWrt など一部の環境では od コマンドがインストールされていないことがあります。そういった環境でも代わりに hexdump がインストールされていたりするのでその場合は対応が可能です。また BusyBox の古いバージョンでは出力のアドレス部分を非表示にする od -A n や 8 進数表示のための -t o1 に非対応だったり od -b にバグがあったりしました。POSIX に準拠していなかったりバグが原因だったりするので、切り捨ててもいいと思うのですが、動くコマンドを使った関数を定義すればいいだけなので対応するのはさほど難しくありません。調べるのが面倒なぐらいです。

if od -A n -t o1 -v /dev/null >/dev/null 2>&1; then
  od_command() { od -A n -t o1 -v; }
elif hexdump -e '16/1 " %03o" "\n"' -v /dev/null >/dev/null 2>&1; then
  od_command() { hexdump -e '16/1 " %03o" "\n"' -v; }
elif od -b -v /dev/null >/dev/null 2>&1; then
  od_command() { od -b -v | while IFS=" " read -r a o; do echo "$o"; done; }
  # od コマンドに互換性がなく hexdump も入ってない場合の対応。そういう環境が
  # 存在するかは不明だが最低限の機能だけを使用しているためどこでも動くことが期待できる
  # -A n に相当するアドレス部の除去はシェルスクリプトで行っている
else
  echo "od command not found" >&2
  exit 1
fi

これで可能な限り多くの環境でほぼ同じ形式で 8 進数出力を行う od_command 関数を手に入れました。(正確には桁揃えのためのスペースの数が異なりますがあとのコードでその違いが吸収されるようなコードを書いています。)

シェルの8進数対応問題

バイナリデータとして操作する場合、数値として何かしらの計算を行うことが多いのではないでしょうか?算術式展開を使用すると od コマンドの出力結果を 8 進数として扱うことが出来ます。算術式展開はシェルの機能で POSIX 準拠シェルであればどのシェルでも使えます。単純な整数の計算程度で exprbc といった外部コマンドを使用する必要はありません。(外部コマンド呼び出しは時間がかかるため、安易に使ってしまうと遅くなってしまいます。)

たとえば 8進数で 110 の場合 n=$((0110)) と頭に 0 をつけることで 72 という数値を得られます。ただしこの書き方に対応していないシェルが存在します。(set -o posix していない)mksh と (emulate -R sh していない)zsh と 古い pdksh です。POSIX 準拠モードに変更すれば動きますが、どんなモードでも動くようにしたいので変更しません。これらのシェルでは代わりに n=$((8#110)) という表記に対応しているので、n=$((0110)) に対応しているかどうかで頭につける文字を変更します。

# 頭 0 に対応していないシェルでは $((010)) は 8 ではなく 10 と解釈されます
[ $((010)) -eq 8 ] && OCT="0" || OCT="8#"

oct="110"

n=$(( ${OCT}${oct} )) # 全てのシェルで n = 72 です

仮実装

上記のコードを利用して、バイナリデータとして読み取って計算するコードを実装します。計算の内容は特に意味がないですが ファイルの内容を一バイトずつ XOR していくだけのプログラムです。

xor.sh
#!/bin/sh

[ $((010)) -eq 8 ] && OCT="0" || OCT="8#"

if od -A n -t o1 -v /dev/null >/dev/null 2>&1; then
  od_command() { od -A n -t o1 -v; }
elif hexdump -e '16/1 " %03o" "\n"' -v /dev/null >/dev/null 2>&1; then
  od_command() { hexdump -e '16/1 " %03o" "\n"' -v; }
elif od -b -v /dev/null >/dev/null 2>&1; then
  od_command() { od -b -v | while IFS=" " read -r a o; do echo "$o"; done; }
else
  echo "od command not found" >&2
  exit 1
fi

# 110 145 154 154 157 040 127 157 162 154 144 012 という並びを1バイト1行に変換
od_serialize() {
  while IFS= read -r line; do
    eval "set -- $line"
    # set することで桁揃えのスペースの違いを吸収している
    # eval は zsh の SH_WORD_SPLIT 対策と
    # IFS にスペースが入っていない場合への対応のため
    for oct; do
      echo "$oct"
    done
  done
}

od_command | od_serialize | {
  value=0
  while IFS= read -r oct; do
    value=$(($value ^ ${OCT}${oct}))
  done
  printf '%2x\n' "$value"
}

od_serialize 関数はデータを処理しやすくするために 1 バイト 1行 の形に整形しています。

ここまでの内容で終わっていれば記事にするつもりもなかったのですが・・・

ksh が異様に遅い

軽くベンチマークしていて速いと定評のある ksh でなぜか異様に遅いことが判明しました。Linux 上ではそこまでひどくないのですが、WSL1 上だと特に酷いです。

# data.dat は 100KB のファイル
$ time sh xor.sh < data.dat
WSL1 ash (busybox) bash dash ksh mksh yash zsh
real 3.256s 2.793s 1.379s 11.368s 1.589s 4.455s 3.856s
user 1.266s 2.609s 0.594s 2.172s 1.141s 1.906s 1.984s
sys 5.094s 3.031s 2.109s 11.016s 1.969s 7.234s 5.141s
Linux ash (busybox) bash dash ksh mksh yash zsh
real 0.692s 1.071s 0.371s 2.429s 0.670s 1.220s 0.969s
user 0.762s 1.521s 0.420s 1.306s 0.865s 1.435s 1.202s
sys 0.556s 0.393s 0.328s 1.557s 0.291s 0.749s 0.479s

こんなものなんでは?と思うかもしれませんが ksh は通常、他のシェルよりも速いことが多いので異常です。かかってる時間を見てみると sys の割合が多いです。WSL1 では sys(カーネル)に相当する部分が遅いことがしばしばあるので、このコードは他に比べてカーネルが処理する部分が多いのでしょう。

そこで色々実験してみると以下の形が遅いことがわかりました。

# oct.txt は10万行のファイル
$ time ksh -c 'cat oct.txt | while IFS= read -r oct; do :; done'
real    0m7.936s
user    0m1.063s
sys     0m6.969s

# この形式であれば速い
$ time ksh -c 'while IFS= read -r oct; do :; done < oct.txt'
real    0m0.249s
user    0m0.234s
sys     0m0.016s

ただしパイプを使ったからと言って必ずしも遅くなるとは限らず、ちゃんとした理由までつかめてないのですが、どうもパイプを使うなどしてサブシェルで read すると極端に遅くなる気がします。パフォーマンスは重視してないとはいえ、この差はちょっと見過ごしたくないのでパフォーマンス改善することにしました。

パフォーマンス改善

処理しやすくするために od_serialize で1バイト1行の形式にしていましたが、これによって 1 行が 16 行のデータなってしまっていたので、これをやめます。パイプ(標準入出力)で渡していたデータが少なくなるので他のシェルでも効果が期待できます。

#!/bin/sh

[ $((010)) -eq 8 ] && OCT="0" || OCT="8#"

if od -A n -t o1 -v /dev/null >/dev/null 2>&1; then
  od_command() { od -A n -t o1 -v; }
elif hexdump -e '16/1 " %03o" "\n"' -v /dev/null >/dev/null 2>&1; then
  od_command() { hexdump -e '16/1 " %03o" "\n"' -v; }
elif od -b -v /dev/null >/dev/null 2>&1; then
  od_command() { od -b -v | while IFS=" " read -r o o; do echo "$o"; done; }
else
  echo "od command not found" >&2
  exit 1
fi

od_command | {
  value=0
  while IFS= read -r line; do
    eval "set -- $line"
    for oct; do
      value=$(($value ^ ${OCT}${oct}))
    done
  done
  printf '%2x\n' "$value"
}
WSL1 ash (busybox) bash dash ksh mksh yash zsh
real 3.256s 2.793s 1.379s 11.368s 1.589s 4.455s 3.856s
2.780s 1.676s 1.125s 0.599s 1.343s 3.850s 1.675s
user 1.266s 2.609s 0.594s 2.172s 1.141s 1.906s 1.984s
0.516s 0.844s 0.094s 0.422s 0.594s 0.703s 0.406s
sys 5.094s 3.031s 2.109s 11.016s 1.969s 7.234s 5.141s
2.438s 0.953s 1.188s 0.188s 0.938s 3.438s 1.500s
Linux ash (busybox) bash dash ksh mksh yash zsh
real 0.692s 1.071s 0.371s 2.429s 0.670s 1.220s 0.969s
0.594s 0.755s 0.328s 0.417s 0.506s 0.853s 0.487s
user 0.762s 1.521s 0.420s 1.306s 0.865s 1.435s 1.202s
0.414s 0.666s 0.223s 0.383s 0.417s 0.564s 0.396s
sys 0.556s 0.393s 0.328s 1.557s 0.291s 0.749s 0.479s
0.200s 0.128s 0.148s 0.060s 0.129s 0.316s 0.119s

上の段が修正前。下の段が修正後です。全体的に改善されていますが ksh が劇的に改善されました。WSL 1 上では約 18 倍の速度になっています。思えばデータの行数が 1/16 になっているわけで、ある意味自然な数字とも言えます。ちなみにこのコードで od_command | のパイプ先をサブシェルに変更すると大きく速度が低下します。

od_command | {
・・・
}
# ↓ このように書き換える
od_command | (
・・・
)

やはり ksh は他のシェルに比べてサブシェル間での標準入出力のやり取りが苦手なのかもしれません。

メンテナンス性改善

さてパフォーマンスは改善されましたが、od_command 関数の出力を解釈する処理をそのまま埋め込んだので、メンテナンス性は悪くなりました。どうにか改善できないでしょうか?

外部コマンドとのやり取りでは標準入出力でデータを渡すしかありませんが、シェル内ではシェル関数を使ってデータを渡すことも出来ます。関数呼び出しのオーバーヘッドがありますがそこまで速度は低下しません。そこでコールバック関数を使う方法に書き換えます。

xor.sh
#!/bin/sh

[ $((010)) -eq 8 ] && OCT="0" || OCT="8#"

if od -A n -t o1 -v /dev/null >/dev/null 2>&1; then
  od_command() { od -A n -t o1 -v; }
elif hexdump -e '16/1 " %03o" "\n"' -v /dev/null >/dev/null 2>&1; then
  od_command() { hexdump -e '16/1 " %03o" "\n"' -v; }
elif od -b -v /dev/null >/dev/null 2>&1; then
  od_command() { od -b -v | while IFS=" " read -r o o; do echo "$o"; done; }
else
  echo "od command not found" >&2
  exit 1
fi

read_as_binary() {
  od_command | {
    while IFS= read -r line; do
      eval "$1 $line"
    done
    "$2"
  }
}

# --- ここより上は汎用コード ---

callback() {
  for i; do
    value=$(($value ^ ${OCT}${i}))
  done
}

finished() {
  printf '%02x\n' "$value"
}

value=0
read_as_binary callback finished
# この場所でのvalueの値がどうなるかはシェル依存
# od_command | のパイプ先がサブシェルとなるシェルでは 0 のままだが
# ksh などでは計算結果になっている場合があるので注意

read_as_binary 関数は汎用的な関数なので共通コードとして再利用できます。callback 関数はある程度のバイトの塊(通常 16バイト)を引数にして呼び出されます。すべての処理が終わったら finished 関数が呼び出されます。本質的な処理を行っている callback 関数と finished 関数を書き換えるだけで他の処理を行うことが出来ます。

再度パフォーマンスチェックです。

WSL1 ash (busybox) bash dash ksh mksh yash zsh
real 2.780s 1.676s 1.125s 0.599s 1.343s 3.850s 1.675s
2.758s 1.743s 1.092s 0.590s 1.349s 3.815s 1.748s
user 0.516s 0.844s 0.094s 0.422s 0.594s 0.703s 0.406s
0.500s 0.859s 0.188s 0.438s 0.469s 0.750s 0.484s
sys 2.438s 0.953s 1.188s 0.188s 0.938s 3.438s 1.500s
2.516s 1.172s 1.156s 0.203s 1.141s 3.266s 1.547s
Linux ash (busybox) bash dash ksh mksh yash zsh
real 0.594s 0.755s 0.328s 0.417s 0.506s 0.853s 0.487s
0.596s 0.793s 0.316s 0.435s 0.512s 0.885s 0.536s
user 0.414s 0.666s 0.223s 0.383s 0.417s 0.564s 0.396s
0.370s 0.681s 0.242s 0.432s 0.448s 0.564s 0.381s
sys 0.200s 0.128s 0.148s 0.060s 0.129s 0.316s 0.119s
0.246s 0.148s 0.102s 0.029s 0.107s 0.349s 0.182s

このようにコールバック関数を使っても殆どの場合、誤差程度しか違いがありません。ただし zsh は少し注意が必要です。eval や シェル関数呼び出しが他のシェルよりも遅いため、回数によっては影響が大きくなる場合があります。しかし共通コードとして関数にしているため特定のシェル向けのチューニングを行うのも簡単でしょう。例えば zsh 専用の read_as_binary 関数です。

read_as_binary() {
  od_command | {
    while IFS= read -r line; do
      IFS= read -r line2
      IFS= read -r line3
      IFS= read -r line4
      # ${=line} という書き方は zsh 専用で SH_WORD_SPLIT を ON にした場合と同じように引数を処理する
      # 関数呼び出しが遅いので 16 × 4 = 64 個の引数にまとめてから呼び出している
      "$1" ${=line} ${=line2} ${=line3} ${=line4}
    done
    "$2"
  }
}

これで WSL1 上の zsh で 1.748s だったのが 1.505s に改善されました。zsh は変数に NULL 文字を入れられるわけで od コマンドを使わない実装もあるかもしれません。他にも手はあると思いますが極端に遅くなければ問題ないと思ってるので深追いはしないこととします。

さいごに

それにしてもたかだか 100KB 程度でこんなに遅いわけで、やっぱりシェルスクリプトでバイナリデータを扱うものではないですね。使うならば小さいデータに使うことになると思います。使える場面は少ないと思いますが一つ例を。/proc/1/cmdline というファイルにはプロセスID 1 を実行したときのコマンド名と引数が以下のような形式で取得できるのですが

$ cat /proc/1/cmdline
/lib/systemd/systemd --system --deserialize 58

実はこの引数は NULL 文字区切りなのです。

$ cat /proc/1/cmdline | hexdump -C
00000000  2f 6c 69 62 2f 73 79 73  74 65 6d 64 2f 73 79 73  |/lib/systemd/sys|
00000010  74 65 6d 64 00 2d 2d 73  79 73 74 65 6d 00 2d 2d  |temd.--system.--|
00000020  64 65 73 65 72 69 61 6c  69 7a 65 00 35 38 00     |deserialize.58.|
0000002f

こういったデータとしては短いけれど、バイナリデータとして扱わなければいけない。というものをシェルスクリプトで使いたいという時には使えると思います。

4
2
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
2