はじめに
みなさんは未定義動作 (以下、Undefined BehaviorからUBと略す) を見聞きしたり、悩まされたりしたことはありますか?そうです。CやC++でよく聞く「鼻から悪魔」とか「タイムトラベル」ってやつです。モダンな言語として愛されているRustにもUBがあり、私はunsafe
を使ったばかりにその沼にハマってしまいました。
コード
とはいえ、実際のコードを提示しないことには話が始まらないのでコードを示します。
#[cfg(test)]
mod test {
use core::num::NonZeroUsize;
#[test]
fn test() {
pretty_size(0);
}
fn pretty_size(bytes: usize) -> String {
let mut buf = [0u8; 5];
let mut rest = bytes;
let mut head_index: Option<NonZeroUsize> = None;
write_numeric_char(bytes, 1000, &mut buf, 0, &mut rest, &mut head_index);
write_numeric_char(bytes, 100, &mut buf, 1, &mut rest, &mut head_index);
write_numeric_char(bytes, 10, &mut buf, 2, &mut rest, &mut head_index);
write_numeric_char(bytes, 1, &mut buf, 3, &mut rest, &mut head_index);
buf[4] = b'B';
let head = unsafe { head_index.unwrap_unchecked() }.get() - 1;
unsafe { std::str::from_utf8_unchecked(&buf[head..]).to_string() }
}
fn write_numeric_char(n: usize, pow: usize, bytes: &mut [u8], byte_index: usize, r: &mut usize, head: &mut Option<NonZeroUsize>) {
if n >= pow {
bytes[byte_index] = convert_to_numeric_char(*r / pow);
*r -= *r / pow * pow;
head.get_or_insert_with(|| (unsafe { NonZeroUsize::new_unchecked(byte_index + 1) }));
}
}
fn convert_to_numeric_char(a: usize) -> u8 {
match a {
0 => b'0',
1 => b'1',
2 => b'2',
3 => b'3',
4 => b'4',
5 => b'5',
6 => b'6',
7 => b'7',
8 => b'8',
9 => b'9',
_ => unreachable!("{}", a)
}
}
}
どこがUBかすぐわかったあなたは鋭いです。
原因の特定
もしかしたらコンパイラのバグかもしれないとは思ったので、ひとまずminimizeすることを考えました。minimizeとは、再現できる最小ケースを探して確定することで、開発者の手間を大幅に減らすことになるプロセスです。一般的にはすることが望ましいとされ、rustcでもminimizeすることが望ましいとされています。
打ったコマンド
-
cargo check
- 問題ない -
cargo run
- 問題ない -
cargo test
- 問題あり -
cargo clean && cargo test
- 問題あり
ということで、自分が書いたテスト自体に問題がありそうだということがわかりました。
二分探索-likeに関数をコメントアウト
まずは関数単位で絞り込んでいきます。このとき一つずつコメントアウトしていくと線形時間がかかってしまうので、二分探索のような方法1で半分ずつコメントアウトしていきました。そうして上記のコードにたどり着きます。
なんとなくunsafe fn
2が原因な気がしたので、unsafe
な関数3を、非unsafe
な同等の関数の呼び出しで置き換えました。そうすると見事に
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'
です。原因は明らかとなりました。
miriで確認
念の為UBを確認することができるmiriで「自分がUBを引き起こした」という仮説を検証します。
rustup +nightly component add miri
cargo miri test
そうすると、やはりUBでした:
test pretty_size::test::main ... error: Undefined Behavior: entering unreachable code
--> src/pretty_size.rs:134:29
|
134 | let head = unsafe { head_index.unwrap_unchecked() }.get() - 1;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ entering unreachable code
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
= note: BACKTRACE:
= note: inside `pretty_size::pretty_size` at src/pretty_size.rs:134:29
解決
解決は至って簡単です。head_index
を正しい値で初期化するだけです。ついでにこういう事故をまた起こしたりしないようにSAFETY
コメントも書き足しておきましょう。
- write_numeric_char(bytes, 1, &mut buf, 3, &mut rest, &mut head_index);
+ buf[3] = convert_to_numeric_char(rest % 10);
+ // SAFETY: 4 != 0.
+ head_index.get_or_insert_with(|| (unsafe { NonZeroUsize::new_unchecked(3 + 1) }));
buf[4] = b'B';
+ // SAFETY: we've just initialized head_index with some value.
let head = unsafe { head_index.unwrap_unchecked() }.get() - 1;
+ // SAFETY: convert_to_numeric_char yields only b'0'..b'9',
+ // which is valid codepoint for UTF-8 strings.
unsafe { std::str::from_utf8_unchecked(&buf[head..]).to_string() }
そしてcargo test
とcargo clean && cargo test
をしてSIGILLが消えたことを確認します。
めでたしめでたし。
まとめ
「unsafe fn
が必要ではないときは使うな」とよく言われます[要出典]が、たしかにこれぐらいなら使わないほうが良いかもしれません。
おまけ
SIGILLの原因はud2
命令を呼び出していたからでした4。