bin2hexとhex2bin 〜vimをバイナリエディタ的に使う、のおまけ〜

前回エントリで軽く触れたbin2hexとhex2bin。
バイナリファイルと16進数ダンプを相互に変換するプログラム。
拡張性とかそういうのはあまり考えていない。
引数はファイルのみ。
そのうち改造しよう。
まぁ、なんだ。Parsecの使い方の覚え書き程度に。

フォーマット

16進数ダンプするにあたって、まずはフォーマットから。
C言語風なコメントでアドレスを、シェルスクリプト風なコメントでASCIIをそれぞれ括り、まんなかに16進数ダンプを入れていきます。

/* 00000000 */ 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 #...............

こんな感じ。
なんでC言語風なコメントとシェル風なコメント混ぜてしもたんやろ。
昔の俺に聞かないとと分からないなぁ...。


実際には汎用的(?)にhex2binから先に作って、それからそれにあわせてbin2hexを作ったので、実はこれ以外のフォーマットもいける。
というか、コメント部分は一切無視するので何処に入れても構わないし、1行にいくつデータがあっても構わない。
なので前回エントリでやったvimバイナリエディタライクにつかう.vimrcを書いてもデータ破損は無いはず。


そもそもデータ破損は、xxdが1行のデータ数を16としているために、例えば

00000000: 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f  ...............

というxxdフォーマットのデータに対して、ffを挿入すると

00000000: 00 01 02 03 04 05 06 07 ff 08 09 0a 0b 0c 0d 0e 0f  ...............

となるが、xxd -rした際に17個目のデータとなる0fが無視されてしまい、再びxxd -g1すると

00000000: 00 01 02 03 04 05 06 07 ff 08 09 0a 0b 0c 0d 0e  ...............

となってしまうから、
また、xxdは16進数データとasciiとの区別にスペース二個を使っているために、例えば

00000000: 33 34 20 ff ff ff                                34 ...

というデータに対して、ffを消そうとして、dwを押してしまうと

00000000: 33 34 20 ff ff 34 ...

の様に、連続するスペースがすべて消されてしまい、再びxxd -rとxxd -g1をすると

00000000: 33 34 20 ff ff 34                                34 ..4

と、データが増えたり、場合によっては最悪それ以降がすべて消えたりしてしまうことによる。
まぁ、そんなこんななので、データ区切りを明白にしつつ、1行にいくつでもデータを許容すれば、データ破損は防げる、ということになる。多分。


あと、個人的に欲しいなと思って実装したのが、"\a"とバックスラッシュ+asciiと書くことで、そのasciiの文字コードを入れることが出来る。
わざわざ文字コード参照するのメンドイしね。

\H \e \l \l \o \, \  /* <- BackSlash + Space */ \W \o \r \l \d 2e # 2e = \.

というデータは"Hello, World."になります。

bin2hex

そんなわけでバイナリから16進数ダンプ。
タダ単にordしてshowHexするだけなんですが、フォーマットのために色々やってます。
かなり実装が汚い...気がする。そのうちgetoptつけて書き直してー。

import Char
import System.Environment
import Data.List
import Numeric

readARGV :: IO String
readARGV = do
	args <- getArgs
	if null args
		then getContents
		else mapM readFile args >>= return . concat

main :: IO ()
main = do
	readARGV >>= putStr . bin2hex

nColumns :: Int
nColumns = 16

bin2hex :: String -> String
bin2hex = unlines . map showLine . zip [0..] . takes nColumns

takes :: Int -> [a] -> [[a]]
takes _ []	= []
takes n xxs	= let (y,ys) = splitAt n xxs in y:(takes n ys)

charToHex :: Char -> [Char]
charToHex = ([[x,y] | x <- hexChars, y <- hexChars] !!) . ord
	where hexChars = "0123456789abcdef"

padding :: Int -> a -> [a] -> [a]
padding n p xs = replicate (n - length xs) p ++ xs

replaceUnprintable :: Char -> Char
replaceUnprintable c = if isAscii c && isPrint c then c else '.'

showLine :: (Int,String) -> String
showLine (i,xs) =
			let halfColumns = nColumns `div` 2 in
			let (xs1,xs2) = splitAt halfColumns xs in
			"/* " ++ (padding 8 '0' $ showHex i "0") ++ " */ " ++
			(intercalate " " $ take halfColumns $ map charToHex xs1 ++ repeat "  ") ++
			"  " ++
			(intercalate " " $ take halfColumns $ map charToHex xs2 ++ repeat "  ") ++
			" # " ++ (map replaceUnprintable xs)

まぁ、割と短めなんでないでしょうか。
かるーく触れていくと、readARGVはたしかrubyか何かにあったやつからアイデアを拝借。
引数があればそれらをファイルと見なしてread + concat。そーでなければ標準入力から入力。
要はcat的な挙動。


nColumnsは1行に出力するデータ数。ただし16意外にするとバグる予感。(いや、簡単になおるんだけど...)
もとよりハードコーディングでもいいかとか思ってるので特に直す気はない。


takesはwrap...というかなんというか、与えられた幅ずつリストを分割する関数。
ありそうで無いんだよなぁ...。1行に出力するデータ数ずつ入力を区切る。


charToHexは16進数ダンプ。内包表記は後ろの方が「早く」回るのでこの順で生成すると二桁の16進数が作れる。
Numeric.showHexあたりを使えよ、というお話。


paddingはそのままパディング。アドレス部分に使ってる。
桁数とパディングに使う要素を与える。
あー、アドレス8桁超えたら使えねぇや。まぁいいか。


replaceUnprintableはhexdumpとかでよくある、印刷出来ない文字を.で表すための奴。
で、一番のメインがshowLine。
と、言ってもあまりたいしたことない、というか、汚い実装だなぁ...。
" "をrepeatさせて後ろにくっつけて、takeすることで、ascii表示部分をそろえています。
そんくらい。


無駄も含めて45行程度でhexdumpもどき完成。
なんというかこの辺の美しさというか表現力が関数型の魅力な気がする。
まぁ、Cで書いてもそんな行数変わらないと思うけど。

hex2bin

こちらは16進数からバイナリへ。
パースがあるので先のと比べると多少難易度あがる、のかな?
Parsecを使っていきます。今回の場合かなり単純なフォーマットだからチューリングマシン的に先頭からフラグ変えつつ読んだほうが早いやも。

import Text.ParserCombinators.Parsec as Parsec
import Char
import Maybe
import System

readARGV :: IO String
readARGV = do
        args <- getArgs
        if null args
                then getContents
                else mapM readFile args >>= return . concat

main :: IO ()
main = do
        readARGV >>= putStr . hex2bin

hex2bin :: String -> String
hex2bin str = case parse parseIt "" str of
			Left e	-> error $ show e
			Right xs -> map chr $ xs

parseIt :: GenParser Char () [Int]
parseIt = do
	(many $ parseComment <|> parseByte) >>= return . catMaybes
	
parseComment :: GenParser Char () (Maybe Int)
parseComment = (parseSpaces <|> parseLineComment <|> parseMultiComment) >> return Nothing
	where
		parseSpaces = do
			many1 space
		parseLineComment = do
			char '#'
			many $ noneOf ['\n']
		parseMultiComment = do
			string "/*"
			manyTill anyChar $ try $ string "*/"

parseByte :: GenParser Char () (Maybe Int)
parseByte = do
		parseDigit <|> parseRaw
	where
		parseDigit = do
			count 2 hexDigit >>= return . Just . read . ("0x"++)
		parseRaw = do
			char '\\'
			anyChar >>= return . Just . ord

こちらも46行程度。
今見るとびみょーに何やってるの?的な部分がいくつか...。なんでMaybe Intやの?
最初にskipMany parseCommentして、parseByteの最後にも同様にskipMany parseCommentするだけでMaybe消せる気がするが...。まぁいいや。
Parsecそのままなんであまり説明する場所無し。Parsecは使いこなせるようになるとかなり便利ですにゃぁ。
まだ使いこなせてないけど。

16進数ダンプのvim syntax

おまけです。

" Vim syntax file
" Language:	hex file
" Last Change:	2009/08/09 23:50:33.
" Author:	goth_wrist_cut
" URL:		http://d.hatena.jp/goth_wrist_cut/

" Address := [0-9a-fA-F]\+
" Data    := [0-9a-fA-F]\{2\}
" Ascii   := .*
" Line    := /* Address */ Data* # Ascii

if exists( "b:current_syntax" )
	finish
endif

syntax match gothicHexAddress				"/\* *[0-9a-fA-F]\+ *\*/"	contains=gothicHexStartAddr,gothicHexEndAddr
syntax match gothicHexStartAddr		contained	"/\*"
syntax match gothicHexEndAddr		contained	"\*/"
syntax match gothicHexAscii				"#.*"				contains=gothicHexStartAscii
syntax match gothicHexStartAscii	contained	"#"

highlight link gothicHexAddress		Constant
highlight link gothicHexStartAddr	Identifier
highlight link gothicHexEndAddr		Identifier
highlight link gothicHexAscii		Statement
highlight link gothicHexStartAscii	Identifier

let b:current_syntax = "gothicHex"

bin2hexとhex2binとをそれぞれコンパイルしてPATHの通った所に置き、上のsyntaxをgothicHex.vim(仮称)という名前で、~/.vim/syntax/以下に置く。
んで、.vimrcに

augroup Binary
	autocmd!
	autocmd BufReadPre  *.bin let &binary = 1
	autocmd BufReadPost * call BinReadPost()
	autocmd BufWritePre * call BinWritePre()
	autocmd BufWritePost * call BinWritePost()
	autocmd CursorHold * call BinReHex()
	function! BinReadPost()
		if &binary
			silent %!bin2hex
			set ft=gothicHex
		endif
	endfunction
	function! BinWritePre()
		if &binary
			let s:saved_pos = getpos( '.' )
			silent %!hex2bin
		endif
	endfunction
	function! BinWritePost()
		if &binary
			silent %!bin2hex
			call setpos( '.', s:saved_pos )
			set nomod
		endif
	endfunction
	function! BinReHex()
		if &binary
			let s:saved_pos = getpos( '.' )
			let s:modified = &modified
			silent %!hex2bin
			silent %!bin2hex
			call setpos( '.', s:saved_pos )
			let &modified = s:modified
		endif
	endfunction
augroup END

と書けばvim de バイナリエディタ、完成でございます。
詳しくは、前回エントリをば。