Actually Portable Executables ビルドのために GCC をパッチする
(ahgamut.github.io)- Cosmopolitan Libc で既存の C ソフトウェアをさらに多くビルドするには、ビルドシステムをだますだけでは足りず、約 2,000 行規模の GCC パッチでコンパイラが問題のあるコードを自動変換する必要があった
- 障害となったのは、
SIGTERM、EINVALのような システム値がコンパイル時定数ではないため、C のswitch caseラベルやstatic/const struct初期化の規則に引っかかる点だった sed、Python の正規表現、GCC プラグイン、プリプロセッサマクロの横取りまで試したが、caseラベルのエラーはパース中に発生するため、プラグインだけでは防ぎにくかった- 最終的なパッチは
-fportcosmoフラグで有効化され、GCC がエラーを出す直前にswitchをif/gotoに変換し、構造体初期化は実行時に値を埋める形で補う - 無名構造体、
enum、一部のconst int、初期化式内の配列インデックスといった制約は残るが、bash、curl、git、ninja、gccのようなソフトウェアを ソース変更なしで ビルドできる道が開かれた
GCC パッチの目標
- Cosmopolitan Libc で Actually Portable Executables をより簡単に作るため、約 2,000 行の
gccパッチが作成された - パッチ適用後は
./configureまたはcmakeのビルドシステムで次のソフトウェアをビルドできるbashcurlgitninjagcc自身
- ビルドされた実行ファイルは Linux、FreeBSD、MacOS、OpenBSD、NetBSD、Windows でも動作できるはずである
- Windows での実行は直接テストしていないという注記がある
- GitHub Actions でビルドされたバイナリは superconfigure releases から入手できる
Cosmopolitan Libc 移植で行き詰まった点
- Cosmopolitan Libc が 2021 年に
redbeanウェブサーバで注目を集めた後、既存の C ソフトウェアがこの libc 上でどれだけうまく動くかが重要な問いになった - Lua、Wren、Janet、
quickjsといった言語ランタイムを移植する過程で、同じパターンが繰り返し現れた SIGTERM、EINVALのような システム定数をswitch文のcaseラベルに使うと、C のコンパイル規則と衝突する- C 標準では
caseラベルが コンパイル時定数であることを要求している- Cosmopolitan Libc ではシステムインターフェース関連の C プリプロセッサマクロが symbolic である必要があった
- その結果、
switch(errno) { case EINVAL: ... }のようなよくあるコードがコンパイルできないことがあった
switch を if と goto に変える
switch(errno)内のcase EINVAL、case ENOSYSのようなラベルがコンパイル時定数でない場合、GCC はcase label does not reduce to an integer constantエラーを出す- パッチは
switch文を自動的に次の構造へ変換する- 各
case値ごとにif (errno == EINVAL) goto caselabel_EINVAL;という形の分岐を追加 defaultラベルへ移動するgotoを追加- 元の
caseブロックはラベルとgoto endofthis_switchで再構成する
- 各
- この方法は最もエレガントではないが、複数のコードベースで見られたケースを処理できた
ifdefと fallthrough が混ざったswitchまで扱う必要があったため、gotoベースの変換は単純な選択肢だった
struct 初期化にも同じ制約がある
staticまたはconst structの初期化でもコンパイル時定数の問題が発生するEINVALがコンパイル時定数でない場合、次の初期化の一部は C では有効でない- 関数内の通常のローカル
struct初期化は可能 const structstatic struct- グローバル
const struct - グローバル
static struct
- 関数内の通常のローカル
- 実例として CPython の
faulthandlerモジュール がある - 解決策は構造体をダミー値で初期化した後、使用前に正しい値を埋めるコードを追加する方法だった
- 関数内では
if文を追加 - グローバルまたは静的初期化には
__attribute__((constructor))を使う
- 関数内では
テキスト置換と GCC プラグインの限界
- 最初は
sedシェルスクリプトで変換を自動化しようとし、その後 Python スクリプトと正規表現で fallthrough まで処理しようとした - しかし C プリプロセッサと
ifdefのため、テキスト置換を完全に動かすのは難しかった- コンパイル時にどの
ifdefが有効になるか事前に分かりにくいためである
- コンパイル時にどの
- 次の試みは GCC プラグインを使った AST 変換だった
- GCC プラグインは
-fplugin=your-plugin.soで読み込める PLUGIN_PRE_GENERICIZE、PLUGIN_FINISH_PARSE_FUNCTION、PLUGIN_FINISH_DECLのようなイベントで AST にアクセスできるdebug_treeで AST を出力し、walk_tree_without_duplicatesで AST を巡回できる
- GCC プラグインは
- 問題は
caseラベルの検証が GCC のパース中に行われる点だった- プラグインはパース終了後に AST へアクセスする
- 不正な
case SIGTERM:ラベルは AST に残らない - GCC がすでにエラーを出した後なので、プラグインで修正するのは難しかった
プリプロセッサマクロ横取りの実験
- プラグインだけでパース前のエラーを防ぐのが難しかったため、C プリプロセッサと相互作用する方法を試した
- GCC プラグインヘッダの
cpp_reader構造体を通じて、マクロが#define、#undef、使用されるときにコールバックを仕掛けられた - Cosmopolitan Libc のシステム値マクロ構造を利用して一時定数を挿入した
- 例:
extern const int SIGTERM; - 例:
#define SIGTERM SYMBOLIC(SIGTERM) - プラグイン用に
static const int __tmpcosmo_SIGTERM = ...のような一時値を置く
- 例:
- プラグインは
SYMBOLIC(SIGTERM)の使用を横取りして一時値に置き換え、その後PLUGIN_PRE_GENERICIZE段階で AST を再び正しいVAR_DECLに戻した - このマクロハックは
switchとstruct初期化エラーを回避することに成功した - この方法で少なくとも CPython 3.11 のビルドも可能になった
プラグインではなく GCC 自体を直した理由
- Justine Tunney と議論した結果、問題が解けると確認できた後、GCC コードベース自体を直接修正するもっと単純なアプローチが提案された
- プラグインとマクロハックは不要な作業が多く、エッジケースやクラッシュ処理が複雑だった
- 例えば
case ENOSYSだけ変換すればよい状況でも、マクロ置換のせいでENOSYS + 1やprintf("ENOSYS = %d", ENOSYS)のような正当な使用までプラグインが処理しなければならなかった - GCC パッチ方式では別個の
-fpluginではなく-fportcosmoフラグで機能を有効にする - GCC が
case is not constantエラーを出す直前にflag_portcosmoの有効化を確認し、有効なら必要な置換を行う - 既存プラグインの AST 変換コードは、GCC 内部で別のプラグインコールバックを呼ぶ前に実行されるよう移された
- この変更によりマクロ関連のハックコードを削除できた
実際のビルドとパッチ拡張
- 2023 年 6 月 5 日時点で、パッチ済み GCC は以前のプラグインのテストケースをすべて通過し、Cosmopolitan Libc モノレポに新しいバイナリが追加された
- さらに多くのコードをコンパイルしながら修正と改善が続けられた
luaのビルドは簡単になり、g++でcase constantエラーが発生する箇所を見つけてninjaもビルドできるようになった- Python 3.11 に
ncursesを組み込もうとする過程で新たな問題が見つかったncursesにはDATA2(PARODD | PARENB, PARODD)のように構造体初期化値へ 二項式が入るコードがあった- 既存パッチは単純な定数初期化は処理できたが、
PARODD | PARENBのような式は処理できなかった
- その後 C の
caseラベルと構造体初期化要素が任意の式になれるよう、パッチが拡張された- C 標準上、
caseラベルは依然としてコンパイル時定数でなければならない - パッチ済みコンパイラはこのような使用に警告を出すが、コンパイルは継続する
- C++ は
g++11のconstexpr処理と合わないため、任意式の置換対象から除外された
- C 標準上、
- この変更後、
ncursesは問題なくビルドできた
残っている制約と実用的な結論
- パッチは完全ではない
- 一部の無名構造体を処理できない
enum、const intの一部ケースを処理できない- 初期化式内で
SIGTERMを配列インデックスに使うケースを処理できない - 奇妙な
static初期化やSIGTERMをstatic const int8_tに入れる場合に、まれなコンパイラクラッシュが残ることがある
- それでも明白な反例は多く取り除かれ、多くの人気ソフトウェアがスムーズにビルドできる
- より厳しいテスト環境は追加の改善点を明らかにする可能性がある
- 望むなら
switch文やstruct初期化式を手で直すこともできるが、多くの場合はコンパイラが自動処理できる - C ソフトウェアを静的にビルドでき、特に
muslでもビルドできるなら、Cosmopolitan Libc でもビルドできる可能性が高い
3件のコメント
これから neo はメッセージをコードブロックで囲む形式になるのでしょうか? 横スクロールしなければならない点と、赤系のテキストカラーが少し残念ですね…。
ああ、コードブロックで囲まれてしまうのはバグです ;_;
Hacker News のコメント
この記事を書いたのは私だが、タイトルは「Patching GCC to build Actually Portable Executables」であるべきだった。Cosmopolitan Libc と jart の Actually Portable Executable 形式を指しているためだ。
私の GCC パッチにより、vim、emacs、ninja、bash、git、gcc のようなソフトウェアを、通常の autotools/cmake 方式のビルドシステムを通じて Cosmopolitan Libc でビルドできるようになった。ビルドされた実行ファイルは Linux、FreeBSD、MacOS、OpenBSD、NetBSD、Windows で実行できるはずだ。ただし Windows はまだテストしていない。
この手法でビルドしてみたソフトウェアの一覧はこちら: https://github.com/ahgamut/superconfigure
superconfigure スクリプトは、ソフトウェアのビルドに使う通常の configure スクリプトを包み、
--enable-staticのようなフラグを追加するラッパーにすぎない。Cosmopolitan Libc で GCC をビルドしてみたいなら、このリポジトリを使うとよい: https://github.com/ahgamut/musl-cross-make/tree/gccbuild
HN ではもっと良い扱いを受けられることを願う。
たとえば glibc 版と比べて回帰がないことを示せるだけの十分なテストがあったのか気になる。
Go リポジトリにこれに関連する Issue がある: https://github.com/golang/go/issues/51900
全体としてかなりクールだが、その理論的背景が気になる。新しい OS ターゲットを作るなら、論理的には新しい定数を定義するのが正しそうに見える。
下位 OS ごとにその OS の定数をそのまま渡す代わりに、各 OS の定数をどこでも同じになるよう変換すればよい。そうすれば、定数を配列インデックスや case 文に使うコードも正常に動作する。
記事で説明されているアプローチよりオーバーヘッドは増えるかもしれないが、はるかに簡単で、Cosmopolitan libc に新しいプログラムをコンパイルしようとする人にとっても使いやすそうだ。
定数の一覧はここを見ればよい: https://github.com/jart/cosmopolitan/blob/master/libc/sysv/c...
これらの定数のどれかを使うたびに、多数の定数を大きなルックアップテーブルとしてバイナリに含める必要があり、プログラム内で確認が必要になるたびにそのテーブルを経由しなければならない。極端に遅くはないかもしれないが、確実に体感できると思う。
目標は、libc や移植先ソフトウェアのソースコードをあまり変更せずに移植を容易にし、性能も同等かそれ以上のバイナリを作ることだった。その制約の中では、この GCC パッチがプロセスを単純化する最良の方法に思えた。
SIGHUPを配列インデックスの初期化に使うコードベースに十分多く出会ったら、提案された方法を試してトレードオフを測ってみるつもりだ。あるいは自分で試してみて、別個の定数セットのほうが良いかどうか教えてくれてもよい。「明白な反例を取り除くのにかなり時間をかけ、多くの人気ソフトウェアがスムーズにビルドできる」のであれば、正常にコンパイルできるソフトウェアの一覧を公開すると役に立ちそうだ。すでにあるかもしれない。
superconfigure スクリプトは、ソフトウェアのビルドに使う通常の configure スクリプトを包み、
--enable-staticのようなフラグを追加するラッパーにすぎない。APEに実用的な目的があるのか気になる。複数のOSで動く単一の実行ファイルを実際に配布している例はあるのだろうか?
lnav 自体は APE でビルドしていないが、その中に組み込まれたエージェントは APE でビルドしている。ユーザーがそのホストのログを読みたいとき、そのエージェントをリモートに転送する
こうすると OS の種類を判別したり、複数バージョンの実行ファイルを含めたりする追加の手順が不要になる。関連する短い記事はこちら: https://lnav.org/2021/05/03/tailing-remote-files.html
静的コンパイルされたプログラムはすべての土台にあるが、アプリケーションレベルでは少数派だ
APE は「C のような低レベルの AOT 言語が一度コンパイルすればどこでも実行できるなら、誰が仮想マシンを必要とするのか?」という大きな問いを投げかけている
しかも クロスコンパイルはつらい。複数の BSD をすべて備えたビルドファームを持たない小規模なオープンソースプロジェクトでは、そうしたプラットフォーム向けバイナリを最初から配布しないことが多い
kubectl ですら Linux 向けバイナリはあるが BSD 向けはない。OSX を BSD に数えないなら、という話だが。だからこういうプロジェクトには手軽な利点に見える
ユーザーのマシン上でその場でビルドするには大きすぎる。大きなサードパーティ製 C++ 依存関係がいくつもあり、ハードウェアによってビルドに 2 分から 20 分かかる
macOS x86-64+ARM、Linux x86-64、Windows x86-64 で動く必要がある。ARM Linux も対応できればなお良い
今は WASM を検討中だが、インストール済みの WASI ランタイムが必要になる
混乱している人向け: “Actually Portable Executables” https://justine.lol/ape.html
switchをifに変えるのはかなり雑だ。プラットフォーム中立な定数集合にマッピングして、ランタイムシンボルを関数で逆参照すれば改善できそうだたとえば
switch(CosmoMappErrno(EINVAL))やcase COSMO_EINVAL:のようにすれば、コードを goto の塊にしなくて済むもちろん、コンパイラがこうした switch を認識して if ツリーに書き換える代わりに、各値に対する cosmo 専用の特殊定数へラベルを書き換え、switch の入力値を現在のランタイムプラットフォームの値からその cosmo 専用定数へマッピングする関数呼び出しで包む、という意味なら話は別だ。他の値は変更せずそのまま通すとして
それは実際にはもっと単純な コンパイラ変換かもしれない。ただし、複数ある定数集合のうちどれが使われているかを認識し、switch の入力値に適切なマッピング呼び出しを適用しなければならない複雑さがある。ライブラリ側でもそうしたマッピングを作る必要があり、やはり複雑さは必要になる
すでに別の変換方式で動く実装があるので、作者がこれを実装したがるかは分からない
最後に、この種のマッピング方式には誤ったマッピングをする危険があり、負の errno 値を使うコードではマッピング前に入力を反転しなければならない、といった厄介さもある
if-else-goto の配列が気に入っているのは、GCC の他の部分とうまく噛み合っていたからだ。私のパッチ[1]を見れば、この機能を追加するために GCC の既存コードをほんの少ししか変更していないことが分かる
[1]: https://github.com/ahgamut/gcc/tree/portcosmo-11.2
SIGxxxをインデックスとして使うコードにそこまで驚く理由が分からない。シグナル番号はきちんと定義されていて、たいてい連続しており 1 から始まるものとして知られているしかもそのエラーは、デバッグに長くかかった私のパッチのコンパイラエラーと一緒に出てきたので、印象に残る驚きだった
そうしたコードパターンが一般的だと考えているのか気になる。busybox 以外のどんなコードベースにあるのかも知りたい。例が多いなら、そのパターンを処理できるようにパッチを更新するために時間を使うかもしれない
errno がそんなに大きな問題なら、C++ 標準ライブラリと std::error_code を動かせる望みがあるのか分からない
そこでは魔法のようなマクロを使う手がない
std::error_codeは定数ではないので、同じ問題は起きないはずだ