
* Tautan ke perpustakaan dan demo video di akhir artikel. Untuk memahami apa yang terjadi dan siapa orang-orang ini, saya sarankan membaca artikel sebelumnya .
Pada artikel terakhir, kami membiasakan diri dengan pendekatan yang memungkinkan untuk memuat ulang kode c ++. "Kode" dalam hal ini adalah fungsi, data, dan pekerjaan mereka yang terkoordinasi satu sama lain. Tidak ada masalah khusus dengan fungsi, kami mengarahkan aliran eksekusi dari fungsi lama ke yang baru, dan semuanya berfungsi. Masalah muncul dengan data (variabel statis dan global), yaitu dengan strategi sinkronisasi mereka dalam kode lama dan baru. Dalam implementasi pertama, strategi ini sangat canggung: kita cukup menyalin nilai-nilai semua variabel statis dari kode lama ke yang baru, sehingga kode baru, mengacu pada variabel baru, bekerja dengan nilai-nilai dari kode lama. Tentu saja, ini tidak benar, dan hari ini kami akan mencoba untuk memperbaiki kekurangan ini dengan secara simultan memecahkan sejumlah masalah kecil namun menarik.
Artikel ini menghilangkan detail tentang pekerjaan mekanis, seperti membaca karakter dan memindahkan file elf dan mach-o. Penekanannya adalah pada poin-poin halus yang saya temui dalam proses implementasi, dan yang mungkin berguna bagi seseorang yang, seperti saya baru-baru ini, sedang mencari jawaban.
Esensi
Mari kita bayangkan bahwa kita memiliki kelas (contoh sintetis, tolong jangan mencari makna di dalamnya, hanya kode yang penting):
Tidak ada yang istimewa selain variabel statis. Sekarang bayangkan kita ingin mengubah metode printDescription()
menjadi:
void Entity::printDescription() { std::cout << "DESCRIPTION: " << m_description << std::endl; }
Apa yang terjadi setelah kode dimuat ulang? Selain metode kelas Entity
, variabel statis m_livingEntitiesCount
juga masuk ke pustaka dengan kode baru. Tidak ada hal buruk yang akan terjadi jika kita cukup menyalin nilai variabel ini dari kode lama ke yang baru dan terus menggunakan variabel baru, melupakan yang lama, karena semua metode yang menggunakan variabel ini secara langsung ada di perpustakaan dengan kode baru.
C ++ sangat fleksibel dan kaya. Dan sementara keanggunan memecahkan beberapa masalah di c ++ berbatasan dengan kode berbau busuk, saya suka bahasa ini. Misalnya, bayangkan proyek Anda tidak menggunakan rtti. Pada saat yang sama, Anda harus memiliki implementasi kelas Any
dengan antarmuka yang agak aman:
class Any { public: template <typename T> explicit Any(T&& value) { ... } template <typename T> bool is() const { ... } template <typename T> T& as() { ... } };
Kami tidak akan membahas detail implementasi kelas ini. Yang penting bagi kita adalah bahwa untuk implementasi kita memerlukan semacam mekanisme untuk pemetaan yang tidak ambigu dari tipe (compile-time entity) ke dalam nilai variabel, misalnya, uint64_t
(entitas runtime), yaitu tipe "enumerate". Saat menggunakan rtti, hal-hal seperti type_info
dan, lebih cocok untuk kita, type_index
tersedia untuk kita. Tetapi kami tidak memiliki rtti. Dalam hal ini, peretasan yang cukup umum (atau solusi elegan?) Apakah fungsi ini:
template <typename T> uint64_t typeId() { static char someVar; return reinterpret_cast<uint64_t>(&someVar); }
Maka implementasi dari kelas Any
akan terlihat seperti ini:
class Any { public: template <typename T> explicit Any(T&& value) : m_typeId(typeId<std::decay<T>::type>())
Untuk setiap jenis, fungsi akan dipakai tepat 1 kali, masing-masing, setiap versi fungsi akan memiliki variabel statis sendiri, jelas dengan alamat uniknya sendiri. Apa yang terjadi ketika kami memuat ulang kode menggunakan fungsi ini? Panggilan ke versi fungsi yang lama akan dialihkan ke yang baru. Yang baru akan memiliki variabel statisnya sendiri yang sudah diinisialisasi (kami menyalin nilai dan variabel penjaganya). Tapi kami tidak tertarik dengan artinya, kami hanya menggunakan alamatnya. Dan alamat variabel baru akan berbeda. Dengan demikian, data menjadi tidak konsisten: dalam contoh kelas Any
sudah dibuat, alamat variabel statis lama akan disimpan, dan metode is()
akan membandingkannya dengan alamat yang baru, dan "Ini tidak Any
lagi sama dengan Any
" ยฉ.
Rencanakan
Untuk mengatasi masalah ini, Anda memerlukan sesuatu yang lebih pintar dari sekadar menyalin. Setelah menghabiskan beberapa malam di Google, membaca dokumentasi, kode sumber, dan sistem api, rencana berikut ini dibuat di kepala saya:
- Setelah membangun kode baru, kami pergi melalui relokasi .
- Dari relokasi ini kita mendapatkan semua tempat dalam kode yang menggunakan variabel statis (dan terkadang global).
- Alih-alih alamat ke versi variabel baru, kami mengganti alamat versi lama ke tempat relokasi.
Dalam hal ini, tidak akan ada tautan ke data baru, seluruh aplikasi akan terus bekerja dengan versi variabel lama hingga alamat. Itu seharusnya bekerja. Ini tidak bisa gagal berfungsi.
Relokasi
Ketika kompiler menghasilkan kode mesin, ia memasukkan beberapa byte yang cukup untuk menulis alamat sebenarnya dari variabel atau fungsi ke tempat ini di setiap tempat di mana salah satu fungsi dipanggil atau alamat variabel dimuat, dan juga menghasilkan relokasi. Dia tidak bisa segera mencatat alamat aslinya, karena pada tahap ini dia tidak tahu alamat ini. Fungsi dan variabel setelah penautan dapat di bagian yang berbeda, di tempat bagian yang berbeda, di bagian akhir dapat dimuat ke alamat yang berbeda saat runtime.
Relokasi berisi informasi:
- Alamat apa yang Anda perlukan untuk menuliskan alamat fungsi atau variabel
- Alamat fungsi atau variabel mana yang akan ditulis
- Formula tempat alamat ini harus dihitung
- Berapa byte yang dicadangkan untuk alamat ini
Dalam OS yang berbeda, relokasi direpresentasikan secara berbeda, tetapi pada akhirnya mereka semua bekerja dengan prinsip yang sama. Misalnya, di elf (Linux), relokasi terletak di bagian .rela
khusus (dalam versi 32-bit ini .rel
), yang merujuk ke bagian dengan alamat yang perlu diperbaiki (misalnya, .rela.text
- bagian di mana relokasi berada, diterapkan pada bagian .text
), dan setiap entri menyimpan informasi tentang simbol yang alamatnya ingin Anda masukkan di situs relokasi. Dalam mach-o (macOS), yang terjadi adalah kebalikannya, tidak ada bagian terpisah untuk relokasi, sebaliknya, setiap bagian berisi pointer ke tabel relokasi yang harus diterapkan ke bagian ini, dan setiap catatan dalam tabel ini memiliki referensi ke simbol relasional.
Misalnya, untuk kode seperti itu (dengan opsi -fPIC
):
int globalVariable = 10; int veryUsefulFunction() { static int functionLocalVariable = 0; functionLocalVariable++; return globalVariable + functionLocalVariable; }
kompiler akan membuat bagian seperti itu dengan relokasi di Linux:
Relocation section '.rela.text' at offset 0x1a0 contains 4 entries: Offset Info Type Symbol's Value Symbol's Name + Addend 0000000000000007 0000000600000009 R_X86_64_GOTPCREL 0000000000000000 globalVariable - 4 000000000000000d 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 0000000000000016 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 000000000000001e 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4
dan tabel relokasi seperti itu di macOS:
RELOCATION RECORDS FOR [__text]: 000000000000001b X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000015 X86_64_RELOC_SIGNED _globalVariable 000000000000000f X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000006 X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable
Dan inilah fungsi veryUsefulFunction()
(di Linux):
0000000000000000 <_Z18veryUsefulFunctionv>: 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: 48 8b 05 00 00 00 00 mov rax,QWORD PTR [rip+0x0] b: 8b 0d 00 00 00 00 mov ecx,DWORD PTR [rip+0x0] 11: 83 c1 01 add ecx,0x1 14: 89 0d 00 00 00 00 mov DWORD PTR [rip+0x0],ecx 1a: 8b 08 mov ecx,DWORD PTR [rax] 1c: 03 0d 00 00 00 00 add ecx,DWORD PTR [rip+0x0] 22: 89 c8 mov eax,ecx 24: 5d pop rbp 25: c3 ret
jadi setelah menautkan objek ke pustaka dinamis:
00000000000010e0 <_Z18veryUsefulFunctionv>: 10e0: 55 push rbp 10e1: 48 89 e5 mov rbp,rsp 10e4: 48 8b 05 05 21 00 00 mov rax,QWORD PTR [rip+0x2105] 10eb: 8b 0d 13 2f 00 00 mov ecx,DWORD PTR [rip+0x2f13] 10f1: 83 c1 01 add ecx,0x1 10f4: 89 0d 0a 2f 00 00 mov DWORD PTR [rip+0x2f0a],ecx 10fa: 8b 08 mov ecx,DWORD PTR [rax] 10fc: 03 0d 02 2f 00 00 add ecx,DWORD PTR [rip+0x2f02] 1102: 89 c8 mov eax,ecx 1104: 5d pop rbp 1105: c3 ret
Ada 4 tempat di mana 4 byte dicadangkan untuk alamat variabel nyata.
Pada sistem yang berbeda, set kemungkinan relokasi adalah milik Anda. Di Linux pada x86-64, sebanyak 40 jenis relokasi . Hanya ada 9 di MacOS di x86-64. Semua jenis relokasi dapat dibagi secara kondisional menjadi 2 kelompok:
- Relokasi waktu tautan - relokasi yang digunakan dalam proses menautkan file objek ke file yang dapat dieksekusi atau perpustakaan dinamis
- Relokasi waktu pemuatan - relokasi diterapkan pada saat perpustakaan dinamis dimuat ke dalam memori proses
Kelompok kedua termasuk relokasi fungsi dan variabel yang diekspor. Ketika perpustakaan dinamis dimuat ke dalam memori proses, untuk semua relokasi dinamis (termasuk relokasi variabel global), linker mencari definisi simbol di semua perpustakaan yang sudah dimuat, termasuk dalam program itu sendiri, dan alamat simbol yang cocok pertama digunakan untuk relokasi. Jadi, tidak ada yang perlu dilakukan dengan relokasi ini, penghubung akan menemukan variabel dari aplikasi kita sendiri, karena akan masuk ke dalam daftar pustaka dan program yang dimuat sebelumnya, dan mengganti alamatnya dalam kode baru, mengabaikan versi baru dari variabel ini.
Ada titik halus yang terkait dengan macOS dan penghubung dinamisnya. MacOS mengimplementasikan apa yang disebut mekanisme namespace dua tingkat. Jika tidak sopan, maka saat memuat pustaka dinamis, penghubung pertama-tama akan mencari karakter di pustaka ini, dan jika tidak menemukannya, ia akan mencari orang lain. Ini dilakukan untuk tujuan kinerja, sehingga relokasi diselesaikan dengan cepat, yang pada umumnya logis. Tapi ini memutus aliran kami terkait variabel global. Untungnya, ld di macOS memiliki flag khusus - -flat_namespace
, dan jika Anda membangun perpustakaan dengan flag ini, maka algoritma pencarian karakter akan identik dengan yang ada di Linux.
Kelompok pertama termasuk relokasi variabel statis - persis apa yang kita butuhkan. Satu-satunya masalah adalah bahwa relokasi ini tidak ada di perpustakaan yang dikompilasi, karena mereka sudah diselesaikan oleh linker. Oleh karena itu, kita akan membacanya dari file objek tempat perpustakaan dikumpulkan.
Kemungkinan jenis relokasi juga dibatasi oleh apakah kode yang dirakit tergantung pada posisi atau tidak. Karena kami mengumpulkan kode kami dalam mode PIC (kode posisi-independen), relokasi hanya digunakan relatif. Total relokasi yang menarik bagi kami adalah:
- Relokasi dari bagian
.rela.text
di Linux dan relokasi yang dirujuk oleh bagian __text
di macOS, dan - Yang menggunakan karakter dari
.bss
dan .bss
bagian di Linux dan __data
, __bss
dan __common
di macOS, dan - Relokasi bertipe
R_X86_64_PC32
dan R_X86_64_PC64
di Linux dan X86_64_RELOC_SIGNED
, X86_64_RELOC_SIGNED_1
, X86_64_RELOC_SIGNED_2
dan X86_64_RELOC_SIGNED_4
di macOS
Titik halus yang terkait dengan bagian __common
. Linux juga memiliki bagian *COM*
serupa. Variabel global dapat jatuh ke bagian ini . Tetapi, ketika saya menguji dan menyusun sekelompok potongan kode, di Linux, relokasi karakter dari bagian *COM*
selalu dinamis, seperti variabel global reguler. Pada saat yang sama, pada macOS karakter seperti itu kadang-kadang dipindahkan selama menghubungkan jika fungsi dan karakter berada di file yang sama. Oleh karena itu, pada MacOS masuk akal untuk mempertimbangkan bagian ini ketika membaca karakter dan relokasi.
Nah, sekarang kami memiliki satu set semua relokasi yang kami butuhkan, apa yang harus dilakukan dengan mereka? Logikanya di sini sederhana. Ketika tautan menghubungkan perpustakaan, itu menulis alamat simbol yang dihitung dengan rumus tertentu di alamat relokasi. Untuk relokasi kami di kedua platform, rumus ini berisi alamat simbol sebagai istilah. Dengan demikian, alamat terhitung yang sudah direkam dalam tubuh fungsi memiliki bentuk:
resultAddr = newVarAddr + addend - relocAddr
Pada saat yang sama, kita tahu alamat kedua versi variabel - lama, sudah ada dalam aplikasi, dan baru. Tetap bagi kami untuk mengubahnya dengan rumus:
resultAddr = resultAddr - newVarAddr + oldVarAddr
dan menulisnya ke alamat relokasi. Setelah itu, semua fungsi dalam kode baru akan menggunakan versi variabel yang ada, dan variabel baru hanya akan berbohong dan tidak melakukan apa pun. Apa yang kamu butuhkan! Tetapi ada satu titik halus.
Memuat perpustakaan dengan kode baru
Ketika sistem memuat perpustakaan dinamis ke dalam memori proses, itu bebas untuk menempatkannya di mana saja di ruang alamat virtual. Di Ubuntu 18.04 saya, aplikasi dimuat pada 0x00400000
, dan perpustakaan dinamis kami tepat setelah ld-2.27.so
di alamat di area 0x7fd3829bd000
. Jarak antara alamat unduhan program dan pustaka jauh lebih besar daripada jumlah yang akan masuk ke dalam bilangan bulat 32-bit yang ditandatangani. Dan dalam relokasi tautan waktu, hanya 4 byte yang dicadangkan untuk alamat karakter target.
Setelah merokok dokumentasi untuk kompiler dan tautan, saya memutuskan untuk mencoba opsi -mcmodel=large
. Itu memaksa kompiler untuk menghasilkan kode tanpa asumsi tentang jarak antara karakter, sehingga semua alamat diasumsikan 64-bit. Tetapi opsi ini tidak ramah PIC, karena jika -mcmodel=large
tidak dapat digunakan dengan -fPIC
, setidaknya pada macOS. Saya masih tidak mengerti apa masalahnya, mungkin di macOS tidak ada relokasi yang cocok untuk situasi ini.
Di perpustakaan di bawah windows, masalah ini diselesaikan sebagai berikut. Tangan mengalokasikan sepotong memori virtual di dekat lokasi unduhan aplikasi, cukup untuk mengakomodasi bagian perpustakaan yang diperlukan. Kemudian bagian dimuat ke dalamnya dengan tangan, hak yang diperlukan diatur ke halaman memori dengan bagian yang sesuai, semua relokasi dibuka ritsleting dengan tangan, dan segala sesuatu yang lain ditambal. Saya malas Saya benar-benar tidak ingin melakukan semua pekerjaan ini dengan relokasi waktu buka, terutama di Linux. Dan mengapa apa yang sudah diketahui oleh seorang penghubung dinamis? Lagipula, orang-orang yang menulisnya tahu lebih banyak daripada saya.
Untungnya, dokumentasi tersebut menemukan opsi yang diperlukan untuk menunjukkan tempat mengunduh pustaka dinamis kami:
- Apple ld:
-image_base 0xADDRESS
- LLVM lld:
--image-base=0xADDRESS
- GNU ld:
-Ttext-segment=0xADDRESS
Opsi-opsi ini harus diteruskan ke tautan pada saat menghubungkan perpustakaan dinamis. Ada 2 kesulitan.
Yang pertama terkait dengan GNU ld. Agar opsi ini berfungsi, Anda harus:
- Pada saat memuat perpustakaan, area tempat kami ingin memuatnya gratis
- Alamat yang ditentukan dalam opsi harus kelipatan dari ukuran halaman (pada x86-64 Linux dan macOS adalah
0x1000
) - Paling tidak di Linux, alamat yang ditentukan dalam opsi harus kelipatan dari penyelarasan segmen
PT_LOAD
Yaitu, jika tautan mengatur perataan ke 0x10000000
, maka pustaka ini tidak dapat dimuat di alamat 0x10001000
, bahkan dengan mempertimbangkan bahwa alamat tersebut disejajarkan dengan ukuran halaman. Jika salah satu dari kondisi ini tidak terpenuhi, perpustakaan akan memuat "seperti biasa". Saya memiliki GNU ld 2,30 pada sistem saya, dan, tidak seperti LLVM lld, secara default ia menetapkan keselarasan segmen PT_LOAD
ke 0x20000
, yang sangat tidak terlihat. Untuk menyiasatinya, di samping opsi -Ttext-segment=...
, tentukan -z max-page-size=0x1000
. Saya menghabiskan satu hari sampai saya menyadari mengapa perpustakaan tidak memuat di mana saya perlu.
Kesulitan kedua - alamat unduhan harus diketahui pada tahap menghubungkan perpustakaan. Tidak terlalu sulit untuk diatur. Di Linux, cukup mem-parsing pseudo-file /proc/<pid>/maps
, menemukan bagian yang tidak dihuni yang paling dekat dengan program, di mana perpustakaan akan cocok, dan menggunakan alamat awal bagian ini ketika menghubungkan. Ukuran perpustakaan masa depan dapat diperkirakan secara kasar dengan melihat ukuran file objek, atau dengan menguraikannya dan menghitung ukuran semua bagian. Pada akhirnya, kita tidak perlu angka pastinya, tetapi perkiraan ukuran dengan margin.
MacOS tidak memiliki /proc/*
; sebagai gantinya, disarankan untuk menggunakan utilitas vmmap
. Output dari perintah vmmap -interleaved <pid>
berisi informasi yang sama dengan proc/<pid>/maps
. Tetapi di sini kesulitan lain muncul. Jika aplikasi membuat proses turunan yang mengeksekusi perintah ini, dan pengidentifikasi proses saat ini ditetapkan sebagai <pid>
, program akan hang ketat. Seperti yang saya pahami, vmmap
menghentikan proses untuk membaca pemetaan memorinya, dan tampaknya, jika ini adalah proses pemanggilan, maka ada yang salah. Dalam hal ini, Anda perlu menentukan flag tambahan -forkCorpse
sehingga vmmap
membuat proses anak kosong dari proses kami, menghapus pemetaan dari itu dan membunuhnya, sehingga tidak mengganggu program.
Pada dasarnya itu yang perlu kita ketahui.
Menyatukan semuanya
Dengan modifikasi ini, algoritma pemuatan kode akhir terlihat seperti ini:
- Kompilasi kode baru ke file objek
- Untuk file objek, kami memperkirakan ukuran perpustakaan masa depan
- Membaca File Objek Relokasi
- Kami mencari sepotong memori virtual gratis di sebelah aplikasi
- Kami membangun perpustakaan dinamis dengan opsi yang diperlukan,
dlopen
melalui dlopen
- Kode tambalan sesuai dengan relokasi tautan-waktu
- Fungsi tambalan
- Salin variabel statis yang tidak berpartisipasi dalam langkah 6
Hanya variabel penjaga dari variabel statis yang termasuk dalam langkah 8, sehingga mereka dapat disalin dengan aman (dengan demikian mempertahankan "inisialisasi" dari variabel statis itu sendiri).
Kesimpulan
Karena ini hanyalah alat pengembangan, tidak dimaksudkan untuk produksi apa pun, hal terburuk yang dapat terjadi jika pustaka berikutnya dengan kode baru tidak muat ke dalam memori atau secara tidak sengaja memuat di alamat berbeda adalah memulai kembali aplikasi yang di-debug. Saat menjalankan tes, 31 pustaka dengan kode yang diperbarui dimuat ke dalam memori pada gilirannya.
Untuk kelengkapan, 3 bagian yang lebih berat tidak ada dalam implementasi:
- Sekarang perpustakaan dengan kode baru dimuat ke dalam memori di sebelah program, meskipun kode dari perpustakaan dinamis lain yang telah dimuat jauh dapat masuk ke dalamnya. Untuk memperbaikinya, Anda perlu melacak kepemilikan unit terjemahan ke satu atau lain perpustakaan dan program, dan membagi perpustakaan dengan kode baru jika perlu.
- Memuat ulang kode dalam aplikasi multi-utas masih tidak dapat diandalkan (dengan pasti Anda hanya dapat memuat ulang kode yang berjalan di utas yang sama dengan pustaka runloop). Untuk perbaikan, perlu untuk memindahkan bagian dari implementasi ke program yang terpisah, dan program ini, sebelum menambal, harus menghentikan proses dengan semua utas, menambal, dan mengembalikannya untuk bekerja. Saya tidak tahu bagaimana melakukan ini tanpa program eksternal.
- Pencegahan crash aplikasi yang tidak disengaja setelah kode dimuat ulang. Setelah memperbaiki kode, Anda dapat secara tidak sengaja mengubah pointer yang tidak valid dalam kode baru, setelah itu Anda harus me-restart aplikasi. Tidak ada yang salah, tapi tetap saja. Kedengarannya seperti ilmu hitam, aku masih berpikir.
Tetapi implementasi saat ini mulai menguntungkan saya secara pribadi, itu sudah cukup untuk digunakan dalam pekerjaan utama saya. Butuh sedikit membiasakan diri, tetapi penerbangannya normal.
Jika saya sampai pada ketiga poin ini dan menemukan dalam implementasi mereka cukup banyak hal yang menarik, saya pasti akan membagikannya.
Demo
Karena implementasinya memungkinkan penambahan unit siaran baru dengan cepat, saya memutuskan untuk merekam video pendek di mana saya menulis permainan sederhana yang cabul dari awal tentang pesawat ruang angkasa membajak bentangan alam semesta dan menembak asteroid persegi. Saya mencoba untuk tidak menulis dengan gaya "semua dalam satu file", tetapi, jika mungkin, mengatur semuanya di rak, sehingga menghasilkan banyak file kecil (jadi ada begitu banyak tulisan). Tentu saja, kerangka ini digunakan untuk menggambar, input, jendela, dan hal-hal lain, tetapi kode permainan itu sendiri ditulis dari awal.
Fitur utama - Saya hanya menjalankan aplikasi 3 kali: pada awalnya, ketika itu hanya memiliki adegan kosong, dan 2 kali setelah jatuh karena kelalaian saya. Seluruh permainan secara bertahap dituangkan dalam proses penulisan kode. Waktu nyata - sekitar 40 menit. Secara umum, Anda dipersilakan.
Seperti biasa, saya akan senang dengan kritik apa pun, terima kasih!
Tautan ke implementasi