
*链接到文章末尾的库。 本文本身以中等细节概述了库中实现的机制。 macOS的实现尚未完成,但与Linux的实现没有太大不同。 这主要是Linux的实现。
一个星期六下午在github上走时,我遇到了一个库 ,该库为Windows即时实现更新c ++代码。 几年前,我本人从Windows上下来,对此并不后悔,现在所有的编程都在Linux(在家)或macOS(在工作)上完成。 仔细研究一下,我发现上面库中的方法非常流行,并且msvc对Visual Studio中的“编辑并继续”功能使用相同的技术。 唯一的问题是我没有在非Windows下找到任何实现(我看上去很糟吗?)。 对于上面的库作者是否要为其他平台移植端口的问题,答案是否定的。
我必须马上说,我只对不需要更改现有项目代码的选项感兴趣(例如,在RCCPP或cr的情况下,所有可能重新加载的代码应位于单独的动态加载的库中)。
“怎么样?” -我想,开始点香了。
怎么了
我主要做gamedev。 我的大部分工作时间都花在编写游戏逻辑和任何视觉效果的布局上。 我还将imgui用于帮助程序实用程序。 您可能已经猜到,我处理代码的周期是写->编译->运行->重复。 一切都很快进行(增量构建,各种ccache等)。 这里的问题是必须经常重复此循环。 例如,我正在编写一种新的游戏机制,让它成为“跳转”,即有效的受控跳转:
1.汇编并启动了基于动量的执行草案。 我看到我不小心对每个帧施加了脉冲,而不是一次。
2.固定,组装,发射,现在正常。 但是有必要更多地考虑冲动的绝对值。
3.固定,组装,发射,工作。 但是不知何故。 要根据实力去尝试做。
4.根据实力,组装,启动,工作情况编写实施草案。 仅需要在跳跃时更改瞬时速度。
...
10.固定,组装,发射,工作。 但是仍然不是。 可能需要尝试基于gravityScale
的更改gravityScale
。
...
20.太好了,看起来超级棒! 现在,我们在编辑器中取出所有参数,用于gamediz,测试和填充。
...
30.跳转准备就绪。
并且在每次迭代中,您都需要收集代码,并在启动的应用程序中到达我可以跳转的位置。 这通常需要至少10秒钟。 如果我只能在空旷的地方跳下去,那还需要到达哪个地方? 如果我需要能够跳到N个单位的高度的块上? 在这里,我已经需要收集一个测试场景,还需要对其进行调试,并且还需要花费时间。 对于这种迭代,热重载代码将是理想的。 当然,这不是万能药,它并不适合所有情况,并且有时在重新启动后,您需要重新创建游戏世界的一部分,并且必须考虑到这一点。 但是,在许多情况下,这可能很有用,并且可以节省注意力和大量时间。
问题要求和陈述
- 更改代码时,所有功能的新版本应替换相同功能的旧版本
- 这应该可以在Linux和macOS上使用
- 这不需要更改现有的应用程序代码。
- 理想情况下,这应该是静态或动态链接到应用程序的库,而没有第三方实用程序
- 希望此库不会对应用程序性能产生太大影响。
- 如果足以与cmake + make / ninja一起使用
- 如果它可以与debazine版本一起使用就足够了(无需优化,无需修剪字符等)。
这是实现必须满足的最低要求集。 展望未来,我将简要介绍另外实施的内容:
- 将静态变量的值转移到新代码中(请参阅“转移静态变量”一节以了解为什么这很重要)
- 根据依赖关系重新加载(已更改标头->重建
半个项目 所有相关文件) - 从动态库中重新加载代码
实作
在那一刻之前,我完全远离主题领域,因此我不得不从头开始收集和吸收信息。
在较高的层次上,该机制如下所示:
- 我们监视文件系统中源的更改
- 当源发生更改时,库将使用编制命令(已编译此文件)重新构建该文件
- 所有收集的对象都链接到动态加载的库
- 该库被加载到进程地址空间中
- 库中的所有功能将替换应用程序中的相同功能。
- 静态变量的值从应用程序传输到库
让我们从最有趣的开始-函数重载机制。
重新加载功能
这是3种(或几乎)在运行时替换函数的或多或少的流行方法:
显然,前两个选项不合适,因为它们仅适用于导出的函数,并且我们不想用任何属性标记应用程序的所有函数。 因此,功能挂钩是我们的选择!
简而言之,钩子是这样的:
- 找到功能地址
- 该函数的前几个字节被无条件过渡到另一个函数的主体所覆盖
- ...
- 赢利!
在msvc中,有两个标志用于- /hotpatch
和/FUNCTIONPADMIN
。 每个函数开头的第一个写入2个字节,它们什么也不做,以便随后用“短跳转”重写。 第二个功能允许您以nop
指令的形式在每个功能主体的前面留出空间,以“跳远”到所需的位置,因此,在2个跳转中,您可以从旧功能切换到新功能。 例如,您可以在此处阅读有关在Windows和msvc中如何实现此功能的更多信息。
不幸的是,clang和gcc中没有类似的东西(至少在Linux和macOS下)。 实际上,这并不是一个大问题,我们将直接在旧函数的顶部进行编写。 在这种情况下,如果我们的应用程序是多线程的,我们就有陷入麻烦的风险。 如果通常在多线程环境中,我们限制一个线程访问数据,而另一个线程修改数据,则我们需要将执行代码的能力限制在一个线程中,而另一个线程修改该代码。 我还没有弄清楚如何做到这一点,因此该实现在多线程环境中的行为将无法预测。
有一个微妙的地方。 在32位系统上,5个字节足以让我们“跳转”到任何位置。 在64位系统上,如果我们不想破坏寄存器,则需要14个字节。 最重要的是,机器代码的规模中有14个字节是很多字节,如果代码具有任何带有空主体的存根函数,则其长度可能少于14个字节。 我不了解全部事实,但是在思考,编写和调试代码时花了一些时间在反汇编程序后面,并且我注意到所有功能都在16字节边界上对齐(调试版本没有优化,不确定优化代码)。 这意味着在任何两个函数的开始之间将至少有16个字节,足以让我们“阻塞”它们。 但是,我不确定,我只是很幸运,或者今天所有的编译器都这样做。 无论如何,如果有疑问,只需在存根函数的开头声明几个变量,使其足够大。
因此,我们有了第一点-一种将功能从旧版本重定向到新版本的机制。
在复制的程序中搜索功能
现在,我们需要以某种方式从程序或任意动态库中获取所有(不仅是导出的)函数的地址。 如果未从应用程序中切出字符,则可以使用系统api非常简单地完成此操作。 在Linux上,这些是elf.h
和link.h
api,在macOS上是loader.h
和nlist.h
。
- 使用
dl_iterate_phdr
我们浏览了所有已加载的库,实际上是程序 - 查找加载库的地址
- 从
.symtab
节中, .symtab
可以获得有关字符的所有信息,即名称,类型,该节所在的节的索引,大小,并根据虚拟地址和库加载地址来计算其“真实”地址。
有一个微妙之处。 下载elf文件时,系统不会加载.symtab
节(如果不正确,则正确),并且.dynsym
节不适合我们,因为我们无法从中提取可见性为STV_INTERNAL
和STV_HIDDEN
字符。 简而言之,我们不会看到这样的功能:
以及这样的变量:
因此,在第3段中,我们不使用dl_iterate_phdr
提供给dl_iterate_phdr
的程序,而是使用我们从磁盘下载并由某些elf解析器(或在裸api上)解析的文件。 因此,我们不会错过任何东西。 在macOS上,过程相似,只是系统api中的函数名称不同。
之后,我们过滤所有字符并仅保存:
- 可以重新加载的函数是位于
.text
节中的STT_FUNC
类型的字符,其大小非零。 这样的过滤器仅跳过其代码实际上包含在此程序或库中的函数 - 要传递其值的静态变量是位于
.bss
节中的STT_OBJECT
类型的字符
广播单位
要重新加载代码,我们需要知道从哪里获取源代码文件以及如何编译它们。
在第一个实现中,我从.debug_info
节读取了此信息,该节包含DWARF格式的调试信息。 为了使DWARF中的每个编译单元(ET)获得该ET的编译行,必须在编译期间传递-grecord-gcc-switches
。 DWARF本身,我解析了libdwarf库,该库与libelf
捆绑在一起。 除了DWARF的编译命令之外,您还可以获得有关我们的ET对其他文件的依赖性的信息。 但是出于以下几个原因,我拒绝了此实现:
- 图书馆很重
- 解析从〜500 ET编译而来的DWARF应用程序,并进行依赖关系解析,耗时仅10秒钟多一点
10秒启动应用程序太多。 经过一番思考,我将解析DWARF的逻辑重写为解析compile_commands.json
。 只需将set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
添加到CMakeLists.txt中即可生成此文件。 因此,我们获得了所需的所有信息。
依赖处理
由于我们放弃了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
标志。
忍者有一种微妙的关联。 无论-MD
标志是否存在,此生成系统都会生成它们的depfile。 但是生成它们后,它将它们转换为二进制格式,并删除源文件。 因此,启动忍者时,必须传递-d keepdepfile
标志。 另外,由于我不知道的原因,在make的情况下(使用-MD
选项),该文件称为some_file.cpp.d
,而使用ninja的文件称为some_file.cpp.od
。 因此,您需要检查两个版本。
静态变量传递
假设我们有这样的代码(一个非常综合的示例):
我们想要将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
调用60次calledEachUpdate
方法。 我们通过添加一个新字段来更改它:
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的所有必要信息。 - 该库知道目标文件的路径,然后加载并解析depfile。
- 此后,将计算出该程序所有源代码文件的最常用目录,然后递归开始对该目录的监视。
- 文件更改时,库将查看其是否在依赖项哈希图中,如果存在,则使用
compile_commands.json
的编译命令在后台启动更改后的文件及其依赖项的多个编译过程。 - 当程序要求重新加载代码时(在我的应用程序中,将
Ctrl+r
组合指定给它),该库等待编译过程完成并将所有新对象链接到动态库。 - 然后
dlopen
函数将该库加载到进程地址空间中。 - 有关符号的信息是从此库中加载的,并且该库中的符号集与已存在于进程中的符号的整个交集都将重新加载(如果是函数)或进行传输(如果是静态变量)。
这非常有效,尤其是当您至少在较高水平上了解内幕和期望时。
就个人而言,我对缺少这样的Linux解决方案感到非常惊讶,有人真的对此感兴趣吗?
我将很高兴收到任何批评,谢谢!
链接到实施