Post

Application Binary Interface

(Post in Indonesian) Ada API, ada ABI.

Application Binary Interface

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, menjadi 0x1234_1000.
  • Untuk mengakses uint8_t ver_major, program akan mengakses alamat memori dengan offset 1, menjadi 0x1234_1001.
  • Untuk mengakses uint8_t version_calls_, program akan mengakses dengan offset 2, menjadi 0x1234_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 dan b? Ke dalam register CPU? atau memory?
    • Dimana fungsi tersebut akan menaruh return value-nya?
  • Dalam struktur memory sebuah object, misalkan MyObject:
    • Bagaimana mendefinisikan bentuk MyObject dalam memory layout yang bisa dipahami binary lain?
  • 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?”: Desktop View 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

Wikipedia: Application Binary Interface

CppCon 2020: What is an ABI, and Why is Breaking it Bad?

This post is licensed under CC BY 4.0 by the author.