/note/tech

C Isn't A Programming Language Anymore

要約:

■ 1. Cはもはやプログラミング言語ではない

  • C ABIへの怒りと目的の違い:
    • phantomderpも自分もC ABIに強い不満を抱き、修正に取り組んでいる
    • phantomderpはC自体をプログラミング言語として使う環境の改善を目指している
    • 自分はC以外のあらゆる言語を使う環境の改善を目指している
  • 問題の本質:
    • Cの実装が無数にあり定義が曖昧なことや、整数型の階層設計の失敗はそれ自体では自分の問題ではない
    • 問題はCが絶対的な地位に祭り上げられ、言語間のやり取りの在り方を歪めてしまったこと
    • RustやSwiftは自分の言葉で話せず、Cの皮をかぶってCと同じように振る舞わざるを得ない
  • プロトコルとしてのC:
    • Cはプログラミングの共通語であり、全ての汎用言語が話すべきプロトコルになっている
    • Cの実装依存の混乱は、このプロトコルをさらなる悪夢にする限りにおいて問題となる

■ 2. 外部関数インターフェース(FFI)

  • 新言語がOSと対話する必要性:
    • 新言語を作っても、入出力など観測可能な処理を行うにはOSのインターフェースを使う必要がある
    • Linuxでファイルを開こうとすると、manページにはCのヘッダと関数シグネチャしか載っていない
    • 新言語向けのLinuxインターフェースは存在せず、与えられたものを使うしかない
  • FFIの普及:
    • 外部の関数を呼ぶ仕組みとしてFFIが必要になり、Rust、Swift、PythonもC FFIを備えている
    • AMD64 SysV ABIの呼び出し規約自体がCの型で定義されている
  • Cが共通語になった経緯:
    • 主要OSと話すために誰もがCを習得した
    • その結果、言語同士が話す際にもCを使うようになった
    • こうしてCは単なる言語ではなくプロトコルになった

■ 3. Cと話すことの中身

  • Cと話すとは:
    • Cヘッダの形で型と関数の記述を受け取り、型のレイアウトを一致させる
    • リンカを使って関数シンボルをポインタとして解決する
    • 引数を正しいレジスタに置くなど、適切なABIで関数を呼び出す
  • 生じる問題:
    • Cのパーサーは事実上書けない
    • CにはABIも、定義された型レイアウトも実際には存在しない

■ 4. Cヘッダはパースできない

  • C解析の事実上の不可能性:
    • rust-bindgenのようなツールも実際にはlibclangを使ってヘッダを解析している
    • C(++)ヘッダを解析しようとした者はすぐに諦め、C(++)コンパイラに任せる
    • 意味のある解析には#include、typedef、マクロの解決まで必要になる
    • プラットフォームのヘッダ解決ロジックや、環境で何がDEFINEされているかも把握しなければならない
  • Swiftの事例:
    • AppleがObjective-Cの代替としてシステムAPI用に開発した言語であり、ABI安定性を誰よりも追求している
    • (Objective-)C(++)ヘッダをネイティブにインポートし、型をSwift側へ自動的にブリッジできる
    • ClangやLLVMを開発・保守する世界的な専門家たちが開発に関わっている
    • それでもDoug Gregorは、ABIに影響するClangの新属性を追いかけずに済むよう内部でClangを使うと述べている
    • Swiftですらこの問題に正面から取り組む気はない
  • 手作業による翻訳:
    • コンパイル時にCコンパイラを使いたくなければ、ヘッダを手で翻訳するしかない
    • int64_tはi64に置き換えられるが、longが何かは決められない

■ 5. CにはABIが存在しない

  • 可変サイズの整数型:
    • 移植性のために可変サイズとして設計された整数型は、実際に可変サイズである
    • CHAR_BITの問題を除外しても、longのサイズやアラインメントはわからない
  • ターゲットトリプルの多さ:
    • 各プラットフォームの呼び出し規約やABIは主要なプリミティブのレイアウトを定めている
    • しかしABIを決めるのはアーキテクチャでもOSでもなく、x86_64-pc-windows-gnuのようなターゲットトリプルである
    • rustcが対応するターゲットトリプルは176種類あり、ABIが多すぎる
    • さらにstdcallとfastcall、aapcsとaapcs-vfpといった呼び出し規約の違いもある
    • これらのABIは機械可読な形式ではなく、人間向けの文章で書かれたPDFでしか提供されていない
  • clangとgccの不一致:
    • 自作のFFI abi-checkerをx64 Ubuntu 20.04で動かすと、clangとgcc間の単純な整数の値渡しが失敗する
    • 両者は__int128のABIでさえ一致せず、452件中60件のテストが失敗した
    • __int128はgcc拡張だが、AMD64 SysV ABIのPDFで明示的に定義されている
    • rustcの誤りを見つけるために作ったツールで、主要Cコンパイラ同士の不整合が見つかった
    • ABIは嘘である

■ 6. Cを手なずける試み

  • 前提となる状況:
    • Cヘッダの意味的な解析は、そのプラットフォームの公式なCコンパイラにしかできない
    • コンパイラから型や注釈の意味を得ても、全てのサイズ、アラインメント、呼び出し規約がわかるわけではない
  • 第1の選択肢はCとの一体化:
    • コンパイラやランタイムをC(++)で書き、ネイティブにCを話す
    • コード生成でC(++)を出力し、ユーザーにCコンパイラを使わせる
    • gccやclangなど既存の主要Cコンパイラの上にコンパイラを構築する
    • ただしunsigned long longをそのまま公開しない限り、Cの移植性の混乱を引き継ぐ
  • 第2の選択肢は手翻訳:
    • どうせ混乱するなら、型やインターフェースの定義を自言語に手で翻訳してしまえばよい
    • Rustでは日常的に行われており、bindgenを使っても定義はリポジトリに登録されたり手で調整されたりする
    • 他人の独自のCビルドシステムを移植可能に動かすのは手間がかかりすぎるためである
  • 型定義のハードコード:
    • Rustはx64 Linuxのintmax_tをi64と定義している
    • Nimはx64 Linuxのlong longをint64と定義している
    • 多くのコードはCを介さず、中核的な型の定義をプラットフォームABIの一部として固定している
    • intmax_tのサイズ変更は明らかにABIを壊すため、起こらないという前提に立っている

■ 7. intmax_t変更案がもたらす問題

  • phantomderpの提案:
    • intmax_tをlong longから__int128_tに変えると、古いバイナリが誤った呼び出し規約を使ってしまう
    • Transparent Aliasesを使い、新しいアプリだけ関数呼び出しを更新できないかを検討している
  • 他言語から見た問題:
    • 重要な問題に取り組んでいるが、プログラミング言語はこの変更にどう対処すればよいのかわからない
    • どの版のintmax_tと相互運用するかを指定する手段がない
    • Cヘッダ中のintmax_tがどちらの定義を使っているのかもわからない
  • ターゲットトリプルへの影響:
    • 異なるABIを区別する主な手段はターゲットトリプルである
    • x86_64-unknown-linux-gnuは過去20年の主要なデスクトップ、サーバー向けLinuxディストリをほぼ全て含む
    • 現在はこのターゲットでビルドすれば全環境で動くが、intmax_tのサイズ認識が混在すればそうはならない
    • 変更するプラットフォームはx86_64-unknown-linux-gnu2のような新トリプルになるのか疑問である
    • 旧トリプル向けバイナリの実行が許されるなら、新トリプルを作っても不十分かもしれない

■ 8. ABIを壊さずにシグネチャを変える方法

  • C改善の可否:
    • Cが改善できないわけではないが、設計が悪かったために改善が難しくなっている
    • ABI互換の変更は一種の技芸であり、事前の備えがあればはるかに容易になる
  • シンボルバージョニング:
    • glibcはシンボルバージョニングで旧版を残しつつシグネチャやAPIを更新している
    • int32_t my_rad_symbol(int32_t)をmy_rad_symbol_v1としてエクスポートし、利用者のコードはmy_rad_symbolと書く
    • int64_t版をmy_rad_symbol_v2として追加しても、旧ヘッダでビルドしたものはv1を使い続ける
  • 残る互換性の問題:
    • 新ヘッダでビルドしたものは、v2シンボルを持たない旧版ライブラリとリンクできない
    • 新機能を使うには古いシステムとの非互換を受け入れる必要がある
    • 致命的ではないが、新機能が普及して依存できるようになるまで何年も待つことになる
    • 古いプラットフォームのサポートを切るか、動的チェックとフォールバックを実装する必要がある
    • 即座のアップグレードを許すには、古い版が新機能と協調できる前方互換性が必要になる

■ 9. ABIを壊さずに型を変える方法

  • 不透明型の活用:
    • Cの優れた点は、レイアウトが既知の型と未知の型を区別できること
    • ヘッダで前方宣言だけされた型は、利用者がレイアウトを知らず常にポインタ越しに扱う
    • make_valやuse_valをシンボルバージョニングし、レイアウト変更時に関連する全てのバージョンを上げる
    • MyRadTypeV1、MyRadTypeV2とtypedefを用意し、正しい版を使わせる
  • 複数ライブラリ間の問題:
    • lib1がMyRadType*を受け取ってuse_valを呼び、lib2がmake_valの結果をlib1に渡す場合がある
    • 両者が異なる版でビルドされていると、make_val_v1の結果がuse_val_v2に渡されてしまう
    • 対処はこれを禁止して違反者を咎めるか、混在しても問題ない前方互換な設計にするかのいずれかである
  • 前方互換のための技法:
    • 将来の版のために未使用フィールドを予約する
    • 全ての版に共通のプレフィックスを持たせ、どの版かを判別できるようにする
    • 自身のサイズを示すフィールドを持たせ、古い版が新しい部分を読み飛ばせるようにする

■ 10. 事例: MINIDUMP_HANDLE_DATA

  • Microsoftの前方互換性:
    • Microsoftは前方互換性の達人であり、重要なものはアーキテクチャ間でもレイアウト互換に保っている
    • Minidumpapiset.hのMINIDUMP_HANDLE_DATA_STREAMは、バージョン管理された値のリストを記述する
  • ヘッダ構造体のフィールド:
    • SizeOfHeaderはヘッダ自体のサイズで、版の判別と未知フィールドの読み飛ばしに使える
    • SizeOfDescriptorは配列要素のサイズで、要素の版の判別と読み飛ばしに使える
    • NumberOfDescriptorsは配列の長さを表す
    • Reservedは予約領域で、構造体サイズを8の倍数にしてヘッダ後のパディングの疑義をなくすためと考えられる
    • パディングバイトは値が未規定であり、シリアライズされるバイナリ形式のためパディングを一切置いていない
  • 2つの版の配列要素:
    • MINIDUMP_HANDLE_DESCRIPTORとMINIDUMP_HANDLE_DESCRIPTOR_2を定義している
    • 変更は末尾へのフィールド追加のみである
    • 最新版を指すtypedefとしてMINIDUMP_HANDLE_DESCRIPTOR_Nを用意している
    • パディングになり得る部分を再びReservedとして確保している
  • 堅牢性:
    • パディングに注意しているため、32ビットと64ビットで同じレイアウトになる
    • あるアーキテクチャのminidump処理系が全アーキテクチャのminidumpを扱えるため、これは重要である
    • 参照渡しで操作しサイズフィールドを使うというルールに従う限り、非常に堅牢である
    • 守るべきルールが明確である以上、どこかで誤用だと言うしかない
    • ただしMicrosoftはそうは言わず、代わりにひどい対処をするだろう

■ 11. 事例: jmp_buf

  • glibc s390のABI破壊:
    • LWNの記事によれば、glibcはs390で型のABIを壊したことがあり、混乱を招いた
    • setjmpやlongjmpで使う状態保存型jmp_bufのレイアウトを変更した
    • ABI破壊を理解したうえで、責任ある対応としてシンボルバージョニングを行っていた
  • 問題の原因:
    • jmp_bufは不透明型ではなく、Perlのランタイムなど他のものがインラインで保持していた
    • この目立たない型が多くのバイナリに入り込み、Debianの全てを再コンパイルする必要が生じた
  • libcのバージョンを上げる案:
    • 混在環境でSO名を上げると2つのlibcが読み込まれ、同じシンボル名前空間を奪い合う
    • どちらのABIが選ばれるかはELFの差し込みとスコープ規則で決まり、悪夢となる
    • 全員に再ビルドさせる方がまだましな可能性がある

■ 12. intmax_tは本当に変更できるか

  • 変更は事実上不可能:
    • intmax_tはjmp_bufと同様に不透明型ではない
    • 無数の構造体にインラインで含まれ、多くの言語やコンパイラが特定の表現を前提にしている
    • libc、Linux、ディストリのメンテナが管理できない多くの公開インターフェースに含まれている
    • libcがシンボルバージョニングでAPIを対応させても、エコシステム全体に混乱を招く
  • 変更に必要な条件:
    • 新しいターゲットトリプルを作り、旧ABI向けのバイナリやライブラリの実行を禁止する必要がある
    • その作業は可能だが、それを担うディストリは気の毒である
  • x64のintの教訓:
    • 基本的な型は長く同じサイズであるため、検出できない前提が無数のアプリに潜んでいる可能性がある
    • intは本来64ビットになるはずだったが、x64でも32ビットのままである
    • 新しいアーキテクチャとターゲットトリプルであっても、ソフトウェアを新サイズに対応させるのは絶望的だった
  • 結論:
    • 取り返しのつかない過ちもある
    • Cが自己完結した言語なら変更してよいが、実際は悪いプロトコルであり、それでも使わざるを得ない
    • 世界を征服したCは、もはや良いものを手に入れられないのかもしれない
    • イングランドが自らを改善しようとしていないのと同じである

MEMO: