LinuxでのC ++コードのホットリロードの実装

画像







*記事の最後にあるライブラリへのリンク。 この記事自体は、ライブラリに実装されているメカニズムの概要を中程度の詳細で説明しています。 macOSの実装はまだ完了していませんが、Linuxの実装と大差はありません。 これは主にLinuxの実装です。







土曜日の午後、githubを歩き回って、Windows用のC ++コードの更新をその場で実装するライブラリに出会いました。 私自身は数年前に窓から降りて、少し後悔しませんでした。そして今、すべてのプログラミングはLinux(自宅)またはmacOS(職場)で行われています。 少しグーグルで、上記のライブラリからのアプローチが非常に人気があり、msvcはVisual Studioの「編集して続行」機能に同じテクニックを使用することを発見しました。 唯一の問題は、非ウィンドウの下に実装が見つからなかったことです(見た目が悪いのですか?)。 上記のライブラリの作成者が他のプラットフォーム用の移植版を作成するかどうかについての質問に対する答えは「いいえ」でした。







既存のプロジェクトコードを変更する必要がないオプションにのみ興味があっことをすぐに言わなければなりません (たとえば、 RCCPPまたはcrの場合、すべての潜在的にリロードされたコードは動的にロードされた別個のライブラリーにある必要があります)。







「どうして?」 -私は考えて、線香を始めました。







なんで?



私は主にgamedevをしています。 作業時間のほとんどは、ゲームロジックとビジュアルのレイアウトの作成に費やしています。 ヘルパーユーティリティにもimguiを使用します。 ご想像のとおり、コードを操作する私のサイクルは、「書き込み」->「コンパイル」->「実行」->「繰り返し」です。 すべてがかなり迅速に行われます(インクリメンタルビルド、あらゆる種類のccacheなど)。 ここでの問題は、このサイクルを十分頻繁に繰り返さなければならないことです。 たとえば、私は新しいゲームメカニクスを書いています。有効な、制御されたジャンプである「ジャンプ」とします。







1.勢いに基づいた実装案を書き、組み立て、立ち上げました。 一度ではなく、誤って各フレームにインパルスを適用することがわかりました。







2.修正、組み立て、起動、正常になりました。 しかし、インパルスの絶対値をさらに取得する必要があります。







3.修正、組み立て、起動、動作。 しかし、どういうわけかそれは間違っているように感じます。 行うには強さに基づいて試す必要があります。







4.強度、組み立て、起動、動作に基づいて大まかな実装を書きました。 ジャンプ時に瞬間速度を変更するだけで十分です。

...







10.修正、組み立て、起動、動作。 しかし、まだそうではありません。 おそらくgravityScale



変更に基づいて実装を試みる必要があります。

...







20.すごい、すごいですね! ここで、gamediz、test、fillのエディターですべてのパラメーターを取り出します。

...







30.ジャンプの準備ができました。







そして、各反復でコードを収集する必要があり、起動されたアプリケーションで私がジャンプできる場所に到達します。 通常、これには少なくとも10秒かかります。 そして、私はまだ到達する必要があるオープンエリアでのみジャンプすることができますか? また、Nユニットの高さのブロックにジャンプできるようにする必要がある場合はどうなりますか? ここでは、デバッグする必要があり、時間を費やす必要があるテストシーンを収集する必要があります。 このような反復のために、コードのホットリロードが理想的です。 もちろん、これは万能薬ではなく、すべてに適しているわけではありません。また、再起動後でも、ゲームの世界の一部を再現する必要がある場合があり、これを考慮する必要があります。 しかし、多くの場合、これは有用であり、注意と多くの時間を節約できます。







問題の要件と説明





これは、実装が満たさなければならない要件の最小セットです。 これから先、さらに実装されたものを簡単に説明します。









実装



その瞬間まで、私は対象領域から非常に離れていたので、情報をゼロから収集して同化する必要がありました。







高レベルでは、メカニズムは次のようになります。









最も興味深いものから始めましょう-関数リロード機構。







リロード機能



ランタイムで(またはほぼ)実行中の関数を置き換えるための3つの多かれ少なかれ人気のある方法を以下に示します。









最初の2つのオプションは、エクスポートされた関数でのみ機能するため、明らかに適切ではありません。また、アプリケーションのすべての関数を属性でマークしたくないためです。 したがって、関数フックは私たちのオプションです!







要するに、フックは次のように機能します。









残念ながら、clangとgcc(少なくともLinuxとmacOSでは)に似たものはありません。 実際、これはそれほど大きな問題ではありません。古い関数の上に直接記述します。 この場合、アプリケーションがマルチスレッド化されていると、トラブルが発生する危険があります。 通常マルチスレッド環境で、あるスレッドによるデータへのアクセスを制限し、別のスレッドがそれらのデータを変更する場合、別のスレッドがこのコードを変更する間、あるスレッドによるコードの実行能力を制限する必要があります。 私はこれを行う方法を理解していませんでしたので、実装はマルチスレッド環境で予測できない動作をします。







微妙な点が1つあります。 32ビットシステムでは、5バイトで任意の場所に「ジャンプ」できます。 64ビットシステムでは、レジスタを損なう必要がない場合、14バイトが必要です。 一番下の行は、マシンコードのスケールで14バイトが非常に多く、コードに空の本体を持つスタブ関数がある場合、ほとんどの場合、長さが14バイト未満です。 完全な真実はわかりませんが、コードを考え、記述し、デバッグしている間、逆アセンブラーの背後にしばらく時間を費やしました。 これは、2つの関数の開始の間に少なくとも16バイトあることを意味します。これは、それらを「ジャム」するのに十分です。 ここでは表面的なグーグルが主導しましたが 、確かにわかりません。幸運だったのか、今日ではすべてのコンパイラがこれを行います。 いずれにせよ、疑わしい場合は、スタブ関数が十分大きくなるように、スタブ関数の先頭でいくつかの変数を宣言するだけです。







したがって、最初のグレイン、つまり、古いバージョンから新しいバージョンに関数をリダイレクトするメカニズムがあります。







コピーされたプログラムの機能を検索する



ここで、プログラムまたは任意の動的ライブラリから何らかの方法で(エクスポートだけでなく)すべての関数のアドレスを取得する必要があります。 これは、アプリケーションから文字が切り取られない場合、システムAPIを使用して非常に簡単に実行できます。 Linuxでは、これらはelf.h



およびlink.h



api、macOSではloader.h



およびnlist.h



です。









微妙な点が1つあります。 elfファイルをロードするとき、システムは.symtab



セクションをロードせず(間違っている場合は正しい) .dynsym



セクションは私たちに適合しません。可視性STV_INTERNAL



およびSTV_HIDDEN



文字を抽出できないSTV_HIDDEN



です。 簡単に言えば、このような関数は表示されません。







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





そしてそのような変数:







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





したがって、パラグラフ3では、 dl_iterate_phdr



から提供されたプログラムではなく、ディスクからダウンロードし、エルフパーサー(またはベアAPI)でdl_iterate_phdr



したファイルを使用しています。 だから私たちは何も見逃しません。 macOSでは、手順は似ていますが、システムAPIの関数名のみが異なります。







その後、すべての文字をフィルタリングして、次のもののみを保存します。









放送ユニット



コードをリロードするには、どこからソースコードファイルを取得し、どのようにコンパイルするかを知る必要があります。







最初の実装では、DWARF形式のデバッグ情報を含む.debug_info



セクションからこの情報を読み取りました。 このETのコンパイル行を取得するために、DWARF内の各コンパイル単位(ET)に対して、コンパイル中に-grecord-gcc-switches



渡す必要があります。 DWARF自体、libelfにバンドルされているlibdwarfライブラリを解析しlibelf



。 DWARFからのコンパイルコマンドに加えて、他のファイルに対するETの依存関係に関する情報を取得できます。 しかし、いくつかの理由でこの実装を拒否しました。









アプリケーションを起動するのに10秒かかりすぎます。 少し考えてから、DWARFの解析ロジックをcompile_commands.json



解析に書き直しました。 このファイルは、CMakeLists.txtにset(CMAKE_EXPORT_COMPILE_COMMANDS ON)



を追加するだけで生成できます。 したがって、必要なすべての情報を取得します。







依存関係の処理



DWARFを放棄したため、ファイル間の依存関係を処理する別のオプションを見つける必要があります。 私は実際に自分の手でファイルを解析し、それらにinclude



を探したくありませんでしたが、コンパイラ自体よりも依存関係について知っている人はいますか?







clangおよびgccには、いわゆるdepfileをほぼ無料で生成する多くのオプションがあります。 これらのファイルは、makeおよびninjaビルドシステムを使用して、ファイル間の依存関係を解決します。 Depfileの形式は非常に単純です。







 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 \ ...
      
      





コンパイラはこれらのファイルを各ETのオブジェクトファイルの隣に配置します。それらを解析してハッシュマップに入れるだけです。 同じ500 ETのcompile_commands.json



+ depfilesの合計解析には1秒強かかります。 すべてが機能するためには、コンパイルオプションですべてのプロジェクトファイルに対して-MD



フラグをグローバルに追加する必要があります。







忍者に関連する微妙な点が1つあります。 このビルドシステムは、ニーズの-MD



フラグに関係なく、 -MD



生成します。 ただし、生成後、バイナリ形式に変換し、ソースファイルを削除します。 したがって、ninjaを起動するときは、 -d keepdepfile



フラグを渡す必要があります。 また、私には不明な理由で、make( -MD



オプションを使用)の場合、ファイルはsome_file.cpp.d



と呼ばれ、忍者ではsome_file.cpp.od



と呼ばれsome_file.cpp.od



。 したがって、両方のバージョンを確認する必要があります。







静的変数転送



次のようなコードがあるとしましょう(非常に合成的な例):







 // 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; }
      
      





veryUsefulFunction



関数を次のように変更します。







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





veryUsefulFunction



に加えて、新しいコードで動的ライブラリを再起動すると、静的変数static Singleton ins;



、およびSingletor::instance



メソッド。 その結果、プログラムは両方の関数の新しいバージョンの呼び出しを開始します。 ただし、このライブラリの静的ins



はまだ初期化されていないため、最初にアクセスしたときに、 Singleton



クラスのコンストラクターが呼び出されます。 確かにこれは望ましくありません。 したがって、実装は、アセンブリされた動的ライブラリで見つかったこのようなすべての変数の値を、 ガード変数とともに新しいコードとともに古いコードからこの非常に動的なライブラリに転送します。







微妙で一般的に不溶性の瞬間があります。

クラスがあるとします:







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





calledEachUpdate



メソッドcalledEachUpdate



、1秒間に60回呼び出されます。 新しいフィールドを追加して変更します:







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





このクラスのインスタンスが動的メモリまたはスタック上にある場合、コードをリロードした後、アプリケーションがクラッシュする可能性があります。 割り当てられたインスタンスには変数m_someVar1



のみが含まれますが、再起動後、 calledEachUpdate



メソッドはm_someVar2



を変更しようとし、実際にはこのインスタンスに属さないものを変更し、予測不能な結果を​​もたらします。 この場合、状態転送ロジックはプログラマーに転送されます。プログラマーは何らかの方法でオブジェクトの状態を保存し、コードをリロードする前にオブジェクト自体を削除し、再起動後に新しいオブジェクトを作成する必要があります。 ライブラリは、アプリケーションが処理できるonCodePreLoad



およびonCodePostLoad



デリゲートメソッドの形式でイベントを提供します。







この状況を一般的な方法で解決する方法(およびその可能性)がわかりません。 これで、このケース「ほぼ正常」は静的変数に対してのみ機能し、次のロジックを使用します。







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





これはあまり正確ではありませんが、私が思いついたのは最高です。







その結果、ランタイムがデータ構造内のフィールドのセットとレイアウトを変更した場合、コードは予期しない動作をします。 同じことが多相型にも当てはまります。







すべてをまとめる



すべてがどのように連携するか。









これは非常にうまく機能します。特に、少なくとも内部で何が予想され、何が予想されるかを知っている場合はそうです。







個人的には、Linux向けのこのようなソリューションの欠如に非常に驚きましたが、これに本当に興味がある人はいますか?







どんな批判も喜んでいます、ありがとう!







実装へのリンク








All Articles