🧰

FreeBSDのlibcに小さなコミットをした記録(+macOSのバグ)

に公開
1

まえおき

printfをほぼフル機能で再実装するという試みをしていたらFreeBSDのlibcのバグを発見してコミットしてマージされるまでの記録です。また、macOSのバグについても触れています。

これはシリーズ記事の番外編です。
バグの内容解析を細部まで理解するためにはこれらの内容を把握している必要があります。
ただしFreeBSDへのコミット部分に限れば前知識なしで読めると思います。

https://zenn.dev/monksoffunk/articles/c311aab03d6dfc
https://zenn.dev/monksoffunk/articles/664f5407027e81
https://zenn.dev/monksoffunk/articles/495a5096a5bc42
https://zenn.dev/monksoffunk/articles/0bed0c1c94807d
https://zenn.dev/monksoffunk/articles/914e9d9d8c9ee9

バグの発見

バグを発見したのはまったくの偶然です。
自作したprintfの挙動を確認するためにテスターを作っていろいろとテストをしていたことは前回の記事でお話した通りです。そこでは本家のprintfとの出力の比較を行っており、違う場合は自作printfの実装に問題あることがわかります。

比較対象はUbuntu Linux(glibc)、macOS(Libc)、そしてFreeBSD(libc)です。macOSとFreeBSDはどちらもBSD系なのですが、実際には実装に差があります。この点は後ほど触れます。
また、ハードウェアアーキテクチャの違いも見たいので、arm64とamd64の両方で確認します。つまり合計6つの環境でのテストとなります。

問題が起こったのは具体的には次のケースです。

printf( "%.13a", 1.234567890123456789);

%a変換指定子に精度を指定しています。精度13は小数点以下が13桁で表示せよという意味になります。
これはLinuxとmacOSでは以下のようになります。

0x1.3c0ca428c59fbp+0

また、わたしの自作printfも同じ結果となりました。

ところが、FreeBSDで試すと以下のようになります。

0x1.3c0ca428c59fcp+0

最初はバグではなく、仕様によってそうなってしまうのかもしれないといろいろ可能性を考えました。しかし、どのように考えてもこの出力を正当化することは出来ませんでした。精度指定の13により出力は
小数点以下13桁となります。つまり、doubleの仮数部である52ビットがそのまま16進数13桁になるのですから、ここに丸めが発生することはありません。なぜなら14桁目、あるいは53ビット目に来るデータはないからです。

これはもしかしてバグではないか?と疑うようになります。

バグの調査

FreeBSDのように歴史あるlibcにバグがある、それもprintfという極めて一般的な関数のバグです。本当であるならバグ報告すべきものです。しかし、もしかすると勘違いかもしれませんし、バグの正体を掴まずに「こういうコードで出力がおかしいです」といった大雑把なバグレポートはしたくありません。

そこで、本当にバグなのか、また、バグが起こる範囲や条件について調べることにしました。

まず、最初に問題を発見した環境であるarm64のFreeBSDとは別に、amd64上にFreeBSDをインストールして同じテストをしてみます。するとやはり同じ出力となりました。

よりシンプルに入力数値を出力に揃えます。

printf("%.13a", 0x1.1234567890123p+0);

結果は入力値と同じになるはずですがやはり誤って丸めが発生しています。

0x1.1234567890124p+0

また、long doubleでも問題が発生するかどうかを調べます。こちらもやはり同じバグが確認されました。

次にFreeBSDのソースコードを調べて問題のあるコードを絞り込みます。
ソースコードにデバッグ出力のコードを挟み込んでビルドし、生成した改変libcを使ってテストをしていきます。浮動小数点数のビット列を表示したり、各種変数の値を表示させることで問題点が浮かび上がってきました。

まず、FreeBSDの正常な挙動としてprintf("%.12a", 0x1.1234567890123p+0);の例を見てみましょう。

freebsdの%aにおいて精度が指定された際の処理は以下の通り面白いロジックです。

  • 故意に指数を減らして実質的に右シフトを発生
  • 1.0を加算することで狙った位置に丸めを発生
  • 1.0を減算することで丸めを保持しながら元のmantissaの並びを取得

一連の操作によりexpは壊れますが、これは別に保持していた正しいexpを利用するので問題ありません。上記流れを実測してみた結果を貼っておきます。

redux=1.000000
ndigits=13
offset=1019
offset = 4 * ndigits:52 + DBL_MAX_EXP:1024 - 4 - DBL_MANT_DIG:53 = 1019

before set offset:0x1.1234567890123p+0
exp              :1023
manh             :0b00000000000000010010001101000101
manl             :0b01100111100010010000000100100011

after set offset :0x1.1234567890123p-4
exp              :1019
manh             :0b00000000000000010010001101000101
manl             :0b01100111100010010000000100100011

+=redux          :0x1.1123456789012p+0
exp              :1023
manh             :0b00000000000000010001001000110100
manl             :0b01010110011110001001000000010010

-=redux          :0x1.123456789012p-4
exp              :1019
manh             :0b00000000000000010010001101000101
manl             :0b01100111100010010000000100100000

%.12a: 0x1.123456789012p+0

次に問題のあるケースであるprintf("%.13a", 0x1.1234567890123p+0);の例です。

故意に指数を減らすはずが、元の指数と同じ指数となっているため減らすことが出来ていません。
つまり右シフトしないまま1.0を加算しており、結果、暗黙の1と重なることで2進数の指数が+1され、狙っていたビットよりも1ビット上位の桁で丸めが発生してしまうのです。これにより、本来存在しない亡霊ビットによる切り上げが発生。引数の値から変化してしまいます。

redux=1.000000
ndigits=14
offset=1023
offset = 4 * ndigits:56 + DBL_MAX_EXP:1024 - 4 - DBL_MANT_DIG:53 = 1023

before set offset:0x1.1234567890123p+0
exp              :1023
manh             :0b00000000000000010010001101000101
manl             :0b01100111100010010000000100100011

after set offset :0x1.1234567890123p+0
exp              :1023
manh             :0b00000000000000010010001101000101
manl             :0b01100111100010010000000100100011

+=redux          :0x1.091a2b3c48092p+1
exp              :1024
manh             :0b00000000000000001001000110100010
manl             :0b10110011110001001000000010010010

-=redux          :0x1.1234567890124p+0
exp              :1023
manh             :0b00000000000000010010001101000101
manl             :0b01100111100010010000000100100100

%.13a: 0x1.1234567890124p+0

上記の調査により原因が特定されました。

精度が13であるときにも丸め対象になっていることが問題の原因です。
SIGFIGSという有効桁数を指すことを意図したマクロと、精度の桁数+1(整数桁を加算)を比較し、精度の方が小さい場合に丸め処理に入るのですが、doubleではSIGFIGSは15になるため、精度が13だと整数桁を加えた14との比較によって不正に丸め対象になってしまうのです。

#define	SIGFIGS	((DBL_MANT_DIG + 3) / 4 + 1)

つまりSIGFIGSの桁数が1大きくなることがあるのが問題なのですが、このマクロはバッファ量の取得の際にも使われているため迂闊に変更できません。コードを読んでいくと直接mallocはせずに多倍長整数用のメモリ管理関数を介して、文字数ではない単位によるサイズ指定で領域を確保(再利用)しています。64ビット環境では問題ないことを確認したものの、32ビットなど他の環境を考えると現行のSIGFIGSの定義に深い意味があるようにも思えます。FreeBSDの対応する環境は多岐にわたるため、すべての環境でテストする必要が生じる変更はしたくありませんので、SIGFIGSには触らず、丸めの判断だけに用いる別のマクロを定義する方針にしました。

なお、long doubleでも同じ問題がありましたのでこちらも修正します。

ビルド

修正を終えたらビルドしてテストをします。libcだけの変更であっても初めてビルドする際にはbuildworldが必要です。

/usr/src $ sudo make buildworld -j$(sysctl -n hw.ncpu)

2回目以降はlibcだけをmakeする形でも大丈夫なはずです。

/usr/src/lib/libc/$ sudo make -j$(sysctl -n hw.ncpu)

ビルドしたlibcを使ってテストする場合は以下のようにPRELOADしてあげます。

LD_PRELOAD=/usr/obj/usr/src/arm64.aarch64/lib/libc/libc.so.7 (テストの実行ファイル)

実際にはバグの調査の段階からあちこちにデバッグ出力を仕込んでビルドしてテストしていました。その際、printfのデバッグのためにprintfは使えないので、自作のprintfを別名で組み込んでビルドすることになります。2進数出力の変換指定子を書いているとこういうときに便利です。

テストを書く

バグの原因の洗い出しと修正を行ううちに、これなら単なるバグレポートではなく、コミットしてみるのもいいなと考え始めました。OSSへの参加という意味でFreeBSDへのコミットは有意義です(バグレポートももちろん有意義ですが)。そこで、PRを出すための材料を集めていきます。

FreeBSDではKyuaというテストフレームワークを使いますので、今回の問題点が明確になるテストケースを追加しました。今回の問題は80ビット拡張倍精度では顕在化しませんので、doubleと128ビット4倍精度についてのみテストします。

lib/libc/tests/stdio/printfloat_test.c

ATF_TC_WITHOUT_HEAD(hexadecimal_rounding_fullprec);
ATF_TC_BODY(hexadecimal_rounding_fullprec, tc)
{
	/* Double: %.13a with binary64 mantissa=53 */
	testfmt("0x1.1234567890bbbp+0", "%.13a", 0x1.1234567890bbbp+0);

#if defined(__aarch64__)
	/* On arm64, long double is IEEE binary128 (mantissa=113) */
	testfmt("0x1.3c0ca428c59fbbbbbbbbbbbbbbbbp+0", "%.28La", 0x1.3c0ca428c59fbbbbbbbbbbbbbbbbp+0L);
#endif
}

libcのテストをmakeし実行します。
既存のテストの中にはroot権限を要求するケースがあってskipしますので、その場合はroot権限で実行しましょう。今回変更したコードはlibcですが、mainブランチは手元環境よりも進んでいるためにいくつかのライブラリも同時にプリロードしてやる必要がありました。

Arm64環境(Amd64など環境によりパスが異なる)

/usr/src/lib/libc/tests/stdio $ sudo make
/usr/src/lib/libc/tests/stdio $ sudo make install
/usr/src/lib/libc/tests/stdio $ sudo su
/usr/src/lib/libc/tests/stdio $ LD_PRELOAD="/usr/obj/usr/src/arm64.aarch64/lib/libsys/libsys.so.7  /usr/obj/usr/src/arm64.aarch64/lib/libc/libc.so.7 /usr/obj/usr/src/arm64.aarch64/lib/libmd/libmd.so.7" kyua test -k /usr/tests/lib/libc/stdio/Kyuafile

このテストのほかに自作のテストを回すこともしています。手元ではArm64とAmd64の2つの環境で問題ないことを確認しました。今回の変更は本当に小さなものなので影響も極小ですが、それでも慎重に慎重を重ねる必要があります。万が一にも新たなバグを生むことになっては目も当てられません。

FreeBSDとは

ここでFreeBSDについて簡単に触れておきましょう。
20年以上前にはFreeBSDはポピュラーなUnix系OSで、多くのホビーユーザーが自身のPCに(実験的にという場合が多かったですが)FreeBSDをインストールしていたものです。ちなみにFreeなBSDを動かしたいmacユーザーはNetBSD/mac68kもしくはNetBSD/ppcを使うことが多かった記憶があります。いずれにしてもカリフォルニア大学バークレー校を起源とする派生OSです(もっというと現行macOSのDawrinはMachとBSDのハイブリッドですね)。

Linuxの存在感が大きくなるにつれて目立たなくなった印象が強いものの、実はFreeBSDはいまでもサーバ用途などで活躍しています。たとえばNetflixがFreeBSDを使っていることは有名です。また、PS4やPS5、nintendo switchなどのゲームコンソールがFreeBSDの少なくとも一部を利用しているそうです。つまりばりばり現役のOSです。そんなOSのlibcにコミットできるというのはなかなかおもしろい機会だと感じました。

FreeBSDへのコミット

FreeBSDは自身のサイトにリポジトリを持っていますが、最近ではGitHubに出店的にsrcを持っていてそちらでPRを受けることも可能になっています。ただしあくまで出張所の扱いなため、GitHubでは完結しません。また、1つのPRの中でコミットは(最終的に)1つにまとめられ、網羅的な説明をコミットメッセージに付けることが求められます。このあたりは部外者にはちょっとわかりにくいところでしたが、考えてみれば1PR1コミットという形式はgit logをするだけで粒度の揃った変更を追うことができる見通しの良さが利点です。

FreeBSD Contribution Guidelines for GitHub
https://github.com/freebsd/freebsd-src/blob/main/CONTRIBUTING.md

FreeBSD Commit Log Messages
https://docs.freebsd.org/en/articles/committers-guide/#commit-log-message

今回の実際のPRは以下です。
説明をどこまですべきかを悩み、あまり詳細に長々と書くのもなあと遠慮がちにまとめたOPだけでは意図を十分に伝えることが難しかったようなので何回かやりとりすることになりましたが、最終的には優しいメンテナさんが理解してくださいました。
「いちげんさん」でしかないわたしでも相手にしてくださるコミュニティで助かりました。
https://github.com/freebsd/freebsd-src/pull/1837

前述の通りこのGitHubは出張所であるため、実際には公式サイトで改めてメンテナさんがコミットをしてマージされます。GitHubはClosed扱いとなっていて一見マージされていないように見えますが、やや遅れて(数日〜数週?)公式サイトのリポジトリから無事に変更が反映されました。めでたしめでたし。

macOSのバグ

ところでFreeBSDベースであるmac(Darwin)のLibcにも同じ問題がありそうなものですがなぜか正しい出力を返します。その代わりと言ってはなんですけど、今回の自作printfとの比較テストを行っていく過程でmacOSのLibcにも%aに別のバグがあることが判明しました。

具体的には以下のケースです。

printf("%.0a\n", 1.5);
printf("%.0a\n", 1.53);
printf("%.0a\n", 1.55);
printf("%.0a\n", 1.56);

これは以下のように丸めが期待されます。

0x2p+0
0x2p+0
0x2p+0
0x2p+0

ところが実際の出力では丸めが正しく行われず、ジグザグな値を返します。
これはC99準拠違反ですね。

0x1p+0
0x2p+0
0x1p+0
0x2p+0

macOSのLibcは以下に公開されています。
https://github.com/apple-oss-distributions/Libc

これを見ると、少なくとも問題の%aについてはかなり前(10年以上前?)のFreeBSDのコードをベースにしていることがわかります。独自にOSを改良するのはもちろん問題ありませんが、FreeBSDのコミットを追いかけていればメンテ可能な部分ではあるのでうーんというところです。

Appleに対してわたしにできるのは報告しかないのでバグをFeedbackしました(AppleはOSSであってもPRは受け付けないので報告以外にコミットする手段がない)。しかしFeedbackのシステムではどうやら要求するログがないからなのか、ステータスがUnable to Diagnose with Current Informationになってしまいます。

仕方なく、Developer Forumにも投稿しておきました。中の人が認識してくれたようなのでいつか修正されるかもしれません。

https://developer.apple.com/forums//thread/803076

あとがき

というわけでprintfを再実装していくとlibcのバグを発見できちゃうのでみんなも再実装しよう。

Discussion

stanakastanaka

はじめまして。
以前、printfを自作していた際に、macOSの %a の丸めの挙動で詰まっていたのですが、記事を拝見して腑に落ちました。
検証内容などがどれも参考になり、非常に丁寧で素晴らしい記事だと感じました。
おかげで理解が深まり、とても助かりました。ありがとうございました。