*記事の最後にあるライブラリへのリンク。 この記事自体は、ライブラリに実装されているメカニズムの概要を中程度の詳細で説明しています。 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ユニットの高さのブロックにジャンプできるようにする必要がある場合はどうなりますか? ここでは、デバッグする必要があり、時間を費やす必要があるテストシーンを収集する必要があります。 このような反復のために、コードのホットリロードが理想的です。 もちろん、これは万能薬ではなく、すべてに適しているわけではありません。また、再起動後でも、ゲームの世界の一部を再現する必要がある場合があり、これを考慮する必要があります。 しかし、多くの場合、これは有用であり、注意と多くの時間を節約できます。
問題の要件と説明
- コードを変更するとき、すべての関数の新しいバージョンは同じ関数の古いバージョンを置き換える必要があります
- これはLinuxおよびmacOSで動作するはずです。
- 既存のアプリケーションコードを変更する必要はありません。
- 理想的には、これは、サードパーティのユーティリティを使用せずに、アプリケーションに静的または動的にリンクされたライブラリでなければなりません
- このライブラリは、アプリケーションのパフォーマンスに大きな影響を与えないことが望ましいです。
- これがcmake + make / ninjaで動作する場合は十分です
- debazineビルドで動作する場合は十分です(最適化なし、文字のトリミングなしなど)
これは、実装が満たさなければならない要件の最小セットです。 これから先、さらに実装されたものを簡単に説明します。
- 静的変数の値を新しいコードに転送する(これが重要である理由については、「静的変数の転送」セクションを参照してください)
- 依存関係に基づいたリロード(ヘッダーの変更->再構築
プロジェクトの半分すべての依存ファイル) - 動的ライブラリからコードをリロードする
実装
その瞬間まで、私は対象領域から非常に離れていたので、情報をゼロから収集して同化する必要がありました。
高レベルでは、メカニズムは次のようになります。
- ソースの変更についてファイルシステムを監視します
- ソースが変更されると、ライブラリは、このファイルが既にコンパイルされているコンパイルコマンドを使用してそれを再構築します
- 収集されたすべてのオブジェクトは、動的にロードされたライブラリにリンクされます
- ライブラリはプロセスのアドレス空間にロードされます
- ライブラリのすべての関数は、アプリケーションの同じ関数を置き換えます。
- 静的変数の値は、アプリケーションからライブラリに転送されます
最も興味深いものから始めましょう-関数リロード機構。
リロード機能
ランタイムで(またはほぼ)実行中の関数を置き換えるための3つの多かれ少なかれ人気のある方法を以下に示します。
- LD_PRELOADを使用したトリック -たとえば、
strcpy
関数を使用して動的にロードされたライブラリを構築し、アプリケーションの起動時にライブラリではなくstrcpy
バージョンをstrcpy
ようにします。 - PLTおよびGOTテーブルの変更-エクスポートされた関数を「オーバーロード」できます
- 関数フック -実行スレッドをある関数から別の関数にリダイレクトできます
最初の2つのオプションは、エクスポートされた関数でのみ機能するため、明らかに適切ではありません。また、アプリケーションのすべての関数を属性でマークしたくないためです。 したがって、関数フックは私たちのオプションです!
要するに、フックは次のように機能します。
- 関数アドレスが見つかりました
- 関数の最初の数バイトは、別の関数の本体への無条件の遷移によって上書きされます
- ...
- 利益!
msvcには、このための2つのフラグがあります-/hotpatch
と/FUNCTIONPADMIN
。 各関数の先頭にある最初のものは2バイトを書き込みますが、「ショートジャンプ」を使用して後続の書き換えを行う場合は何も行いません。 2番目の方法では、各関数の本体の前に空のスペースをnop
命令の形式で残して、目的の場所への「ロングジャンプ」を行うことができます。 これがWindowsおよびmsvcでどのように実装されているかについては、たとえばこちらをご覧ください 。
残念ながら、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
です。
-
dl_iterate_phdr
を使用して、ロードされたすべてのライブラリと、実際にはプログラムをdl_iterate_phdr
ます。 - ライブラリがロードされているアドレスを見つける
-
.symtab
セクションから、文字に関するすべての情報、つまり名前、タイプ、セクションが存在するセクションのインデックス、サイズを取得し、仮想アドレスとライブラリロードアドレスに基づいて「実際の」アドレスを計算します
微妙な点が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の関数名のみが異なります。
その後、すべての文字をフィルタリングして、次のもののみを保存します。
- 再ロードできる関数は、
.text
セクションにあるSTT_FUNC
タイプの文字で、サイズはゼロではありません。 このようなフィルターは、コードが実際にこのプログラムまたはライブラリに含まれている関数のみをスキップします - 値を転送する静的変数は、
.bss
セクションにあるタイプSTT_OBJECT
文字です
放送ユニット
コードをリロードするには、どこからソースコードファイルを取得し、どのようにコンパイルするかを知る必要があります。
最初の実装では、DWARF形式のデバッグ情報を含む.debug_info
セクションからこの情報を読み取りました。 このETのコンパイル行を取得するために、DWARF内の各コンパイル単位(ET)に対して、コンパイル中に-grecord-gcc-switches
渡す必要があります。 DWARF自体、libelfにバンドルされているlibdwarfライブラリを解析しlibelf
。 DWARFからのコンパイルコマンドに加えて、他のファイルに対するETの依存関係に関する情報を取得できます。 しかし、いくつかの理由でこの実装を拒否しました。
- 図書館はかなり重い
- 〜ETからコンパイルされたDWARFアプリケーションの解析、依存関係の解析では、10秒強かかりました
アプリケーションを起動するのに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));
これはあまり正確ではありませんが、私が思いついたのは最高です。
その結果、ランタイムがデータ構造内のフィールドのセットとレイアウトを変更した場合、コードは予期しない動作をします。 同じことが多相型にも当てはまります。
すべてをまとめる
すべてがどのように連携するか。
- ライブラリは、プロセスに動的にロードされたすべてのライブラリのヘッダーを反復処理し、実際にはプログラム自体が文字を解析およびフィルター処理します。
- 次に、ライブラリはアプリケーションディレクトリと親ディレクトリで
compile_commands.json
ファイルを再帰的に見つけようとし、そこからETに関する必要な情報をすべて引き出します。 - ライブラリはオブジェクトファイルへのパスを知っているため、depfilesをロードして解析します。
- その後、プログラムソースコードのすべてのファイルの最も一般的なディレクトリが計算され、このディレクトリの監視が再帰的に開始されます。
- ファイルが変更されると、ライブラリは依存関係のハッシュマップにあるかどうかを確認し、ある場合、
compile_commands.json
コンパイルコマンドを使用して、変更されたファイルとその依存関係のコンパイルプロセスをバックグラウンドで開始します。 - プログラムがコードのリロードを要求すると(私のアプリケーションでは、
Ctrl+r
の組み合わせがこれに割り当てられます)、ライブラリはコンパイルプロセスの完了を待ち、すべての新しいオブジェクトを動的ライブラリにリンクします。 - このライブラリは
dlopen
関数dlopen
してプロセスのアドレス空間にロードされます。 - シンボルに関する情報はこのライブラリからロードされ、このライブラリからのシンボルのセットとプロセスに既に存在するシンボルの交差部分全体がリロード(関数の場合)または転送(静的変数の場合)されます。
これは非常にうまく機能します。特に、少なくとも内部で何が予想され、何が予想されるかを知っている場合はそうです。
個人的には、Linux向けのこのようなソリューションの欠如に非常に驚きましたが、これに本当に興味がある人はいますか?
どんな批判も喜んでいます、ありがとう!