3 ポイント 投稿者 GN⁺ 2023-07-15 | 3件のコメント | WhatsAppで共有
  • Cosmopolitan Libc で既存の C ソフトウェアをさらに多くビルドするには、ビルドシステムをだますだけでは足りず、約 2,000 行規模の GCC パッチでコンパイラが問題のあるコードを自動変換する必要があった
  • 障害となったのは、SIGTERMEINVAL のような システム値がコンパイル時定数ではないため、C の switch case ラベルや static/const struct 初期化の規則に引っかかる点だった
  • sed、Python の正規表現、GCC プラグイン、プリプロセッサマクロの横取りまで試したが、case ラベルのエラーはパース中に発生するため、プラグインだけでは防ぎにくかった
  • 最終的なパッチは -fportcosmo フラグで有効化され、GCC がエラーを出す直前に switchif/goto に変換し、構造体初期化は実行時に値を埋める形で補う
  • 無名構造体、enum、一部の const int、初期化式内の配列インデックスといった制約は残るが、bashcurlgitninjagcc のようなソフトウェアを ソース変更なしで ビルドできる道が開かれた

GCC パッチの目標

  • Cosmopolitan Libc で Actually Portable Executables をより簡単に作るため、約 2,000 行の gcc パッチが作成された
  • パッチ適用後は ./configure または cmake のビルドシステムで次のソフトウェアをビルドできる
    • bash
    • curl
    • git
    • ninja
    • gcc 自身
  • ビルドされた実行ファイルは Linux、FreeBSD、MacOS、OpenBSD、NetBSD、Windows でも動作できるはずである
    • Windows での実行は直接テストしていないという注記がある
  • GitHub Actions でビルドされたバイナリは superconfigure releases から入手できる

Cosmopolitan Libc 移植で行き詰まった点

  • Cosmopolitan Libc が 2021 年に redbean ウェブサーバで注目を集めた後、既存の C ソフトウェアがこの libc 上でどれだけうまく動くかが重要な問いになった
  • Lua、Wren、Janet、quickjs といった言語ランタイムを移植する過程で、同じパターンが繰り返し現れた
  • SIGTERMEINVAL のような システム定数switch 文の case ラベルに使うと、C のコンパイル規則と衝突する
  • C 標準では case ラベルが コンパイル時定数であることを要求している
    • Cosmopolitan Libc ではシステムインターフェース関連の C プリプロセッサマクロが symbolic である必要があった
    • その結果、switch(errno) { case EINVAL: ... } のようなよくあるコードがコンパイルできないことがあった

switchifgoto に変える

  • switch(errno) 内の case EINVALcase 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 struct
    • static struct
    • グローバル const struct
    • グローバル static struct
  • 実例として CPython の faulthandler モジュール がある
  • 解決策は構造体をダミー値で初期化した後、使用前に正しい値を埋めるコードを追加する方法だった

テキスト置換と GCC プラグインの限界

  • 最初は sed シェルスクリプトで変換を自動化しようとし、その後 Python スクリプトと正規表現で fallthrough まで処理しようとした
  • しかし C プリプロセッサと ifdef のため、テキスト置換を完全に動かすのは難しかった
    • コンパイル時にどの ifdef が有効になるか事前に分かりにくいためである
  • 次の試みは GCC プラグインを使った AST 変換だった
    • GCC プラグインは -fplugin=your-plugin.so で読み込める
    • PLUGIN_PRE_GENERICIZEPLUGIN_FINISH_PARSE_FUNCTIONPLUGIN_FINISH_DECL のようなイベントで AST にアクセスできる
    • debug_tree で AST を出力し、walk_tree_without_duplicates で AST を巡回できる
  • 問題は 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 に戻した
  • このマクロハックは switchstruct 初期化エラーを回避することに成功した
  • この方法で少なくとも CPython 3.11 のビルドも可能になった

プラグインではなく GCC 自体を直した理由

  • Justine Tunney と議論した結果、問題が解けると確認できた後、GCC コードベース自体を直接修正するもっと単純なアプローチが提案された
  • プラグインとマクロハックは不要な作業が多く、エッジケースやクラッシュ処理が複雑だった
  • 例えば case ENOSYS だけ変換すればよい状況でも、マクロ置換のせいで ENOSYS + 1printf("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++11constexpr 処理と合わないため、任意式の置換対象から除外された
  • この変更後、ncurses は問題なくビルドできた

残っている制約と実用的な結論

  • パッチは完全ではない
    • 一部の無名構造体を処理できない
    • enumconst int の一部ケースを処理できない
    • 初期化式内で SIGTERM を配列インデックスに使うケースを処理できない
    • 奇妙な static 初期化や SIGTERMstatic const int8_t に入れる場合に、まれなコンパイラクラッシュが残ることがある
  • それでも明白な反例は多く取り除かれ、多くの人気ソフトウェアがスムーズにビルドできる
  • より厳しいテスト環境は追加の改善点を明らかにする可能性がある
  • 望むなら switch 文や struct 初期化式を手で直すこともできるが、多くの場合はコンパイラが自動処理できる
  • C ソフトウェアを静的にビルドでき、特に musl でもビルドできるなら、Cosmopolitan Libc でもビルドできる可能性が高い

3件のコメント

 
ryuheechul 2023-07-15

これから neo はメッセージをコードブロックで囲む形式になるのでしょうか? 横スクロールしなければならない点と、赤系のテキストカラーが少し残念ですね…。

 
xguru 2023-07-16

ああ、コードブロックで囲まれてしまうのはバグです ;_;

 
GN⁺ 2023-07-15
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

    • このパッチが GCC アップストリームに入る可能性があるのか気になる。APE 実行ファイルと cosmopolitan libc は非常にクールな技術なので、もっと使いやすくなってほしい。
    • いいね。Nim のようなトランスパイル言語の裏側でこれを使う場合、問題が起きる可能性がありそうか気になる。
    • busybox のビルドも試してみたのか気になる。
    • 最近 lobste.rs で jart の sockpuppet だという理由でブロックされたのを見たが、今になって本当に別人だったとわかった。
      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 を配列インデックスの初期化に使うコードベースに十分多く出会ったら、提案された方法を試してトレードオフを測ってみるつもりだ。あるいは自分で試してみて、別個の定数セットのほうが良いかどうか教えてくれてもよい。
  • 「明白な反例を取り除くのにかなり時間をかけ、多くの人気ソフトウェアがスムーズにビルドできる」のであれば、正常にコンパイルできるソフトウェアの一覧を公開すると役に立ちそうだ。すでにあるかもしれない。

    • vim、emacs、ninja、bash、git、gcc のようなソフトウェアをビルドできる。この手法でビルドしてみた一覧はこちら: https://github.com/ahgamut/superconfigure
      superconfigure スクリプトは、ソフトウェアのビルドに使う通常の configure スクリプトを包み、--enable-static のようなフラグを追加するラッパーにすぎない。
  • APEに実用的な目的があるのか気になる。複数のOSで動く単一の実行ファイルを実際に配布している例はあるのだろうか?

    • Logfile Navigator(https://lnav.org) では、リモートホストと通信するエージェントとして APE実行ファイルを使っている
      lnav 自体は APE でビルドしていないが、その中に組み込まれたエージェントは APE でビルドしている。ユーザーがそのホストのログを読みたいとき、そのエージェントをリモートに転送する
      こうすると OS の種類を判別したり、複数バージョンの実行ファイルを含めたりする追加の手順が不要になる。関連する短い記事はこちら: https://lnav.org/2021/05/03/tailing-remote-files.html
    • Java/Clojure/Kotlin プログラマー、.Net プログラマー、JS、Python、Ruby、Erlang、そしてあらゆる 仮想マシンベースの言語のプログラマーが思い浮かぶ
      静的コンパイルされたプログラムはすべての土台にあるが、アプリケーションレベルでは少数派だ
      APE は「C のような低レベルの AOT 言語が一度コンパイルすればどこでも実行できるなら、誰が仮想マシンを必要とするのか?」という大きな問いを投げかけている
    • たとえば maven リポジトリのように、同じ id/version の組み合わせに複数のアーティファクトを置くのが面倒な場所では、実行ファイルの配布を少し簡単にできそうだ。PyPI にも似たような問題がありそうだ
      しかも クロスコンパイルはつらい。複数の BSD をすべて備えたビルドファームを持たない小規模なオープンソースプロジェクトでは、そうしたプラットフォーム向けバイナリを最初から配布しないことが多い
      kubectl ですら Linux 向けバイナリはあるが BSD 向けはない。OSX を BSD に数えないなら、という話だが。だからこういうプロジェクトには手軽な利点に見える
    • 今後のサイドプロジェクトはそういう形でリリースしようと思っている。クライアント専用 SPA に対しても Redbean 実行ファイルをビルドしている。こういうものが複数のシステムでそのまま動くという発想は驚きだ
    • プラットフォーム非依存でそのまま実行できる実行ファイル形式があると助かる シェーダーコンパイラがある
      ユーザーのマシン上でその場でビルドするには大きすぎる。大きなサードパーティ製 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

    • これに言及するコメントを追加したが、まだ反映されていない。たぶんすぐ変わると思う。ここで @dang を呼んでもいいのだろうか?
  • switchif に変えるのはかなり雑だ。プラットフォーム中立な定数集合にマッピングして、ランタイムシンボルを関数で逆参照すれば改善できそうだ
    たとえば switch(CosmoMappErrno(EINVAL))case COSMO_EINVAL: のようにすれば、コードを goto の塊にしなくて済む

    • ただ、それでは未修正のコードをコンパイルする助けにはならない。コンパイラ内部で生成される 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 を試す前に git、bash、gcc、curl など複数のコードベースをビルドしてみたが、そういうコードは見たことがなかった
      しかもそのエラーは、デバッグに長くかかった私のパッチのコンパイラエラーと一緒に出てきたので、印象に残る驚きだった
      そうしたコードパターンが一般的だと考えているのか気になる。busybox 以外のどんなコードベースにあるのかも知りたい。例が多いなら、そのパターンを処理できるようにパッチを更新するために時間を使うかもしれない
  • errno がそんなに大きな問題なら、C++ 標準ライブラリと std::error_code を動かせる望みがあるのか分からない
    そこでは魔法のようなマクロを使う手がない

    • 問題はその 魔法のようなマクロだったようだ。普通マクロは定数に展開されるが、今はそうではなくなったので一部が壊れた
      std::error_code は定数ではないので、同じ問題は起きないはずだ