Application Binary Interface
(Post in Indonesian) Ada API, ada ABI.
Mungkin kita sudah biasa dengar istilah API (Application Programming Interface) sebagai “aturan” yang ditentukan aplikasi/library untuk aplikasi lain agar bisa memanggil function yang disediakan. Sebenarnya ada lagi “aturan” di assembly yang harus dipenuhi saat sebuah binary program ingin memanggil fungsi dari suatu library yang sudah berbentuk binary. Istilahnya dinamakan ABI (Application Binary Interface). Post ini akan mendemonstrasikan apa itu ABI dan apa yang terjadi apabila kita tidak memenuhi ABI dari dua buah program, atau yang biasa dinamakan ABI breakage.
Post ini akan menggunakan bahasa pemrograman C++. Jangan khawatir, penulis mencoba untuk membuat kode yang cukup umum dan bisa dimengerti oleh pengguna bahasa lain.
Demonstrasi
Referensi kode di bawah bisa diakses di sini. Selamat bereksperimen!
Mari kita tinjau apa yang sebenarnya terjadi ketika kita memanggil sebuah fungsi. Ambil contoh kode C++ berikut, aplikasi kita (main.cpp
) akan membuat object MyObject
dan memanggil fungsi GetVersion
yang akan membaca informasi versi yang telah kita tentukan.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "my_library_v1/my_library.hpp"
#include <iostream>
int main(int argc, char* argv[]) {
MyObject the_object = {
.ver_minor = 0xCD // 0xAD = 205
.ver_major = 0xAB, // 0xDE = 171
};
int version = GetVersion(the_object);
std::cout << "Version: " << version << '\n';
return 0;
}
Implementasi GetVersion(MyObject& the_object)
berada di sebuah library terpisah bernama my_library
buatan developer lain, dan kita hanya memiliki hasil compilenya saja.
1
2
3
4
5
6
7
8
struct MyObject {
uint8_t ver_minor;
uint8_t ver_major;
};
extern int GetVersion(MyObject& the_object) {
return the_object.ver_major * 10000 + the_object.ver_minor;
}
Jika kita eksekusi program tersebut, kita akan dapatkan output:
1
Version: 1710205
Program kita bekerja dengan baik.
Update terbaru
Andaikan sang pembuat library ingin meng-update librarynya. Andaikan update tersebut adalah menambahkan informasi berapa kali GetVersion()
dipanggil dengan object tersebut.
Pada my_library
versi 2:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
struct MyObject {
uint8_t ver_minor;
uint8_t ver_major;
void IncrementVersionCalls() {
++version_calls_;
}
int CountVersionCalls() {
return version_calls_;
}
private:
uint8_t version_calls_ = 0;
};
extern int GetVersion(MyObject& the_object) {
the_object.IncrementVersionCalls();
int call_cnt = the_object.CountVersionCalls();
std::cout << "GetVersion was called. Count: " << call_cnt << '\n';
return the_object.ver_major * 10000 + the_object.ver_minor;
}
Struktur MyObject
hanya berubah di bagian private-nya, dan tidak ada API yang berubah dari cara pemanggilan GetVersion()
, maupun cara membuat object MyObject
dari bagian program main
. Kita akan coba jalankan main
menggunakan library versi baru ini:
1
2
GetVersion was called. Count: 89
Version: 1710205
Output-nya tidak sesuai yang diharapkan!
Apa yang terjadi?
Yang terjadi sebenarnya bisa dibilang “kesalahpahaman” dari dua buah binary object kita.
Perbedaan dari struktur memory
Struktur MyObject
yang dipahami oleh program main
kita merupakan struktur yang berasal dari versi 1 dari my_library
, yaitu:
1
2
3
4
struct MyObject {
uint8_t ver_minor;
uint8_t ver_major;
};
Sedangkan struktur MyObject
yang dipahami oleh my_library
versi 2:
1
2
3
4
5
6
7
8
struct MyObject {
uint8_t ver_minor;
uint8_t ver_major;
// IncrementVersionCalls dan GetVersionCalls
// tidak mempengaruhi struktur.
private:
uint8_t version_calls_ = 0;
};
Walaupun hanya berbeda member dalam scope private
yang tidak akan terlihat oleh program main
kita, namun penambahan tersebut mempengaruhi struktur memory MyObject
kita.
Struktur memory dari sebuah object sangatlah penting. Kedua binary (main
dan my_library
) harus memiliki pemahaman yang sama. Urutan setiap member pun juga harus sama, seperti posisi ver_minor
tidak boleh tiba-tiba ditukar dengan ver_major
walaupun secara semantik, object yang dihasilkan sama.
Untuk membuktikan bahwa struktur memory object yang dibaca oleh program kita dan my_library
berbeda, untuk kasus ini kita bisa buktikan dari size-nya. Kita buat program bernama sizecomp
:
1
2
3
4
5
6
7
8
9
10
#include "my_library_v1/my_library.hpp"
#include <iostream>
int main(int argc, char* argv[]) {
// Hasil "size" dalam bytes
std::cout << "My program thinks that sizeof(MyObject) = " << sizeof(MyObject) << '\n';
std::cout << "My library thinks that sizeof(MyObject) = " << GetObjectSize() << '\n';
return 0;
}
Dan untuk my_library
versi 1 dan 2, kita tambahkan implementasi GetObjectSize()
seperti berikut:
1
2
3
extern int GetObjectSize() {
return sizeof(MyObject);
}
Apabila program sizecomp
dijalankan dengan my_library
versi 1:
1
2
My program thinks that sizeof(MyObject) = 2
My library thinks that sizeof(MyObject) = 2
Apabila program sizecomp
dijalankan dengan my_library
versi 2:
1
2
My program thinks that sizeof(MyObject) = 2
My library thinks that sizeof(MyObject) = 3
Maka sudah terlihat ada perbedaan pada pemahaman dari my_library
versi 2.
Konsekuensi
Perlu diketahui bahwa dalam hasil kompilasi C++, program tidak mengenali nama dari setiap member seperti ver_minor
, ver_major
, dan version_calls_
. Program hanya mengetahui posisi setiap member dalam bentuk “offset”. Sebagai contoh, misalkan sebuah object MyObject
berada di alamat memori 0x1234_1000
:
- Untuk mengakses
uint8_t ver_minor
, program akan mengakses alamat memori dengan offset 0, menjadi0x1234_1000
. - Untuk mengakses
uint8_t ver_major
, program akan mengakses alamat memori dengan offset 1, menjadi0x1234_1001
. - Untuk mengakses
uint8_t version_calls_
, program akan mengakses dengan offset 2, menjadi0x1234_1002
.
Offset dari member akan bergantung berdasarkan jumlah byte member sebelumnya, dan beberapa byte juga bisa ditambahkan tergantung compiler dan arsitektur komputer (biasa disebut “alignment padding”, namun kita tidak akan membahasnya disini).
Konsekuensi dari perbedaan ini bisa dilihat seperti berikut. Program yang mengalokasikan object MyObject
adalah program main
kita yang tidak memahami keberadaan version_calls_
. Maka dalam memory, yang dipersiapkan oleh program tersebut hanya 2 byte seperti ini:
1
2
3
4
5
6
7
0x1234_0FFE [???]
0x1234_0FFF [???]
0x1234_1000 [uint8_t ver_minor] <--- MyObject yang dialokasikan oleh main
0x1234_1001 [uint8_t ver_major]
0x1234_1002 [???]
0x1234_1003 [???]
0x1234_1004 [???]
Ketika kita memanggil GetVersion()
milik my_library
versi 2, fungsi tersebut menganggap MyObject
yang kita berikan memiliki struktur yang library tersebut pahami (yaitu 3 byte), maka yang terjadi adalah:
1
2
3
4
5
6
7
0x1234_0FFE [???]
0x1234_0FFF [???]
0x1234_1000 [uint8_t ver_minor] <--- MyObject yang diterima oleh my_library
0x1234_1001 [uint8_t ver_major] |
0x1234_1002 [???] <----------------- my_library menganggap bagian ini adalah 'uint8_t version_calls_'
0x1234_1003 [???]
0x1234_1004 [???]
Bisa kita lihat sekarang my_library mengakses memory yang tidak sesuai alokasi, yang akan dilihat adalah nilai random (undefined behavior).
Interaksi seperti ini biasa kita namakan ABI breakage.
ABI
Dari demonstrasi di atas, kita bisa melihat ABI sebagai “interface” antar dua buah binary object. Hal ini agak berbeda dari API yang mana biasanya dispesifikasikan dari “high level”, seperti nama fungsi yang bisa dipanggil, parameternya seperti apa, hasilnya berbentuk apa, dan lain-lain. Dalam ABI, karena kita bekerja dalam layer low-level (assembly), kita melakukan spesifikasi low-level seperti berikut yang harus dipenuhi kedua binary:
- Dalam memanggil fungsi ke binary lain (termasuk system call), misalkan
int MyFunc(int a, int b)
:- Dimana letak implementasi fungsi
MyFunc
di binary tersebut? (topik ini berkaitan dengan linking dan symbol name mangling) - Dimana kita harus menaruh nilai argument
a
danb
? Ke dalam register CPU? atau memory? - Dimana fungsi tersebut akan menaruh return value-nya?
- Dimana letak implementasi fungsi
- Dalam struktur memory sebuah object, misalkan
MyObject
:- Bagaimana mendefinisikan bentuk
MyObject
dalam memory layout yang bisa dipahami binary lain?
- Bagaimana mendefinisikan bentuk
- Dan lain-lain.
Beberapa aspek di atas biasanya sudah diatur dari spesifikasi ABI yang dibuat oleh vendor CPU, seperti ARM ABI atau Itanium C++ ABI.
Konsekuensi dari ABI breakage bisa bermacam-macam, seperti compile gagal yang bisa kita ketahui di awal, atau undefined behavior yang sangat sulit kita sadari, seperti demonstrasi di atas.
Walaupun bugnya terlihat menyeramkan dan sulit di-debug, para library developer seharusnya sudah memastikan ABI breakage sangat sulit terjadi kepada penggunanya. Jadi, kita sebagai pengguna biasa tidak perlu takut.
Memastikan program kita tidak “rusak”
Untuk memastikan ABI incompatibility seperti di atas tidak terjadi. Ada dua sisi opini disini:
- Menerapkan versioning pada library, misalnya pada versi 7.0.0 sang developer library ingin mengubah struktur object agar performa lebih kencang.
- Aplikasi yang dicompile menggunakan library versi sebelum 7.0.0 tidak bisa dijalankan dengan library 7.0.0, atau harus di-compile ulang menggunakan versi 7.0.0
- Konsekuensinya, komputer kita harus mempersiapkan banyak versi dari library yang sama.
- Pastikan bahwa library kita selalu ABI compatible/stable.
- Aplikasi yang dicompile menggunakan library versi lama, tetap bisa jalan dengan library versi baru.
- Konsekuensinya, sangat sulit untuk meng-improve library kita, karena kita harus memenuhi aturan agar ABI stable, seperti tidak boleh mengubah struktur object (menambah jenis object baru diperbolehkan).
Setiap library mempunyai stance-nya masing-masing dengan alasannya tersendiri. Contohnya, komite C++ lebih memilih untuk memastikan librarynya ABI stable. Keputusan tersebut disambut dengan kurang baik oleh komunitas.
Mengubah ABI juga terkadang bisa merugikan user, berikut contoh argumen yang diberikan Marshall Clow di CppCon2020 “What is an ABI, and Why is Breaking it Bad?”: Kutipan dari talk CppCon2020 tentang ABI
Sebuah aplikasi (Photoshop) mengeluarkan versi baru yang mempunyai dependency ke library versi lebih baru (dylib). Walaupun aplikasi utama berjalan dengan baik karena developer Photoshop sudah mempersiapkan library tersebut, plugin dari third party belum tentu bisa berjalan dengan baik juga.
Developer plugin harus menyesuaikan plugin mereka dengan dylib versi terbaru, dan hal ini merupakan sebuah proses yang memakan waktu. Belum tentu juga developer tersebut punya waktu dan kemauan untuk mengurus hal tersebut.
Apa hal paling gampang yang bisa dilakukan seorang user agar bisa bekerja seperti biasanya? Mau tidak mau, kembali ke versi Photoshop yang lama.
Kesimpulan
ABI (Application Binary Interface) merupakan sebuah “aturan” yang harus disepakati dan dipahami dua buah binary object yang saling berinteraksi. Berdasarkan demonstrasi di atas, perubahan struktur object, walaupun hanya menambahkan member private tetap bisa menyebabkan ABI breakage. Perlu perhatian khusus dalam me-maintain library agar ABI breakage tidak terjadi, seperti memastikan memory layout suatu object yang public tidak berubah.
ABI incompatibilty sangatlah sulit di-debug. Kita sebagai pengguna library, biasanya hanya menerima bentuk binary dari library tersebut tanpa memikirkan apakah ABI compatible atau tidak. Kalaupun tidak compatible, yang kita hanya bisa lakukan adalah mencari versi yang compatible, atau compile ulang aplikasi kita (apabila kita punya source-nya).
Referensi
C++ ABI: the only thing that is more important than performance
Understanding the C++ ABI Breakage debate
React Native: C++ ABI stability Guidelines