プログラミング
C言語を救うためにはABIを救わなければならない
To Save C, We Must Save ABI (thephd.dev)
要約
この記事は、C言語におけるABI(Application Binary Interface)の重要性と、その安定性が直面する課題について論じています。ABIはコンパイラがハードウェアリソースの利用方法やデータ構造の配置について行う仮定であり、C言語のABI問題はC++や他の多くのソフトウェアにも影響を与えます。筆者は、ABIの制約がパフォーマンスの低下や設計空間の縮小を招く現状を指摘しつつも、ABIを改善・維持することの必要性を訴えています。
全文翻訳
前回のアプリケーションバイナリインターフェース(ABI)の安定性に関する火花散るような記事の後、まさか次の記事のタイトルがこれになるとは誰も予想していなかったでしょう?特に、ジェイソン・ターナー氏が全く同じテーマで行った非常に人気の高いC++ Weeklyの記事と直接矛盾しているため、これは特に悪いように思えます。ジェイソン氏の見解は110%正しく、私もそれに深く熱烈に同意します。ABIに関する私の最後の記事、「バイナリバンシーとデジタルデーモン」という不気味なタイトルでしたが、そこでも実装者が標準(そしてその逆ではない)に合わせるために標準ライブラリを後方互換性のない変更を加え、さらにABIを鞭として、新しく導入された機能の存在を脅かすことさえあることを示しました。しかし、ABIの安定性とそのすべての影響に対してこれほど激しい憎悪を抱いているなら、なぜそれを救う必要があると主張するのでしょうか?それは完全に破壊され、この世から追放されるべきではないでしょうか?前回の記事で私が主張した、反人間的な存在なのではないでしょうか?もしかしたら、私はBig Business™やBig MoneyⓇに感染してしまい、ABIの安定性を宣伝するためにここにいるのでしょうか?おそらく、ひっそりと標準ライブラリの取り組みに参加し、ABIが良いと信じ込ませるための心理作戦としてここにいるのかもしれません。あるいは、単に私はついに正気を失い、皆が私の書くものを無視し始めることができるのかもしれません!(幸運にも、あるいは不運にも)それらのどれも起こっていません。私の正気はすべてそのまま残っており、私は買収されていませんし、私が取り組んでいる唯一の標準ライブラリは私の自身のものです。それは、あるgitサーバー上のプライベートリポジトリに、どこかのRAIDストレージの中にしまわれています。しかし、私が着実に気づいているのは、より良い標準ライブラリのためにどれだけ私が扇動し、啓蒙しても、そしてMSVC STLを凌駕するビットコンテナをどれだけ書いても、それは単に私がビット演算に64ビット数を使用できるのに対し、彼らはバイナリ互換性の理由で32ビットに固執しているからですが、これらのシステムは私のために調子を変えることはないということです。私のためでも、ジェイソン・ターナー氏のためでもありません。あるいは、実際には、過去の選択のためにパフォーマンスと設計空間を失うことにうんざりしている他の誰のためでもありません。私たちは、このような永続的な決定を下すほど賢くはなかったのです。しかし、これは諦める必要があるという意味ではありません。結局のところ、ABIを壊す方法は一つだけではありません。冗談はさておき、皆が「ABI」とは本当に何であるかを理解していることが重要です。今回はCの側面からABIを見て、それが私たちを何を修正できなくさせているのかを見てみましょう。
モンスター - アプリケーションバイナリインターフェース
アプリケーションバイナリインターフェース、これをABIと略して呼ぶことにしますが、これは構造体を作成したり、CまたはC++コードで関数を書いて実際に何かをするときに、あなたが毎回署名する目に見えない契約です。特に、それはコンパイラが、単一のルーチンを超えた事柄を交渉する際に、コンピュータの実際のハードウェアリソースのビット単位の表現と使用方法について行う仮定です。これには以下のようなものが含まれます。
* 構造体/クラスのメンバーの位置、順序、レイアウト。
* 単一関数の引数型と戻り値型(C++のみ:および関連するオーバーロードされた関数)。
* 特定のクラスの「特殊メンバー」(C++のみ)。
* 仮想関数の階層と順序(C++のみ)。
* その他。
この記事はCに焦点を当てているため、ABIのC++部分についてはあまり心配しません。Cは物事をより単純に行う方法も持っているため、実質的には最も重要な2つのことに集約されます。構造体のメンバーの位置、順序、レイアウト。そして、関数の引数型と戻り値型です。もちろん、C++はC標準ライブラリ全体をほとんどそのまま、ごくわずかな変更で取り込むため、CのABIの問題はC++のABIの問題になります。実際、Cはあまりにも多くのソフトウェアの基盤となっているため、Cが自身で何をするかは実質的に皆の問題です。
ABIは具体的にCでどのように現れるのでしょうか?さて、簡単な例を挙げましょう。
CのABI
CのABIは「シンプル」です。なぜなら、あなたが書いた関数と、それがバイナリに吐き出されるシンボルとの間に、実質的に1対1の対応があるからです。例えば、long longパラメータを取り、long long値を返すdo_stuffという関数を宣言した場合、コードは次のようになります。
```c
#include <limits.h>
extern long long do_stuff(long long value);
int main () {
long long x = do_stuff(-LLONG_MAX);
/* wow cool stuff with x ! */
return 0;
}
```
そして、x86_64ターゲットの結果のアセンブリは次のようになります。
```assembly
main:
movabs rdi, -9223372036854775807
sub rsp, 8
call do_stuff
xor eax, eax
add rsp, 8
ret
```
これは、関数呼び出しの前に行われる単一レジスタで渡される64ビット数値としては妥当に見えます。さて、引数をこの場合64ビット数値であるlong longから、__int128_tのようなものに変更した場合に何が起こるか見てみましょう。
```c
#include <limits.h>
extern __int128_t do_stuff(__int128_t value);
int main () {
__int128_t x = do_stuff(-LLONG_MAX);
return 0;
}
```
型が変わっただけです!アセンブリはあまり変わらないはずですよね?
```assembly
main:
sub rsp, 8
mov rsi, -1
movabs rdi, -9223372036854775807
call do_stuff
xor eax, eax
add rsp, 8
ret
```
…ああ、いくつかの変更がありました。特に、rdiレジスタだけでなく、rsiも操作しています。これは、do_stuffの定義の中身やその仕組みを見ることなく、コンパイラがdo_stuffの定義を書く人たちとの間で契約を結んだことを示しています。long longバージョンでは、1つのレジスタのみが使用されると予想されます。そして、x86_64(64ビット)コンピュータでは、それはrdiでなければなりません。__int128_tバージョンでは、128ビットすべてを保持するために、rsiとrdiの2つのレジスタが使用されると予想されます。これは、do_stuffの定義を提供する人が、CPUのレジスタに至るまで、全く同じ規約を使用することを知って設定されます。これはソースコードレベルの契約ではありません。コンパイラがあなたの代わりに、他のコンパイラや他のマシンと結んだ契約なのです。これがアプリケーションバイナリインターフェースです。
私たちが強調する問題は、CおよびほとんどのCライクなABIに非常に特有のものであることに注意してください。例えば、__int128_tベースのdo_stuffのアセンブリをC++で示した同じmainの例を以下に示します。
```assembly
main:
push rax
movabs rdi, -9223372036854775807
mov rsi, -1
call _Z8do_stuffn
xor eax, eax
pop rcx
ret
```
この_Z8do_stuffnは、__int128_t引数を受け取るdo_stuff関数があることを示す方法です。引数型が奇妙な文字に変換されて最終的な関数名に注入されるため、C++リンカは、Cのものと比較して、どのシンボルを好むかで混乱することはありません。これは名前マングリングと呼ばれます。
この記事では、Cが名前マングリングを採用することを呼びかけるつもりはありません(Clangとその[[overloadable]]属性を除いて)。これは、私たちが説明していることを大幅に簡単にします。それでも、Cの直接的/非マングリングされたシンボルは、実際にはどれほど不安定になりうるのでしょうか?現在、Cコンパイルコードのアセンブリでの呼び出しには、関数の名前という1つの情報しかありません。単にdo_stuffを呼び出します。コード内でdo_stuffという名前のシンボルを見つけることができる限り、それはdo_stuffを呼び出すでしょう。では、do_stuffを実装してみましょう!
ABIを別の側面から見る
最初のバージョンはlong longのものですよね?簡単な関数にしましょう。負かどうかをチェックし、特定の数値(0)を返します。そうでなければ、何も行いません。以下は、do_stuffの定義を含む.cファイルです。
```c
long long do_stuff (long long value) {
if (value < 0) {
return 0;
}
return value;
}
```
負の数に対してのみですが、クランプのようなものです。いずれにせよ、この子が悪さをするものを見てみましょう。
```assembly
do_stuff:
xor eax, eax
test rdi, rdi
cmovns rax, rdi
ret
```
おお、すごい!cmovnsも見えます!しかし、全体として、このアセンブリはrdiの値を確認しているだけで、それは良いことです!そして、それを他の側に戻しています。