プログラミング
32ビット組み込みシステムにおけるGoランタイムバグの追跡
Hunting Down a Go Runtime Bug on 32-Bit Embedded Systems (sigma-star.at)
要約
Go言語で書かれたアプリケーションが32ビットARM組み込みLinuxシステムで断続的にクラッシュする問題が発生しました。このクラッシュは、Goのnetpollメカニズムがepollイベントを誤って解釈することに起因していました。32ビットシステム特有のポインタ表現の差異により、イベントファイルディスクリプタとソケットファイルディスクリプタが混同され、ランタイムエラーを引き起こしていました。この問題は、ポインタのnil値をタグ付きポインタとして使用することで解決されました。
全文翻訳
私たちの日常業務は通常、ソフトウェアスタックの深層にあるLinuxとセキュリティのトピックを中心に展開しています。それでも、考えているよりも頻繁に、はるかに上位で動作するアプリケーションのデバッグに行き着くことがあります。時にはそのような問題はLinuxカーネルに起因し、時には他の場所に起因します。このブログ記事では、Goランタイム内のバグをどのように見つけて修正したかを示します。
最近、顧客からGoで書かれたアプリケーションが、彼らの組み込みLinuxシステムの一つで時々クラッシュするという報告がありました。
はじめに
クラッシュは常に同じ致命的なエラーで、以下のシグネチャを持っていました。
runtime: netpoll: eventfd ready for 5
fatal error: runtime: netpoll: eventfd ready for something unexpected
...
スタックトレース...
当初、アプリケーション自体にバグがあり、修正が必要だと仮定しました。しかし、エラーをより詳しく調べたところ、Goのnetpollメカニズムにおける内部的な前提条件がもはや満たされていないように見えました。
エラーメッセージは、src/runtime/netpoll_epoll.go の netpoll() から来ています。
if ev.Events != linux.EPOLLIN {
println("runtime: netpoll: eventfd ready for", ev.Events)
throw("runtime: netpoll: eventfd ready for something unexpected")
}
このコードパスでは、netpollコードは EPOLLIN のみが発生するイベントであると期待していますが、実際には別の値を受け取っています。私たちのケースでは、5、つまり EPOLLIN|EPOLLOUT を受け取りました。コードは EPOLLIN のみを要求しているのに、なぜ epoll は突然 EPOLLIN 以上のイベントを報告するのでしょうか?
さらに深く掘り下げる前に、このエラーメッセージを検索エンジンにかけ、誰かが同じ問題に直面したことがあることを願いました。これは、Goプロジェクトのイシュートラッカーのレポートに直接私たちを導きました:runtime: netpoll: eventfd ready for something unexpected。
このイシューは、私たちが目にした致命的なエラーと全く同じものを説明していました。しかも、32ビットARM組み込みLinuxシステム上でです!報告者たちはまた、クラッシュは長期間実行されるアプリケーションで発生すると述べていました。これも顧客の説明と一致していました。ビンゴ!
このイシューは2025年3月から未解決のままでした。Goのメンテナーたちは、この問題に対する一つの修正の試みを却下していました。
イシューのコメントから、この致命的なエラーは32ビットARMおよびi386 Linuxシステムでのみ発生し、カーネルバージョンは様々であることがわかりました。古いカーネルもあれば、新しいカーネルもありました。x86_64やarm64システムでこれを見た報告者は一人もいませんでした。
挑戦を受け入れる
この問題には複数の報告者がありましたが、修正はありませんでした。そこで、私たちは自らこの問題に深く入り込むことにしました。少なくとも、Goの開発者たちにより良い情報を提供できると考えました。
当初、epollは32ビットARMまたはi386で異なる動作をするのではないかと疑いました。しかし、epollはカーネルの汎用的なコアコードであるため、この考えはすぐに捨てました。なぜ32ビットARMまたはi386でのみ偽のイベントセットを返すのでしょうか?
それでも、このエラーが32ビットシステムでのみ発生するという事実は私たちを悩ませました。次のステップとして、Goのnetpollコードにおけるepollの使用法を見直しました。LLMの助けを借りて、src/runtime/netpoll_epoll.go を調べ、整数変換のような32ビット特有の落とし穴に焦点を当てました。
レビューにより、Goのnetpollコードが struct epoll_event の data フィールドを使用していることが明らかになりました。Linux epollはカーネル内に8バイトのクッキーを格納し、それをユーザー空間に返すイベントの一部として返します。アプリケーションはこのクッキーを使用して、イベントにメタデータを添付します。例えば、異なるイベントソースを区別するためです。
struct epoll_event {
__poll_t events; /* Go netpoll の ev.Events */
__u64 data; /* Go netpoll の ev.Data */
};
Goランタイムの奥深くでは、メインのイベントハンドラは、イベントがイベントfdに属するかソケットfdに属するかを知る必要があります。それは、ev.Data を内部イベントfdオブジェクトのアドレスと比較することで決定します。
if *(**uintptr)(unsafe.Pointer(&ev.Data)) == &netpollEventFd {
...
}
コードをさらに読み進めると、ev.Data には netpollEventFd への生のポインタ、またはソケットごとのオブジェクトである pollDesc へのタグ付きポインタのいずれかが格納されていることがわかりました。ポインタタグは、リサイクルされた pollDesc オブジェクトを区別するカウンターである fdseq です。
ここまでは順調です。同じフィールドに生のポインタとタグ付きポインタを混在させるのは怪しいと思われましたが、クラッシュとどう関係するのかはまだ不明でした。
Goがメモリ内にタグ付きポインタをどのように配置するかを調べることで、問題の核心がついに明らかになりました。
ひらめきの瞬間
32ビットプラットフォームでは、Goのタグ付きポインタロジックは、完全な32ビットアドレスと最大32ビットのタグを8バイトのワードにパックします。タグは下位4バイトに、アドレスは上位4バイトに入ります。
アドレスが 0x00123456 でタグが 0x12 のタグ付きポインタを格納すると、ev.Data は次のように埋められます。
ev.Data[0:4] ev.Data[4:8]
+------------------+-------------------+
| fdseq | *pollDesc |
| (例: 0x00000012) | (例: 0x00123456) |
+------------------+-------------------+
一方、生のポインタを格納すると、ev.Data には以下の内容が残ります。
ev.Data[0:4] ev.Data[4:8]
+------------------+-------------------+
| &netpollEventFd | 0 (変更なし) |
| (例: 0x00123456) | 0x00000000 |
+------------------+-------------------+
下位4バイトにはオブジェクトのアドレスが格納されます。アドレスは32ビットシステムでは4バイトしかないため、上位4バイトは変更されません。
これにより、問題の根本原因がついに明らかになりました。ev.Data と &netpollEventFd の比較は、ev.Data の下位4バイトのみを評価します。コードは ev.Data を uintptr にキャストしますが、これは32ビットプラットフォームでは4バイトです。そのため、netpollEventFd オブジェクトのアドレスが fdseq とエイリアス(重複)します。
fdseq が &netpollEventFd と一致するほど大きくなると、netpoll ロジックはソケットfdをイベントfdと誤認します。内部的な前提条件が崩壊し、その中には、準備されたイベントが単に EPOLLIN であるという前提も含まれます。
このエイリアスは32ビットリトルエンディアンシステムでのみ発生することに注意してください。64ビットシステムでは、比較は常に8バイト全体をカバーします。32ビットビッグエンディアンシステムでは、比較はアドレスを含む上位4バイトを読み取ります。
fdseq は、&netpollEventFd と一致する前に数百万に達する必要があります。そのため、この問題は長期間実行されるプログラム、つまり時間とともに大量の pollDesc オブジェクトを作成するプログラムでのみ発生します。典型的な32ビットARM Linuxシステムでは、netpollEventFd はテストプログラムのメモリマップが示すように、アドレス空間の最初の3 MiB内の読み取り専用セクションに配置されます。
$ pmap `pidof netpoll_test`
204: /opt/netpoll_test
00010000 2696K r-x-- netpoll_test
002c0000 2192K r---- netpoll_test
004f0000 180K rw--- netpoll_test
...
したがって、クラッシュが発生するためには、fdseq は約300万の値に達する必要があります。
テストケース
私たちはこの問題のためのスタンドアロンのテストケースも作成しました。私たちのテストシステムでは、数分以内にクラッシュをトリガーしました。
$ /tmp/repro.arm.system netpoll eventfd-alias reproducer (GOOS=linux GOARCH=arm)
runtime.netpollEventFd is at address 0x223008
crash expected around cycle 2240520
cycle 2236988
runtime: netpoll: eventfd ready for 4
fatal error: runtime: netpoll: eventfd ready for something unexpected
runtime stack:
...
問題の修正
私たちは、netpoll がイベントfdとソケットfdを区別する方法を変更する修正を提案しました。生のポインタ &netpollEventFd を格納する代わりに、修正ではタグ付きポインタとして nil pollDesc を格納します。タグ付きポインタをアンパックした結果が nil の場合、そのイベントはイベントfdに属し、そうでない場合はソケットfdに属します。これにより、ev.Data は常にタグ付きポインタを含み、エイリアスは解消されます。数日後、私たちの修正はマージされました。
要約
Goアプリケーションが32ビットARM組み込みLinuxシステムで、致命的なnetpollエラーにより散発的にクラッシュしました。このエラーはepollの問題のように見えましたが、epoll自体は正常に動作していました。Goランタイムは、netpollEventFd への生のポインタと、タグ付き pollDesc ポインタの両方を、8バイトの ev.Data フィールドに格納します。32ビットリトルエンディアンシステムでは、生のポインタが fdseq タグとエイリアスします。長期間実行されるプログラムが数百万の pollDesc オブジェクトをリサイクルすると、netpoll はソケットfdをイベントfdと誤認してクラッシュします。私たちの修正では、イベントfdのためにタグ付き nil pollDesc を格納することで、エイリアスを解消しました。
このバグは2020年に Go 1.14 でGoランタイムに混入し、2025年3月の最初の報告まで気づかれず、2026年にようやく修正されました。推測するしかありませんが、