64
51

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

シェルスクリプトはバイナリを扱えない。さてどうしよう……

Last updated at Posted at 2015-02-05

この投稿は、POSIX原理主義者以外には何の価値もない変態記事だ。また、GNU AWKやzshを常用している人にも価値がない。以上に該当する方は、黙ってこの記事を閉じよう。

シェルスクリプトはバイナリデータを読み書きできない

POSIX原理主義者である私は、Base64エンコード/デコードをするためにPOSIX標準ではないbase64コマンドに依存するのが嫌だった。そこで、POSIX標準コマンドだけで作ってしまえと思ったのだが、そこには大きな壁が立ちはだかった。

シェルスクリプトはバイナリデータを加工できないのである。全然扱えないのではなく、まともに扱えないと言うべきかもしれないが……。

Base64コーデックを作りたければ、バイナリデータの読み書きができなければならない。ところが次のコマンドを手持ちの環境で実行してみればまともに扱えないことがよくわかる。

AWK(非GNU版)にバイナリコードを読ませる
$ printf 'This is \000 a pen.\n' | awk '{print $0;}'
This is                    # null終端扱いされて、a pen.が無かったことに…OTL
$ 
シェル変数(zsh以外)にバイナリコードを読ませる
$ str=$(printf 'abc\000def')
$ echo "$str"
abcdef                     # お、うまくいったか?
$ echo ${#str}
6                          # あぁ、nullが取り除かれて6文字になってる…orz
$ 

つまり、AWKもシェルも、変数にnull(0x00)を格納できないということだ。ただし、ここまでも何回か記しているようにGNU版AWKとzshはできるのだが……。

主なコマンドのバイナリデータ対応状況(独自調査)

ここで現状を整理しよう。「全然扱えない」ではなく、「まともに」と言い直したが、コマンドによって扱えるものもある。

バイナリデータ取り扱い不可 バイナリデータ取り扱い可
awk bash cut echo grep sed sh sort xargs awk(GNU版) cat dd head od printf tail tr wc

実装によっても違うだろうが、だいたいこんな感じだと思う。その主な原因は、データを行という単位に処理する設計であるかどうかによる。あぁ主要なコマンドが非対応じゃないか、もうだめだー。他言語に乗り換えよう。

いや、POSIX原理主義者はそんなことで諦めてはいけない!非対応コマンドはあるが対応コマンドもあるじゃないか!

バイナリデータ対応コマンドで受けて、テキストデータで処理し、バイナリデータで吐き出してやればいい!

というわけでこの方針に基づき、POSIX原理主義を貫きながらバイナリデータをさばくノウハウを教えよう。そして自作のcatやbase64コマンドをもって、そのノウハウが口だけではないことを示す。

Part1. バイナリデータを受け取るには

さぁまずはデータの受け取り方だ。でもこれは簡単。odコマンドでバイナリデータをダンプして、あとは都合よく加工すればいい。

odコマンドに渡すべきオプション

ダンプをとると普通はアドレスも付いてくる。しかし、純粋なデータストリームにしたいならアドレスを非表示にするオプション-A nをつけておくとよいだろう。

また、ファイルや標準入出力のバイナリデータの最小単位はバイトであるからダンプは1バイト単位になるようにすべきだし、また無駄にテキストファイルサイズを増やしてパフォーマンスを下げないためには16進表現で出力する方がよいだろう。この場合-t x1というオプションをつける。

最後に、-vというオプションも付けておくこと。これがないと同一の値がある程度連続した場合に表示をはしょることがあるからだ。

まとめると、バイナリデータをシェルスクリプトの最初には次の1行を書くとよい。

$ od -A n -t x1 -v <ココにファイル名>

trコマンドで都合のよいテキストデータ列に

odコマンドの出力はスペースや改行などで人間向けにレイアウトされているが、機械処理には無駄なので次にやることは無駄な文字列を削ぐ作業だ。それにはtrコマンドを使うとよい。

必要な文字は"0"~"9"と"a"~"f"の16種類であるので、その他は取り除く。そうすれば、後続のコマンドでは2文字ずつ処理していけば済むようになる。

ただし気をつけなければならないのは、改行コードを無闇に除去してはいけないということ。sedやAWK、grepなどのテキスト処理コマンドの処理は行単位が基本であるため、とにかく1行(改行コードが出てくるかファイル終端に達するかするまで)丸ごと読み込まないと作業をしないのだ。だからtrで改行コードまで取り除いてしまうと、次のテキスト処理系コマンドは全部読み終えるまで後続のコマンドにデータを渡さず、パイプが詰まってしまう。UNIXらしい並列処理が効かなくなるうえ、メモリもバカ喰いする。

というわけで、odの次に繋げるtrコマンドは、次のようにするのがよいだろう。

tr -Cd '0123456789abcdef\n'

ところでなぜ文字の範囲を'0-9a-f'と書かないのかというと、それはSystem V系のtrとBSD系のtrで書式が違い、可搬性が損なわれてしまうからだ。POSIXでは後者の仕様が規定されているが、POSIX原理主義は可搬性確保のために貫いているので、POSIX外の仕様だからといって簡単に切り捨てはしないのだ。

あとは煮るなり焼くなり

ここまでくればもう何も困ることは無いので、sedなりawkなり好きなコマンドを使って煮るなり焼くなり思いのまま。もし、2文字(1バイト)ずつ改行しておきたいというならtrの次に下のまとめに示してあるようなsedを書くとよいだろう。ちなみにLFという変数を介してsedを綺麗に書くテクニック(sedコマンドで文字列を改行に置換する、しかもスマートに置換する)を利用している。

シェルスクリプトでバイナリデータを読み込む(ここまでのまとめ)
LF=$(printf '\\\n_'); LF=${LF%_}

od -A n -t x1 -v <ココにファイル名> |
tr -Cd '0123456789abcdef\n'        |
sed "s/../&$LF/g"

Part2. バイナリデータを書き出すには

次は出力。これは結構面倒だが、printf用のフォーマットで出力文字列を生成し、printfに生成させるというやり方をする。ここでいうprintfはAWKのprintf関数ではなくコマンドのprintfだ。(GNU版は別だが、AWKのprintfはnullを出力できない)

AWKで一部文字をエスケープして扱う

AWKの場合、null(0x00)を自力で出力することができないため、printfコマンドが解釈できる形の文字列として出力しなければならない。

大多数の文字はそのまま出力できるが、一部はprintfで改変を受けてしまうし、一部はprintfに伝わる前に改変を受けてしまうので、そういった文字は全てエスケープして渡さなければならない。下記の表にその一覧を示す。

文字 AWK内での表現 文字列長 エスケープする理由
% "%%" 2 printfのメタ文字だから
\ "\\\\\\\\" 4 printfやシェルのメタ文字だから
null "\\\\000" 5 テキストとして表現できないから
LF "\\\\n" 3 シェルに区切り文字扱いされてしまうから
CR "\\\\r" 3 シェルに区切り文字扱いされてしまうから
タブ "\\\\t" 3 シェルに区切り文字扱いされてしまうから
垂直タブ "\\\\v" 3 シェルに区切り文字扱いされてしまうから
改ページ "\\\\f" 3 シェルに区切り文字扱いされてしまうから
半角空白 "\\\\040" 5 シェルに区切り文字扱いされてしまうから
" "\\\"" 2 シェルに引数の囲み文字扱いされてしまうから
' "\\'"'"'" 2 シェルに引数の囲み文字扱いされてしまうから
- "\\\\055" 5 この文字が先頭に来るとprintfにオプション扱いされてしまうから
0~9 "\\\\060"~"\\\\072" 5 直前に\0oo(ooは2桁の8進数)という文字列があると一部環境(MacOS X等)で誤変換されてしまうから

尚、このエスケープを反映したサンプルコードをこの投稿の最後に例示する。

xargs経由で渡すための工夫

printfコマンドは、残念ながらフォーマット文字列を標準入力から受け取ることができない。そうなると引数として渡さざるを得ないというわけでxargsコマンドの出番なのだが、xargsコマンドを使うとなったらいくつか制約がある。

引数の文字列長がARG_MAXを超えられない

一つ目の制約はこれである。1行のコマンドとして許容される文字列長(ARG_MAX)を超えるようであれば、超えないように文字列を分割し、printfを複数回起動しなければならない。

そこで先程のAWKで出力する際に、エスケープしている状態での文字列長を記憶しておき、その累積値(及び"printf "というコマンドの文字列長の合計)がARG_MAXを超えてしまう前に一旦改行コードを出力しなければならない。上記の表に文字列長を記したのはそういう理由である。

尚、ARG_MAXはgetconfというPOSIXの範囲のコマンドで簡単に調べられる。

だが!!! どういうわけか、LinuxでもFreeBSDでも、ARG_MAXより4000バイトくらい小さい値にしないとxargsがエラーを返す。 次のコマンドを実行してみればよくわかる。

ARG_MAXからそこそこ小さくても怒られるこの理不尽さ……
$ getconf ARG_MAX
262144               # ←FreeBSD 10.1だと262144バイトらしい

# そこでARG_MAXより4698バイト少ない"0"を流し込むと
$ printf '%0257446d' 0 | xargs echo >/dev/null
xargs: insufficient space for argument  # ARG_MAXより十分小さいのに怒られる
$ 

仕方が無いので私はARG_MAX値の半分(そこまでしなくてもよいとは思うが)だけを利用するようにした。

標準入力から渡ってくる文字列の最後に区切り文字を必ずつける

これは「どの環境でも使えるシェルスクリプトを書くためのメモ 」の中でも書いたが、一部のxargs実装は渡される文字列の末端にもスペースや改行の区切り文字を要求するものがある。(それが無いと、最後の文字列は無視される)

これは具体的に言うとAIXのxargs実装なのだが、そういう環境への配慮もPOSIX原理主義者なら怠ってはならない。

できた。シェルスクリプト版catコマンド

以上のノウハウを駆使し、POSIXの範囲で実装したシェルスクリプトがこちら。標準入力からバイナリファイルを読み込んで一旦テキスト形式に変換した後、元のバイナリ形式に戻すだけのコード。結果だけ見ればcatみたいなものだ。

bincat.sh
#! /bin/sh

# === バイナリ→テキスト変換パート ===================================
LF=$(printf '\\\n_'); LF=${LF%_}    # 0) 準備
od -A n -t x1 -v                  | # 1) 16進ダンプ(アドレス無し)
tr -Cd '0123456789abcdefABCDEF\n' | # 2) 空白はトル(でも改行コードは残すべし)
sed "s/../&$LF/g"                 | # 3) 1バイト1行に(しなくてもよいけど)
grep -v '^$'                      | #    sedで出来た空行をトル
#
# === データ加工パート ===============================================
#
# (ここで煮るなり焼くなりいろいろやる)
#
# === テキスト→バイナリ変換パート ===================================
awk -v ARGMAX=$(getconf ARG_MAX) '                                        #
  BEGIN{                                                                  #
    # 0) --- 定義 ------------------------------------------              #
    ORS    = "";                                #   引数の                #
    LF     = sprintf("\n");                     #   最大許容文字列長は    #
    maxlen = int(ARGMAX/2) - length("printf "); # ←ARG_MAXの約半分とする #
    arglen = 0;                                                           #
    # 1) --- バイナリ変換用のハッシュテーブルを作る --------              #
    # 1-1) 全ての文字が素直に変換できるものとして一旦作る                 #
    for (i=1; i<256; i++) {                                               #
      hex = sprintf("%02x",i);                                            #
      fmt[hex]  = sprintf("%c",i);                                        #
      fmtl[hex] = 1;                                                      #
    }                                                                     #
    # 1-2) 素直には変換できない文字をエスケープ表現で上書きする           #
    fmt["25"]="%%"      ; fmtl["25"]=2; # "%"                             #
    fmt["5c"]="\\\\\\\\"; fmtl["5c"]=4; # (back slash)                    #
    fmt["00"]="\\\\000" ; fmtl["00"]=5; # (null)                          #
    fmt["0a"]="\\\\n"   ; fmtl["0a"]=3; # (Line Feed)                     #
    fmt["0d"]="\\\\r"   ; fmtl["0d"]=3; # (Carriage Return)               #
    fmt["09"]="\\\\t"   ; fmtl["09"]=3; # (tab)                           #
    fmt["0b"]="\\\\v"   ; fmtl["0b"]=3; # (Vertical Tab)                  #
    fmt["0c"]="\\\\f"   ; fmtl["0c"]=3; # (Form Feed)                     #
    fmt["20"]="\\\\040" ; fmtl["20"]=5; # (space)                         #
    fmt["22"]="\\\""    ; fmtl["22"]=2; # (double quot)                   #
    fmt["27"]="\\'"'"'" ; fmtl["27"]=2; # (single quot)                   #
    fmt["2d"]="\\\\055" ; fmtl["2d"]=5; # "-"                             #
    for (i=48; i<58; i++) {             # "0"~"9"                         #
      fmt[sprintf("%02x",i)]=sprintf("\\\\%03o",i);                       #
      fmtl[sprintf("%02x",i)]=5;                                          #
    }                                                                     #
  }                                                                       #
  {                                                                       #
    # 3) --- 出力文字列をprintfフォーマット形式で生成 ------              #
    linelen = length($0);                                                 #
    for (i=1; i<=linelen; i+=2) {                                         #
      if (arglen+4>maxlen) {print LF; arglen=0;}                          #
      hex = substr($0, i, 2);                                             #
      print      fmt[hex];                                                #
      arglen += fmtl[hex];                                                #
    }                                                                     #
  }                                                                       #
  END {                                                                   #
    # 4) --- 一部のxargs実装への配慮で、最後に改行を付ける -              #
    if (NR>0) {print LF;}                                                 #
  }                                                                       #
'                                                                         |
xargs -n 1 printf

これ単体では全く役に立たないが、シェルスクリプトでバイナリデータを煮るなり焼くなりしたければこのコードをベースに作るとよいかも。

というわけでこのノウハウを応用したbase64クローンコマンドを作った。→こちら

パフォーマンスはオリジナルに比べるとかなり劣るものの、コンパイル無しに、コピーするだけで、どの環境でも、長期間使える実装ができたのだった。

おしまい

64
51
2

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
64
51

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?