プログラミング
C++ ABIを徹底的に理解する
Thoroughly Understanding C++ ABI (ykiko.me)
要約
この記事は、C++のアプリケーションバイナリインターフェース(ABI)について、CPUとOSの互換性、オブジェクトファイルフォーマット、データ表現、関数呼び出し規約といった側面から詳細に解説しています。ABIの安定性がプログラムの互換性にいかに重要であるかを、具体的な例を交えながら説明しています。
全文翻訳
目次CPU & OSオブジェクトファイルフォーマットデータ表現関数呼び出し規約C++標準明示的なオブジェクトパラメータstatic operator()コンパイラ固有のデファクトスタンダードワークアラウンド重要なオプションランタイム & ライブラリユーザーコードextern “C”結論この記事は、元の中国語版をGemini 2.5 Proを使用してAIが翻訳したものです。軽微な不正確さが残る可能性があります。
アプリケーションバイナリインターフェース、一般にABIと呼ばれるこの概念は、馴染み深くもあり、馴染みなくもあります。馴染み深いとはどういう意味でしょうか?トラブルシューティングの際にしばしば議論され、記事で頻繁に言及され、時にはそれが引き起こす互換性の問題に対処しなければならないこともあります。馴染みなくありとはどういう意味でしょうか?もし誰かにABIとは何かと尋ねられたら、あなたはそれが何であるかを知っているが、正確な言葉で説明するのは非常に難しいことに気づくでしょう。結局、WIKIが言うように、「ABIは2つのバイナリプログラムモジュール間のインターフェースです」と言うだけかもしれません。それに問題はありますか?いいえ、一般的な説明としては十分です。しかし、少し空虚に感じられるかもしれません。この状況はコンピュータサイエンスの分野では珍しくありません。著者は以前、リフレクションについて議論した記事で全く同じ状況に遭遇しました。根本的に、CSは絶対的な厳密さを追求する学問ではなく、多くの概念には厳密な定義がなく、慣習的な理解であることが多いのです。したがって、定義にこだわりすぎるのではなく、これらのいわゆるバイナリインターフェースが実際に何であるか、そしてそれらの安定性に影響を与える要因を見ていきましょう。
CPU & OS
最終的な実行可能ファイルは、最終的には特定のCPU上の特定のオペレーティングシステムで実行されます。CPUの命令セットが異なれば、確実にバイナリの非互換性につながります。例えば、ARM上のプログラムは、(何らかの仮想化技術が使用されない限り) x64プロセッサ上で直接実行することはできません。命令セットが互換性がある場合はどうでしょうか?例えば、x64プロセッサはx86命令セットと互換性があります。それは、x86プログラムがx64オペレーティングシステム上で確実に実行できることを意味するのでしょうか?ここでオペレーティングシステムが関わってきます。具体的には、オブジェクトファイルフォーマット、データ表現、関数呼び出し規約、ランタイムライブラリといった要因を考慮する必要があります。これらの点は、オペレーティングシステムレベルでのABIの規則と見なすことができます。4番目の点については、後で専用のセクションで議論します。以下では、x64プラットフォームを例にとり、最初の3つの点について議論します。x64、x86-64、x86_64、AMD64、Intel 64はすべて、x86命令セットの64ビットバージョンを指します。x64プラットフォームには、主に2つの一般的なABIがあります。
Windows x64 ABI: 64ビットWindowsオペレーティングシステム用
x86-64 System V ABI: 64ビットLinuxおよびさまざまなUNIXライクオペレーティングシステム用
動的ライブラリから関数を呼び出すことは、単純に以下の3つのステップとして見ることができます。
特定のフォーマットに従って動的ライブラリを解析する。
シンボル名に基づいて、解析結果から関数アドレスを検索する。
関数パラメータを渡し、関数を呼び出す。
オブジェクトファイルフォーマット
動的ライブラリをどのように解析するのでしょうか?ここでABIのオブジェクトファイルフォーマットに関する規則が登場します。独自のリンカを書きたい場合、最終的な実行可能ファイルは対応するプラットフォームのフォーマット要件を満たす必要があります。Windows x64はPE32+実行可能ファイルフォーマットを使用しており、これはPE32(Portable Executable 32-bit)の64ビットバージョンです。System V ABIはELF(Executable Linkable Format)実行可能ファイルフォーマットを使用します。pe-parseやelfioのような解析ライブラリ(興味があれば自分で書くこともできます)を使用して実際の実行ファイルを解析し、シンボルテーブルを取得することで、関数名と関数アドレスのマッピングを得ることができます。
データ表現
関数アドレスを取得した後、次のステップはどのように呼び出すかです。呼び出す前に、パラメータを渡す必要がありますよね?パラメータを渡す際、データ表現の一貫性に特別な注意を払う必要があります。これはどういう意味でしょうか?例えば、以下のファイルを動的ライブラリにコンパイルしたとします。
struct X{ int a; int b; }; int foo(X x){ return x.a + x.b; }
その後、後続のバージョンアップで構造体の内容が変更され、ユーザーのコードで見られる構造体の定義が次のようになるとします。
struct X{ int a; int b; int c; };
そして、古いバージョンのコードからコンパイルされた動的ライブラリにリンクし、その関数を呼び出そうとします。
int main(){ int n = foo({1, 2, 3}); printf("%d\n", n); }
成功するでしょうか?もちろん、失敗します。この種の誤りは、いわゆるODR(One Definition Rule)違反と見なすことができます。後続のセクションでさらに例を議論します。上記の状況は、ユーザーが積極的にコードを変更したことによって引き起こされたODR違反です。しかし、積極的にコードを変更しない場合、構造体のレイアウトの安定性を保証できるでしょうか?これはABIのデータ表現によって保証されます。例えば、基本型のサイズとアラインメントを指定します。Windows x64はlongを32ビットと指定しますが、System Vはlongを64ビットと指定します。また、structやunionのサイズとアラインメントなども指定します。注意点として、C言語の標準は依然としてABIを指定していません。System V ABIは主にC言語の用語と概念を使用して書かれているため、C言語のABIを提供していると見なすことができます。Windows x64 ABIには、CとC++の間に非常に明確な境界はありません。
関数呼び出し規約
次に、関数パラメータの渡しに進みます。関数は単なるバイナリデータの一部であることを知っています。関数を実行することは、単に関数のエントリポイントアドレスにジャンプし、そのコードを実行し、終了したらジャンプバックすることを意味します。パラメータ渡しは、データを格納する場所を見つけることに他なりません。この場所は、呼び出しの前後にアクセスしてデータを取得できるようにする必要があります。どのような場所を選択できるでしょうか?主に4つの選択肢があります。
グローバル(グローバル変数)
ヒープ(ヒープ)
レジスタ(レジスタ)
スタック(スタック)
グローバル変数を使ったパラメータ渡しは魔法のように聞こえますが、実際には、コードを書く際に、設定などの繰り返し渡す必要があるパラメータはグローバル変数に変更されることがよくあります。しかし、すべてのパラメータがグローバル変数渡しに適しているわけではなく、スレッドセーフティをさらに慎重に考慮する必要があることは明らかです。ヒープを使ったパラメータ渡しは信じられないように聞こえますが、実際には、C++20のスタックレスコルーチンはコルーチン状態(関数パラメータ、ローカル変数)をヒープに格納します。しかし、通常の関数呼び出しでは、パラメータを渡すたびに動的メモリ割り当てが必要な場合、それは確かにいくらか贅沢です。そのため、主にレジスタとスタックを使ったパラメータ渡しを検討します。選択肢が多いのは良いことですが、ここではそうではありません。呼び出し側がパラメータをレジスタ経由で渡すべきだと考えている場合、パラメータをレジスタに格納します。しかし、呼び出し先はパラメータをスタック経由で渡すべきだと考えているため、スタックからデータを取得します。不一致が生じ、スタックからガベージ値が読み取られる可能性が非常に高く、コードに論理的な誤りが発生し、プログラムがクラッシュします。呼び出し側と呼び出し先が同じ場所にパラメータを渡すことをどのように保証するのでしょうか?これは関数呼び出し規約が登場する場面だと、あなたはすでに推測しているはずです。具体的には、呼び出し規約は以下を指定します。
関数パラメータの渡しの順序:左から右か、右から左か?
関数パラメータと戻り値の渡し方:スタック経由か、レジスタ経由か?
呼び出しの前後に変更されないレジスタはどれか?
スタックフレームのクリーンアップを担当するのは誰か:呼び出し側か、呼び出し先か?
C言語の可変長関数をどのように処理するか?
32ビットプログラムでは、__cdecl、__stdcall、__fastcall、__thiscallなど、多くの呼び出し規約があり、当時のプログラムはその互換性の問題に大きく悩まされました。64ビットプログラムでは、統一が大きく達成されています。主に2つの呼び出し規約があり、Windows x64 ABIとx86-64 System V ABIによって指定されたものです。