Mengimplementasikan hot reload kode C ++ di Linux

gambar


* Tautan ke perpustakaan di akhir artikel. Artikel itu sendiri menguraikan mekanisme yang diterapkan di perpustakaan, dengan detail menengah. Implementasi untuk macOS belum selesai, tetapi tidak jauh berbeda dari implementasi untuk Linux. Ini terutama merupakan implementasi untuk Linux.


Berjalan di sekitar github pada suatu Sabtu sore, saya menemukan perpustakaan yang mengimplementasikan pembaruan kode c ++ dengan cepat untuk windows. Saya sendiri turun dari windows beberapa tahun yang lalu, tidak menyesal sedikit pun, dan sekarang semua pemrograman dilakukan baik di Linux (di rumah) atau di macOS (di tempat kerja). Googling sedikit, saya menemukan bahwa pendekatan dari perpustakaan di atas cukup populer, dan msvc menggunakan teknik yang sama untuk fungsi "Edit dan lanjutkan" di Visual Studio. Satu-satunya masalah adalah bahwa saya tidak menemukan implementasi di bawah non-windows (apakah saya terlihat buruk?). Untuk pertanyaan kepada penulis perpustakaan di atas apakah ia akan membuat port untuk platform lain, jawabannya adalah tidak.


Saya harus segera mengatakan bahwa saya hanya tertarik pada opsi di mana saya tidak perlu mengubah kode proyek yang ada (seperti, misalnya, dalam kasus RCCPP atau cr , di mana semua kode yang berpotensi dimuat kembali harus berada di perpustakaan terpisah yang dimuat secara dinamis).


"Bagaimana bisa begitu?" - Saya pikir, dan mulai menyalakan dupa.


Mengapa


Saya terutama melakukan gamedev. Sebagian besar waktu kerja saya dihabiskan untuk menulis logika permainan dan tata letak visual apa pun. Saya juga menggunakan imgui untuk utilitas pembantu. Siklus saya bekerja dengan kode, seperti yang mungkin Anda duga, adalah Write -> Compile -> Run -> Repeat. Semuanya terjadi dengan sangat cepat (peningkatan bertahap, semua jenis ccache, dll.). Masalahnya di sini adalah bahwa siklus ini harus diulang cukup sering. Misalnya, saya menulis mekanisme permainan baru, biarlah "Lompat", Lompat yang valid dan terkontrol:


1. Menulis rancangan implementasi berdasarkan momentum, disusun, diluncurkan. Saya melihat bahwa saya secara tidak sengaja menerapkan pulsa ke setiap frame, dan tidak sekali.


2. Memperbaiki, memasang, meluncurkan, sekarang normal. Tetapi akan perlu untuk mengambil nilai absolut dari impuls lebih.


3. Memperbaiki, memasang, meluncurkan, bekerja. Tapi entah kenapa rasanya salah. Perlu untuk mencoba atas dasar kekuatan untuk dilakukan.


4. Menulis rancangan implementasi berdasarkan kekuatan, dirakit, diluncurkan, karya. Hanya perlu mengubah kecepatan sesaat pada saat lompat.
...


10. Memperbaiki, mengumpulkan, meluncurkan, bekerja. Tapi tetap saja tidak. Mungkin perlu mencoba implementasi berdasarkan perubahan pada gravityScale .
...


20. Hebat, ini terlihat super! Sekarang kita ambil semua parameter di editor untuk gamediz, test dan fill.
...


30. Lompatan siap.


Dan pada setiap iterasi, Anda perlu mengumpulkan kode dan dalam aplikasi yang diluncurkan sampai ke tempat di mana saya bisa melompat. Ini biasanya membutuhkan setidaknya 10 detik. Dan jika saya hanya bisa melompat di area terbuka, mana yang masih harus dijangkau? Dan jika saya harus dapat melompat ke blok dengan ketinggian N unit? Di sini saya sudah perlu mengumpulkan tempat uji, yang juga perlu debugged, dan yang juga perlu menghabiskan waktu. Ini untuk iterasi sedemikian rupa sehingga pemuatan ulang kode menjadi ideal. Tentu saja, ini bukan obat mujarab, ini tidak cocok untuk semuanya, dan setelah mem-boot ulang kadang-kadang Anda perlu membuat ulang bagian dari dunia game, dan ini harus diperhitungkan. Tetapi dalam banyak hal, ini bisa bermanfaat dan bisa menghemat perhatian dan banyak waktu.


Persyaratan dan pernyataan masalah


  • Saat mengubah kode, versi baru dari semua fungsi harus menggantikan versi lama dari fungsi yang sama
  • Ini harus bekerja di Linux dan macOS
  • Ini seharusnya tidak memerlukan perubahan pada kode aplikasi yang ada.
  • Idealnya, ini harus perpustakaan, secara statis atau dinamis terkait dengan aplikasi, tanpa utilitas pihak ketiga
  • Sangat diharapkan bahwa perpustakaan ini tidak terlalu mempengaruhi kinerja aplikasi.
  • Cukup jika ini bekerja dengan cmake + make / ninja
  • Sudah cukup jika ini akan bekerja dengan build debazine (tanpa optimasi, tanpa pemangkasan karakter, dll.)

Ini adalah serangkaian persyaratan minimum yang harus dipenuhi oleh suatu implementasi. Ke depan, saya akan menjelaskan secara singkat apa yang diterapkan tambahan:


  • Mentransfer nilai variabel statis ke kode baru (lihat bagian "Mentransfer variabel statis" untuk mencari tahu mengapa ini penting)
  • Reload berdasarkan dependensi (header diubah -> dibangun kembali setengah proyek semua file dependen)
  • Memuat ulang kode dari pustaka dinamis

Implementasi


Sampai saat itu, saya benar-benar jauh dari area subjek, jadi saya harus mengumpulkan dan mengasimilasi informasi dari awal.


Pada level tinggi, mekanismenya terlihat seperti ini:


  • Kami memantau sistem file untuk perubahan sumber
  • Ketika sumber berubah, perpustakaan membangunnya kembali menggunakan perintah kompilasi yang file ini sudah dikompilasi
  • Semua objek yang dikumpulkan ditautkan ke perpustakaan yang dimuat secara dinamis
  • Perpustakaan dimuat ke ruang alamat proses
  • Semua fungsi dari perpustakaan menggantikan fungsi yang sama dalam aplikasi.
  • Nilai-nilai variabel statis ditransfer dari aplikasi ke perpustakaan

Mari kita mulai dengan yang paling menarik - mekanisme reload fungsi.


Reload fungsi


Berikut adalah 3 cara yang kurang lebih populer untuk mengganti fungsi dalam (atau hampir) runtime:


  • Trik dengan LD_PRELOAD - memungkinkan Anda untuk membangun pustaka yang dimuat secara dinamis dengan, misalnya, fungsi strcpy , dan membuatnya sehingga ketika Anda memulai aplikasi mengambil versi saya dari strcpy alih-alih perpustakaan
  • Ubah tabel PLT dan GOT - memungkinkan Anda untuk "kelebihan" fungsi yang diekspor
  • Hooking fungsi - memungkinkan Anda untuk mengarahkan utas eksekusi dari satu fungsi ke fungsi lainnya

2 opsi pertama, jelas, tidak cocok, karena hanya berfungsi dengan fungsi yang diekspor, dan kami tidak ingin menandai semua fungsi aplikasi kami dengan atribut apa pun. Karena itu, Fungsi hooking adalah pilihan kami!


Singkatnya, pekerjaan hooking seperti ini:


  • Alamat fungsi ditemukan
  • Beberapa byte pertama dari fungsi ditimpa oleh transisi tanpa syarat ke tubuh fungsi lain
  • ...
  • Untung!
    Di msvc ada 2 flag untuk ini - /hotpatch dan /FUNCTIONPADMIN . Yang pertama di awal setiap fungsi menulis 2 byte, yang tidak melakukan apa-apa, untuk penulisan ulang berikutnya dengan "lompatan pendek". Yang kedua memungkinkan Anda untuk meninggalkan ruang kosong di depan tubuh masing-masing fungsi dalam bentuk instruksi nop untuk "lompat jauh" ke lokasi yang diinginkan, jadi dalam 2 lompatan Anda dapat beralih dari fungsi lama ke yang baru. Anda dapat membaca lebih lanjut tentang bagaimana ini diterapkan di windows dan msvc, misalnya, di sini .

Sayangnya, tidak ada yang serupa di dentang dan gcc (setidaknya di Linux dan macOS). Sebenarnya ini bukan masalah besar, kami akan menulis langsung di atas fungsi lama. Dalam hal ini, kami berisiko mendapat masalah jika aplikasi kami multithreaded. Jika biasanya dalam lingkungan multi-utas kami membatasi akses ke data oleh satu utas sementara utas lain memodifikasinya, maka kita perlu membatasi kemampuan untuk mengeksekusi kode pada satu utas sedangkan utas lain mengubah kode ini. Saya belum menemukan cara untuk melakukan ini, sehingga implementasinya akan berperilaku tak terduga di lingkungan multi-utas.


Ada satu titik halus. Pada sistem 32-bit, 5 byte sudah cukup bagi kita untuk "melompat" ke sembarang tempat. Pada sistem 64-bit, jika kita tidak ingin merusak register, kita perlu 14 byte. Intinya adalah bahwa 14 byte dalam skala kode mesin cukup banyak, dan jika kode memiliki fungsi rintisan dengan badan kosong, kemungkinan panjangnya kurang dari 14 byte. Saya tidak tahu seluruh kebenaran, tetapi saya menghabiskan waktu di belakang disassembler sambil berpikir, menulis dan men-debug kode, dan saya perhatikan bahwa semua fungsi diselaraskan pada batas 16-byte (debug build tanpa optimisasi, tidak yakin tentang kode yang dioptimalkan). Dan ini berarti bahwa di antara dua fungsi awal akan ada setidaknya 16 byte, yang cukup bagi kita untuk "macet" mereka. Googling dangkal mengarah ke sini , namun, saya tidak tahu pasti, saya hanya beruntung, atau hari ini semua penyusun melakukan ini. Bagaimanapun, jika ragu, cukup deklarasikan beberapa variabel di awal fungsi rintisan sehingga menjadi cukup besar.


Jadi, kami memiliki butir pertama - mekanisme untuk mengalihkan fungsi dari versi lama ke yang baru.


Cari fungsi dalam program yang disalin


Sekarang kita perlu entah bagaimana mendapatkan alamat semua fungsi (tidak hanya diekspor) dari program kami atau pustaka dinamis sewenang-wenang. Ini dapat dilakukan cukup sederhana menggunakan api sistem jika karakter tidak keluar dari aplikasi Anda. Di Linux, ini adalah api dari elf.h dan link.h , di macOS, loader.h dan nlist.h .


  • Menggunakan dl_iterate_phdr kita pergi melalui semua pustaka yang dimuat dan, pada kenyataannya, program
  • Temukan alamat tempat perpustakaan dimuat
  • Dari bagian .symtab mendapatkan semua informasi tentang karakter, yaitu nama, jenis, indeks bagian di mana ia berada, ukuran, dan juga menghitung alamat "nyata" berdasarkan alamat virtual dan alamat pustaka memuat alamat

Ada satu kehalusan. Saat mengunduh file elf, sistem tidak memuat bagian .symtab (benar jika salah), dan bagian .dynsym tidak cocok untuk kami, karena kami tidak dapat mengekstraksi karakter dengan visibilitas STV_INTERNAL dan STV_HIDDEN . Sederhananya, kita tidak akan melihat fungsi-fungsi seperti:


 // some_file.cpp namespace { int someUsefulFunction(int value) // <----- { return value * 2; } } 

dan variabel-variabel tersebut:


 // some_file.cpp void someDefaultFunction() { static int someVariable = 0; // <----- ... } 

Jadi, dalam paragraf 3, kami tidak bekerja dengan program yang diberikan dl_iterate_phdr kepada dl_iterate_phdr , tetapi dengan file yang kami unduh dari disk dan diurai oleh beberapa parser elf (atau di api kosong). Jadi kami tidak ketinggalan apa pun. Pada macOS, prosedurnya mirip, hanya nama-nama fungsi dari api sistem yang berbeda.


Setelah itu, kami memfilter semua karakter dan hanya menyimpan:


  • Fungsi yang dapat dimuat ulang adalah karakter dari jenis STT_FUNC terletak di bagian .text , yang berukuran bukan nol. Filter semacam itu hanya melompati fungsi yang kodenya benar-benar terdapat dalam program atau pustaka ini
  • Variabel statis yang nilainya ingin Anda transfer adalah karakter bertipe STT_OBJECT terletak di bagian .bss

Unit Siaran


Untuk memuat ulang kode, kita perlu tahu di mana mendapatkan file kode sumber dan cara mengompilasinya.


Dalam implementasi pertama, saya membaca informasi ini dari bagian .debug_info , yang berisi informasi debug dalam format DWARF. Agar setiap unit kompilasi (ET) dalam DWARF mendapatkan garis kompilasi untuk ET ini, Anda harus melewatkan -grecord-gcc-switches selama kompilasi. DWARF sendiri, saya parsing perpustakaan libdwarf, yang dibundel dengan libelf . Selain perintah kompilasi dari DWARF, Anda dapat memperoleh informasi tentang dependensi ET kami pada file lain. Tetapi saya menolak implementasi ini karena beberapa alasan:


  • Perpustakaan cukup berat
  • Mem-parsing aplikasi DWARF yang dikompilasi dari ~ 500 ET, dengan parsing dependensi, membutuhkan waktu lebih dari 10 detik

10 detik untuk memulai aplikasi terlalu banyak. Setelah beberapa pemikiran, saya menulis ulang logika parsing DWARF ke parsing compile_commands.json . File ini dapat dihasilkan hanya dengan menambahkan set(CMAKE_EXPORT_COMPILE_COMMANDS ON) ke CMakeLists.txt Anda. Dengan demikian, kami mendapatkan semua informasi yang kami butuhkan.


Penanganan ketergantungan


Karena kita meninggalkan DWARF, kita perlu menemukan opsi lain, bagaimana menangani dependensi antar file. Saya benar-benar tidak ingin mem-parsing file dengan tangan saya dan mencari include di dalamnya, dan siapa yang tahu lebih banyak tentang dependensi daripada kompiler itu sendiri?


Ada sejumlah opsi dalam dentang dan gcc yang menghasilkan apa yang disebut depfiles hampir gratis. File-file ini menggunakan sistem make dan ninja build untuk menyelesaikan dependensi antar file. Depfile memiliki format yang sangat sederhana:


 CMakeFiles/lib_efsw.dir/libs/efsw/src/efsw/DirectorySnapshot.cpp.o: \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/base.hpp \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/sophist.h \ /home/ddovod/_private/_projects/jet/live/libs/efsw/include/efsw/efsw.hpp \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/c++/7.3.0/string \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/c++config.h \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/os_defines.h \ ... 

Compiler meletakkan file-file ini di sebelah file objek untuk setiap ET, itu tetap bagi kita untuk mengurai mereka dan meletakkannya dalam sebuah hashmap. Total parsing compile_commands.json + depfiles untuk 500 ET yang sama membutuhkan waktu lebih dari 1 detik. Agar semuanya berfungsi, kita perlu menambahkan flag -MD secara global untuk semua file proyek dalam opsi kompilasi.


Ada satu kehalusan yang terkait dengan ninja. Sistem build ini menghasilkan depfile terlepas dari keberadaan flag -MD untuk kebutuhan mereka. Tetapi setelah dihasilkan, ia menerjemahkannya ke dalam format binernya, dan menghapus file sumber. Karena itu, ketika memulai ninja, Anda harus melewati flag -d keepdepfile . Juga, untuk alasan yang tidak diketahui oleh saya, dalam kasus make (dengan opsi -MD ) file disebut some_file.cpp.d , sedangkan dengan ninja disebut some_file.cpp.od . Karena itu, Anda perlu memeriksa kedua versi.


Transfer Variabel Statis


Misalkan kita memiliki kode seperti itu (contoh yang sangat sintetik):


 // Singleton.hpp class Singletor { public: static Singleton& instance(); }; int veryUsefulFunction(int value); // Singleton.cpp Singleton& Singletor::instance() { static Singleton ins; return ins; } int veryUsefulFunction(int value) { return value * 2; } 

Kami ingin mengubah fungsi veryUsefulFunction menjadi ini:


 int veryUsefulFunction(int value) { return value * 3; } 

Ketika memuat ulang, di perpustakaan dinamis dengan kode baru, di samping veryUsefulFunction , variabel static Singleton ins; , dan metode Singletor::instance . Akibatnya, program akan mulai memanggil versi baru dari kedua fungsi. Tetapi ins statis di perpustakaan ini belum diinisialisasi, dan oleh karena itu, saat pertama kali diakses, konstruktor dari kelas Singleton akan dipanggil. Tentu saja, kami tidak menginginkan ini. Oleh karena itu, implementasinya mentransfer nilai-nilai semua variabel yang ditemukan di pustaka dinamis yang dirakit dari kode lama ke pustaka yang sangat dinamis ini dengan kode baru beserta variabel penjaganya .


Ada satu momen halus dan umumnya tidak larut.
Misalkan kita memiliki kelas:


 class SomeClass { public: void calledEachUpdate() { m_someVar1++; } private: int m_someVar1 = 0; }; 

Metode dipanggilEachUpdate disebut 60 kali per detik. Kami mengubahnya dengan menambahkan bidang baru:


 class SomeClass { public: void calledEachUpdate() { m_someVar1++; m_someVar2++; } private: int m_someVar1 = 0; int m_someVar2 = 0; }; 

Jika sebuah instance dari kelas ini terletak di memori dinamis atau di stack, setelah memuat ulang kode, aplikasi kemungkinan akan crash. Instance yang dialokasikan hanya berisi variabel m_someVar1 , tetapi setelah reboot, metode calledEachUpdate akan mencoba mengubah m_someVar2 , mengubah apa yang sebenarnya bukan milik instance ini, yang mengarah pada konsekuensi yang tidak dapat diprediksi. Dalam hal ini, logika transfer keadaan ditransfer ke programmer, yang entah bagaimana harus menyimpan keadaan objek dan menghapus objek itu sendiri sebelum kode dimuat ulang, dan membuat objek baru setelah reboot. Perpustakaan menyediakan acara dalam bentuk metode delegasi onCodePostLoad dan onCodePostLoad yang dapat diproses aplikasi.


Saya tidak tahu bagaimana (dan apakah) mungkin untuk menyelesaikan situasi ini secara umum, saya akan berpikir. Sekarang kasus ini "lebih atau kurang normal" hanya akan berfungsi untuk variabel statis, ia menggunakan logika berikut:


 void* oldVarPtr = ...; void* newVarPtr = ...; size_t oldVarSize = ...; size_t newVarSize = ...; memcpy(newVarPtr, oldVarPtr, std::min(oldVarSize, newVarSize)); 

Ini tidak terlalu benar, tetapi ini adalah yang terbaik yang saya dapatkan.


Akibatnya, kode akan berperilaku tidak terduga jika runtime mengubah set dan tata letak bidang dalam struktur data. Hal yang sama berlaku untuk tipe polimorfik.


Menyatukan semuanya


Bagaimana semuanya bekerja bersama.


  • Perpustakaan beralih ke header dari semua perpustakaan yang dimuat secara dinamis ke dalam proses dan, pada kenyataannya, program itu sendiri, mem-parsing dan menyaring karakter.
  • Selanjutnya, perpustakaan mencoba menemukan file compile_commands.json di direktori aplikasi dan di direktori induk secara rekursif, dan mengeluarkan semua informasi yang diperlukan tentang ET dari sana.
  • Mengetahui path ke file objek, pustaka memuat dan mem-parsing depfile.
  • Setelah itu, direktori paling umum untuk semua file kode sumber program dihitung, dan pemantauan direktori ini dimulai secara rekursif.
  • Ketika suatu file berubah, perpustakaan melihat untuk melihat apakah itu ada dalam hashmap dependensi, dan jika ada, memulai beberapa proses kompilasi dari file yang diubah dan dependensinya di latar belakang, menggunakan perintah kompilasi dari compile_commands.json .
  • Ketika program meminta Anda memuat ulang kode (dalam aplikasi saya, kombinasi Ctrl+r ditugaskan untuk ini), pustaka menunggu penyelesaian proses kompilasi dan menautkan semua objek baru ke pustaka dinamis.
  • Pustaka ini kemudian dimuat ke ruang alamat proses dlopen fungsi dlopen .
  • Informasi tentang simbol diambil dari perpustakaan ini, dan seluruh persimpangan set simbol dari perpustakaan ini dan simbol yang sudah hidup dalam proses dapat dimuat ulang (jika itu adalah fungsi) atau ditransfer (jika itu adalah variabel statis).

Ini bekerja dengan sangat baik, terutama ketika Anda tahu apa yang ada di balik tudung dan apa yang diharapkan, setidaknya pada tingkat tinggi.


Secara pribadi, saya sangat terkejut dengan kurangnya solusi untuk Linux, adakah yang benar-benar tertarik dengan ini?


Saya akan senang dengan kritik apa pun, terima kasih!


Tautan ke implementasi

Source: https://habr.com/ru/post/id435260/


All Articles