Rustでバイナリをパースする方法はいくつかありますが、nomでやってみたら以外と簡単だったので記事にします。
nomとは
GitHub: https://github.com/Geal/nom
Rustのパーサコンビネータライブラリのデファクトスタンダード的な存在です。昔はマクロ中心の記述が中心で、マクロに慣れないと辛いところもありましたが、バージョン5からimpl Traitを活用した関数中心の記述に変更されました。マクロを使わないと書けないところも残っていたりしますが、かなり簡単にパーサコンビネータを使うことができます。文字列だけでなくバイナリのパーサを書くのにも使えます。
目標
バイナリを読む練習として、今回は仕様がかなり簡単な部類のバイナリファイルであるbmp(Windows Bitmap)ファイルを読むことを目標とします。フォーマットはWikipediaを読めばわかります。bmpファイルの先頭に BITMAPFILEHEADER というファイルヘッダがあり、その後に情報ヘッダが続きます。情報ヘッダにはいくつかバージョンの違いがあるようですが、最初の40バイトは共通のようですので、そこまで読めたらあとの情報は切り捨てます。結論としては、ファイルを読み込んで次のBitMap
構造体を作成することを目標とします。今回は簡単のため、インデックスカラーは扱わず、24ビットフルカラーか8ビットグレースケールのみ扱うこととします。
#[derive(Debug)]
struct BitMapFileHeader {
/// ファイルサイズ
file_size: u32,
/// ファイル先頭からビットマップデータまでのオフセット
offset: u32,
}
#[derive(Debug)]
struct BitMapInfoHeader {
/// 横幅
width: i32,
/// 縦幅
height: i32,
/// 1ピクセルあたりのビット数
bits_per_pixel: u16,
/// 圧縮形式
compression_method: u32,
/// 画像データサイズ
image_size: u32,
/// 水平方向の解像度
horizontal_resolution: i32,
/// 垂直方向の解像度
vertical_resolution: i32,
/// カラーインデックスの数
n_color_palette: u32,
/// 使用されているカラーインデックスの数
n_colors_used: u32,
}
struct BitMap {
file_header: BitMapFileHeader,
info_header: BitMapInfoHeader,
/// ピクセルデータ
pixels: Vec<u8>,
}
下準備
nomを使うために、次の依存関係をCargo.tomlに追記します。
[dependencies]
nom = "5"
use
文を追加します。
use nom::bytes::complete::{tag, take};
use nom::number::complete::*;
use nom::IResult;
use nom::{error::ErrorKind, Err};
use std::io::Read;
use std::path::Path;
nomがパースの対象として扱える型は、今の所&[u8]
か&str
、つまりメモリ上にロードされたバイナリ列か文字列です。今回はbmpファイルを読み込むので、ファイルの中身をメモリ上にロードします。
fn read_file<P: AsRef<Path>>(file_path: P) -> Vec<u8> {
let mut file = std::fs::File::open(file_path).expect("file open failed");
let mut buf = Vec::new();
file.read_to_end(&mut buf).expect("file read failed");
buf
}
BITMAPFILEHEADERの読み込み
まずはファイルの先頭にあるBITMAPFILEHEADERを読み出すことを考えます。このヘッダの定義を抜き出してみると、次のようになります。
先頭からのオフセット | サイズ | 格納する情報 |
---|---|---|
0バイト | 2バイト | マジックナンバー 0x42, 0x4d |
2バイト | 4バイト | ファイルサイズ |
6バイト | 2バイト | 常に0 |
8バイト | 2バイト | 常に0 |
10バイト | 4バイト | ファイルヘッダの先頭アドレスからビットマップデータの先頭アドレスまでのオフセット(単位はバイト) |
まず先頭は2バイトのマジックナンバー0x42, 0x4d
なので、これに合致するかどうか判定します。そのためにnom::bytes::complete::tag
を使います。これの定義は次のようになります。
pub fn tag<'a, T: 'a, Input: 'a, Error: ParseError<Input>>(
tag: T
) -> impl Fn(Input) -> IResult<Input, Input, Error> where
Input: InputTake + Compare<T>,
T: InputLength + Clone,
長ったらしくてぱっと見ではよくわからなくなりますが、戻り値がimpl Fn
なので、関数として呼び出せることがわかります。そしてこの関数は、入力を受け取ってそれがtagに渡したバイナリ列と合致するか判定します。要するに次のように使います。ただし右辺に出てくるinput
は読み出したファイルの内容で、&[u8]
型とします。
let (input, _) = tag(b"\x42\x4d")(input)?;
tag(b"\x42\x4d")(input)
は、inputの先頭2バイトがb"\x42\x4d"
と一致するか判定します。この結果はResult
型であり、一致しない場合はErr
となります。成功時の結果を得るためには?
で取り出します。取り出されるのはタプルです。タプルの1番目は、入力を読み進めてて残った部分、今回はファイル先頭から2バイト進んだところを指す&[u8]
となります。これを新しいinput
として定義します。タプルの2番目には、入力のうち``b"\x42\x4d"と一致した部分のスライスです。これはもう必要ないので
_`に代入します。
ここで、再定義したinput
はファイルデータの3バイト目(オフセット2バイト)を指しています。ここからは4バイト整数のファイルサイズが格納されています。これを読み出すには、nom::number::complete::le_u32
を用います。この関数は名前を見てわかる通り、リトルエンディアンの32bit符号なし整数を読み出します。これを使うと、ファイルサイズは次のように読み出せます。
let (input, file_size) = le_u32(input)?; // ファイルサイズ
戻り値のタプルのうち、1番目は先程と同様に、右辺で渡しているinput
からさらに4バイト読み進めたのこりのスライスです。2番目は読み込んだ4バイトに入っていたデータをリトルエンディアンとして解釈して出力したu32
の値です。これは必要な情報なのでfile_size
と名前をつけておきます。
さて、ここまででtag
とle_u32
というnomの関数を使ってきましたが、どちらもタプルを返しています 1 。このタプルの1番目は、入力を読み込んで使わなかった部分のスライスで、2番目は関数が読み込んだデータを解釈して生成した値であり、場合によって型はまちまちです。つまり、nomでパーサを書く場合、次のような関数を定義して使います。
fn my_parser(input: &[u8]) -> IResult<&[u8], MyData>;
このmy_parser
関数は、input
の先頭部分を解釈し、IResult
型を返します。IResult
は次のように定義されており、中身はResult
です。
type IResult<I, O, E = (I, ErrorKind)> = Result<(I, O), Err<E>>;
Result
のうちOk
に入るのはタプルであり、タプルの中身は先程解説したのと同様、残りのインプットと、パーサが解釈した結果(MyData
型)を返します。このような1部分を解釈するパーサ関数を組み合わせて全体のパーサを作成することがnomの基本方針です。
それではこの方針に基づき、bmpファイルの先頭を読み込み、BitMapFileHeader
構造体を返す関数read_bitmap_file_header()
を書いてみます。
fn read_bitmap_file_header(input: &[u8]) -> IResult<&[u8], BitMapFileHeader> {
let (input, _) = tag(b"\x42\x4d")(input)?; // 先頭のマジックナンバー
let (input, file_size) = le_u32(input)?; // ファイルサイズ
let (input, _) = tag([0u8, 0])(input)?; // 0が入っている予約領域
let (input, _) = tag([0u8, 0])(input)?; // 0が入っている予約領域
let (input, offset) = le_u32(input)?; // 先頭から画像データdまでのオフセット
Ok((input, BitMapFileHeader { file_size, offset }))
}
tag
とle_u32
を用いて、順番に定義通り読み出していくだけです。最後に集まった情報(file_size
とoffset
)からBitMapFileHeader
を生成し、読み残しのinput
と一緒にタプルにして返します。
この関数内では、input
を読み進めるたびに再定義しています。別の名前にすることも可能です。ただしややこしくなるので同じ名前にするのがいいでしょう。
ところで、?
を使っていることからわかるように、読み込みに失敗した関数はErr
を返します。
情報ヘッダの読み込み
先述の通り、情報ヘッダには複数バージョンが存在しますが、40バイト読んであとは無視します。この場合、BITMAPINFOHEADERの定義通りに読んでいきます。
fn read_bitmap_info_header(input: &[u8]) -> IResult<&[u8], BitMapInfoHeader> {
let (input, _) = le_u32(input)?; // ヘッダのサイズ
let (input, width) = le_i32(input)?; // 横幅
let (input, height) = le_i32(input)?; // 縦幅
let (input, _) = tag(&[1u8, 0])(input)?; // プレーン数 (常に1)
let (input, bits_per_pixel) = le_u16(input)?; // 1ピクセルあたりのビット数
let (input, compression_method) = le_u32(input)?; // 圧縮形式
let (input, image_size) = le_u32(input)?; // 画像データサイズ
let (input, horizontal_resolution) = le_i32(input)?; // 水平方向の解像度
let (input, vertical_resolution) = le_i32(input)?; // 垂直方向の解像度
let (input, n_color_palette) = le_u32(input)?; // カラーインデックスの数
let (input, n_colors_used) = le_u32(input)?; // 使用するカラーインデックスの数
Ok((
input,
BitMapInfoHeader {
width,
height,
bits_per_pixel,
compression_method,
image_size,
horizontal_resolution,
vertical_resolution,
n_color_palette,
n_colors_used,
},
))
}
ちょっと長くなりましたが、やってることはread_bitmap_file_header()
と同じく、順番に読んでいき、結果と残りの入力を返しているだけです。
ビットマップ全体の読み込み
ヘッダ部分を読むパーサができたので、ビットマップ全体を読む関数を作ります。
fn read_bmp(input: &[u8]) -> Result<BitMap, Err<(&[u8], ErrorKind)>> {
let (input, file_header) = read_bitmap_file_header(input)?;
let (input_pixels, input_info_header) = take(file_header.offset - 14)(input)?;
let (_, info_header) = read_bitmap_info_header(input_info_header)?;
let pixels = input_pixels.to_vec(); // ピクセルデータをそのままVec<u8>に格納
Ok(BitMap {
file_header,
info_header,
pixels,
})
}
file_header
を読んだ後、ピクセルデータが格納される領域までのオフセットfile_header.offset
に基づき、input
をread_bitmap_info_header()
に渡すinput_info_header
と、ピクセルデータを指すinput_pixels
に分けます。これにはnom::bytes::complete::take
を用います。file_header.offset
はピクセルデータのファイル先頭からのオフセットですが、input
はすでにファイルヘッダの分だけ読み進めているので、take
に渡す値からその分を引いておきます。
今回はピクセル情報を生データそのままVec
にしていますが、ピクセル情報を他の型へパースすることにもnomが使えるはずです。
ビットマップを表示する
ビットマップが読み込めたか調べるため、ヘッダ情報とピクセルを表示するBitMap::print
を実装します。各ピクセルの明度に基づいてターミナル上にAscii Art風の表示をさせます。
impl BitMap {
fn print(&self) {
println!("{:?}", self.file_header);
println!("{:?}", self.info_header);
let bytes_per_pixel = i32::from(self.info_header.bits_per_pixel) / 8;
let w = self.info_header.width * bytes_per_pixel;
let w = if w % 4 == 0 { w } else { w - w % 4 + 4 };
for y in (0..self.info_header.height).rev() {
for x in 0..self.info_header.width {
let i = (y * w + x * bytes_per_pixel) as usize;
let value = if bytes_per_pixel == 3 {
(self.pixels[i] as u32 + self.pixels[i + 1] as u32 + self.pixels[i + 2] as u32) / 3
} else {
self.pixels[i] as u32
};
let c = if value < 64 {
'*'
} else if value < 128 {
'+'
} else if value < 192 {
'-'
} else {
' '
};
print!("{}", c);
}
println!();
}
}
}
実行する
main
関数は次のように実装します。エラーが起きた場合は表示だけはするようにします。
fn main() {
let data = read_file("sample.bmp"); // ファイルを読み込んでVec<u8>にする
match read_bmp(&data) { // パースする
Ok(bmp) => {
bmp.print();
}
Err(err) => {
println!("{:?}", err);
}
}
}
読み込む画像として、次のようなRustのロゴを小さくしたものを用意します。
これをWindows BitMapとしてsample.bmpに保存し、実行したところ以下のような出力となりました。
BitMapFileHeader { file_size: 1722, offset: 1146 }
BitMapInfoHeader { width: 24, height: 24, bits_per_pixel: 8, compression_method: 0, image_size: 576, horizontal_resolution: 11811, vertical_resolution: 11811, n_color_palette: 256, n_colors_used: 256 }
-
- *+*++---
--**** +***+--
***+-- ---***-
-**+ - -***
-+*+ **--
+**+---------- -**-
-**************+ -*+-
+*+*+***********- ++*-
-+- + ***- ***+ - *-
-**+- ***+ -*** -+*+
-** **********- -*+
+*+ **********+ -**-
** ***- +*** -**+
+**---***+ -***+ +***
+********** ********
*********** +*******-
+*+ -**-
***++- +-+**-
-*+ + **
--*-*- -++++-
*+**********+-
*+*****++-
+ +---
ヘッダ情報、ピクセルともに正しく読み込めていることが確認できました。
おわりに
今回題材に取り上げたビットマップファイルでは、ヘッダを順番に読んでいくだけで、nomの各種コンビネータの力はあまり発揮されているように感じませんが、それでも自力でパースするよりはずっと楽なはずです。古い記事ではnomはマクロ中心と紹介されていたりしますが、バージョン5以降は関数中心になり、敷居も下がっているので試してみるのはいかがでしょうか。
-
正確には、
tag
はそういった機能をもつimpl Fn
を返します。 ↩