Kebanyakan kompiler C memungkinkan Anda untuk mengakses array
extern
dengan batas yang tidak ditentukan, misalnya:
extern int external_array[]; int array_get (long int index) { return external_array[index]; }
Definisi external_array mungkin ada di unit terjemahan lain dan mungkin terlihat seperti ini:
int external_array[3] = { 1, 2, 3 };
Pertanyaannya adalah apa yang terjadi jika definisi yang terpisah ini berubah seperti ini:
int external_array[4] = { 1, 2, 3, 4 };
Atau lebih:
int external_array[2] = { 1, 2 };
Akankah antarmuka biner dipertahankan (asalkan ada mekanisme yang memungkinkan aplikasi untuk menentukan ukuran array pada saat run time)?
Anehnya, pada banyak arsitektur,
meningkatkan ukuran array melanggar kompatibilitas antarmuka biner (ABI). Mengurangi ukuran array juga dapat menyebabkan masalah kompatibilitas. Pada artikel ini, kita akan melihat lebih dekat kompatibilitas ABI dan menjelaskan cara menghindari masalah.
Tautan di bagian data dari file yang dapat dieksekusi
Untuk memahami bagaimana ukuran array menjadi bagian dari antarmuka biner, pertama-tama kita perlu memeriksa tautan di bagian data dari file yang dapat dieksekusi. Tentu saja, detailnya tergantung pada arsitektur spesifik, dan di sini kita akan fokus pada arsitektur x86-64.
Arsitektur x86-64 mendukung pengalamatan relatif terhadap penghitung program, yaitu akses ke variabel array global, seperti pada fungsi
array_get
ditunjukkan sebelumnya, dapat dikompilasi menjadi instruksi
movl
tunggal:
array_get: movl external_array(,%rdi,4), %eax ret
Dari ini, assembler membuat file objek di mana instruksi ditandai sebagai
R_X86_64_32S
.
0000000000000000 : 0: mov 0x0(,%rdi,4),%eax 3: R_X86_64_32S external_array 7: retq
Langkah ini memberi tahu linker (
ld
) cara mengisi lokasi yang sesuai dari variabel
external_array
selama menautkan saat membuat executable.
Ini memiliki dua konsekuensi penting.
- Karena offset variabel ditentukan pada waktu pembangunan, pada waktu berjalan tidak ada overhead untuk menentukannya. Satu-satunya harga adalah akses ke memori itu sendiri.
- Untuk menentukan offset, Anda perlu mengetahui ukuran semua data variabel. Kalau tidak, tidak mungkin untuk menghitung format bagian data selama tata letak.
Untuk implementasi C yang berorientasi pada
Executable dan Link Format (ELF) , seperti pada GNU / Linux, referensi ke variabel
extern
tidak mengandung ukuran objek. Dalam contoh
array_get
ukuran objek tidak diketahui bahkan oleh kompiler. Faktanya, seluruh file assembler terlihat seperti ini (hanya mengabaikan informasi promosi dari
-fno-asynchronous-unwind-tables
, yang secara teknis diperlukan untuk kepatuhan psABI):
.file "get.c" .text .p2align 4,,15 .globl array_get .type array_get, @function array_get: movl external_array(,%rdi,4), %eax ret .size array_get, .-array_get .ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)" .section .note.GNU-stack,"",@progbits
Tidak ada informasi ukuran untuk
external_array
dalam file assembler ini: referensi karakter hanya pada baris dengan instruksi
movl
, dan satu-satunya data numerik dalam instruksi adalah ukuran elemen array (tersirat oleh
movl
dikalikan dengan 4).
Jika ELF memerlukan ukuran untuk variabel yang tidak terdefinisi, maka bahkan tidak mungkin untuk mengkompilasi fungsi
array_get
.
Bagaimana linker mendapatkan ukuran karakter yang sebenarnya? Dia melihat definisi simbol dan menggunakan informasi ukuran yang dia temukan di sana. Ini memungkinkan kompiler untuk menghitung tata letak bagian data dan mengisi gerakan data dengan offset yang sesuai.
Objek ELF Umum
Implementasi C untuk ELF tidak mengharuskan programmer untuk menambahkan markup kode sumber untuk menunjukkan apakah suatu fungsi atau variabel terletak di objek saat ini (yang mungkin perpustakaan atau executable utama) atau di objek lain. Tautan dan pemuat dinamis akan menangani ini.
Pada saat yang sama, ada keinginan untuk file yang dapat dieksekusi untuk tidak mengurangi kinerja dengan mengubah model kompilasi. Ini berarti bahwa ketika mengkompilasi kode sumber untuk program utama (yaitu, tanpa
-fPIC
, dan dalam kasus khusus ini tanpa
-fPIE
), fungsi
array_get
dikompilasi ke
dalam urutan perintah yang
persis sama sebelum memperkenalkan objek dinamis yang dibagikan. Selain itu, tidak masalah jika variabel
external_array
didefinisikan dalam file yang paling dasar yang dapat dieksekusi atau apakah ada objek bersama yang dimuat secara terpisah pada saat dijalankan. Instruksi yang dibuat oleh kompiler sama dalam kedua kasus.
Bagaimana ini mungkin? Bagaimanapun, objek ELF umum adalah posisi-independen. Mereka dimuat di
alamat acak yang tidak dapat diprediksi saat runtime. Namun, kompiler menghasilkan urutan kode mesin yang mengharuskan variabel-variabel ini ditempatkan pada
offset tetap yang dihitung selama penautan , jauh sebelum program dimulai.
Faktanya adalah bahwa hanya satu objek yang dimuat (file yang dapat dieksekusi utama) menggunakan offset tetap ini. Semua objek lain (pemuat dinamis itu sendiri, pustaka runtime C, dan pustaka lain yang digunakan oleh program) dikompilasi dan dikompilasi sebagai objek yang sepenuhnya bebas posisi (PICs). Untuk objek seperti itu, kompiler memuat alamat aktual setiap variabel dari tabel offset global (GOT). Kita dapat melihat bundaran ini jika kita mengkompilasi contoh
-fPIC
dengan
-fPIC
, yang mengarah ke kode perakitan seperti itu:
array_get: movq external_array@GOTPCREL(%rip), %rax movl (%rax,%rdi,4), %eax ret
Akibatnya, alamat variabel
external_array
tidak lagi hardcoded dan dapat diubah pada waktu berjalan dengan menginisialisasi catatan GOT dengan tepat. Ini berarti bahwa pada saat dijalankan, definisi
external_array
dapat berada di objek bersama yang sama, objek bersama lainnya, atau program utama. Loader dinamis akan menemukan definisi yang sesuai berdasarkan aturan pencarian karakter ELF dan menghubungkan referensi simbol yang tidak terdefinisi dengan definisi dengan memperbarui catatan GOT ke alamat sebenarnya.
Kami kembali ke contoh asli, di mana fungsi
array_get
dalam program utama, sehingga alamat variabel ditentukan secara langsung. Gagasan kunci yang diterapkan dalam tautan adalah bahwa program utama akan memberikan definisi variabel
external_array
,
bahkan jika itu sebenarnya didefinisikan dalam objek umum saat runtime . Alih-alih menentukan definisi awal variabel dalam objek bersama, loader dinamis akan memilih
salinan variabel di bagian data file yang dapat dieksekusi.
Ini memiliki dua konsekuensi penting. Pertama-tama, ingat bahwa
external_array
didefinisikan sebagai berikut:
int external_array[3] = { 1, 2, 3 };
Ada penginisialisasi di sini yang harus diterapkan pada definisi dalam file utama yang dapat dieksekusi. Untuk melakukan ini, dalam file yang dapat dieksekusi utama, sebuah tautan ke lokasi salin dari simbol ditempatkan. Perintah
readelf -rW
menunjukkannya sebagai memindahkan
R_X86_64_COPY
.
Bagian relokasi '.rela.dyn' pada offset 0x408 berisi 3 entri:
Jenis Info Offset Nilai Simbol Nilai Simbol Nama + Tambah
0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 000000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 000000000000000000 __gmon_start__ + 0
0000000000404020 0000000300000005 R_X86_64_COPY 000000000000404020 external_array + 0
Seperti gerakan lainnya, gerakan salin ditangani oleh pemuat dinamis. Ini termasuk operasi penyalinan bitwise sederhana. Target salinan ditentukan oleh offset perpindahan (
0000000000404020
dalam contoh). Sumber ditentukan pada saat runtime berdasarkan pada nama simbol (
external_array
) dan nilainya. Saat membuat salinan, pemuat dinamis juga akan melihat ukuran karakter untuk mendapatkan jumlah byte yang perlu disalin. Untuk membuat semua ini mungkin, simbol
external_array
secara otomatis diekspor dari file yang dapat dieksekusi sebagai simbol tertentu sehingga terlihat oleh loader dinamis pada saat run time. Tabel simbol dinamis (
.dynsym
) mencerminkan ini, seperti yang ditunjukkan oleh perintah
readelf -sW
:
Tabel simbol '.dynsym' berisi 4 entri:
Num: Nilai Ukuran Tipe Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE DEFAULT LOKAL UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
3: 0000000000404020 12 OBJEK DEFAULT GLOBAL 22 external_array
Dari mana informasi tentang ukuran objek berasal (12 byte, dalam contoh ini)? Linker membuka semua objek umum, mencari definisi dan mengambil informasi tentang ukurannya. Seperti sebelumnya, ini memungkinkan penghubung untuk menghitung tata letak bagian data sehingga offset tetap dapat digunakan. Sekali lagi, ukuran definisi di executable utama adalah tetap dan tidak dapat diubah pada saat dijalankan.
Dynamic linker juga mengalihkan tautan simbolik dalam objek yang dibagikan ke salinan yang dipindahkan di executable utama. Ini memastikan bahwa dalam keseluruhan program hanya ada satu salinan variabel, seperti semantik bahasa C. Jika tidak, jika variabel berubah setelah inisialisasi, pembaruan dari file yang dapat dieksekusi utama tidak akan terlihat oleh objek bersama dinamis dan sebaliknya.
Dampak pada kompatibilitas biner
Apa yang terjadi jika kita mengubah definisi
external_array
di objek bersama tanpa menautkan (atau mengkompilasi ulang) program utama? Pertama, pertimbangkan untuk
menambahkan elemen array.
int external_array[4] = { 1, 2, 3, 4 };
Ini akan menghasilkan peringatan dari loader dinamis saat runtime:
main-program: Symbol `external_array' has different size in shared object, consider re-linking
Program utama masih berisi definisi
external_array
dengan ruang hanya 12 byte. Ini berarti bahwa salinan tidak lengkap: hanya tiga elemen pertama dari array yang disalin. Akibatnya, akses ke elemen array
extern_array[3]
tidak ditentukan. Pendekatan ini tidak hanya mempengaruhi program utama, tetapi juga seluruh kode dalam proses, karena semua referensi ke
extern_array
diarahkan ke definisi di program utama. Ini termasuk objek generik yang memberikan definisi
extern_array
. Dia mungkin tidak siap untuk menghadapi situasi di mana elemen array dalam definisinya sendiri telah menghilang.
Bagaimana dengan mengubah arah yang berlawanan, menghapus elemen?
int external_array[2] = { 1, 2 };
Jika program menghindari mengakses elemen array
extern_array[2]
, karena entah bagaimana mendeteksi panjang array berkurang, maka ini akan berhasil. Setelah array, ada beberapa memori yang tidak digunakan, tetapi ini tidak akan merusak program.
Ini artinya kita mendapatkan aturan berikut:
- Menambahkan elemen ke variabel array global melanggar kompatibilitas biner.
- Menghapus item dapat merusak kompatibilitas jika tidak ada mekanisme yang menghindari akses ke item yang dihapus.
Sayangnya, peringatan loader dinamis terlihat lebih tidak berbahaya daripada yang sebenarnya, dan untuk elemen jarak jauh tidak ada peringatan sama sekali.
Bagaimana menghindari situasi ini
Mendeteksi perubahan ABI cukup mudah dengan alat seperti
libabigail .
Cara termudah untuk menghindari situasi ini adalah dengan mengimplementasikan fungsi yang mengembalikan alamat array:
static int local_array[3] = { 1, 2, 3 }; int * get_external_array (void) { return local_array; }
Jika definisi array tidak dapat dibuat statis karena cara itu digunakan di perpustakaan, sebagai gantinya kita dapat menyembunyikan visibilitasnya dan juga mencegah ekspornya dan, karenanya, menghindari masalah pemotongan:
int local_array[3] __attribute__ ((visibility ("hidden"))) = { 1, 2, 3 };
Semuanya jauh lebih rumit jika variabel array diekspor karena alasan kompatibilitas ke belakang. Karena larik dari pustaka terpotong, program utama yang lama dengan definisi larik yang lebih pendek tidak akan dapat memberikan akses ke larik lengkap untuk kode klien baru jika digunakan dengan larik global yang sama. Sebagai gantinya, fungsi akses dapat menggunakan array yang terpisah (statis atau tersembunyi), atau mungkin array terpisah untuk elemen yang ditambahkan di akhir. Kerugiannya adalah bahwa tidak mungkin untuk menyimpan semuanya dalam array berkelanjutan jika variabel array diekspor untuk kompatibilitas mundur. Desain antarmuka sekunder harus mencerminkan hal ini.
Menggunakan kontrol versi karakter, Anda dapat mengekspor beberapa versi dengan ukuran berbeda, tidak pernah mengubah ukuran dalam versi tertentu. Menggunakan model ini, program terkait baru akan selalu menggunakan versi terbaru, mungkin dengan ukuran terbesar. Karena versi dan ukuran simbol diperbaiki oleh editor tautan secara bersamaan, mereka selalu konsisten. Pustaka GNU C menggunakan pendekatan ini untuk variabel historis
sys_errlist
dan
sys_siglist
. Namun, ini masih tidak menyediakan array kontinu tunggal.
Semua hal dipertimbangkan, fungsi accessor (misalnya, fungsi
get_external_array
atas) adalah pendekatan terbaik untuk menghindari masalah kompatibilitas ABI ini.